程序员修炼之道——第六章 当你编码时

当你编码时

传统智慧认为,项目一旦进入编码阶段,工作主要就是机械地把设计转换为可执行语句。我们认为,这种态度是许多程序丑陋、低效、结构糟糕、不可维护和完全错误的最大一个原因。
编码不是机械工作。如果它是,上世纪80年代初期人们寄予厚望的所有CASE工具早就取代了程序员。每一分钟都需要做出决策——如果要让所得得程序享有长久,无误和富有生产力的“一生”,就必须对这些决策进行仔细地思考判断。
不主动思考他们的代码的开发者就是在靠巧合编程——代码也许能工作,但却没有特别的理由说明它们为何能工作。在“靠巧合编程”中,我们提倡要更积极地参与编码过程
尽管我们编写的大部分代码能快速执行,我们偶尔也会开发出一些算法,可能会让最快的处理器都陷入困境。在“算法速率”中,我们将论估算代码的速度方法,并且还给出一些提示,告诉你怎样在潜在问题中发生之前就发现它们。
注重实效的程序员批判地思考所有代码。包括我们自己的。我们偶尔也会开发出一些算法,可能会让最快的处理器都陷入困境。在算法速率中。我们将讨论估算代码的速度方法,并且还给出一些提示,告诉你怎样在建在问题发生之前就发现它们。
注重实效的程序员批判地思考所有代码,包括我们自己的。我们不断地在我们的程序和设计中看到改进的余地。在“重构”中,我们将讨论一些即使我们还处在项目中期,也能帮助我们修正现有代码的技术。
只要你在制作代码。你就应当记住,有一天你必须对其进行测试。要让代码易于测试,这样你将增加它实际通过测试的可能性;我们将在易于测试的代码中发展这一思想
最后,在“邪恶的向导”中,我们建议你小心那些替你编写大量代码的工具,除非你理解他们在做什么
我们大多数人都能够近乎自动地驾驶汽车——我们不用明确地命令我们的脚踩踏板,或是命令我们的手臂转动方向盘——我们只是想“减速并右转”。但是,可靠的好司机会不断查看周围的情况、检查潜在的问题、并且让自己在万一发生意外时处在有利的位置上。编码也这样——它也许在很大程度上只是例行公事,但保持警觉能够很好地防止灾难的发生

靠巧合编程

你有没有看过老式黑白战争片?一个疲惫的士兵警觉地从灌木丛里钻出来。前面有一片空旷地:那里有地雷吗?还是可以安全通过?没有任何迹象表明那是雷区——没有标记、没有带刺的铁丝网、也没有弹坑。士兵用他的刺刀戳了戳前方的地面,又赶紧缩回来。以为会发生爆炸。没有。于是他紧张地向前走了一会儿,刺刺这,戳戳那里。最后,他确信这个地方是安全的,于是直起身子,骄傲地正步向前走去,结果却被炸成了碎片。
士兵起初的探测没有发现雷区里。每天都有成百的陷阱在等着抓住我们。记住士兵的故事,我们应该警惕,不要得出错误的结论。我们应该避免靠巧合编程——依靠运气和偶然的成功——而要深思熟虑地编程

怎样靠巧合编程

假定Fred 接受了一项编程任务。他敲入一些代码。进行测试,代码好像能工作,他又敲入一些代码,进行试验,代码好像还能工作,在进行了几周这样的编码之后,程序突然停止了工作,Fred花了数小时设法修正它,却仍然不知道原因何在。他可能会花上大量的时间四处检查这段代码,却仍然无法修正它,不管他做什么,代码好像就是不能正常工作。
Fred不知道代码为什么失败,因为他一开始就不知道它为什么能工作。假定进行的是Fred所做的有限“测试”,代码好像能工作,但那不过是一种巧合。受到错误信息的鼓励,Fred冲进了头脑空白的状态。现在,大多数聪明人可能都知道有人像Fred,但我们更知道。我们不能依靠巧合——对吗?
有时我们可能会依靠巧合。有时要把“幸运的巧合”与有目的的计划混为一谈实在很容易,让我们来看一些例子。

实现的偶然

实现的偶然是那些只是因为代码现在编写方式才能发生的事情。你最后会依靠没有计入文档的错误或是边界条件。
假定你用坏数据调用一个例程。例程以一种特定的方式加以响应,而你的代码就以该响应为基础。但原作者并没有预期例程会以那样的方式工作——它甚至从未被考虑过。当例程甚至没有被设计成能做你想要做的事情,但看起来它确实工作得很好。以错误的次序,或是错误的语境中进行调用,是一个与之相关的问题。

paint(g);
invalidate();
validate();
revalidate();
repaint();
paintImmdiately(r);

看来Fred 在不顾一切地设法把某样东西显示在屏幕上。但这些例程从来没有被设计成这样的方式调用尽管他们看起来能工作,但那些实在只是一个巧合。
雪上加霜,当组件终于得到绘制出来,Fred不会再回去找出似是而非的调用。它现在能工作了,最高不要再画蛇填足。
我们很容易被这样的思路愚弄。你为什么冒把能工作的东西弄糟的风险呢?嗯,我们可以考虑几条理由:

  • 它也许不是真的能工作——它也许只是看起来来能工作。
  • 你依靠的边界条件也许只是一个偶然。在不同的情形下(或许是不同的屏幕分辨率),它的表现可能就会不同。
  • 没有记入文档的行为可能会随着库的下一次发布而变化。
  • 多余的和不必要的调用会使你的代码变慢。
  • 多余的调用还会增加引入它们自己的新bug的风险

语境的偶然

你还可能遇到“语境的偶然”。假定你在编写一个实用模块。只是因为你现在为GUI环境编写代码,该模块就必须依靠给你的GUI吗?你是否依靠说英语的用户?有文化的用户?你还靠别的什么没有保证的东西?

隐含的假定

巧合可以在所有层面上让人误入歧途——从生成需求直到测试。特别是测试,充满了虚假的因果关系和巧合的输出。很容易假定X是Y的原因,但正如我们在调式中所说的:不要假定,要证明。
在所有层面上,人们都在头脑里带着许多假定工作——但这些假定很少被计入文档。而且在不同的开发者之间常常是冲突的。并非以明确的事实为基础的假定是所有项目的祸害。

怎样深思熟虑地编程

我们想要让编写代码所花的时间更少,想要尽可能在开发周期的早期抓住并修正错误,想要在一开始就少制造错误。如果我们能深思熟虑地编程,那对我们会有所帮助:

  • 总是意识到你在做什么。Fred让事情慢慢失去了控制,知道最后被煮熟,就像“石头汤与煮青蛙”的青蛙一样
  • 不要盲目地编程,试图构建你不完全理解的应用,或是使用你不熟悉的技术,就是希望自己被巧合误导。
  • 按照计划行事,不管计划是在你的头脑中,在鸡尾酒餐巾的背面,还是在某个CASE工具生成的墙那么大的输出结果上。
  • 依靠可靠的事物。不要依靠巧合或假定。如果你无法说出各种特定情形的区别,就假定是最坏的。
  • 为你假定建立文档。“按合约编程”有助于澄清你头脑中的假定,并且有助于把它们传达给别人。
  • 不要只是测试你的代码,还要测试你的假定。不要猜测;要实际尝试它。编写断言测试你的假定。如果你的断言是对的,你就改善了代码中的文档。如果你发现你的假定是错的,那么就为自己庆幸吧。
  • 为你的工作划分优先级。把时间花在重要的方面;很有可能,它们是最难的部分,如果你的基本原则或基础设施不正确,再花哨的铃声和口哨也是没用的。
  • 不要做历史的奴隶。不要让已有的代码支配将来的代码。如果不再适用,所有的代码都可以被替换。即使是在一个程序中,也不要让你已经做完的事情约束你下一步要做的事情——准备好进行重构。这一决策可能会影响项目的进度。我们的假定是其影响将小于不进行改动造成的影响。
  • 所以下次有什么东西看起来能工作,而你却不知道为什么,要确定它不是巧合。

算法速率

在“估算”中,我们对穿过城区所需要时间、或是项目完成所需要时间这样的事情的估算进行了讨论。但是,另外有一种估算,注重实效的程序员几乎每天都要使用:估算算法使用的资源——时间、处理器,内存,等等。
这种估算常常至关重要。给定两种做某事的途径,你选择哪一种?你知道在1000 条记录的情况下你的程序需要运行多久,但是如果增加到1000000条记录呢?代码的哪些部分需要优化?
我们发现,这些问题常常可以通过常识、某种分析,以及叫做big O 的近似计算表示法来加以回答。

我们说估算算法是什么意思?

大多数并非微不足道的算法都要处理某种可变的输入——排序n个字符串、对m*n 矩阵求逆、或是用n位密钥解密消息。通常,这些输入的规模会影响算法:输入越多,运行时间越长,或是使用的内存就越多。
如果关系总是线性的(于是时间的增加与n的值成正比),这一节也就无关紧要了,但是,大多数重要的算法都不是线性的。好消息是大多数算法都是亚线性的。例如,二分查找在查找匹配项时无需查看每一个候选项。坏消息是有一些算法比线性情况要糟糕得多;其运行时间或内存需求的增长要远远快于n处理10个数据项需要1分钟的算法要处理100个数据项可能需要一生的时间。
我们发现,只要我们编写的是含有循环或递归调用的程序,我们就会下意识地检查运行时间和内存需求。这很少是形式过程,而是快速地确认我们在做的事情在各种情形下是有意义的。但是,有时我们确实会发现自己在进行更为详细的分析。那就是用上O()表示法的时候了。

O()表示法

O()表示法是处理近似计算的一种数学途径。当我们写下某个特定的排序例程对n个记录进行排序所需的时间是O(n^2)时,我们的意思是,在最坏的情况下,所需时间随n的平方变化。使记录数加倍,时间大约将增加4倍,把O视为“阶为……”

常识估算

  • 简单循环。 如果某个简单循环从1 运行到n,那么算法很可能就是O(n)——时间随n线性增加,其例子有穷举查找、找到数组中最大值、以及生成校验和
  • 嵌套循环。如果你需要在循环中嵌套另外的循环,那么你的算法就变成了O(mxn)
  • 二分法。 如果你的算法在每次循环时把事物集合一分为二,那么它很可能是对树型O(log(n))算法。对有序列表的二分查找、遍历二叉树、以及查找机器字中的第一位置的位,都可能是O(ln(n))算法。
  • 分而治之。 划分其输入,并独立地在两个部分上进行处理,然后再把结果组合起来的算法可能是O(nln(n))。经典例子是快速排序,其工作方式是:把数据划分为两半,并递归地对每一半进行排序。尽管在技术上是O(n^2)。但因为其行为在馈入的是排序过的输入时会退化,快速排序的平局均时间是O(nln(n))
  • 组合。 只要算法考虑事务的排列,其运行时间就可能失去控制。这是因为排列涉及到阶乘。得出5个元素的组合算法所需的时间:6个元素需要6倍的时间,7个元素则需要42倍的时间。其例子包括许多公认的算法难题——旅行商问题、把东西最优地包装进容器中、划分成一组数、使每一组都有相同的总数,等等。在特定问题领域中,常常用启发式方法减少这些类型的算法的运行时间。

实践中的算法速率

在你的职业生涯中,您不大可能花费大量的时间编写排序例程,如果不付出相当的努力,现有的库中的例程很可能会胜过你编写的任何东西,但是,我们前面描述的基本的算法类型会不时地再度出现。无论何时你发现自己在编写简单循环,你都知道你有一个O(n)算法,如果循环含有内循环,那么就是O(m*n)

最好的并非总是最好的

你还需要在选择合适的算法时注重实效——最快的算法对于你的工作并非总是最好的。假定输入集很小,直接了当的插入排序的性能和快排一样好,而你用于编码和调试的时间将更少。如果你的选择的算法有高昂的设置开销,你也需要注意。对于小输入集,这些设置时间可能使运行时间相形见绌,并使算法变得不再适用。

重构

随着程序的演化,我们有必要重新思考早先的决策,并重写部分代码,这一过程非常自然,代码需要演化;它不是静态的事物。
遗憾二点是,最为常见的软件开发的比喻是修建建筑,但使用建筑作为指导性的比喻暗示了以下步骤:

  1. 建筑师绘制出蓝图。
  2. 承包商挖掘地基,修建上层建筑、布设管线、并进行最后装修。
  3. 随后客房高兴的住进来,如果有任何问题,就叫维修人员来修。

可是软件的工作方式与此并不怎么相似,与建筑相比,软件更像是园艺——它比混凝土更有机,你根据最初的计划和各种条件在花园里种植许多花木。有些花木茁壮成长,另一些注定成为堆肥。你可能会改变植物的相对位置,以有效利用光影、风雨的交互作用。过度生长的植物会被分裁或修剪,颜色不协调的会被移栽到从美学上更怡人的地方。你拔出野草,并分给需要额外照料的植物施肥。你不断关注花园的 兴旺,并按照需要(对土壤、植物、布局)做出调整
商业人士喜欢修建建筑的比喻:它比园艺更科学,它可以重复,具有严格的管理报告层次,等等。但我们不是在修建摩天大楼——我们不用受物理和各种条件限制的约束
园艺比喻与软件开发的现实要接近得多。或许特定的例程已变得太大,或是试图完成太多的事情——它需要被一分为二。没有按照计划完成的事情要被清除或修减
重写、重做和重新架构代码整合起来,称为重构

你应在何时进行重构

当你遇到绊脚石——代码不再合适,你注意到有两样东西其实应该合并或是其他任何多尼来说是“错误”的东西——不要对改动犹豫不决。应该现在就做。无论代码具有下面的哪些特征,你都应该重构代码。

  • 重复。你发现了对DRY原则的违反
  • 非正交的设计。你发现有些代码或设计可以变得更为正交
  • 过时的知识。事情变了,需求转移了,你对问题的了解加深了。代码需要跟上这些变化。
  • 性能。为改善性能,你需要把功能从系统的一个区域转移到另一个区域。

重构你的代码——四处移动功能,更新先前的决策——事实上是痛苦管理的一次练习。让我们面对它,四处改动源码可能相当痛苦:他几乎已经在工作,现在事实上却要被撕毁了。许多开发者不愿意只是因为代码不完全正确就撕毁代码。

现实世界的复杂情况

于是你去找你的老板或客户,对他们说:“这些代码能工作,但我需要再用一周时间重构它”
我们不能印出他们的回答。
时间压力常常被用作不进行重构的借口。但这个借口并不成立:现在没能重构,沿途修正问题将需要投入多得多的时间——那时将需要考虑更多的依赖关系。我们会有更多的时间可用吗?根据我们的经验,没有。
你也许可以用一个医学上的比喻来向老板解释这一原则:把需要重构的代码当作是一种“肿瘤”。切除它需要进行“侵入性”的外科手术。你可以现在手术、趁它还小把它取出来。你可以现在手术、趁它还小把它取出来,你也可以等他增大并扩散——但那时再切除它就会更昂贵、更危险。等久一点,“病人”就有可能会丧命。
追踪需要重构的事物。如果你不能立刻重构某样东西,就一定要把它列入计划确保受到影响的代码的使用者知道该代码计划要重构,以及这可能会怎样影响他们

怎样进行重构

重构肇始于Smalltalk 社群,并且与其他趋势(比如设计模式)一道,它已开始赢得更广泛的听众。但作为一个话题,它仍然相当新;没有多少关于它的出版物。第一本关于重构的重要书籍,是与本书同时出版的。
就其核心而言,重构就是重新设计。你或你们团队的其他人设计的任何东西都可以根据新的事实、更深的理解、变化的需求、等等,重新进行设计。但如果你无节制地撕毁大量代码,你可能会发现自己处在比一开始更糟糕的位置上。

  1. 不要试图在重构的同时增加功能。
  2. 在开始重构之前,确保你拥有良好的测试。尽可能经常运行这些测试。这样如果你的改变破坏了任何东西,你就能很快知道。
  3. 采取短小、深思熟虑的步骤:把某个字段从一个类移往另外一个,把两个类似的方法融合进超类中。重构常常涉及到进行许多局部改动,继而产生更大规模的改动。如果泥使你的步骤短小,并在每个步骤之后进行测试,你将能够避免长时间的调试

确保对模块做出的剧烈改动——比如以一种不兼容的方式更改了其接口或功能——会破环构建,这也很有帮助。也就是说,这些代码的老客户应该无法通过编译,于是你可以很快找到这些老客户,并作出必要的改动,让它们及时更新。
所以,下次你看到不怎么合理的代码时,既要修正它,也要修正依赖于它的每样东西。要管理痛苦:如果它现在有损害,但以后的损害会更大,你也许最好一劳永逸地修正它。记住软件的熵中的教训:不要容忍破窗户。

易于测试的代码

软件IC是人们在讨论可复用性和基于组件的开发时喜欢使用的比喻。意思是软件组件应该就像集成电路芯片一样进行组合。这样只要在你使用的组件已知是可靠才能行之有效。
芯片在设计时就考虑了测试——不知是在工厂,在安装,而且也是在部署现场进行测试。

单元测试

硬件的芯片级测试大致等价于软件中的单元测试——在隔离状态下对每个模块进行测试,目的是检验其行为,一旦我们在受控的(甚至是人为的)条件下对模块进行了彻底的测试,我们就能够更好地了解在广阔的世界上将怎样起反应。
软件的单元测试是对模块进行演练的代码。在典型情况下,单元测试将建立某种人工环境,然后调用被测试模块中的例程,然后,它根据已知的值,或是同一测试先前返回的结果(回归测试),对返回的结果进行检查
随后,当我们把我们的“软件IC”装配进完整系统中时,我们将有信心,各个部分都能够如预期的那样工作,然后我们可以使用同样的单元测试设施把系统当作整体进行测试。我们将在“无情的测试”中讨论对系统的大规模检查。
但是,在我们走那么远之前,我们需要决定在单元级测试什么。在典型情况下,程序员会随便把一些数据扔给代码,就说已经测试过了,应用“按合约设计”后面的思想,我们可以做得好得多。

针对合约进行测试

我们喜欢把单元测试视为针对合约的测试想要编写测试用例,确保给定的单元遵守其合约。这将告诉我们两件事情:代码是否符合合约,以及合约的含义是否与我们所认为的一样。我们想要通过广泛的测试用例与边界条件,测试模块是否实现了它允诺的功能。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值