专栏名称: Cocoa开发者社区
CocoaChina苹果开发中文社区官方微信,提供教程资源、app推广营销、招聘、外包及培训信息、各类沙龙交流活动以及更多开发者服务。
目录
相关文章推荐
iOS中文站  ·  iPhone ... ·  5 天前  
iOS中文站  ·  iPhone ... ·  5 天前  
51好读  ›  专栏  ›  Cocoa开发者社区

代码优化的最优方式

Cocoa开发者社区  · 公众号  · ios  · 2017-08-02 11:12

正文

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


你可能在想,又有人在这么说了。我能理解。从字面意思可以明确地判断,任何类型的优化都是好事,所以你想把它学好是件很自然的事情。


不要脱离大家,认为自己是更好的开发者。不要避免变成日常 WTF 中的 “Dan”,因为你相信代码优化是正确的事情(Right Thing to Do™)。在工作中你会为此喊到自豪。


计算机硬件越来越快,软件制作越来越容易,但任何你希望做到的简单的事情最后总是会花更长的时间,你因此而摇头(顺便说一句,这种现象称为 Wirth 定律),决心顺其自然。


你真是高尚,但别再这样了。



停止!



无论你在编程方面有多么丰富的经验,你这样做都是在严重阻碍自己的目标。


怎么会这样呢?让我们回到主题。


首先,什么是代码优化?


通常我们在定义它时,都假设我们希望代码有更好的性能。我们会说通过编写或重写代码,达到尽可能减少使用内存或磁盘空间,或最大限度减少 CPU 时间或网络带宽,或者尽可能少的使用额外核心的目的。


实际上,我们有时候会默认另一个定义:编写更少的代码。


但是以此为目标首先写出来的的代码往往成为别人的眼中钉。谁的眼中钉?可能是下一个需要理解你代码的倒霉蛋,甚至可能就是你自己。像你这样聪明能干的人其实可以避免自毁:保持目标,但重新评估你做事的方法,就算它们在表面看起来没有问题。



所以代码优化现在还是一个模糊的术语。我们会在下面考虑一些其它优化代码的方法。


我们先来听听高人的建议,一起探索 Jackson 有名的代码优化规则:


  1. 不要这样做。

  2. (仅对专家而言!) 仍然不要这样做。



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



我要从一个相当尴尬的极端例子开始。很久以前的一段时光,我正在涉足精彩的、鱼与熊掌不可兼得的 SQL 世界。问题是,那时我踩到了蛋糕却再也不想吃掉它,因为它湿了并且开始闻起来像臭脚。


我正在涉足精彩的、鱼与熊站不可兼得的 SQL 世界。问题是,那时我踩到了蛋糕…


请稍等。让我从我打的这个比方中恢复过来,并解释一下。


那时我正在为一个内部网应用程序做研发,我希望将来有一天它能成为所工作的小公司的完全集成的管理系统。 它将为他们跟踪一切,而不像他们当时运行的系统,它不会丢失数据,因为它将由 RDBMS(关系型数据库管理系统)支持,而不是其他开发人员使用的不可靠的、自产的、扁平的文件。我想从一开始就把各种东西设计得尽可能智能,因为我有一块空白的石板。对于这个系统的想法在我的脑海中像烟花一样爆炸,我开始设计表 - 给客户关系管理(CRM)的联系人表和他们的许多上下文的变化的表,会计模块,库存,采购,内容管理系统(CMS),以及项目管理, 这个系统我很快就会自己试用。


所有这一切都停顿下来,朝开发和性能的方向,你猜对了,因为要优化。


我看到,对象之间(表示为表的行)在现实世界中可以有许多不同的关系, 我们可以从跟踪这些关系中获益:我们可以保留更多地信息,并最终将业务分析自动化到所有地方。鉴于这是一个工程问题,我做了一个似乎是对系统灵活性的优化。


在这一点上,重要的是要注意你的脸,因为我不会被追究责任,如果你的手掌打了你的脸。准备好了吗?我创建了两个表:relationship 及其有外键引用到的 relationship_type。relationship 可以指向整个数据库中的任意两行,并描述它们之间的关系的性质。



哦,伙伴!我刚刚优化了灵活性,该死地太多了。



实际上太多了。现在我有一个新问题:给定的 relationship_type 自然在每个给定的行组合之间都不会有意义。虽然一个人能通过与公司之间的雇用关系使得这变得有意义,但是这在语义上是绝对不能对等的。比如两个文档之间。


我们将只向 relationship_type 添加两列,指定可以应用哪个表。 (如果你已猜想我想通过将这两列移动到一个引用 relationship_type.id 的新表来实现这一点,那么给你加分,这样做可以在语义上应用于多个表的关系,而无需重复表名。因为如果我需要更改表名,但忘记在所有适用的行中更新,这会导致 Bug 的产生!后患无穷。)



幸运的是,在沿着这条路走了一段之后,我在一条线索风暴中被无意地打醒了。当我醒来时,我意识到我已经或多或少地设法重新实现了 RDBMS 的内部的外键相关表。通常,我喜欢在大功告成的时刻,大喊“我是超神了”,但遗憾的是,各位,这并不是我要说的优化方法之一。 忘记这个失败的设定——这个设定带来的膨胀差点毁了我的简单应用程序的后端,它的 DB 几乎没有任何测试数据,几乎不可用。



让我们退后一步,看看这里的许多衡量标准中的其中两个。一个是灵活性,这是我的既定目标。在这种情况下,我的优化本质上是建筑性的,甚至还不成熟:



(我将在我即将发表的文章中提到,“多云,有过早优化的机会”)然而,我的解决方案却因过于灵活而失败。另一个度量标准,可伸缩性,是我甚至还没有考虑过的,但这样至少免去了可能发生的附带的损害。


没错,“心好累”。



这对我来说是一个很好的教训,让我知道了优化是如何完全失败的。我的完美主义彻底崩溃:我的聪明使我产生了我所做过的最客观、最不明智的解决方案之一。



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



当你在拥有工作原型和测试套件以证明其正确性之前,倾向于重构,考虑下将这种冲动引导到其他方向。Sudoku 和 Mensa 是个不错的选择的,但也许一些实际上直接使你的项目受益的东西会更好:


  1. 安全性

  2. 运行时稳定性

  3. 清晰度和编码风格

  4. 编码效率

  5. 测试有效性

  6. 性能剖析

  7. 你的工具包/ DE

  8. DRY(避免重复代码)


但要留意:优化这些特定任务之一都可能需要其他任务的代价来实现。至少,这是以时间为代价的。


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


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


我的任务是增加一些功能。在所有要实现的代码中都需要重复一个这样的功能,并且未来的任何代码将必须仔细的复制以利用新功能。


显然,只有为了我自己的头脑清楚(高价值)和任何未来的开发者才需要去进行重构。但是,因为我是将新的代码加入代码库,我首先写了测试,进行回归测试,以确保我任何的重构没有问题。事实上,只是这样做:我遇到两个错误,我不会注意到生成所有冗繁而费解的脚本输出。


最后,我以为我做得很好。在重构之后,我用一些简单的代码行实现了被认为是一个难点的功能,使我的老板印象深刻。而且,程序性能总体上提高了一个数量级。但不久之后,同一位老板告诉我,我进度太慢了,而且这个项目应该早就完成了。(潜台词:编码效率是优先考虑的问题。)


注意:优化任何特定的[方面]将以牺牲其他方面为代价。至少,这是以时间为代价的。


我仍然认为我的选择是正确,即使当时代码优化没有得到老板的赞赏。没有重构和测试,我想事实上可能要花更长的时间才能找到正确的路,也就是说,专注于编码速度实际上会阻碍它的发展。(嘿,那是我们的主题!)


与我在我做过的一个小型项目工作相比。在项目中,我尝试了一个新的模板引擎,并希望从一开始就养成良好的习惯,尽管尝试新的模板引擎并不是项目的最终目标。


我注意到,我添加的几个代码区域非常相似,而且每个区域需要指向同一个变量三次,DRY警铃在我的脑海中响起,我开始试图用模板引擎来找到正确的方法做事情。


事实证明,经过几个小时的无结果的调试之后,我发现这个模板引擎不可能像我想象的那样。不仅没有完美的解决DRY;根本没有任何一个解决方案!


我试图调整自己的价值观,我完全破坏了我的编码效率和幸福感,因为这条弯路导致我的项目花费了我那天可能取得的进步。


即使这样,我完全错了吗?有时,值得一点投资,特别是在新的技术背景下,要更早地了解最佳实践,而不是稍后。减少代码重写和撤销的坏习惯,对吧?


不,我认为即使在我的代码中寻找减少重复的方法也是不明智的,这与我在以前的处事中的态度形成了鲜明的对比。原因是场景是一切:我在一个小型项目中探索一个新的技术,而不是长期解决。一些额外的线路和代码重复不会伤害任何人,但失去专注,伤害了我和我的项目。


等等,所以寻求最佳做法可能是一个坏习惯?有时。如果我的主要目标是学习新的引擎,或者普通的学习,是很花费的时间的:修修补补,找出约束,通过研究发现不相关的功能和问题。但我忘了这不是我的主要目标,这让我付出了代价。


这是一种艺术,就像我说的。而这种开发的艺术受益于提醒,不要这样做。至少让你考虑在工作时有哪些是有的价值,在你的工作场景中哪些又是最重要的。


那第二条规则呢?什么时候才能够进行优化?



2. 不要这么做了:有人已经做过这些了



好的,无论是你还是别人,当架构被设置好,数据流被考虑清楚并文档化,就可以开始编码了。


让我们把"不要这么做"扩展下:不要进行编码。


这听起来像是提前优化,但和别的优化不一样,这个非常重要。是为了避免可怕的 NIHS 或“Not Invented Here”综合征 - 假设你的优先级包括代码性能和最小化开发时间。如果没有,如果你的目标完全以学习为导向的话,可以跳过下一节。


有人会自以为是地重新发明方形车轮,就算像你我一样的诚实谦虚的人,也会因为认识的不全面而犯这样的错误。了解堆栈中每个 API 和工具的每个选项,并且随着它们的发展深化知识,做到与时俱进,这当然是需要很多工作的。但是,这段时间的投入能帮你成为这个领域的专家,让你避免像 CodeSOD 上的人一样,做各种无用的尝试。



检查标准库,框架生态系统,以及那些已经解决了你的问题的自由和开源软件



有可能,您所处理的概念具有相当的标准和众所周知的名称,因此快速的互联网搜索可以节省大量的时间。


例如,我最近正在准备对棋盘游戏的 AI 策略进行一些分析。我早上醒来就意识到,如果我使用一些我记得的组合概念,我计划的分析可以更快地达到效果。在这个时候我自己并没有意识到这个概念的算法,但我已经知道正确的名字,并去搜索。 不过,经过大约 50 分钟的研究和一些初步代码的尝试,我发现我并没有把我这些半成品伪代码正确地实现。(有一个博客文章,那个作者假定算法有着不正确的输出,实现的算法不能正确的匹配假设情况,评论者也指出了这一点,然而多年后,问题解决否?)那时,我戒除了早茶,我搜索[我的编程语言]的概念名称。30 秒后,我找到了 GitHub 上的正确代码,然后还是行动。一切都要具体化,包括具体的语言,而不是对计划做出假设。



花时间来设计数据结构和实现算法



…重复一次,不要玩代码高尔夫(code golf)。在实际项目中将正确性和清晰度放到高优先级。




如你所见,这样并没有解决你使用的工具链中的内置问题,也没有在网络上获得使用许可。你需要有自己的方案。


参考建议如下:


  • 做好设计,以便可以很简单地向新手程序员解释其原理。

  • 编写一个符合该设计所期望的测试。

  • 编写代码,以便新手程序员可以从中轻松了解其整体设计结构。


简单性,某些情况下很难去遵循。这就是编码习惯和代码风格、艺术和工艺以及优雅的魅力所在。 在这一点上,你正在做的工作显然是从工程角度来看的,但再次强调,不要玩代码高尔夫。在实际项目中将正确性和清晰度放到高优先级。


如果你喜欢视频, 有人按照以上步骤进行了演示 。不喜欢看视频的人,我就用语言简单概述一下:这是一个在谷歌求职面试中的算法编码测试。面试者首先以易于沟通的方式设计算法。在编写任何代码之前,都会设计所期望的输出示例,然后代码自然地跟随输出。


至于测试本身,我知道在某些圈子里,测试驱动的开发可能会引起争议。我认为部分原因是它太火了,花了大量时间开发以追求极致。(我们试图从一开始将一个变量优化到最佳,以至于搬起石头砸了自己的脚) 即使 Kent Beck 也不会把 TDD 用到如此极端,尽管是他发明了极限编程,还撰写了那本关于 TDD 的书。所以从简单的东西开始,确保你的输出是正确的。毕竟,你无论如何都会手动编码,不是吗?(如果你是“编程高手”,写完代码之后都不会再运行一遍,那你应该让之后的代码维护者进行一下测试,以免他们打破你厉害的代码实现。 )所以与其进行手动的、可视的文本比较,不如预备一份测试代码,让计算机代替你执行。


在机械化实现算法和数据结构的过中程,要避免进行逐行的优化,也别使用可自定义的低级语言的全局变量声明(extern) (如果你用 C 编码,那就是汇编,如果你用 Perl 编码,那就是 C 了,以此类推。) 。理由很简单:如果你的算法完全被替换了,你要在这个过程的后期才会发现它是否需要,那么你的低级优化工作最终就没有效果了。



一个 ECMAScript 示例



在优秀社区代码评审站点 exercism.io, 我最近发现一个练习题 ,建议为重复数据删除或清晰度尝试进行优化。 我进行重复数据删除优化,只是为了证明如果你进行 DRY(Don't Repeat Yourself)事情会变得多么可笑。不然的话,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。那很容易,跟一开始就对代码做的改动一样容易,但也存在着无意中破坏代码的风险。


同时,我的变量之后被命名得怪怪的,就像 bottlePhrase 的有 bottle 却与 bottle 没有任何关联。唯一避免这种情况的办法是准确地预测将要发生的变化类型,并使用更通用的术语,如在变量名中使用 vessel 或 container 取代 bottle 。


这种未雨绸缪的智慧的实用性还未证实。 你想改变这些的几率是多少? 如果你这么做了,改变之后真的会变得便利吗?如 bottlePhrase 的例子,如果你想本地化到一种语言,有超过两个复数形式,你会怎么做?没错,重构,而代码之后可能看起来更糟。


但是当你的需求确实改变了,你不只是试图预测需求,那么也许是时候该重构了。或者你仍可以把它推迟:你会加入多少容器类型或本地化,现实吗?不管怎样,当你需要在二者之间清楚地做出平衡时,你可以观摩 Katrina Owen 的这个演示


回到我自己的丑陋的例子:不用说,重复的益处不大。可能还需付出相应的代价。


除了首先要花更长的时间来编写,阅读、调试和维护也不再简单了。想象一下可读性的层级,允许适度的重复。例如, 把四节诗的每一个变体都讲清楚



但我们还没有优化!



现在你的算法已经实现,并且你已经证明了它的输出是正确的,祝贺你!你有了基线!


最后,该是优化的时候了,对吧?不,还不能做。 现在是采取基线并做一个好基准的时候。为你的期望设置一个阈值,并将其黏贴到测试套件中。然后,如果某个东西突然使这个代码变慢,即使它仍然有效,你会在它完全引起问题之前就知晓。


仍然推迟优化,直到你让一个完整的相关的用户体验得以实施。在这之前,你的目标会更明确。


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


完成之后,现在是创建和用基准检验端到端测试,这些测试覆盖了你的系统的最常见的实际使用场景。


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


又或许你已经确定,在现实生活的情景中,有些东西太慢了,或者占用了太多的内存。



好的,现在你可以优化了



只有一种方法可以客观地看待它。现在是分解 火焰图 和其他性能分析工具的时候了。经验丰富的工程师常常也不一定比新手猜得更准,但这不是重点:唯一确定的方式是使用性能分析工具。在优化代码性能的过程中,这始终是第一要事。


在给定的端到端测试中,你可以了解什么将真正产生最大的影响。(稍后,即在部署之后,监视使用模式是一个很好的方法,以便在未来监视你的系统的最相关的那些方面)


请注意,你并不是试图把性能分析工具用到其最深处。你更多是在寻求函数级的性能分析而不是语句级的性能分析。一般来说,因为在这一点上你的目标只是要找出哪处算法是瓶颈。


既然你已经使用性能分析工具去识别系统的瓶颈,现在你可以实际尝试去优化,要相信这是值得的。同时,你也可以验证这样做的效果。当然,这要感谢你一直以来所做的基线基准测试。



总体技巧



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



在整体算法级,一个技巧是强度折减。但是,在减少公式循环的情况下,请注意留下注释。不是每个人都知晓或记得每一个组合数学公式。另外,要小心你的数学运用:有时候你认为是强度折减,结果并没有。 例如,假设 x * (y + z) 有一些清晰的算法意义。如果你的大脑接受过算法训练,不管出于什么原因,会自动地分解同类项,你可能会想把它重写为 x * y + x * z。一方面,这在读者和清晰的算法意义之间留下了障碍。(更糟糕的是,现在实际上效率较低,因为需要额外的乘法运算,这就像是循环展开却事与愿违)在任何情况下,为你的意图项添加注释将大有帮助,甚至可能在你犯错之前就帮你发现自己的错误。


无论你是使用公式,还是仅仅把一个基于循环的算法替换为另一个基于循环的算法,你都要准备好测量其差异。


但也许通过改变数据结构可以获得更好的性能。你自己要弄明白要用的数据结构以及可供选择的其他数据结构上的各种操作之间的性能差异。也许一个散列作用在你的上下文中看起来有点乱,但其优越的搜索时间比数组更值得采用吗?这是需要权衡的类型,由你来决定。


这可以归结为当你调用一个便利函数时,知道哪些算法是为你执行的。所以最后这和强度折减实际上是一样的。了解你的供应商的库在幕后做什么,不但是为了性能, 而且也是为了避免无意中造成的漏洞


微优化



好的,你的系统的功能已经完成,但是从用户体验的观点来看,性能还可以进一步微调。假设你已经做了所有你能做的调优,现在是时候考虑我们一直以来在回避的优化。考虑一下,因为这一级的优化仍然是一种与清晰性和可维护性的权衡。但是,你已经决定就在此刻,所以继续进行语句级的性能分析,既然你处在整体系统的上下文中,实际上它是重要的。


就像你所使用的库一样,在编译器或解释器的层级,无数的工程时间为你而耗费。(毕竟,编译器优化和代码生成 本身就是巨大的话题)。 在 处理器级 更是如此。 试图去优化代码而不 清楚底层发生的事情 就好像认为拥有四轮驱动意味着你的车也能够容易地停下来。


在此之外很难给出很好的通用性建议,因为这真的取决于你的技术栈以及你的性能分析工具指向的是什么。但是,因为你正在测量,你已经处于一个很好的位置来寻求帮助,如果解决方案不能从问题上下文中有机地、直观地呈现自身给你。(睡上一觉,以及花时间思考其他事情也会有所帮助)


在这一点上,取决于上下文和伸缩性需求,Jeff Atwood 可能会建议干脆添加硬件,这可能比开发人员的时间要便宜。


你或许不会选择这个方法。这样也有助于你去探索其他类别的代码优化技术:



更具体地说:



在任何情况下,我都建议你不要采纳下面的这些做法:


将一个变量用于多个不同的目的。考虑到可维护性,这样的做法就好像是在驾驶一辆没加油的汽车。只有在最最极端的封闭场景中这样做才会有点用处,而即使是在这些场景中,我也认为永远也不要这样做。这是编译器该干的事儿。如果你自己这样做了,那么如果删掉了一行代码,就会引入一个 BUG。你应该思考一下为了好像是节省了内存的错觉是否值得去这样做?


不明就里地使用宏和内联函数。是的,函数调用的开销是一种成本。不过要避免掉它常常会让你的代码更难调试,而有时还会让程序变得更慢。这项技术之所以哪儿都能用到只是因为它曾经是一个好办法,黄金的锤子(Golden hammer)就是一个例子。


手动展开循环。这里要再次提一下, 这种循环优化的形式有时几乎总是要比通过一种像编译那样的自动处理来进行的优化要更好, 并不用牺牲掉你的代码的可读性。


最后两个代码优化的示例有点教人感觉讽刺的是,它们实际会收到反面的效果。当然,因为你是在做基准测试,所以你可以就部分代码支持或者反对这些优化建议。但是尽管可能会看到有性能上的提升,也要回过头来考虑考虑艺术性的方面,看看这样的收获是否能值得你在可读性和可维护性上付出代价。



致各位:选择最佳优化



尝试性能优化是有益的。 然而,通常情况下,会进行过早优化,同时伴随着一些不良的副作用,最具讽刺意味的是这会导致性能下降。 我希望你们能够更加欣赏优化的艺术和科学,而且最重要的是它的正确的语境。


如果这能让我们抛弃从一开始就编写完美代码的想法,并写出正确的代码,我会很高兴。我们必须记住要从上到下优化,测量瓶颈在哪里,并在修复之前和之后进行测量。这是实现优化的最佳优化策略。祝你好运。



关于作者



Kevin Bloch, 瑞士


PostgreSQL、JavaScript、Perl 和 Haxe 都是 Kevin 的专长,由于他从小学期间开始就对编程有着浓厚的兴趣,所以他已经掌握了许多编程技术。他的职业生涯可以用桌面开发的先驱和全栈开发人员来概括,但他最喜欢最关注的点是项目管理、后端技术和游戏开发。他可以独自一人敬业工作,也愿意为团队效力。 [点击继续...]


一周精选

CocoaChina

C

C

iOS App 稳定性指标及监测

公司开发者账号申请(2017最新版)

在GitHub上最受欢迎的大多是库或框架

[iOS]终极横竖屏切换解决方案

ARKit 从零到一:教你编写 AR 立方体、平面检测与视觉效果、放置几何体并应用物理学

iOS高级调试&逆向技术