重温 Prechelt 比较 Java、Lisp、C/C++ 和脚本语言的论文

2021-08-09 02:58:44

1999 年,Lutz Prechelt 在 COMMUNICATIONS OF THE ACM(1999 年 10 月/第 42 卷,第 10 期)上发表了一篇开创性的文章,名为 Comparing Java vs. C/C++ Efficiency Differences to Interpersonal Differences,即 Java VS C,它似乎具有后来(2000 年 3 月)被扩展成一篇完整的论文,对 C、C++、Java、Perl、Python、Rexx 和 Tcl 进行搜索/字符串处理程序的实证比较,此后称为 Scripting VS Java/C。在那篇论文中,他们分析了一项研究的数据(该研究是为另一篇论文运行的,我们稍后会看到)在该研究中,参与者被要求解决一个问题,包括将电话号码编码为字典中的数字和单词的组合,大概是为了使人类更容易记住他们以后可能想要回忆的电话号码(这是在移动电话普及之前的时期)。同样的问题后来被 Ron Garret(又名 Erann Gat)在他的短论文 Lisp 作为 Java 的替代品,即 Lisp VS Java,从 2000 年开始使用。在这篇文章中,我想重新审视这些论文,分析它们方法和结论,并试图找出自这些文章发表以来的 21 年中是否发生了任何变化,我自己编写了解决问题的方法,就好像我自己参与了其中一项研究一样。我用 Java 编写了一个解决方案,而没有研究任何其他解决方案,以了解我的解决方案(和 Java 16)如果参与其中会如何完成。之后,我分析了其他一些解决方案并将它们与我的进行了比较。根据我的发现和 Prechelt 本人在他的论文中的一些评论,我决定编写第二个 Java 程序来匹配在更动态的语言中常用的完全不同的策略。我也在 Rustin 中实现了相同的算法,以便了解一种非常现代的系统语言会如何做。这应该允许我们通过使用相同的精确算法(在可能的范围内)更直接地比较现代语言,消除程序员选择的不同算法可能产生的潜在巨大差异。

Prechelt 的 Java VS C 中使用的所有数据,以及 Scripting VS Java/C 中使用的 Java/C/C++ 解决方案中使用的所有数据,都来自对称为 PSP(个人软件过程)的软件开发技术的早期研究。这项研究实际上是在 1996 年 8 月和 1998 年 10 月之间进行的!这意味着一些参与者实际上使用的是 JDK 的第一个版本!该研究的目的与研究 Java 和 C/C++ 之间的差异无关,而是研究 PSP 的有效性(结果表明,大多数情况下,PSP 实践者具有更可预测、更稳定的性能,但工作速度也稍慢)。 55 名研究生参与了这项研究,其中 24 名使用 Java,9 名使用 C,13 名使用 C++,另外两名使用Modula-2 和名为 Sather-K 的东西!大多数学生来自 PSP 课程或 Java 高级课程。尽管如此,这是 Java VS C 论文所基于的数据。它包括 24 个用 Java 编写的程序,11 个用 C++ 编写,5 个用 C 编写。提供给研究参与者的说明,稍微改编为 Lisp VS Java 文章,可以在 flownet.com 上找到。所有程序都实现相同的功能,即电话号码到字串的转换....转换是由字符到数字的固定映射定义的,如下所示:ejnqrwxdsyftamcivbkul opgh z0 1 1 1 2 2 2 3 3 3 4 4 5 5 6 6 6 7 7 7 8 8 8 9 9 9 程序的任务是找到一个单词序列,使这些单词中的字符序列与电话号码中的数字序列完全对应。必须找到所有可能的解决方案并打印。解决方案是逐字创建的,如果在该过程中的某个时间点无法插入字典中的单词,则电话号码中的一位数字会出现在该位置的结果中。许多电话号码根本没有解决方案.

如果您想“参与”研究,请尽可能按照此链接中给出的说明进行操作。然后,您可以使用我的 GitHub 存储库将您的解决方案与我的和其他人的解决方案进行比较,其中包含有关如何轻松将您的解决方案添加到当前基准测试并运行它的说明!这是一个很好的问题,不是太容易,但也不是很难。在研究发表 21 年后,我在互联网上随机偶然发现了它,我发现它很有趣,想自己参与!显然,我不是唯一一个。 Ron Garret(当时在美国宇航局工作,后来在早期的谷歌工作)发现它很有趣,可以运行他自己的研究(已经提到的 Lisp VS Java 之一),重用同样的问题,但只要求 Lisp 程序员解决它。他从互联网新闻组招募的志愿者那里得到了 16 个解决方案。甚至我认为是计算机科学名人的 Peter Norvig 也在他的网站上发布了他自己用 Common Lisp 编写的问题解决方案。嗯,基本上,Java 很慢,使用太多内存,并且使用它编写一些东西至少需要与 C 或 C++ 一样多的努力。此外,至少在 Prechalt 的情况下,程序员之间的差异通常大于语言之间的差异。从 JDK 1.2 开始,Java 程序通常比用 C 或 C++ 编写的程序慢得多。它们也消耗更多的内存。然而,即使在一种语言中,由不同程序员编写的同一程序的实现之间的人际差异(坏/好比)也远大于 Java 和 C/C++ 之间的平均差异。

...对于给定的编程问题,“脚本语言”(Perl、Python、Rexx、Tcl)比传统语言更有效率。在运行时间和内存消耗方面,它们通常比 Java 好,也不比 C 差多少或 C++。一般来说,由于同一语言中不同的程序员,语言之间的差异往往小于典型差异。 ... Lisp 的性能在执行速度上与 C++ 相当或更好 ...它还具有显着更低的可变性,这转化为降低的项目风险。此外,与 C++ 或 Java 相比,开发时间显着缩短且可变性更小。内存消耗可与 Java 相媲美... Prechelt 详细描述了他的实验设置和参与者的概况,以及每项研究中涉及的许多注意事项。我强烈建议浏览 PSP 论文,这是他后来发表的文章的开端。请注意,原始研究中有两组不同的学生:PSP 学生,他们已经学会了如何使用一种技术 (PSP) 来明确地提高他们的工作质量,而对照组,主要是 Java 课程的学生,他们必须参加研究。大多数 C/C++ 提交都来自 PSP 组(17 VS 5),但 Java 提交的分布更均匀(14 VS 10)。重要的是要记住,PSP 学生已经习惯于花更多的时间来分析问题并试图防止他们的程序出现缺陷,而且这项研究最重要的要求是使程序尽可能可靠。这意味着使用 Java 与 C/C++ 的学生类型存在一些差异。在 Java VS C 中,Prechalt 甚至提到,平均而言,Java 程序员在 Java 方面的编程经验只有 C/C++ 程序员在 C/C++ 方面的一半,这是很自然的,因为这项研究是在 Java 刚刚发布的时候进行的。第一次。

无论如何,在 PSP 论文和 Java VS C 中,Prechelt 对研究的局限性非常真诚,鉴于数据(以及他们使用 1998 年发布的 Java 1.2 的事实),他的结论非常可靠。然而,使用 Scripting VS Java/C,事情变得更加模糊。请注意,Prechelt 再次为 Java 和 C/C++ 程序使用了相同的数据,但对于脚本语言组,参与者基本上是在互联网组中发现的随机人员:Perl、Python、Rexx 和 Tcl 程序都是后期提交的1999 年,在我在几个 Usenet 新闻组(comp.lang.perl.misc、de.comp.lang.perl.misc、comp.lang.rexx、comp.lang.tcl、comp. lang.tcl.announce、comp.lang.python、comp.lang.python.announce)和一个邮件列表(称为“Fun with Perl”,[email protected])。另请注意,解决解决方案所花费的时间是自我报告的,脚本语言参与者没有以任何方式受到监控,这与参加第一项研究的参与者非常不同(其中许多人习惯于花更多时间思考问题,正如 PSP 所鼓励的那样)。 Prechelt 提到脚本程序员报告的工作时间可能不准确。嗯,这可能是他所有论文中最大的轻描淡写。该论文发现脚本的中位时间为 3.1 小时,而非脚本组为 10.0 小时。请注意,根据您查看数据的方式,您还可以得出结论,自我报告时间的参与者比实际监控的参与者快 3 倍。原始 PSP 研究中超过一半的学生花费了超过在这个问题上工作了一天,他们不仅在那个研究中使用了 Java,而且基本上使用了他们想要使用的任何语言(尽管 Prechelt 似乎只在他后来的论文中使用了 Java 结果)!

不过,还有一些更有趣的发现。例如,脚本比非脚本短两到三倍,程序可靠性差异的结果相互矛盾,这一发现让许多其他研究的作者感到困惑,特别是在静态 VS 动态类型语言领域(因为这表明使用静态类型实际上不会导致更可靠的程序)。如果 Prechelt 主要是直言不讳地指出他的发现的注意事项,那么 Garret 就少得多。目前尚不清楚如何为他的研究收集数据,因为除了说明我们尽可能复制原始研究的情况外,根本没有描述该程序。考虑到参与者是从网络组招募的,我怀疑受试者被监控到位,这让我觉得收集的开发时间数据应该是一粒盐。然而,报告的时间似乎与 Prechelt 研究中脚本编写组自我报告的时间相似:Lisp 用户为 2 到 8.5 小时,Prechelt 研究中脚本编写组为 3 到 10 小时。 ... 一个古老的经验法则,它说程序员每小时的代码内联量(LOC/小时)大致独立于编程语言。他用这个推理得出结论,如果这是真的,那么自我报告的时间应该离现实不远。 Lisp 在运行时优于 Java,与 C++ 相媲美,在编程工作量和结果可变性方面都优于 C++。基于像 Garret 这样的小型研究,我发现 Lisp 如此伟大的前提是没有根据的。当然,他可能会用自己的经历,也可能是其他人的证词来将其视为事实,但是在这样的论文中,您必须在做出这样的滑动陈述时提供您的来源。个人感觉不够好。

然而,数据确实表明 Lisp 的生产力与当时的脚本语言一样多,并且速度可与 C 和 C++ 相媲美,这确实是一项了不起的成就。我在阅读一篇关于 Juliain 的文章时遇到了 Prechelt 的电话号码问题,作者说:我的心碎了,因为 Common Lisp 是一种很好的语言,工作很愉快,但在工业界几乎没有人使用它。 Java 中的大量代码,即使在 Lisp 中编写代码所需的时间更少**。程序员的时间发生了什么比机器的时间更重要?此类声明的来源是 Garret 的 Lisp VS Java。当我第一次阅读那篇论文时,我对它的结论很着迷,所以我决定“参与”这项研究,看看他们的结论是否现实,也许 Java 16(写作时的最新版本)至少改进了一个与 Common Lisp 的伟大相比一点点。我阅读了 Garret 论文中链接的说明,并立即开始着手研究。我设法在大约 1 小时内编写了所有样板代码(读取输入、清理它、收集和打印结果、基本测试)。同时,我正在决定使解决方案高效的最佳策略,并决定使用我已经熟悉的 Trie 数据结构。我又花了一个小时左右的时间,根据说明中给出的例子,通过更多的测试来编写一个基本的问题解决方案......但实际上完成了算法,不幸的是,花了我更多的时间(我不写这样的算法每天),特别是因为一个我无法轻易解决的小错误(当我在任何位置都找不到单词时,我试图注入一个数字,而不是仅在潜在单词的开头......一旦找到原因,修复起来就很简单了)。

总而言之,我估计我的总时间约为 3.5 小时。这几乎是 Norvig 的两倍,但至少让我感到欣慰的是,与研究中的其他参与者相比,我设法做得相当好(同样,那些被实际监控的人,而不仅仅是自我报告的人):3 到C/C++ 为 25 小时,Java 为 4 到 63 小时。未受监控的参与者报告说 Lisp 需要 2 到 8.5 个小时,脚本语言需要大约 1 到 10 个小时。我的解决方案使用 Java 16 需要 167 行代码,而 Lisp 的范围为 51 到 182,Java 为 107 到 500+,C/C++ 为 130 到 500+,脚本语言大约为 50 到 200。由于计算机在过去 21 年中取得了长足的进步,原始输入文件和字典太小了,无法使程序运行一秒钟以上!所以我不得不生成一些更长的输入,以便更好地了解我的解决方案的性能。鉴于似乎没有一项研究发布了他们收集的解决方案,我使用 Peter Norvig 的 CLimplementation 作为基准。在 Lisp VS Java 中,大多数 Common Lisp 解决方案具有非常相似的速度和内存使用量,因此我认为 Norvig 的实现也将与其他解决方案相近。你可以在这个 GitHub 存储库上阅读完整的说明来自己运行测试,其中包括我的解决方案,我在互联网上找到的其他几个,除了我对 Norvig 的 Java 和 Rust 解决方案所做的端口。

在我们讨论可用于解决问题的不同策略后,该分析的结果将在下一节中显示。脚本组中的大多数程序员使用他们语言提供的关联数组,并存储要通过其数字编码检索的字典单词。搜索算法只是尝试从该数组中检索,使用当前手机剩余部分的增加长度的前缀number 作为key。找到的任何匹配都会导致稍后完成新的部分解决方案。相比之下,基本上所有的非脚本程序员都选择了以下解决方案之一。在简单的情况下,他们只是将整个字典存储在一个数组中,通常以原始字符形式和相应的电话号码表示形式。然后他们为要编码的电话号码的每个数字选择并测试整个字典的十分之一,仅使用第一个数字作为关键字来限制搜索空间。这导致一个简单但效率低下的解决方案。更复杂的情况使用一个 10 叉树,其中每个节点代表某个数字,高度为 n rep 的节点重发单词的第n个字符。如果从根到该节点的路径代表单词的编号编码,则将单词存储在该节点上。这是最有效的解决方案,但需要比较大量的语句来实现树结构。这个分析在我看来是正确的!结果证明我的解决方案是他所说的最有效的解决方案(他描述了一种 Trie 数据结构),Norvig 的解决方案正是大多数脚本组提出的解决方案,它更容易实现。这种策略上的差异对每个程序的长度及其效率都有明显的影响。因此,当我们比较使用广泛不同策略的程序时,我们无法真正将我们的结论推断为所使用语言的差异。换句话说,无论使用何种语言,使用 trie 策略的程序(大多数非脚本程序)都将比使用关联数组的程序(大多数脚本程序)更长。然而,注意到该语言似乎以某种方式引导程序员使用一种或另一种策略仍然很有趣。为了看看如果我选择 Norvig 的策略而不是使用 Trie 会发生什么,我决定将他的 CL 代码尽可能地移植到 Java。我也针对 Rust 做了同样的事情,以便获得算法运行速度的基准(或者我认为)。

更新(2021 年 7 月 25 日):我现在也在 Dartand Julia 中添加了解决方案!请参阅 Incl。飞镖和包括。 Julia 标签在下面链接的结果中,以查看它们的表现。 Java 1 实现了与其他实现不同的算法(Trie,而不是关联映射)。运行带有限制内存使用的标志的 Java 会使 Java 结果有所下降,但不会太多。请参阅结果电子表格上的第三次运行以了解 Java 结果如何变化。 Rust 解决方案随着输入数量的增加而变得非常慢。我在 Rust Discord Channel 上向 Rust 开发人员询问了为什么它这么慢,但经过多次建议,结果并没有显着改善(上面显示的结果包括一些建议)。我什至生成了一个火焰图来分析 Rust 代码,但找不到任何快速修复。编辑 (2021-07-31):我为此基准编写的 Rust 代码有几个问题,我决定在下一篇博文中详细讨论!如果您没有时间:Rust 可以运行得更快,但其他语言也可以运行得更快,而且根据新的基准测试,Java 实现可能比 Rust 最快的实现运行得更快帮助使 Rust 更快。 Common Lisp 可能已经落后了,但这可能只是因为它没有像 Java 和 Rust 实现那样优化。出乎意料的是,Common Lisp 是总体上最好的语言,运行速度最快,除了最大的输入大小(即便如此,它也只被 Java 实现的不同算法打败)并且消耗很少的内存。

Java 似乎仍然比低级语言(在这种情况下为 Rust)消耗更多的内存,现在甚至比Common Lisp(参见我上面关于调整 JVM 内存设置的评论,这并没有真正的帮助)。另一方面,它可以非常快。 Rust 解决方案显示了运行任意数量的输入所需的内存是多少(对于任何输入大小,内存都是相同的,这是预期的,因为加载字典会占用大部分内存),但不幸的是性能下降输入数量增加(见上文注释)。这似乎表明,即使速度至关重要,使用 Rust 也可能不会自动获胜。 Java 端口(Java 2)对于原始 CL 有 99 LOC VS 74 LOC。Java 中的大多数额外行实际上来自我必须手动实现的几个函数:<T> List<T> append( List< T> list, T item ) - 需要以便在 Trie 的每个分支下递归而不影响当前的部分解决方案。 char[] removeIfNotLetterOr ......