短函数是一种气味--函数长度科学综述

2020-08-30 00:38:01

这是我从科学角度研究编程概念的博客系列的第一部分。在这一部分中,我挖掘了我能找到的与函数长度有关的每一项研究,填补了一些原始研究的空白,并检验了我们可以学到的东西。

2000年后似乎没有专门关注函数长度的研究,但是通过最初的研究,我们发现现代代码库表现出类似的行为。

我们还发现,在实证实验中,短函数会使代码调试速度变慢,一些较弱的证据表明,它们也会使添加新功能的速度变慢,但修改速度会更快。

在编程知识中,很难找到美化长函数的源码。例如,这一点在被广泛引用的“清洁代码”一书中得到了编纂,该书指出:

函数的第一条规则是它们应该很小。函数的第二条规则是它们应该比那个小。函数长度不应为100行。函数几乎不应该有20行长。

偏爱短函数绝不是一种新的实践--1986年的一项研究“软件设计实践的经验研究”指出,“保持模块小”是一种好的设计实践。他们还引用了1974年的一本书,书中写道:“许多编程标准将模块大小限制在一页(或50-60行源代码)内”。

80年代的短路功能与今天的小功能是不同的。在1991年的一项研究中([1]),小函数和大函数之间的分界线是142条线,按照现代标准,这条线长得惊人。同样,1984年的一项研究以50行为增量将函数分组到桶中([2])。

然而,现在绝大多数函数都在50行以下。快速分析一下Eclipse(一个流行的开源IDE),就会发现它的源代码中每个方法平均有8.6行代码。上面提到的2008年出版的“Clean Code”一书指出,函数不应该超过20行,作者在其他上下文中表示,他通常更喜欢使用单行函数,并且作者用Ruby编写的函数中大约有一半是单行函数。

函数大小的这种变化可能部分是由于编程语言的变化。在80年代,Fortran的“模块”通常被认为是一个函数和一些变量。模块和函数是软件的基本构件,而现在大多数https://www.tutorialspoint.com/fortran/fortran_modules.htm)或C++程序员将“模块”定义为由多个函数组成的类。

许多研究侧重于分析模块大小,而没有指定模块是什么,所以我只包括专门说明它们分析功能的研究。这一点,再加上我们认为的软件基本构件的变化,再加上功能大小的历史变化,使我们很难适当地分析和汇总这些研究,但我们当然可以尝试一下。

我能找到的关于函数长度的第一次研究始于80年代初。如今,大多数研究都是在类和包级别上检查代码,而在80年代,面向对象编程仍然相当少见,函数是软件的主要构建块,因此检查函数及其错误倾向性非常有意义。

早期对函数长度和缺陷密度的研究发现,非常小的函数往往具有更高的缺陷密度。史蒂夫·麦康奈尔(Steve McConnell)在“代码完成”(Code Complete)中引用了这一点:“应该允许例程有机地增长到100-200行,几十年的证据表明,如此长的例程不会比更短的例程更容易出错。”此外,他还引用了一项研究,该研究称65行或更长的例程开发成本更低。(有关代码完成中使用的研究的更广泛内容,请参阅附录)。

为了进一步深入研究,1984年([2])的一项研究将函数分组到具有50行增量的桶,并测量了每个桶的误差密度。他们发现,平均而言,较小的函数每行包含更多缺陷:

一项后续研究([3])检查了五个Fortran项目,并指出“没有发现模块大小和故障率之间的显著关系”,尽管在同一段落中,作者还指出“尽管如此,[小模块]表现出最高的平均故障率,因为即使只有一个故障的小模块也会表现出非常高的故障率”。不清楚为什么作者说模块大小和故障率没有显著关系,然后又说小模块的平均故障率最高。从[3]中得出的另一个结论是,更大的功能开发成本更低。

第三个实证研究([1])分析了450个例程,发现“小”例程(源语句少于143条,包括注释)每行代码的错误比大例程多23%,但修复成本是大例程的2.4倍。这很有趣,因为它与我们稍后将看到的其他一些实验结果形成了对比。

到目前为止,看起来短的函数与较高的缺陷密度相关,但是由于所检查的函数的大小,您怀疑这些研究与现代软件工程的相关性是情有可原的。

现代缺陷预测方法以大数据集和机器学习方法为特征。它们主要集中在将“平均方法长度”和“代码行”等特征与缺陷关联起来,试图创建缺陷预测模型。

几项这样的研究已经发现方法的大小和缺陷之间存在相关性。例如,[4]发现一个类中最长的方法的大小与释放后的缺陷呈正相关。这是否意味着我们应该将我们的长方法重构为短方法以避免缺陷?

结果发现答案是否定的。在这些相同的研究中,方法的数量也与缺陷相关:

简单地说,如果我们有一个很长的函数,并将其分割成较小的函数,我们并不是在消除缺陷的根源,而是简单地从一个切换到另一个。

在其他缺陷预测研究中也可以看到同样的效果。例如,在[5]中,我们看到“每个类的加权方法”和“平均方法复杂度”都与缺陷相关,其中前者被定义为“类中方法的数量”,后者被定义为“每个类的平均方法大小”。

在这两项研究中,这两个指标都与缺陷有统计上的显著相关性,这似乎是缺陷预测文献中许多其他研究的趋势。

文献没有提供一种直接的方法来衡量在预测缺陷时哪个特征(长度或方法数量)更重要。例如,“平均方法复杂度”和“类中方法的数量”等度量很可能简单地充当行数的二阶估计器,在这种情况下,我们将简单地比较哪个度量与底层度量的相关性更好。

此外,我们感兴趣的是在更细粒度的级别上检查函数的长度。发现方法的数量和方法的长度都与缺陷相关并不能给我们任何有形的东西,而且做这种类或模块级别的相关性分析也不会让我们走得很远。

然而,许多这些研究中使用的数据集是开放的,我们可以下载它们,尝试自己检查函数长度和缺陷之间的关系。

是时候吓唬你们中间的维基百科编辑了,做一些原创性的研究吧。所有代码和数据都可以在https://github.com/softwarebyscience/function-length/.上找到。

我采用了两个数据集,其中包含我们感兴趣的特性,例如每个类的方法数量和代码行。我使用的第一个数据集被简单地称为“Eclipse Bug Database”,它包含来自三个主要Eclipse版本的数据([7])。

此外,我使用了来自http://bug.inf.usi.ch/download.php的“Bug Forecast DataSet”,因为它似乎比Eclipse Bug Database大。这些数据是在2005到2009年间从5个不同的Java项目中收集的-有关更详细的解释,请参见[6]。

这里,较小的方法显然有较高的缺陷密度,最小的缺陷密度大致可以在方法平均在10-15行左右的类中找到。从图表中可能很难看到的是,缺陷密度开始向右略微增加。

第二个数据集的结果更有趣一些(红色移动平均值):

也许最令人惊讶的是,与第一个数据集相比,分布是非常不同的,尽管我本以为分布看起来非常相似。我没有很好的理论来解释为什么会出现这种情况(除了我的代码中有一个bug,尽管我确实检查了它几次)。

这里还有其他一些有趣的细节。第二个数据集中的平均错误密度较高,这可能是因为我们考虑了~4年内的所有错误,而第一个数据集只考虑最近6个月和特定类型的错误(有关详细信息,请参见[6]和[7])。此外,还有一些零长度函数。这些可能是抽象类的方法,意味着要被覆盖。在分析发布后的缺陷时,我们看到了大致相似的情况,只是声音更大(图表可以在附录中找到)。

在这一点上,值得注意的是,这两个数据集都是用Java编写的,Java是单行方法的承诺语言。Java类通常有一行getter和setter,它们通常非常简单(有时甚至是自动生成的),只返回或设置私有变量,我预计这会使结果偏向较小的函数。

然而,从这两个数据集中我们可以看到,平均方法长度在5行以下的类倾向于增加缺陷密度。

为了超越缺陷并更好地理解短函数的影响,我们将检查一些相关的实验。我找到了3个实验,我们可以用它们来衡量不同函数大小的实际效果。

最古老的是1988年的([8])。这项研究考察了注释和过程对73行代码斑点的影响,无论是作为单一的整体过程还是在将其拆分成较小的过程之后。这项研究有大约150名学生作为测试对象。研究发现,当添加评论时,学生在整体版本的程序中回答问题的效果最好,但当删除评论时,整体版本的表现最差。

另一项实验是在2016年进行的[9]。根据Robert C.Martin“Clean Code”中的建议对程序进行了修改,例如,通过将代码提取到具有描述性名称的函数。在10名参与者中,有5名受试者被分配到重构版本,5名受试者被分配到未重构版本。然后,参与者被要求完成一系列三项任务:添加新功能、更改现有功能和修复错误。

作者没有给出重构前后的平均行数,但是从提供的例子来看,重构后的方法大多在5-15个非空行之间,而原来的方法在23-65行之间。

作者的结论是,使用较长功能的参与者在调试和实现新功能方面速度较快,但在修改现有功能时速度较慢。然而,似乎没有统计显著性测试,这会很有帮助,因为在测量添加新功能所需的时间时,样本量很小,结果很接近。

第三项研究始于2015年([10]),当时专业开发人员对他们不熟悉的重构和未重构代码执行错误修复任务。由于作者进行了几次实验,所以我只做了这样的实验:引入新类不会改变类结构,而重构完全是通过改变类的方法来完成的。在这个实验中,“(作者)提取了几个辅助方法,以便方法名称可以作为过程中各个步骤的有用描述”,这与之前的实验类似。不幸的是,作者没有深入讨论重构类型的更多细节,例如,也没有提供重构前和重构后的函数长度。但是现在,我假设函数长度与前面的实验相似,考虑到两个实验引用了相同的最佳实践编程源码(Robert C.Martin的“Clean Code”),这似乎是合理的。

在实验中,在未重构的原始版本中,错误修复花费的时间更少(大约8.5分钟对14.5分钟),结果在统计上很有意义。作者认为,这是因为开发人员习惯了旧的约定,尽管这也可以用来支持之前实验的结果,在以前的实验中,修复错误的时间更长,功能更短。

上面的研究和解释都有一些警告,但是因为其中两个研究似乎支持这样的观点,即较短的函数(小于20行)与较长的错误修复时间相关,在进一步的研究出现之前,我愿意接受这个想法。也有一种情况可以证明,较小的功能在修改现有功能时更好,而在实现新功能时更差,但证据较弱。

最后,令人惊讶的是,较长的函数在代码理解方面做得如此出色-从任何一项研究中都很难得出结论:在理解代码时,较长的函数会输给较短的函数。

所有测量缺陷密度的研究都发现,功能越小,缺陷密度越高。一种可能的解释是由[2]提出的,他提出错误的增加是由于他们所说的“接口错误”-即“那些与存在于模块本地环境之外但模块使用的结构相关联的错误”。这将包括诸如调用错误函数之类的错误。

在更一般的水平上,在[11]的综述中发现了元件大小和缺陷密度之间的相似关系。虽然他们没有直接检查函数长度,但他们的解释很有趣。具体地说,作者声称,部件的最佳大小遵循人类短期记忆的限制,即7+-2个项目。按照这个逻辑,函数的任何“最佳”平均长度(如果存在)都可能是由短期记忆的限制决定的。当我们回顾我们最初的研究时,这个理论变得有趣起来,在第二个数据集中,平均方法长度为5-7行的类似乎缺陷最少,这与7+-2范围非常相似。但是,我不会对此进行过多解读-正如我前面提到的,Java的特性可能会使“最佳”的平均方法大小看起来比实际的要小。

另一个模型是由[12]提出的,其中发现模块(任意软件模块,而不是函数)中的预期缺陷数量可以用AX+b形式的数学函数而不仅仅是AX来最佳建模。这实质上是说,创建模块本身是有成本的,而创建函数本身总是会带来一些开销,这似乎是合理的。例如,函数总是会导致额外的间接性,并依赖于程序员想出一个好的名称,当另一个程序员调用错误的函数或曲解函数名时,这些加在一起可能会导致错误。这样的模型可以解释我们在第二个数据集分析中看到的U型曲线。

上述研究并不是没有批评。1999年的一项研究([13])指出,尺寸和缺陷之间的关系不是因果关系,而是线的数量只是与缺陷相关,而不是直接导致缺陷。当然,这意味着我们不能基于它可靠地预测缺陷。

此外,他们还批评了对极其嘈杂的数据使用曲线拟合,并指出虽然我们可以拟合模块大小与缺陷密度之间的曲线,但这并不意味着我们可以非常可靠地预测缺陷。

虽然我从根本上不反对上述任何批评,但我认为。与作者提出的更复杂的模型相比,检验平均数仍然有其存在的空间。当偏爱小功能的建议如此普遍,而且似乎没有任何科学依据时,这一点尤其正确。

当然,与任何研究一样,我们可能遗漏了许多其他变量。例如,可能小函数在经常更改的类或包中更常见,由于更改与更多的缺陷相关,所以小函数也是如此。然而,检验这些理论超出了这篇文章的范围。

考虑到短函数往往导致更长的调试时间,并且非常短的函数往往具有更高的缺陷密度(无论是在历史数据集中还是在现代数据集中),使用非常短的函数的情况就变得很弱了。

正如我们从历史研究中看到的那样,“做空”的定义随着时间的推移而发生了变化。尽管如此,如果我们专注于对2000年后的数据和研究的数据集分析,并查看1-3行长的函数(在任何研究中都短到可以归类为短),那么证据是不平衡的:使用它们的理由很少,而不使用它们的理由却很多。因此,软件开发人员应该警惕将他们的代码分成太小的片断,并在有选择的情况下积极避免引入非常短的(1-3行)函数。至少不必要的单行函数(即,不包括getter、setter等)。应该几乎被禁止。

对于较长的函数,情况就不那么清楚了。2000年前的研究似乎没有显示较长功能的缺陷密度有任何增加,但我们的数据集探索有点与此相矛盾。在实验中,较长的函数似乎带来了改进,至少在调试时间上是这样。然而,很难定义“长”函数的门槛,因为许多研究都没有提供任何关于这方面的数据。

欢迎来到无聊栏目。这一部分包含诸如免责声明、上述帖子中未包括的研究以及一些糟糕的科学等内容。

首先,我已尽力将我的意见与报章上的意见分开。然而,这不是一篇科学论文或同行评议的研究,我是根据我个人的判断,根据一些不完整的数据得出结论的。我相信,如果我们要让编程更多地建立在良好的科学基础上,这是至关重要的,特别是因为有很多人在没有科学证据的情况下提出了主张。

为了这篇文章,我费力地阅读了可能是我最终参考的两倍的研究报告。大多数看似相关的研究在闭门检查后结果并非如此。对于较早的研究,一个主要原因是许多研究研究了“模块”和缺陷之间的关系,而不是函数/方法/例程和缺陷。有些研究(如[2])确实将模块定义为子例程,在这种情况下,我在这里包括了它们,但其他研究只是简单地没有将模块或定义模块定义为其他特定的东西。

甚至看起来,“代码完成”中引用的一些与函数长度相关的研究实际上研究的不是直接函数。具体地说,Code Complete说,“另一项研究发现,例程大小与错误无关,即使结构复杂性和数据量与错误相关。”参考文献[12]。然而,在研究作者所说的“模块”的含义时,这似乎是一种误解:

程序由称为“模块”的可单独编译子程序组成;通常,每个模块支持一个或多个系统功能。

“识别容易出错的软件-Em。

.