在这篇文章中,我将讨论一个很少有人想到的问题。各种过程的计算机模拟正变得越来越普遍。这项技术非常棒,因为它使我们能够节省时间和材料,而这些时间和材料本会用于无谓的化学、生物、物理和其他类型的实验。机翼剖面流动的计算机模拟模型可以显著减少在真实风洞中测试的原型数量。如今,人们越来越信任数值实验。然而,在计算机模拟的胜利光环下,没有人注意到软件复杂性不断增长的问题。人们将计算机和计算机程序仅仅视为获取必要结果的手段。我担心很少有人知道并关心软件规模的增长会导致软件错误数量的非线性增长。将计算机仅仅视为一个大计算器来使用是危险的。所以,这就是我的想法——我需要与他人分享这个想法。
起初,我打算将这篇文章命名为“如果程序员不能开发药物,为什么医生可以编写程序?”。设想一位程序员——他不被允许开发和制备药物。原因显而易见:他没有必要的教育。然而,在编程方面并非如此简单。看起来,一位学会编程的医生,将理所当然地成为一名成功的、有用的程序员——尤其考虑到掌握或多或少可接受的编程技能比有机化学和药物制备原理容易得多。
这里藏着一个陷阱。计算机实验需要像真实的实验一样细致。实验室工作人员被教导在实验后清洗试管并确保它们是无菌的。但很少有人真正关心某个数组意外地未初始化的问题。
程序员们都很清楚,软件越复杂,出现的错误就越复杂和难以捉摸。换句话说,我指的是代码规模增长伴随的错误数量的非线性增长。执行化学或任何其他科学计算的程序远非简单,不是吗?这就是危险所在。医生兼程序员犯错是没关系的。任何程序员,无论多么熟练,都会时不时犯错。不 OK 的是,人们越来越信任这些结果。你计算完就继续做别的事情。
那些从事编程作为职业活动的人知道这种方法的危险性。他们知道什么是未定义行为,以及程序如何仅仅“看起来”就能正常工作。有无数的文章和书籍解释了如何正确开发单元测试并确保计算的正确性。
这就是程序员的世界。但我担心,化学家/物理学家/医生的世界并非如此。他们从不编写复杂的程序——他们根本不是那样思考的。他们使用计算机,就像它只是一个大计算器一样。这个比喻是一位读者提出的。让我在这里完整地引用他的评论,以便讲英语的读者也能在文章翻译后了解它。
我可以根据自己的经验告诉你一些关于这个主题的事情。虽然我是一名专业程序员,但我实际上来自一个物理学家家庭,并且接受过物理学教育。在我必须选择进入哪所大学的时候,血统的呼唤比我对 IT 光明未来的信念更强大。所以,我进入了一所物理大学,该大学在我家乡下诺夫哥罗德的一个大型研究所的监督下,实际上是一个“幼儿园”。了解该主题的人会立即猜到我指的是哪个研究所和哪所大学。
在那里学习期间,我自然而然地成为编程(尤其是物理建模的数学方法)方面最好的学生之一。与此同时,我也发现了以下几点:
1. 物理学家倾向于将计算机视为一个大型多功能计算器,允许你绘制 Eta 与 Theta 的图,而 Gamma 趋于无穷大。正如人们可以自然预期的那样,他们主要对图本身感兴趣,而不是程序。
2. 作为第一个事实的后果,程序员不被视为一种职业。程序员只是那个知道如何使用大计算器绘制所需图表的人。他们根本不在乎它是怎么完成的。抱歉,你说什么?静态分析?版本控制?哦,拜托,各位!C++ 是程序员的语言;物理学家用 FORTRAN 编写!
3. 作为前一个事实的后果,任何打算致力于编写物理建模程序的人,即使是通用的,即使是极其复杂的,也只是大计算器的一个附属物。他甚至不是一个人——只是一种……顺便说一句,不仅我被物理学家这样对待(毕竟我只是一个普通学生)——甚至研究所里最好的计算机建模专家,也是在我们大学教授计算方法课程的人,当我为写学期论文而转向他作为我的论文导师时,他几乎直截了当地对我说:“他们会鄙视你,所以要准备好忍受。”
我不想忍受,毕业后离开了计算机建模领域,转向程序员不被视为“次等人”的领域。我希望这个例子能帮助你理解为什么像在相对大型(约 20-30 名开发人员)计算机建模项目上引入静态分析这样的倡议是徒劳的。可能根本就没有人知道这是什么。即使团队中碰巧有这样一个人,他们也很可能会把他排挤掉,因为他们不需要你那些时髦的程序员花哨的东西。“我们没有它们已经有一百年了——将来还会继续。”
对于那些还没感到厌烦的人,再讲一个故事。我的父亲,尽管已经退休,仍然在我所在的下诺夫哥罗德一家非常大的国防工程企业工作(这是我们城市最大的企业之一,也是全国最大的企业之一;同样,那些了解情况的人会猜到 ;))。他一生都在用 FORTRAN 编程。他开始编程时还在使用穿孔卡片。我不怪他没有学习 C++。对他来说,十年前就已经太晚了——但他现在仍然做得很好。然而,这家企业有某些安全措施,其中 2/3 的员工或多或少都在从事编程工作。
1. 完全没有互联网。如果你需要文献——你去图书馆。Stack Overflow?那是什么?如果你需要发送电子邮件,你必须向老板提交书面请求,解释你想发送给谁以及为何发送。只有少数选定的人才能“凭收据”使用互联网。谢天谢地,他们至少有一个内部网络。
2. 你的电脑上没有管理员权限。也许这个限制对普通白领来说有意义,但我无法想象一个程序员在这种情况下感到满意。
3.(与主题无关;只是说明。)你甚至不能带一部带有集成摄像头的手机(如今还有没有摄像头的吗?)。
结果是,即使是年轻的员工也用 FORTRAN 编写代码,而真正有技能的程序员却很少。我对此很确定,因为我指导了一个 25 岁的年轻人,我父亲曾推荐他作为一名有前途的程序员。
我的结论是:他们还停留在 80 年代。即使有相当不错的薪水,我也不想去那里。
这只是来自知识精英阶层的两个例子。我无意贬低任何人——他们做得足够好,但看着我父亲有时不得不与之搏斗的“风车”,我的心在流血。(谢天谢地,我最近说服他开始使用 git 了。)在一个拥有百万行代码的项目中没有 OOP,没有静态分析——什么都没有。
这仅仅是因为人类在自己不擅长的领域往往非常保守。
Ilja Mayzus. 原始评论。
这个故事的核心是将计算机视为大计算器的理念。在这种情况下,你不需要比它的弟弟——袖珍计算器——了解更多。而且,它在许多领域实际上就是这样使用的。我们暂时岔开话题,看看物理学界。让我们看看另一个理论是如何被证实的。为此,我将再次引用布莱恩·格林(Bryan Greene)的书《优雅的宇宙:弦论、隐藏的维度以及对终极理论的追求》[1] 的大段摘录。
我们都挤在莫里森(Morrison)的办公室里,我和他一起使用他的电脑。阿斯平沃尔(Aspinwall)告诉莫里森如何将他的程序显示在屏幕上,并向我们展示了所需的输入的精确格式。莫里森妥善格式化了我们前一晚生成的计算结果,一切准备就绪。
我们进行的具体计算,粗略地说,是确定某个粒子种类——弦的特定振动模式——在穿越我们整个秋天都在识别的某个 Calabi-Yau 分量宇宙时产生的质量。我们希望,遵循前面讨论的策略,这个质量将与在空间撕裂的翻转过渡(flop transition)产生的 Calabi-Yau 形状上进行的类似计算完全一致。后者是相对容易的计算,我们几周前已经完成了;结果是 3,在我们使用的特定单位下。由于我们现在正在计算机上进行所谓的镜像计算,我们期望得到一个非常接近 3 但不完全是 3 的结果,例如 3.000001 或 2.999999,微小的差异来自于舍入误差。
莫里森坐在电脑前,手指悬停在回车键上。随着紧张气氛的加剧,他说:“开始了”,并启动了计算。几秒钟后,计算机返回了结果:8.999999。我的心沉了下去。难道空间撕裂的翻转过渡会破坏镜像关系,这可能表明它们实际上不会发生?然而,几乎立刻,我们都意识到肯定有什么不对劲。如果由两种形状产生的物理学之间存在真正的差异,那么计算机计算得到一个如此接近整数的结果的可能性极小。如果我们的想法错了,那么没有任何理由期望得到比随机数字集合更好的结果。我们得到了一个错误的结果,但这个结果可能表明我们只是犯了一个简单的算术错误。阿斯平沃尔和我走到黑板前,片刻之后就找到了我们的错误:我们在几周前进行的“更简单的”计算中漏掉了一个因子 3;真实结果是 9。因此,计算机结果正是我们想要的。
当然,事后的吻合只具有微弱的说服力。当你知道你想要的结果时,很容易找出一种方法来得到它。我们需要做另一个例子。由于已经编写了所有必要的计算机代码,这并不难。我们小心翼翼地在上面的 Calabi-Yau 形状上计算了另一个粒子质量,这次确保没有错误。我们发现结果是:12。我们又一次挤在电脑旁,启动了计算。几秒钟后,它返回了 11.999999。吻合。我们已经证明了所谓的镜像就是镜像,因此空间撕裂的翻转过渡是弦理论物理学的一部分。
听到这个消息,我从椅子上跳起来,在办公室里狂奔以示胜利。莫里森在电脑后面得意洋洋。然而,阿斯平沃尔的反应却截然不同。“这很好,但我早就知道会这样,”他平静地说。“我的啤酒呢?”
我真心相信他们是天才。但让我们暂时设想一下,如果是一些普通学生用这种方法来计算积分呢?我想程序员不会认真对待。如果程序直接生成了 3 呢?这个错误会被当作最终证据吗?我认为在他们自己或他们的科学家同事重新检查后会澄清。尽管如此,“理想化真空中的球形程序员”对此事实感到极度恐惧。
现实就是这样。不仅仅是个人电脑这样使用——集群系统也被用于科学计算。最可怕的是,人们信任程序产生的结果。未来我们将处理更多此类计算,软件错误的代价也将越来越高。
难道是时候改变一些东西了?
是的,没有人能阻止我给自己贴创可贴;我想我可以在感冒时推荐一些药物。但仅此而已。我不能钻牙或开处方。
难道你认为创建软件系统的开发人员,其责任超出特定范围,也应该确认他们的技能,这不合理吗?
我知道存在各种认证。但现在我说的是另一件事。认证旨在确保程序代码符合某些标准。它以间接的方式部分地防止了粗制滥造。但是,认证是严格要求的地方范围相当狭窄。它显然不能涵盖所有以及任何地方,在这些地方粗心使用大计算器可能会造成很大危害。
我想你们中的许多人会觉得我的担忧过于抽象。因此,我建议研究一些现实生活中的例子。有一个开源软件包 Trans-Proteomic Pipeline (TPP),旨在解决生物学中的各种任务。毫无疑问,它被使用着——由其开发人员和一些第三方组织使用。我相信其中的任何错误都已经是潜在的问题。它有错误吗?是的,它有;而且还在不断出现。我们在一年前检查了这个项目,并在博客文章“Trans-Proteomic Pipeline (TPP) 项目分析”中进行了报告。
自从那时以来有什么变化吗?没有。项目正在继续开发并积累新的错误。大计算器理念占了上风。开发人员没有编写一个拥有最少错误数量的高质量项目。他们只是在解决自己的任务;否则,他们就会对去年的文章做出某种反应,并考虑引入一些静态分析工具。我不是说他们必须选择 PVS-Studio;还有许多其他静态代码分析器。重点是,他们负责任的应用正在继续累积最琐碎的错误。让我们看看他们又发现了什么新鲜的错误。
在上一篇文章中,我提到了错误的循环条件。新软件包版本中也有。
|
|
PVS-Studio 的诊断消息:V521 使用 ',' 运算符的此类表达式很危险。请确保表达式正确。spectrastpeaklist.cpp 504
在检查“i != this->m_bins->end(), j != other->m_bins->end()”时,逗号前的表达式没有任何检查。',' 运算符用于按从左到右的顺序执行其左右两边的表达式,并**返回右边表达式的值**。正确的检查应该是这样的:
i != this->m_bins->end() && j != other->m_bins->end()
相同的缺陷也可以在以下片段中找到:
这个错误不会导致输出错误的计算结果——它会导致崩溃,这反而更好。但是,不提及这些错误会很奇怪。
|
|
PVS-Studio 的诊断消息:V522 可能会发生空指针“pepIndx”的解引用。asapcgidisplay2main.cxx 534
相同的缺陷也可以在以下片段中找到:
|
|
在此代码中,分析器一次捕获了两个未清除的数组。
V530 函数 'empty' 的返回值需要被利用。tag.cxx 72
V530 函数 'empty' 的返回值需要被利用。tag.cxx 73
您应该调用 clear() 函数而不是 empty()。
|
|
PVS-Studio 的诊断消息:V603 对象已被创建但未使用。如果要调用构造函数,应使用“this->ExperimentCycleRecord::ExperimentCycleRecord(....)”。mascotconverter.cxx 101
ExperimentCycleRecord() 构造函数没有达到预期目的,它没有初始化任何东西。开发者可能是个优秀的化学家,但如果他不知道如何正确使用 C++ 语言,他使用未初始化内存进行的计算就毫无价值。这就像使用脏试管一样。
该行“ExperimentCycleRecord(0,0,0,True,False);”创建了一个临时对象,该对象将在之后被销毁,而不是调用另一个构造函数。这种错误模式在文章“不要在未知的水域中徘徊。第一部分”中有详细讨论。
相同的缺陷也可以在以下片段中找到:
|
|
PVS-Studio 的诊断消息:V628 可能该行被不正确地注释掉了,从而改变了程序的运行逻辑。interprophetmain.cxx 175
在 'if' 运算符之后,几行执行一些操作的代码被注释掉了。结果,程序逻辑与预期大相径庭。程序员不希望在执行条件后进行任何操作。相反,'if' 运算符会影响下面的代码。因此,测试的输出不仅取决于“testType!=NO_TEST”条件,还取决于“getIsInteractiveMode()”条件。也就是说,测试可能什么也不测试。因此,我强烈建议不要完全依赖一种测试方法(例如 TDD)。
印刷错误无处不在。如果因为这样的错误,你在游戏中获得的生命点比应得的少,那还不是最糟糕的。但是,在计算化学反应时,不正确的数据意味着什么?
|
|
PVS-Studio 的诊断消息:V519 变量 'data->ratio[0]' 被连续两次赋值。可能这是一个错误。请检查行:130, 131。asapcgidisplay2main.cxx 131
同一个变量被错误地赋予了两个不同的值。正确的代码是这样的:
|
|
这段代码随后被复制并粘贴到程序的其他部分。
正确比较有符号和无符号值需要一些技巧。普通计算器不处理无符号值,但 C++ 语言却可以。
|
|
PVS-Studio 的诊断消息:V555 表达式“ppw_ref.size() - have_cluster > 0”将按“ppw_ref.size() != have_cluster”工作。proteinprophet.cpp 6767
程序员想要执行“ppw_ref.size() > have_cluster”检查。但他却得到了完全不同的结果。
为了更清楚地说明,让我们假设我们有一个 32 位的 'size_t' 类型。假设函数“ppw_ref.size()”返回 10,而变量 have_cluster 等于 15。函数 ppw_ref.size() 返回无符号类型 'size_t'。根据 C++ 规则,在执行减法之前,减法运算符的右侧操作数也必须是 'size_t' 类型。目前没问题:左边是 10u,右边是 15u。
这是减法
10u - 15u
这就是问题所在。那些 C++ 规则告诉我们,两个无符号变量相减的结果也必须是无符号的。
这意味着 10u - 15u = FFFFFFFBu。正如你所知,4294967291 大于 0。
大计算器暴动成功了。编写一个正确的理论算法只是工作的一半。你还需要编写正确的代码。
以下是类似的错误:
|
|
PVS-Studio 的诊断消息:V547 表达式“b + tau >= 0”始终为真。无符号类型值始终 >= 0。spectrastpeaklist.cpp 2058
正如你所看到的,变量 'tau' 的取值范围是 [-75, 75]。为了避免数组越界,使用了检查 b + tau >= 0。我猜你已经明白了,这个检查是无效的。变量 'b' 具有 'unsigned' 修饰符。这意味着“b + tau”表达式的结果也是无符号的。而无符号值总是大于或等于 0。
|
|
PVS-Studio 的诊断消息:V612 循环内无条件 'return'。residuemass.cxx 1442
循环内有一个 'return' 运算符,它在任何情况下都会被调用。循环最多只能执行一次,然后函数终止。这里要么是印刷错误,要么是 'return' 运算符前缺少了某个条件。
|
|
PVS-Studio 的诊断消息:V636 表达式“used_count_ / rts_.size()”已从“int”类型隐式转换为“double”类型。考虑使用显式类型转换以避免丢失小数部分。例如:double A = (double)(X) / Y;。rtcalculator.cxx 6406
由于函数返回 double 类型的值,我倾向于认为以下情况是合理的。
当变量 'used_count_' 被赋值为 5,而函数 rts_.size() 返回 7 时,近似结果为 0.714。然而,在这种情况下,函数 getUsedForGradientRate() 将返回 0。
变量 'used_count_' 是 'int' 类型。rts_.size() 函数也返回一个 'int' 值。发生整数除法,结果显而易见:为零。然后零被隐式转换为 double,但这无关紧要。
为了修复这个缺陷,代码应该重写如下:
return static_cast<double>(used_count_) / rts_.size();
其他类似缺陷:
函数 setPepMaxProb() 包含几个大小相似的块。在这个片段中,你可以感受到复制粘贴技术的特殊味道。使用它自然会导致错误。我不得不“大幅”删减示例文本。错误在删减后的代码中非常明显,但在原始代码中几乎看不见。是的,这是对静态分析工具的推广,尤其是对 PVS-Studio 的推广。
|
|
V525 包含相似代码块的代码。请检查行 4664、4690、4716、4743、4770 中的项 'max3'、'max4'、'max5'、'max6'、'max6'。proteinprophet.cpp 4664
PVS-Studio 的诊断消息:V525 包含相似代码块的代码。请检查行 4664、4690、4716、4743、4770 中的项 'max3'、'max4'、'max5'、'max6'、'max6'。proteinprophet.cpp 4664
不幸的是,V525 诊断会产生许多误报,因此被归类为三级警告。但如果克服了懒惰,研究这类警告,你可能会发现许多类似的优秀错误。
|
|
PVS-Studio 的诊断消息:V614 可能未初始化指针 'pScanIndex'。sqt2xml.cxx 476
如果函数 rampOpenFile() 返回 NULL,此程序可能会在最后崩溃。这还不算关键,但很令人不快。
这是另一个可能未初始化的变量:
|
|
PVS-Studio 的诊断消息:V599 即使“DiscriminantFunction”类包含虚函数,也没有出现虚析构函数。discrimvalmixturedistr.cxx 206
许多类继承自 DiscriminantFunction 类。例如,DiscrimValMixtureDistr 类就是如此。它的析构函数会释放内存,因此,非常希望调用它。不幸的是,DiscriminantFunction 类的析构函数没有声明为虚函数——并由此产生所有后果。
有许多小缺陷,它们不会造成严重后果,但仍然很不 pleasant 存在于你的代码中。还有一些奇怪的片段,但我无法确定它们是否不正确。这里有一个:
|
|
PVS-Studio 的诊断消息:V607 无属主表达式“done_[charge]”。mixturemodel.cxx 1558
这是什么?不完整的代码?或者程序员只是想指出,如果“done_[charge] < 0”条件为真,则不应执行任何操作?
这里有一个不正确的内存释放方式。不太可能造成严重后果,但代码确实有问题。
|
|
PVS-Studio 的诊断消息:V611 使用“new T[]”运算符分配的内存使用“delete”运算符释放。请检查此代码。最好使用“delete [] pepString;”。pepxfield.cxx 1023
正确的做法是编写“delete [] pepString”。还有许多其他类似的缺陷:
还有一个不正确的“--”运算符实现。它似乎没有任何地方使用,否则这个错误会很快暴露出来。
|
|
PVS-Studio 的诊断消息:V524 奇怪的是“--”函数的体与“++”函数的体完全等价。charindexedvector.hpp 81
运算符“--”和“++”的实现方式相同。它们一定是复制粘贴的。
我们就此打住吧。这一切都不是很有趣,而且文章已经够大了。一如既往,我敦促开发人员不要仅限于修复提到的缺陷。请下载并自行使用 PVS-Studio 检查该项目。我可能遗漏了很多错误。我们甚至可以为您提供免费的注册密钥。
不幸的是,这篇文章显得有些混乱。作者到底想说什么?我将尝试以非常简练的形式重复我想与您分享的想法。
那么,我们该怎么办?
首先,我希望您能意识到这个问题,并告知您相关领域的同事。程序员很早就知道,软件复杂性的增长以及大型项目中低级错误可能轻易地导致巨大危害。另一方面,那些将编程和计算机仅仅视为工具的人不知道这一点,也不去想它。因此,我们需要引起他们对这个问题的关注。
这里有一个类比。想象一个人拿到一根棍子,开始猎杀一些动物。棍子在他的手中逐渐变成石斧,然后是剑,最后是枪。但他仍然只是用它来击晕野兔。不仅这种使用武器的方式效率低下;现在也危险得多(他可能会意外地射伤自己或他的同伴)。“程序员”部落的猎人很快就适应了这些变化。其余的人没有时间这样做——他们忙于猎杀野兔。毕竟,都是关于野兔的。我们需要告诉这些人,他们必须学习,无论他们是否喜欢。这将改善每个人的生活。四处挥舞你的枪是没有用的。