代码优化:优化的最佳方式

已发表: 2022-03-11

性能优化是代码面临的最大威胁之一。

你可能在想,不是那些人中的另一个。 我明白。 从词源来看,任何类型的优化显然都应该是一件好事,所以自然而然地,你想擅长它。

不仅仅是为了让自己从人群中脱颖而出,成为一个更好的开发者。 不仅是为了避免在The Daily WTF上成为“Dan”,还因为您认为代码优化是正确的做法。 你为你的工作感到自豪。

计算机硬件越来越快,软件越来越容易制作,但无论你只是想能够做什么简单的事情,该死的总是比上一次花费更长的时间。 你对这种现象摇头(顺便说一句,被称为沃斯定律),并决心逆势而上。

那是你的高尚,但停止。

停下来!

无论你在编程方面有多么有经验,你都面临着阻碍自己目标的最大危险。

为何如此? 让我们备份。

首先,什么代码优化?

通常,当我们定义它时,我们假设我们希望代码性能更好。 我们说代码优化是编写或重写代码,使程序使用尽可能少的内存或磁盘空间,最大限度地减少其 CPU 时间或网络带宽,或充分利用额外的内核。

在实践中,我们有时会默认使用另一个定义:编写更少的代码。

但是,您为此目标编写的先发制人的坏蛋代码更有可能成为某人的眼中钉。 谁的? 下一个必须理解您的代码的倒霉人,甚至可能是您自己。 像你这样聪明有能力的人可以避免自我破坏:保持你的目标崇高,但重新评估你的手段,尽管它们似乎毫无疑问是直觉的。

代码打高尔夫球:+197%,性能:-398%,简单性:-9999%

所以代码优化是一个有点模糊的术语。 那是在我们甚至考虑可以优化代码的其他一些方法之前,我们将在下面。

让我们先听听贤者的建议,一起探索杰克逊著名的代码优化规则:

  1. 不要这样做。
  2. (仅限专家!)要这样做。

1. 不要这样做:引导完美主义

我将从一个相当令人尴尬的极端示例开始,很久以前,我刚刚涉足 SQL 的美妙世界,吃蛋糕吃吧。 问题是,然后我踩到了蛋糕,不想再吃了,因为它湿了,开始闻起来像脚。

我刚刚在 SQL 的美妙的、吃蛋糕和吃它的世界中沾沾自喜。 问题是,然后我踩到了蛋糕……

等待。 让我从我刚刚提出并解释的比喻的车祸中解脱出来。

我在做一个 Intranet 应用程序的研发,我希望有一天它可以成为我工作的小企业的一个完全集成的管理系统。 它将为他们跟踪所有内容,并且与他们当时的系统不同,它永远不会丢失他们的数据,因为它将由 RDBMS 支持,而不是其他开发人员使用的片状本土平面文件。 我想从一开始就尽可能智能地设计一切,因为我有一张白纸。 这个系统的想法在我的脑海中像烟花一样爆炸,我开始设计表格——联系人及其用于 CRM、会计模块、库存、采购、CMS 和项目管理的许多上下文变体,我很快就会对其进行测试。

这一切都停止了,开发性能方面,因为......你猜对了,优化。

我看到对象(表示为表行)在现实世界中可能有许多不同的相互关系,我们可以从跟踪这些关系中受益:我们将保留更多信息,并最终可以在整个地方自动化业务分析。 将此视为一个工程问题,我做了一些看起来像是系统灵活性优化的事情。

在这一点上,看好你的脸很重要,因为如果你的手掌受伤了,我将不承担任何责任。 准备好? 我创建了两个表: relationship和一个外键引用的表, relationship_typerelationship可以引用整个数据库中任意两行,并描述它们之间关系的性质。

数据库表:员工、公司、关系、relationship_type

天啊。 我刚刚优化了那种该死的灵活性。

太多了,事实上。 现在我遇到了一个新问题:给定的relationship_type在每个给定的行组合之间自然没有意义。 虽然一个person与一个company有一个employed by关系可能是有道理的,但这在语义上永远不可能等同于两个document之间的关系。

好的,没问题。 我们只需将两列添加到relationship_type ,指定此关系可以应用于哪些表。 (如果您猜到我考虑通过将这两个列移动到引用relationship_type.id的新表来规范化这一点,那么这里的奖励点,以便在语义上适用于一对以上表的关系不会重复表名。毕竟,如果我需要更改表名并且忘记在所有适用的行中更新它,它可能会产生错误!回想起来,至少错误会为栖息在我头骨上的蜘蛛提供食物。)

数据库表:relationship_type和applyable_to,以及relationship_type的两列的卷积数据用箭头表示

谢天谢地,在沿着这条路走得太远之前,我在一场线索风暴中昏迷不醒。 当我醒来时,我意识到我已经或多或少地在自身之上重新实现了 RDBMS 的内部外键相关表。 通常我会享受那些以我大摇大摆地宣布“我是如此元”而结束的时刻,但不幸的是,这不是其中之一。 忘记扩展失败吧——这种设计的可怕膨胀使我仍然很简单的应用程序的后端几乎无法使用,它的数据库几乎没有任何测试数据。

使用外键,卢克!

让我们稍等片刻,看看这里的众多指标中的两个。 一是灵活性,这是我的既定目标。 在这种情况下,我的优化,本质上是架构,甚至还为时过早:

代码优化步骤:架构是程序优化的第一部分

(我们将在我最近发表的文章《如何避免过早优化的诅咒》中对此进行更多讨论。)尽管如此,我的解决方案由于过于灵活而失败了。 另一个指标,可扩展性,是我什至还没有考虑过的指标,但它成功地摧毁了至少同样引人注目的附带损害。

没错,“哦”。

双面掌,当一个面掌不切时

这对我来说是一个强有力的教训,告诉我优化是如何完全出错的。 我的完美主义彻底崩溃:我的聪明才智让我提出了我做过的最客观不聪明的解决方案之一。

优化你的习惯,而不是你的代码

当您发现自己甚至在拥有工作原型和测试套件以证明其正确性之前就倾向于重构时,请考虑在哪里可以引导这种冲动。 Sudoku 和 Mensa 很棒,但也许可以直接使您的项目受益的东西会更好:

  1. 安全
  2. 运行时稳定性
  3. 清晰度和风格
  4. 编码效率
  5. 测试有效性
  6. 剖析
  7. 您的工具包/DE
  8. 干燥(不要重复自己)

但请注意:优化其中任何一个特定的结果都会以牺牲其他人为代价。 至少,它是以时间为代价的。

在这里很容易看出编写代码有多少艺术。 对于上述任何一项,我可以告诉你关于它的太多或太少被认为是错误的选择的故事。 谁在做这里的思考也是上下文的重要组成部分。

例如,关于 DRY:在我的一份工作中,我继承了一个代码库,其中至少 80% 是冗余语句,因为它的作者显然不知道如何以及何时编写函数。 另外 20% 的代码是令人困惑的自相似。

我的任务是为其添加一些功能。 一个这样的特性需要在所有要实现的代码中重复,并且任何未来的代码都必须仔细复制粘贴才能使用新特性。

显然,它需要重构只是为了我自己的理智(高价值)和任何未来的开发人员。 但是,因为我是代码库的新手,所以我首先编写了测试,以确保我的重构不会引入任何回归。 事实上,他们就是这样做的:我在脚本生成的所有 gobbledygook 输出中发现了两个我不会注意到的错误。

最后,我认为我做得很好。 重构之后,我用几行简单的代码实现了一个被认为是困难的功能,给我的老板留下了深刻的印象。 此外,代码的整体性能要高出一个数量级。 但没过多久,同样的老板告诉我我太慢了,项目应该已经完成​​了。 翻译:编码效率是一个更高的优先级。

当心:优化任何特定 [方面] 都会以牺牲其他人为代价。 至少,它是以时间为代价的。

我仍然认为我在那里选择了正确的课程,即使当时我的老板没有直接赞赏代码优化。 如果没有重构和测试,我认为实际上需要更长的时间才能真正正确——即,专注于编码速度实际上会阻碍它。 (嘿,这就是我们的主题!)

将此与我在一个小型副项目中所做的一些工作进行对比。 在项目中,我正在尝试一个新的模板引擎,并希望从一开始就养成良好的习惯,尽管尝试新的模板引擎并不是项目的最终目标。

当我注意到我添加的几个块彼此非常相似,而且每个块需要引用同一个变量 3 次时,DRY 的钟声在我脑海中响起,我开始寻找正确的方法来做我试图用这个模板引擎做的事情。

事实证明,经过几个小时无果而终的调试,模板引擎目前无法以我想象的方式实现这一点。 不仅没有完美的DRY 解决方案; 根本没有任何DRY 解决方案!

试图优化我的这一价值,我完全破坏了我的编码效率和我的幸福感,因为这条弯路让我的项目失去了那天本来可以取得的进展。

即使那样,我完全错了吗? 有时值得进行一些投资,尤其是在新的技术背景下,更早地而不是晚地了解最佳实践。 更少的代码重写和坏习惯撤消,对吧?

不,我认为即使寻找一种方法来减少代码中的重复也是不明智的——这与我在之前的轶事中的态度形成鲜明对比。 原因是环境就是一切:我正在一个小型游戏项目中探索一项新技术,而不是长期安定下来。 一些额外的行和重复不会伤害任何人,但是失去焦点会伤害我和我的项目。

等等,所以寻求最佳实践可能是一个坏习惯? 有时。 如果我的主要目标是学习新引擎,或者学习一般性的学习,那么这将是值得花时间的:修补,找到限制,通过研究发现不相关的功能和陷阱。 但我忘记了这不是我的主要目标,这让我付出了代价。

就像我说的,这是一门艺术。 这种艺术的发展得益于提醒,不要这样做。 它至少让你考虑在你工作时哪些价值观在起作用,哪些价值观在你的环境中对最重要。

第二条规则呢? 我们什么时候可以真正优化?

2. 不要这样做:有人已经这样做了

好的,无论是您还是其他人,您都会发现您的架构已经设置好,数据流已经被考虑和记录,是时候编写代码了。

让我们更进一步:不要编写代码

这本身可能闻起来像过早的优化,但它是一个重要的例外。 为什么? 为了避免可怕的 NIHS 或“未在此处发明”综合症——假设您的优先事项包括代码性能和最小化开发时间。 如果不是,如果您的目标完全以学习为导向,您可以跳过下一部分。

虽然人们可能会出于狂妄自大而重新发明方轮,但我相信像你我这样诚实、谦逊的人可能会因为不了解我们可用的所有选项而犯这个错误。 了解堆栈中每个 API 和工具的每个选项,并随着它们的发展和发展而掌握它们,这当然是一项艰巨的工作。

但是,投入这段时间是让您成为专家的原因,并使您不会成为 CodeSOD 上第 10 亿人,因为他们迷人的日期时间计算器或字符串操纵器所留下的破坏痕迹而受到诅咒和嘲笑。

(这个通用模式的一个很好的对比是旧的 Java Calendar API,但它已经被修复了。)

检查您的标准库,检查您的框架的生态系统,检查是否已经解决了您的问题的 FOSS

很有可能,您正在处理的概念具有非常标准且众所周知的名称,因此快速的互联网搜索将为您节省大量时间。

例如,我最近准备对棋盘游戏的 AI 策略进行一些分析。 一天早上醒来,我意识到如果我简单地使用我记得的某个组合学概念,我正在计划的分析可以更有效地完成几个数量级。 此时我对自己弄清楚这个概念的算法不感兴趣,我已经知道要搜索的正确名称了。 然而,我发现经过大约 50 分钟的研究并尝试了一些初步代码,我并没有设法将我发现的半成品伪代码变成正确的实现。 (你能相信有一篇博文,作者假设错误的算法输出,错误地实现算法以匹配假设,评论者指出这一点,然后几年后,它仍然没有修复?)那时,我的早茶开始,我搜索了[name of concept] [my programming language] 。 30 秒后,我从 GitHub 获得了可证明正确的代码,并开始着手我真正想做的事情。 只是变得具体并包括语言,而不是假设我必须自己实现它,这意味着一切。

是时候设计数据结构和实现算法了

…再次,不要打代码高尔夫。 在实际项目中优先考虑正确性和清晰度。

时间投入:10 小时,执行时间:+25%,内存使用:+3%,混乱:100%

好的,你已经看过了,没有任何东西可以解决你的问题,内置到你的工具链中,或者在网络上获得自由许可。 你推出你自己的。

没问题。 建议很简单,按以下顺序:

  1. 设计它,以便向新手程序员解释它会很简单。
  2. 编写一个符合该设计产生的期望的测试。
  3. 编写代码,以便新手程序员可以轻松地从中收集设计。

简单,但可能很难遵循。 这就是编码习惯和代码气味以及艺术、工艺和优雅发挥作用的地方。 此时您正在做的事情显然有工程方面的问题,但同样,不要玩代码高尔夫。 在实际项目中优先考虑正确性和清晰度。

如果您喜欢视频,这里或多或少是遵循上述步骤的人之一。 对于不喜欢视频的人,我总结一下:这是 Google 求职面试中的算法编码测试。 受访者首先以易于沟通的方式设计算法。 在编写任何代码之前,有一个工作设计所期望的输出示例。 那么代码自然如下。

至于测试本身,我知道在某些圈子里,测试驱动开发可能会引起争议。 我认为部分原因是它可能被过度使用,虔诚地追求到牺牲开发时间的地步。 (再一次,从一开始就试图过多地优化一个变量,这是在自取其辱。)即使是 Kent Beck 也没有将 TDD 推向如此极端,他发明了极端编程并写了关于 TDD 的书。 所以从一些简单的事情开始,以确保你的输出是正确的。 毕竟,无论如何,您都会在编码后手动执行此操作,对吗? (如果您是一位摇滚明星程序员,以至于在第一次编写代码后甚至不运行代码,我深表歉意。在这种情况下,也许您会考虑让代码的未来维护者进行测试,这样您就知道他们不会打破你的真棒实现。)因此,你已经让计算机为你完成了这项工作,而不是进行手动的视觉差异,并进行适当的测试。

在实现算法和数据结构的相当机械的过程中,避免进行逐行优化,甚至不要考虑使用自定义的低级语言 extern(如果您使用 C 编码,则使用汇编,如果您使用 C '正在使用 Perl 等进行编码)。 原因很简单:如果你的算法被完全替换了——直到过程后期你才知道是否需要这样做——那么你的低级优化工作最终将没有效果。

一个 ECMAScript 示例

在优秀的社区代码审查网站 exercism.io 上,我最近发现了一个明确建议尝试优化重复数据删除或清晰度的练习。 我针对重复数据删除进行了优化,只是为了展示如果你采取 DRY 会变得多么荒谬——如上所述,这是一种有益的编码思维方式——太过分了。 这是我的代码的样子:

 const zeroPhrase = "No more"; const wallPhrase = " on the wall"; const standardizeNumber = number => { if (number === 0) { return zeroPhrase; } return '' + number; } const bottlePhrase = number => { const possibleS = (number === 1) ? '' : 's'; return standardizeNumber(number) + " bottle" + possibleS + " of beer"; } export default class Beer { static verse(number) { const nextNumber = (number === 0) ? 99 : (number - 1); const thisBottlePhrase = bottlePhrase(number); const nextBottlePhrase = bottlePhrase(nextNumber); let phrase = thisBottlePhrase + wallPhrase + ", " + thisBottlePhrase.toLowerCase() + ".\n"; if (number === 0) { phrase += "Go to the store and buy some more"; } else { const bottleReference = (number === 1) ? "it" : "one"; phrase += "Take " + bottleReference + " down and pass it around"; } return phrase + ", " + nextBottlePhrase.toLowerCase() + wallPhrase + ".\n"; } static sing(start = 99, end = 0) { return Array.from(Array(start - end + 1).keys()).map(offset => { return this.verse(start - offset); }).join('\n'); } }

那里几乎没有任何重复的字符串! 通过这样写,我为啤酒歌曲手动实现了一种文本压缩形式(但仅限于啤酒歌曲)。 究竟有什么好处? 好吧,假设你想唱关于用罐头而不是瓶子喝啤酒的歌。 我可以通过将一个单一bottle实例更改为can来实现这一点。

好的!

…正确的?

不,因为那时所有的测试都会中断。 好的,这很容易解决:我们将在单元测试规范中搜索和替换bottle 。 这与首先​​对代码本身执行此操作一样容易,并且存在无意中破坏事物的风险。

同时,我的变量后来会被奇怪地命名,像bottlePhrase这样的东西根本与瓶子没有任何关系。 避免这种情况的唯一方法是准确预见将要进行的更改类型,并在我的变量名称中使用更通用的术语,例如vesselcontainer代替bottle

以这种方式面向未来的智慧是非常值得怀疑的。 你想要改变任何东西的几率是多少? 如果你这样做了,你所做的改变会如此方便吗? 在bottlePhrase示例中,如果您想本地化为具有两种以上复数形式的语言怎么办? 没错,重构时间,代码可能看起来更糟。

但是,当您的需求确实发生了变化,并且您不只是试图预测它们时,​​么也许是时候进行重构了。 或者,也许您仍然可以推迟:实际上,您将添加多少船只类型或本地化? 无论如何,当您需要平衡重复数据删除和清晰度时,非常值得观看 Katrina Owen 的演示。

回到我自己的丑陋例子:不用说,重复数据删除的好处在这里甚至都没有得到充分体现。 同时,它的成本是多少?

除了一开始需要更长的时间来编写之外,现在阅读、调试和维护也变得不那么简单了。 想象一下允许适度重复的可读性级别。 例如,将四个经文变体中的每一个都拼出来。

但是我们还没有优化!

现在您的算法已经实现,并且您已经证明它的输出是正确的,恭喜! 你有底线!

最后,是时候……优化了,对吧? 不,还是不要这样做。 是时候拿你的基线做一个很好的基准了。 为您的期望设置一个阈值并将其粘贴在您的测试套件中。 然后,如果有什么东西突然让这段代码变慢了——即使它仍然可以工作——你会在它出现之前知道的。

仍然推迟优化,直到您实现了完整的相关用户体验。 在那之前,您可能针对的是与您需要的完全不同的代码部分。

去完成你的应用程序(或组件),如果你还没有,在你去的时候设置你所有的算法基准。

完成此操作后,这是创建和基准测试涵盖系统最常见的实际使用场景的端到端测试的好时机。

也许你会发现一切都很好。

或者,也许您已经确定,在现实生活中,某些东西太慢或占用太多内存。

OK,现在可以优化了

只有一种方法可以对此保持客观。 是时候分解火焰图和其他分析工具了。 有经验的工程师可能会或可能不会比新手更频繁地猜测,但这不是重点:确定的唯一方法是分析。 这始终是优化代码以提高性能的过程中要做的第一件事。

您可以在给定的端到端测试期间进行概要分析,以了解真正产生最大影响的因素。 (稍后,在部署之后,监控使用模式是一种很好的方式,可以让您了解系统的哪些方面与未来最相关。)

请注意,您并没有尝试完全深入地使用分析器 - 通常,您正在寻找函数级分析而不是语句级分析,因为此时您的目标只是找出哪个算法是瓶颈.

现在您已经使用分析来确定系统的瓶颈,现在您可以实际尝试优化,确信您的优化是值得的。 您还可以证明您的尝试有多有效(或无效),这要归功于您在此过程中所做的基线基准测试。

整体技术

首先,请记住尽可能长时间地保持高水平:

在整个算法级别,一种技术是强度降低。 但是,在将循环简化为公式的情况下,请注意留下评论。 不是每个人都知道或记得每一个组合公式。 另外,在使用数学时要小心:有时你认为可能会降低力量的结果最终不是。 例如,假设x * (y + z)有一些明确的算法含义。 如果你的大脑在某个时候被训练过,无论出于何种原因,自动取消对类似术语的分组,你可能会想将其重写为x * y + x * z 。 一方面,这在读者和已经存在的清晰算法含义之间设置了障碍。 (更糟糕的是,由于需要额外的乘法运算,它现在实际上效率较低。这就像循环展开只是弄脏了它的裤子。)无论如何,快速记录一下你的意图会有很长的路要走,甚至可以帮助你看到你的在你提交之前自己的错误。

无论您是使用公式还是只是用另一种基于循环的算法替换基于循环的算法,您都可以测量差异。

但也许你可以通过改变你的数据结构来获得更好的性能。 了解您需要对正在使用的结构以及任何替代方案执行的各种操作之间的性能差异。 也许哈希在您的上下文中工作看起来有点混乱,但是优越的搜索时间值得它超过数组吗? 这些是由您决定的权衡类型。

您可能会注意到,这归结为在调用便利函数时知道哪些算法正在代表您执行。 所以归根结底,这实际上与降低强度是一回事。 了解供应商的库在幕后所做的工作不仅对性能至关重要,而且对避免无意的错误也很重要。

微优化

好的,您的系统功能已经完成,但是从用户体验的角度来看,性能可以进一步微调。 假设您已经尽了最大努力,那么是时候考虑一​​下我们迄今为止一直在避免的优化了。 考虑一下,因为这种优化水平仍然是对清晰度和可维护性的权衡。 但是您已经决定是时候了,所以继续进行语句级别的分析,现在您处于整个系统的上下文中,这实际上很重要。

就像您使用的库一样,在编译器或解释器的层面上,为了您的利益,已经投入了无数的工程时间。 (毕竟,编译器优化和代码生成本身就是巨大的话题)。 甚至在处理器级别也是如此。 试图在不知道最低级别发生的情况下优化代码就像认为拥有四轮驱动意味着您的车辆也可以更轻松地停止。

除此之外,很难给出好的通用建议,因为这实际上取决于您的技术堆栈以及您的分析器所指向的内容。 但是,因为您正在测量,如果解决方案不能从问题上下文中有机地和直观地呈现给您,您已经处于寻求帮助的绝佳位置。 (睡眠和花时间思考其他事情也有帮助。)

此时,根据上下文和扩展要求,Jeff Atwood 可能会建议简单地添加硬件,这可能比开发人员的时间更便宜。

也许你不会走那条路。 在这种情况下,探索各种类型的代码优化技术可能会有所帮助:

  • 缓存
  • Bit hacks 和那些特定于 64 位环境的
  • 循环优化
  • 内存层次优化

进一步来说:

  • C 和 C++ 中的代码优化技巧
  • Java中的代码优化技巧
  • 优化 .NET 中的 CPU 使用率
  • ASP.NET Web 场缓存
  • SQL 数据库调优或特别是调优 Microsoft SQL Server
  • 缩放 Scala 的游戏! 框架
  • 高级 WordPress 性能优化
  • 使用 JavaScript 原型和范围链进行代码优化
  • 优化 React 性能
  • iOS动画效率
  • Android 性能提示

无论如何,我确实为您提供了一些注意事项

不要为多个不同的目的重用一个变量。 就可维护性而言,这就像在没有油的情况下运行汽车。 只有在最极端的嵌入式情况下,这才有意义,即使在那些情况下,我认为它不再有意义。 这是编译器组织的工作。 自己做,然后移动一行代码,你就引入了一个错误。 节省内存的幻想对你来说值得吗?

不要在不知道原因的情况下使用宏和内联函数。 是的,函数调用开销是一种成本。 但是避免它通常会使您的代码更难调试,有时实际上会使它变慢。 偶尔使用这种技术只是因为它是一个好主意,这就是金锤的一个例子。

不要手动展开循环。 同样,这种形式的循环优化几乎总是通过像编译这样的自动化过程得到更好的优化,而不是牺牲代码的可读性。

最后两个代码优化示例具有讽刺意味的是,它们实际上可能是反性能的。 当然,由于您正在执行基准测试,因此您可以为您的特定代码证明或反驳这一点。 但即使你看到了性能提升,也请回到艺术方面,看看在可读性和可维护性方面的收获是否值得。

这是你的:优化优化

尝试性能优化可能是有益的。 但是,通常情况下,它做得非常过早,会带来一连串不良的副作用,而且最讽刺的是,它会导致性能下降。 我希望你对优化的艺术和科学,最重要的是,它的适当背景有了更广泛的理解。

如果这能帮助我们摆脱从一开始就编写完美代码的想法,转而编写正确的代码,我会很高兴。 我们必须记住自上而下进行优化,证明瓶颈在哪里,并在修复它们之前和之后进行测量。 这是优化优化的最佳、最优策略。 祝你好运。