程序员修炼之道-通向务实的最高境界
第一版前言
每个开发人员都是独特的,有各自的优势和劣势,以及偏好和厌恶的东西。诚如随着时间的推移,每个人都将打造自己的个人环境。这种环境将像程序员的爱好、衣服或发型一样,能强烈地反映出他或她的个性。然而,作为务实的程序员,你们会共有许多如下特征:
- 早期的采纳者/快速的适配者
- 你对技术和技巧有一种直觉,喜欢尝试。当接触到新东西时,你可以很快地掌握它们,并把它们与其他的知识结合起来。你的信心来自经验。
- 好奇
- 你倾向于问问题。这真不错——你怎么做到的?你对那个库有意见吗?总在听人说起的量子计算到底是什么?符号链接是怎么实现的?你热衷于收集各种> 细微的事实,坚信它们会影响自己多年后的决策。
- 批判性的思考者
- 你在没有得到证实前很少接受既定的现实。当同事们说“因为就该这么做”,或者供应商承诺会解决所有的问题时,你会闻到挑战的味道。
- 现实主义
- 你试图理解所面临的每个问题的本质。这种现实主义让你对事情有多困难、需要用多长时间有一个很好的感知。一个过程应该很难,或是需要点时间才> 能完成,对这些的深刻理解,给了你坚持下去的毅力。
- 多面手
- 你努力熟悉各种技术和环境,并努力跟上新的进展。虽然目前的工作可能要求你在某个专门领域成为行家,但你总是能够进入新的领域,迎接新的挑> 战。
提示1 关注你的技艺
提示2 思考!思考你的工作
为了成为一名务实的程序员,我们要求你在做事的时候,思考一下自己正在做什么。这不是对当前实践做的一次性审计——而是对每天里每一个项目所做的每一个决定进行的批判性评估。不要用自动辅助驾驶系统偷懒,而是要不断地思考,即时地批判自己的工作。
在一个项目的整体结构中,总有个性和技艺的空间。考虑到软件工程的当前状态,这一点尤为正确。今天的土木工程师,很难接受中世纪大教堂建造者使用的技术——百年后我们的工程看上去也一样古老,而我们的技艺仍将受到尊重。
伟大的草坪需要每天的点滴护理,伟大的程序员也是如此。管理顾问喜欢在谈话中抛出“改善(Kaizen)”这个词。“改善”是一个日语术语,意思是不断地做出许多小的改进。这被认为是日本制造业生产率和质量大幅提高的主要原因之一,并被全世界广泛效仿。改善也适用于个人。每一天都要努力打磨你的技能,并往技能库里添加新的工具。与伊顿草坪不同,你会在几天内就看到效果。几年下来,你会对成果惊讶不已——经验业已开花结果,技能早就茁壮成长。
第1章 务实的哲学
毫无疑问,你的事业是你自己的,更重要的是,你的人生是你的——都是你自己所拥有的。
务实的程序员的特质是什么?是他们面临问题时,在解决方案中透出的态度、风格及理念。他们总是越过问题的表面,试着将问题放在更宽泛的上下文中综合考虑,从大局着想。毕竟,若不去了解来龙去脉,结合实际如何谈起?又怎能做出明智的妥协和合理的决策?
他们的另一个成功点是他们为所做的一切负责。
了解所做工作的来龙去脉有一个好处,那就是更容易把握软件必须做到多好。接近完美往往才是唯一的选项,这通常需要做许多折衷方案。
1 人生是你的
人生是你自己的,是你在拥有、经营和创造。
我们和很多沮丧的开发者交谈过。他们的担忧多种多样。一些人感觉自己的工作停滞不前。还有一些人认为自己的技术已经过时了。有人觉得自己没有得到应有的重视,有人觉得薪水过低,有人觉得团队已经一团糟。一些人想去亚洲或是欧洲工作,一些人想在家工作。
对此,我们总是给出相同的答案。
“为什么你不考虑改变一下呢?”
他们缩在那里,期盼着事情会自己变好。他们眼睁睁地看着自己的技能过时,却抱怨公司没有给予培训。他们乘着公交车,路过广告林立的街道,顶着凄雨寒风,钻入写字楼工作。
提示3 你有权选择
2 我的源码被猫吃了
在你的职业发展、学习教育,以及你的项目、每天的工作等各方面对你自己负责,对你的行为负责,这是务实哲学的基石之一。一个务实的程序员能完全掌握自己的职业生涯,从不害怕承认无知和错误。有些事在编程中会令人不快,但却必然会发生——即使最好的项目也无法幸免。尽管有彻底的测试,有优秀的文档,有完备的自动化,结果还是出了问题——交付被推迟,未曾预料的技术问题出现。
一旦这些事情发生,尝试依靠我们的专业性去解决问题。这意味着要诚实和坦率。我们固然会为我们的能力而骄傲,但面对缺点时也必须诚实——承认我们犯了错误,而且是因为我们的无知而犯下的。
首先,你的团队需要能信赖和依赖你——你也应该同样地放心依赖他们每个人。有研究文献表明,团队信任对于创造力和协作至关重要。在一个以信任为基础的健康环境中,你可以安全地说出你的想法,表达你的思想。学会依赖你的团队成员,他们也会依赖你。如果缺少信任,那么……
责任意味着你对某事积极认同。你保证事情能搞定,并为之做出承诺,但你不必直接掌控事情的每个方面。除了个人尽力做好,你必须分析超出你控制范围的风险情况。如果责任的伦理内涵过于含糊,或是面对无法实现的情况,抑或风险过大,你都有权不承担责任。你必须根据自己的价值观和判断做出决定。
不要把问题归咎于别人或其他什么事情上,也不要寻找借口。不要把所有问题都归咎于供应商、编程语言、管理或是同事。这些因素都可能是问题的一部分。它们的确会对解决方案造成影响,但不是给你的借口。
如果你面临供应商帮不上忙这样的风险,就应该制订一个应急方案。如果磁盘挂起——你所有的源码都在里面——而你没有备份,这就是你的错。跟你的老板说“我的源码被猫吃了”解决不了问题。
提示4 提供选择,别找借口
如果你打算跟别人解释为什么做不完、为什么延期、为什么搞砸,在此之前先等等,听一下自己的内心。讲给你显示器上的橡皮鸭听听,或是先对着猫说一遍。你的那些借口听起来合理吗?还是很愚蠢?你的老板听到会怎样?
把谈话在心里过一遍。其他人可能说什么?他们会问,“你试过这样做吗……”“为什么你不考虑一下那样做?”而你怎么回答?在你跑去告诉他们坏消息前,还有什么你可以再试试的?有时,你已经知道他们会说什么,那么就直接帮他们搞定。
给出选择,而不是找借口。不要说搞不定;解释一下要做些什么才能挽回这个局面。是否必须扔掉这些代码呢?给他们讲讲重构的价值(参见第216页的话题40:重构)。你是否需要一点时间来做原型?因为只有这样才能决定后面怎么做最好(参见第57页的话题13:原型与便签)。为了防止错误再次发生,你是否需要引入更好的测试(参见第220页的话题41:为编码测试和第288页的无情的持续测试)或增加自动化流程?
别害怕请教别人,别害怕承认自己需要帮助。
当你意识到自己在说“我不知道”时,一定要接着说“——但是我会去搞清楚”。用这样的方式来表达你不知道是非常好的,因为接着你就可以像一个专家一样承担起责任。
3 软件的熵
有很多因素会导致软件腐烂。最重要的一个似乎是项目工作中的心理性状态,或者说文化。即使是一个单人团队,你的项目的心理性状态也是个非常脆弱的东西。即使有最合理的计划和最佳的人员,项目还是可能在生命周期中逐步荒废、腐烂。但也有一些项目在经历了巨大的困难、持续不断的挫折之后,成功地对抗了天然的无序化倾向,走出了困境。
无视一个明显损坏的东西,会强化这样一种观念:看来没有什么是能修好的,也没人在乎,一切都命中注定了。所有的负面情绪会在团队成员间蔓延,变成恶性循环。
提示5 不要放任破窗
不要搁置“破窗”(糟糕的设计、错误的决定、低劣的代码)不去修理。每发现一个就赶紧修一个。如果没有足够的时间完全修好,那么就把它钉起来。也许你可以注释掉那些糟糕的代码,显示一行“尚未实现”的信息,或用假数据先替代一下。采取行动,预防进一步的损害发生,表明一切尽在你的掌握中。
消防部门的首要任务当然是灭火,何必管过程中的那些附带损害呢?但是他们在清醒地评估了形势后,出于对自己控制这场火势能力的绝对自信,还是尽力兼顾了不对财物造成不必要的毁害。这也是软件开发中应该遵循的方法:不要只是因为一些东西非常危急,就去造成附带损害。破窗一扇都嫌太多。
一扇破窗——一段设计糟糕的代码,一个让团队在整个项目周期内都必须要遵守的糟糕管理决定——就是一切衰退的开始。如果你发现自己正处在有几扇破窗的项目中,就非常容易陷入这样的想法——“反正代码所有其他部分都是一坨屎,我只是随大流而已。”项目运作在这个时间点前是不是一直良好并不重要。
4 石头做的汤和煮熟的青蛙
你可能处在这样一种状况下——清楚地知道需要做些什么,以及怎样去做。整个系统就在你的眼前——你知道这样做就对了。但当你为做整件事去征求意见的时候,见到的往往是推脱和茫然的眼神。人们想成立一个委员会,然后申请预算,之后事情会变得异常繁杂。每个人都会守着自己的一亩三分田。有时我们称之为“筹备期的劳累”。这个时候,就该拿出石头了——找出你合理的请求,然后不断完善。一旦有成果产出,展示给人们看,让他们大吃一惊。现在可以用上“当然了,它还可以更好,只要我们再加点……”这句话,而且要假装你并不在意。这时先坐下来,等他们开始问你要不要加些你原本想要的功能。人们都觉得,加入一个推进中的成功项目更容易一些。因为只要一窥未来,大家就能团结在一起。
提示6 做推动变革的催化剂
项目进展缓慢,完全失去了控制——这是很常见的症状。大多数软件灾难都始于微不足道的小事,项目的拖延也是一天天累积而成的。系统一个特性接一个特性地偏离规范,一个接一个的补丁加到代码上,最终原始代码无影无踪。往往就是一件件小事的累积破坏了团队和士气。
提示7 牢记全景
软件的熵讨论的破窗问题不同。破窗理论中,人们失去打败熵的斗志是因为他们觉得没其他人在乎。而青蛙仅仅只是未察觉到变化。
不要学寓言里的青蛙,永远留意着大局,持续不断地审视你身边发生的事情,而不要只专注于你个人在做的事情。
不要看,马上回答,你头顶的天花板上有几盏灯?屋子里一共有几盏灯?有多少人?有没有发现什么东西有违和感,感觉它们不应该属于这里?这是一个情景感知的练习,从童子军到海豹突击队,人们都在练习这种技巧。先养成仔细观察周围环境的习惯,然后在项目中这样做。
5 够好即可的软件
如《IEEE软件》杂志上一篇由爱德华·尤登写的文章《够好即可的软件就是最好的》[You95]所述,你能训练自己写出够好即可的软件——对用户、未来的维护者来说够好即可,只要好的程度能让你自己内心平静就可以。你会发现,你变得更有效率,用户也更快乐。而且,可能让你更开心的是,更短的孵化期促使你的程序实际上更好了。
无视来自用户方面的需求,一味地向程序中堆砌功能,一次又一次打磨代码,这是很不专业的表现。心浮气躁当然不值得提倡,比如承诺一个无法兑现的时间尺度,然后为了赶上截止期而去删减必要的边角工程,这同样是不专业的做法。
提示8 将质量要求视为需求问题
人们经常会遇到需要权衡利弊的情况。令人惊讶的是,许多用户宁愿今天就用上一个毛糙的软件,也不愿意多等上一年再用那个打磨光亮、功能齐备的版本(而且,实际上他们一年后真正需要的东西可能完全不同)。许多预算紧张的 IT 部门会同意这样的说法。与构想中的明天那个完美的软件相比,今天就还不错的软件通常更讨人喜欢。如果你早点给用户一点东西玩,他们的反馈常常能引领你做出更好的最终方案(参见第51页的话题12:曳光弹)。
不要让过度的修饰和精炼侵蚀掉一个完好的程序。继续前行,让代码在它该有的位置驻留一段时间。它或许并不完美,不要紧的——它就算永不完美也没关系。
6 知识组合
随着新技术的出现,以及语言和环境的发展,你的知识会变得过时。不断变化的市场力量可能会使经验变得陈旧而无关紧要。鉴于技术社会变化的速度越来越快,这种事情可能会发生得特别迅速。
当你的知识价值下降时,你对于公司或客户的价值也在下降。
我们可以将程序员所了解的一切有关计算过程的事实、工作的应用领域,以及所有经验,视为他们拥有的知识组合。管理知识组合和管理金融投资组合非常的类似:
1.正规投资者有定期投资的习惯。
2.多样化是长线成功的关键。
3.聪明的投资者会平衡保守型和高风险高回报型投资的组合。
4.投资者用低买高卖来获得最大的回报。
5.应定期审查和重新平衡投资组合。
要想事业成功,你必须用同样的指导方针管理你的知识组合。
好消息是,管理这类投资是一项技能,就像其他技能一样——可以学会。诀窍是让自己一开始就这样做,并养成习惯。制定一个你能遵循的例行规程,照此去做,直到大脑将其内化。做到这个程度后,你会发现自己会自动吸收新知识。
- 定期投资
- 就像金融投资一样,你必须定期为你的知识组合投资,即使数量有限。习惯本身就和总数量一样重要,所以要安排一个固定的时间和地点,这有助于撇开常见的干扰。下一部分将列出一些示例目标。
- 多样化
- 你知道的东西越多,你的价值就越大。起码要知道目前工作中特定技术的来龙去脉,但不要就此打住。计算机技术变化迅猛——今天的技术热点可能到了明天就接近无用(至少不那么受欢迎)。熟悉的技能越多,越能适应变化。
- 风险管理
- 不同技术在从高风险高回报到低风险低回报的区间均匀分布。把所有的钱都投在高风险股票中绝非明智,因为有可能突然崩盘,同样你也不应该把所有的钱都投资在保守的领域,那样可能错失良机。不要把所有的技术鸡蛋都放在一个篮子里。
- 低买高卖
- 在一项新兴技术变得流行之前就开始学习,可能和发现一只被低估的股票一样困难,但是所得到的收获会和此类股票的收益一样好。在Java刚发明的时候就去学习,可能有很大风险,不过当Java流行后,那些早期用户都获得了相当丰厚的回报。
- 重新评估调整
- 这是一个充满活力的行业。你上个月开始研究的热门技术现在可能已经凉下来了。也许你需要刷新一下有段时间没用过的数据库技术。或者,不妨去尝试另一种语言,它可能会让你在新的工作中处于更有利的地位……
提示9 对知识组合做定期投资
- 每年学习一门新语言
- 不同的语言以不同的方式解决相同的问题。多学习几种不同的解决方法,能帮助自己拓宽思维,避免陷入陈规。此外,要感谢丰富的免费软件,让我们学习多种语言非常容易。
- 每月读一本技术书
- 虽然网络上有大量的短文和偶尔可靠的答案,但深入理解还是需要去读长篇的书。浏览书店页面后挑选和你当前项目主题相关的技术图书。一旦你养成习惯,就一个月读一本。在你掌握了当前正在使用的所有技术后,扩展你的领域,学习一些和你的项目不相关的东西。
- 还要读非技术书
- 记住,计算机是由人来使用的,你做的事情是为了满足人的需要,这非常重要。和你一起工作的是人,雇佣你的也是人,黑你的还是人。不要忘记方程式中人的那一面,它需要完全不同的技能集(我们称这些为软技能,听起来很容易,但实际上它们很硬核,难以掌握)。
- 上课
- 在本地大学或是网上找一些有趣的课程,或许也能在下一场商业会展或是技术会议上找到。
- 加入本地的用户组和交流群
- 不要只是去当听众,要主动参与。独来独往对你的职业生涯是致命的;了解一下公司之外的人们都在做什么。
- 尝试不同的环境
- 如果你只在Windows下工作,那么就花点时间在Linux上。如果你只使用简单的编辑器和Makefile,那就试试最新的炫酷复杂的IDE,反之亦然。
- 与时俱进
- 关心一下和你当前项目不同的技术,阅读相关的新闻和技术帖。这是一种很好的方式,可以了解用到那些不同技术的人的经验及他们所用的特殊术语,等等。
- 持续投资非常重要。一旦你进入了对某个新语言或新技术的舒适期,向前走,再学一个。
- 你是否在项目中使用过这些技术并不重要,甚至要不要把它们放在你的简历中也不重要。学习的过程将会扩展你的思维,为你打开全新可能性的大门,让你领悟新的做事方式。想法的交叉传授是很重要的;试着把你领悟到的东西应用到你当前的项目中。即使项目没有用到某项技术,你也可以借鉴一些想法。例如,熟悉面向对象,你就可以用不同的方式来编写朴素的C程序,理解函数式编程范式,就能用不同的方式来写Java,等等。
但不要停在这里,把找到答案作为一项个人挑战。问问周围的人,或是上网搜索——不要仅限于大众领域,还要试试在学术领域找一下。
如果你无法自己找到答案,去寻觅有能力找到答案的人,而不要让问题沉寂下去。和其他人交谈有助于构建你的人际网络,而且你还会惊奇地发现,在这个过程中你会找到一些其他不相关问题的解决方案——旧有的知识组合会不断地扩大……
永远不要低估商业主义的力量。网络搜索引擎有时仅仅是把热门的东西列在最前面而已,并不能说明这是你的最佳选择,而且内容提供商也可以花钱把它们的东西排到前列。书店有时仅仅是把一本书摆在显著的位置而已,并不能说明这是一本好书,甚至不能说明这本书很流行,可能只是有人花钱把它摆在了那里。
提示10 批判性地分析你读到和听到的东西
- 问“五个为什么”
- 我最喜欢的咨询技巧是:至少问五次“为什么”。就是说,每当有了答案后,还要追问“为什么”。像个烦人的四岁小孩那样经常性重复提问,不过记得要比小朋友更有礼貌。这样做可以让你更接近本源。
- 谁从中受益
- 虽然听起来有点世俗,不过追踪钱的流动更容易理清脉络。其他人或其他组织的利益可能和你自己的一致,也可能不一致。
- 有什么背景
- 每件事都发生在它自己的背景下,这也是为何“能解决所有问题”的方案通常不存在,而那些兜售“最佳实践”的书或文章实际上经不起推敲。“最适合谁”是一个值得考虑的好问题,类似的还有先决条件是什么、后果是什么,以及是短期的还是长期的。
- 什么时候在哪里可以工作起来
- 是在什么情况下?太晚了吗?太早了吗?不要停留在一阶思维下(接下来会发生什么),要进行二阶思考:当它结束后还会发生什么?
- 为什么这是个问题
- 是否存在一个基础模型?这个基础模型是怎么工作的?
走出去和那些与你当前项目无关的人谈谈技术,和别的公司的人聊聊。试着在公司餐厅建立你的人脉,或是去参加本地的聚会,找一些志同道合的人。
7 交流!
即使拥有最好的想法、漂亮的代码、最务实的思想,如果不能和他人交流,最终都无法孕育出果实。缺乏有效的沟通,好点子就成了一个孤儿。
提示11 英语就是另一门编程语言
- 了解听众
- 传达信息的过程才算是交流。为了把信息送达,你需要了解受众的需求、兴趣和能力。
- 与所有的沟通形式一样,这里的窍门是收集反馈。不要只是等待问题的出现:把它问出来。注意看肢体语言和面部表情。神经语言规划有一个预设假定,“所获对方的反应即沟通的意义。”在交流的过程中,不断提高对听众的了解。
- 明白自己想说什么
- 在更正式的商务沟通方式中,最困难的部分可能是捋出你到底想说什么。
- 计划好你想说什么,写一个大纲,然后问自己:“这是否用正确的方式向我的听众传达了我想表达的东西?”精炼到不能更精炼为止。
- 选择时机
- 你说的东西不仅要内容合适,说的时间也要合适。有时只需要问一个简单的问题“现在是讨论……的好时机吗?”
- 挑选风格
- 根据听众调整你的表达方式。有些人想要一份正式的“只含事实”的简报。另一些人则喜欢在谈正事之前高谈阔论一番。
- 不过,还是要记住,相互沟通这件事你占了其中的一半。如果有人想让你用一段话谈谈某件事,而你发现那不是三两句就讲得清楚的,那么如实告之。记住,这样的反馈也是一种交流方式。
- 让它看起来不错
- 想法很重要。但听众还希望有个好看的包装。
- 让听众参与
- 我们常常发现,相对最终定稿的文档,编写出文档这个过程更为重要。只要有可能,让读者参与到文档的初稿中来。听取他们的反馈,汲取他们的智慧。这样能建立良好的工作关系,而且通过这个过程,能编写出更好的文档。
- 做倾听者
- 如果想让别人听你说话,有一个技巧必须掌握:听他们说。即使你掌握了全部信息,甚至是在一个正式的会议上站在20个西装革履的人面前——如果你不听他们的,他们也不会听你的。
- 通过提问鼓励人们交谈,试着让他们总结你的发言。把会议变成一场对话,你将更有效地表达你的观点。说不定你还可以学到一些东西。
- 回应别人
- 当你问别人一个问题时,如果他们不回答,你会觉得他们不礼貌。那么,当别人发电子邮件或备忘录给你,问你一些信息,请你做一些事情时,你有多少次没有回应?日常生活忙忙碌碌,忘点事情太常见了。一定要记得回复邮件,就算简单地说一句“我稍后答复你”都好。随时知会别人,能让人更容易原谅你偶然的疏忽,让人觉得你并没有忘记他们。
提示12 说什么和怎么说同样重要
务实的程序员将文档视为整个开发过程的一个组成部分。为了让编写文档变得更容易一点,我们要避免重复劳动和浪费时间,让文档总是在手边——直接写在代码里。
提示13 把文档嵌进去,而不要栓在表面
用源码中的注释生成好看的文档非常容易,建议给模块和导出函数都加上注释,这能在其他开发者使用的时候,给他们很大的助力。
不过,有人说必须给每个函数、数据结构、类型声明等都分别加上注释,我们并不赞同这种做法。这种机械的注释方式实际上会导致代码更难维护:一旦你想改点什么,就需要改变两个东西。因此,将非API的注释限制在只用来讨论其为何存在及其意图、目标。当代码已经展示了事情怎样完成时,注释是多余的——因为这违反了DRY原则。
第2章 务实的方法
8 优秀设计的精髓
提示14 优秀的设计比糟糕的设计更容易变更
能适应使用者的就是好的设计。对代码而言,就是要顺应变化。因此要信奉ETC原则(Easier To Change,更容易变更)——就该如此。
ETC 是一种价值观念,不是一条规则
价值观念是帮助你做决定的:我应该做这个,还是做那个?当你在软件领域思考时,ETC 是个向导,它能帮助你在不同的路线中选出一条。就像其他一些价值观念一样,你应该让它漂浮在意识思维之下,让它微妙地将你推向正确的方向。
ETC里有一个隐含的前提。多条路线中的哪一条更易于将来的变更,ETC假定我们有能力辨别。很多时候,常识通常就不会错,你完全可以据此推断。然而有时你找不到线索。这也没关系。这种情况下,我们觉得你可以做两件事。第一件事,假设不确定什么形式的改变会发生,你也总是可以回到终极的“容易变更”的道路上:试着让你写的东西可替换。这样,无论未来发生什么,这块代码都不会成为路障。这似乎有点极端,但不管怎样,实际上你一直应该这样做。做起来并不难,想着一直保持代码的解耦和内聚就够了。第二件事,把它当作培养直觉的一种方式。在工程日志中记下你面临的处境:你有哪些选择,以及关于改变的一些猜测。在源码中留个标签,以便之后必须修改这块代码时,进行回顾并给自己留下反馈记录。下一次在行进的道路上再碰到类似的分岔口时,这会有所帮助。
9 DRY——邪恶的重复
大多数人认为维护始于程序发布,这里的维护指修复Bug和增强特性。我们觉得这些人搞错了。程序员一直处于维护模式下,从未间断。我们的理解每天都在变化。当我们在项目中埋头工作时,新的需求会不断出现,已有的需求也会发展。也可能是环境发生了变化。不管具体原因是什么,维护从来不是个离散的活动,而是整个开发过程中的常态。
在一个系统中,每一处知识都必须单一、明确、权威地表达。
提示15 DRY——不要重复自己
DRY 不限于编码让我们先解决一个问题吧。在本书的第一版中,我们没能很好地解释什么是“不要重复自己”。许多人认为它仅指编码,将DRY的意思限定为“不要复制粘贴源码”。
DRY 针对的是你对知识和意图的复制。它强调的是,在两个地方表达的东西其实是相同的,只是表达方式有可能完全不同。
准确的命名即可表述清要做什么。如果有人需要了解更多细节,源码里应有尽有。这就是DRY!
大多数情况是你需要缓存数据,以避免重复进行昂贵的运算。这里的技巧可以将负面影响限制在局部。违背的部分不会被暴露到外部世界:只有类里面的方法才用担心相关行为的正确性。
无论什么时候,只要模块暴露出数据结构,就意味着,所有使用这个数据结构的代码和模块的实现产生了耦合。但凡有可能,都应采用一组访问器函数来读写对象的属性。如果未来需要增加功能,这样做能让事情更容易一些。
代码必须持有外部那个东西已经蕴含的知识。它需要了解API、数据的schema、出错码的含义,或是其他什么东西。这里的重复是指两个事物(代码和外部实体)必须拥有接口的表征知识。一端发生改变,另一端就会坏掉。
对于内部API,去找个工具,用来将API描述成一种中立的格式。这些工具通常能生成文档、模拟API、功能测试。之后还可以生成不同语言的API客户端。理想情况下,这个工具会把API保存在一个中心仓库中,以便不同的团队共享。
你会发现,公开的API越来越多采用类似OpenAPI 的东西来做正式的规范。这可以方便你将API规范导入你的本地API工具,更可靠地和服务集成。如果找不到这样的规范,可以考虑自己创建一个并发布。不只是别人会觉得它有用,甚至你在维护它时也能获得帮助。
最难检测到且最难处理的重复类型,可能发生在同一项目的不同的开发人员之间。整块的功能集可能会在不经意间重复,而这种重复或许好几年都未被发现,最终导致了维护问题。
我们认为解决这个问题的最好方法是鼓励开发人员之间积极频繁的交流。可以来一次日常Scrum 晨会。可以开一个论坛(比如Slack 频道)用于讨论常见问题。这提供了一种非侵入性的通信方式——甚至可以跨越多个场所——为说的每件事都留下永久记录。
指派团队中的一个人做项目知识管理员,他的工作就是促进知识的传播。在源码目录树中设置一个集中的位置,存放工具程序和工具脚本。要重视阅读其他人的源码和文档,不管使用非正式的形式还是通过正式的代码审核。这不是偷窥别人的工作——而是在向他们学习。记住,这是个互惠的过程——如果别人研读(乱翻?)你的代码,也不要想太多。
提示16 让复用变得更容易
你要努力的方向,应该是孕育出一个更容易找到和复用已有事物的环境,而不是自己重新编写。如果复用不容易,人们就不会这么做。如果你未能复用,就有重复知识的风险。
10 正交性
“正交性”是从几何学中借用来的术语。若两条直线相交后构成直角,它们就是正交的。
在计算科学中,这个术语象征着独立性或解耦性。对于两个或多个事物,其中一个的改变不影响其他任何一个,则这些事物是正交的。在良好设计的系统中,数据库相关代码应该和用户界面保持正交:你可以变更界面但不应影响数据库,切换数据库而不必更换界面。
当系统的组件相互之间高度依赖时,就没有局部修理这回事。
提示17 消除不相关事物之间的影响
我们希望设计的组件自成一体:独立自主,有单一的清晰定义的意图(在Structured Design: Fundamentals of a Discipline of Computer Program andSystems Design [YC86] 一书中,Yourdon和Constantine将其称为内聚)。当组件彼此隔离时,你知道可以变更其中一个组件,而不必担心影响到其他组件。只要不去改变组件的对外接口,就可以放心,不会发生波及整个系统的问题。
但凡编写正交的系统,就能获得两个主要的收益:提高生产力及降低风险。
- 获得生产力
- 将变更限制在局部后,开发时间和测试时间都会减少。
- 正交的方法同时促进了重用。
- 组合正交组件能获得相当微妙的生产力提升。假设一个组件能做M件独特的事情,另一个能做N件。如果它们是正交的,组合起来就能做M×N件事。
- 减少风险
- 代码中病变的部分被隔离开。
- 这样获得的系统不那么脆弱。
- 正交系统可能更利于测试,因为为其组件设计和运行测试更加容易。
- 你不会被特定的供应商、产品或平台紧紧束缚。
让我们来看看一些方法,它们可以将正交性原则用于实际工作中。
- 设计
- 系统应该由一组相互协作的模块构成,每个模块实现的功能应独立于其他模块。有时这些模块组件被组织到不同的层次上,每一层都做了一级抽象。这种分层的实现是设计正交系统的有力途径。因为每一层只使用它下面一层提供的抽象,所以可以在不影响代码的情况下极其灵活地更改底层实现。分层还可以降低模块之间依赖关系失控的风险。
- 可以用一个简单的方法来测试设计的正交性。当你规划好组件后,问问自己:如一个特别功能背后的需求发生显著改变,有多少模块会受影响?对于一个正交系统,答案应该是“一个”。
- 工具包和程序库
- 在引入第三方工具包和程序库时,请注意保持系统的正交性。技术选择要明智。
- 编码
- 每当你写下代码时,就有降低软件的正交性的风险。你不仅需要盯着正在做的事情,还要监控软件的大环境。如果不这样,其他模块中的功能就很可能无意间重复了,或是把已有的知识表达了两次。
有几种技术可以用来保持正交性:
- 保持代码解耦
- 编写害羞的代码——模块不会向其他模块透露任何不必要的信息,也不依赖于其他模块的实现。
- 如果你需要改变一个对象的状态,让该对象替你来完成。这样做能让你的代码和其他代码实现隔离,更有可能保持正交性。
- 避免全局数据
- 只要代码引用全局数据,就会将自己绑定到共享该数据的其他组件上。即使只打算对全局数据进行读操作,也可能引发问题(例如突然需要将代码改为多线程的情形)。一般来说,如果总是显式地将任何需要的上下文传递给模块,那么代码会更容易理解和维护。在面向对象的应用程序中,上下文通常作为参数传给对象的构造函数。在其他代码中,也可以创建一个包含上下文的数据结构,并将结构的引用传出去。
- 避免相似的函数
- 重复代码是结构问题的症状。想要更好的实现,可以看看《设计模式》中的策略模式。
- 养成不断质疑代码的习惯。只要有机会就重新组织、改善其结构和正交性。
- 测试
- 基于正交性设计和实现的系统更容易测试。由于系统组件之间的交互是形式化的,且交互有限,因此可以在单个模块级别上执行更多的系统测试。这是一个好消息,因为模块级别(或单元)的测试比集成测试更容易列举出来执行。
- 编写单元测试本身就是一个有趣的正交性测试。
- 修Bug也是评估整个系统的正交性的好时机。遇到问题时,评估一下修复行为的局部化程度。
- 文档
- 真正符合正交性的文档,应该能够在不改变内容的情况下显著地改变外观。字处理程序提供的样式表和宏可以做到这一点。但我个人更喜欢使用Markdown这样的标记系统:在编写时,我们只关注内容,而将呈现留给随便什么工具去处理。
11 可逆性
如果某个想法是你唯一的想法,那就没有比它更危险的东西了。——埃米尔-奥古斯特·沙尔捷(阿兰)Propos sur la religion,1938
实现一件事情的方法往往不止一种,提供第三方产品的供应商通常也不止一个。如果你加入的项目被“只有一种方法行得通”这种短视的观念所束缚,很可能有“惊喜”等着你。
变化不需要多么剧烈,甚至不必立刻发生。随着时间推移和项目推进,你可能就会发现自己已陷入一个无法立足的境地。每做出一个关键决定,项目团队就会投身于一个更具体的目标——由于选择变少,视野会越来越狭隘。
一旦决定使用某个供应商的数据库,或是某个架构模式,抑或是特定的部署模型,就是在采取一系列无法回退的行动,除非付出巨大的代价。
假设在项目的早期,你决定使用来自供应商 A的关系型数据库。很久以后,在性能测试期间,却发现该数据库非常慢,而来自供应商 B的文档型数据库要快得多。对于大多数传统项目,这会很不幸。大多数情况下,对第三方产品的调用在整个代码中是纠缠不清的。但如果你真的将数据库的概念抽象出去——让它只是以服务形式提供持久化——现在你就可以灵活地中途换马了。
错误在于认为任何决定都是板上钉钉的——而没有为可能出现的意外做好准备。与其认为决定是被刻在石头上的,还不如把它们想象成写在海滩的沙子上。一个大浪随时都可能袭来,卷走一切。
提示18 不设最终决定
你能做的就是让修改更容易一点。将第三方API隐藏在自己的抽象层之后。将代码分解为多个组件:即使最终会把它们部署到单个大型服务器上,这种方法也比一开始做成庞然大物,然后再切分要容易得多。(我们的伤疤可以证明这一点。)
提示19 放弃追逐时尚
没有人知道未来会怎样,我们也不例外。要让你的代码具备“摇滚”精神:顺境时摇摆滚动,逆境时直面困难。
12 曳光弹
就像枪手一样,你在尝试于黑暗中击中目标。因为用户以前从未见过这样的系统,所以需求可能是模糊的。或许你可能正在使用不熟悉的算法、技术、语言或库,因此将面临大量的未知因素。由于项目需要时间去完成,所以几乎可以确定,在完成之前,工作所处的环境一定会改变。典型的反应是要把系统定死;把各种需求逐条列出来,制成大量的文件;约束好每一项未知的东西,并且限定环境;开火时采用航迹推算法。总之,前面先做大量的计算,然后开枪,希望能命中。然而,务实的程序员更喜欢使用相当于曳光弹的软件。
寻找重要的需求,那些定义了系统的需求。寻找你有疑问的地方,那些你认为有重大风险的地方。然后对开发进行优先级排序,首先从这些地方开始编码。
提示20 使用曳光弹找到目标
实际上,今天的项目架构都很复杂,有大量的外部依赖,需要诸多的工具,曳光弹也就变得更为重要。对于我们来说,最初的曳光弹就是,创建一个简单的工程,加一行“hello world!”,并确保其能编译和运行。然后,我们再去找整个应用程序中不确定的部分,添加上让它们跑起来的骨架。
曳光代码不是一次性的:编写它是为了持续使用。代码中需要包含所有的错误检查、结构、文档、自检查这些任何生产代码都应具备的东西。它只是功能还不完整。但是,只要在各个组件间,从一头到另一头全部打通,就可以检查出离目标有多么接近,并在必要时做出调整。一旦抵达目标,再添加功能就很容易了。
曳光弹式开发和项目不会结束这种理念是一致的:总有东西需要改,总有新功能需要加。这是一个逐步递增的方法。
使用曳光代码有很多优势:
- 用户可以更早地获得能工作的东西
- 如果能成功地传达你正在做什么(参见第288页的话题52:取悦用户),用户会知道他们看到的是不成熟的东西。这样他们就不会因为缺少功能而失望,而在系统有了明显的进展时则会欢呼鼓舞。用户的持续购买,和项目的推进一样,都会产生贡献。这样的用户,也极有可能会告诉你每次迭代离目标又接近了多少。
- 开发者构造了一个可以在其中工作的框架
- 最让人气馁的是一张上面什么也没写的纸。如果已经打通了应用程序的所有层面,并将它们通过代码表达出来,那么团队就不需要太多无中生有的东西。这使每个人都更有效率,并促进了一致性。
- 你有了一个集成平台
- 当系统已经打通时,你就有了一个环境,可以在做完单元测试后立刻把代码一块块加进来。
- 你有可以演示的东西
- 项目赞助商和公司高层倾向于在最不适当的时候想起看看演示。只要使用曳光代码,你总是有东西可以演示。
- 你对进度有更好的感觉
- 在曳光弹式开发中,开发人员逐个跟踪案例。做完一个后再做下一个,度量性能和向用户展示进度要容易得多。因为每个独立的开发环节要小得多,这样就避免了在编写巨型代码块时,一次次地在周报中汇报这块东西已经完成了95%。
曳光代码也一样。当你不能100%确定要做什么的时候,就用这个技术。如果你一开始的一系列尝试出错了:用户说“我不是这个意思”,或是数据在你需要的时候还没到位,或是有一些性能问题,请不要诧异,去弄清楚如何改变你已经做好的东西,想办法让它靠近目标。你会受益于这种精益的开发方式。小块代码惯性也小——容易快速地变更。你能够以更快的速度、更小的成本,收集到针对应用程序的反馈,并生成一个新的更准确的版本。而且,应用程序的每个主要组件都已经出现在曳光代码中,用户可以确信他们看到的东西基于现实,而不仅仅是一个纸面规范。
你可能认为曳光代码这个概念与原型制作无异,只不过换了个咄咄逼人的名字。但它们是有区别的。当使用原型时,你的目标是探索最终系统的特定方面。如果有了一个真正的原型,最终你将扔掉验证构思时捆绑在一起的所有东西,并总结经验教训,最后正确地重新编码。
原型生成的是一次性代码;曳光代码虽然简单但是完整,它是最终系统框架的组成部分。可以将原型制作看作是在发射一颗曳光弹之前进行的侦察和情报收集工作。
13 原型与便签
险,以一种能大幅降低成本的方式获得修正的机会。与汽车制造商一样,我们可以用原型去测试项目的单个或多个特定方面。
一般来说原型是基于代码的,但并非总是如此。像汽车制造商一样,我们可以用不同的材料制造原型。便签非常适合构建动态事务的原型,例如工作流和应用逻辑。用户界面的原型,可以是白板上的一幅画,也可以是用绘图程序画的一个无功能的模型,还可以通过界面制作工具来完成。
原型被设计出来,只是为了回答几个问题,因此比要投入生产的应用程序成本更低,开发速度更快。其代码可以忽略一些不重要的细节——那些以后可能对用户非常重要,但目前还不重要的东西。
你会选择用原型来研究什么类型的东西呢?答案是,任何有风险的东西,任何之前没有尝试过或对最终系统来说很关键的东西,任何未经证实、实验性或可疑的东西,以及任何让你不舒服的东西。
原型设计是为了学习经验。它的价值不在于产生的代码,而在于吸取的教训。这正是原型的意义所在。
提示21 用原型学习
当制作一个原型时,哪些细节可以忽略?
- 正确性
- 你可以在适当的地方使用替代数据。
- 完整性
- 原型只需要满足有限的功能,可能只有一个预先选好的输入数据片段及单个菜单选项。
- 健壮性
- 错误检查可以不完整,甚至完全没有都行。如果你偏离了预定的航线,原型机很可能烧毁在绚丽的烟火中——那又如何!
- 格式
- 原型代码可能并不需要太多注释和文档(尽管围绕从原型中获取的经验,可能会产生大量文档,但是相对而言,原型系统本身的文档要少得多)。
有许多原型用于对还在考虑中的整个系统建模。和曳光弹相反,原型系统中的所有单个模块都不需要有特别的功能。实际上,甚至可能不必编写代码来创建架构原型——在白板上贴一些便签和索引卡就够了。尽量推迟思考细节,因为你要确定的是,系统的各个部分是怎么结合成一个整体的。下面列出了一些特定领域,你可能希望在架构原型中找到其相关问题的答案:
- 主要组件的职责是否恰当,有没有定义清晰?
- 主要组件之间的协作是否定义清晰?
- 耦合度最小化了吗?
- 你能确定重复的潜在来源吗?
- 接口的定义和约束能否接受?
- 在执行过程中是否每个模块都有访问所需数据的途径?在需要数据的时候,能访问到吗?
如果没有设定正确的期望值,演示用的原型很容易因为表面的完整而产生欺骗性。项目赞助商和管理人员可能会坚持部署原型(或其后代)。提醒他们,你可以用软木和胶带做出一辆很棒的新车原型,但你不会在高峰时间驾驶它!
如果使用得当,原型利于在开发的早期就识别出潜在的问题点,并给予纠正——此时修正错误不仅廉价还容易。这能帮你节省大量的时间和金钱,极大地减少你的苦难。
14 领域语言
计算机的语言会影响你怎样思考问题,影响你怎样看待信息的传播。每一门语言都有一个特性列表——比如这些时髦的术语:静态类型还是动态类型,早期绑定还是晚期绑定,函数式还是面向对象,继承模型,mixin,宏机制——所有这些对问题的解决方案,既可能提供建议也可能扰乱视听。同样是设计解决方案,用C++的方式和用Haskell的思想,得到的结果会大为不同,反之亦然。与之相对,问题域的语言同样也能反过来启发程序设计的解决方案,而且我们认为这更为重要。
提示22 靠近问题域编程
收集需求、设计、编码、发布,这套传统的方法不再有效,原因之一是它离不开一个前提——我们知道需求是什么。可惜我们很少真的有所了解。商务用户对自己想要达成目标的设想很模糊,而且他们既不知道也不关心细节。这正是我们价值的体现之处——能凭直觉感知到意图并将其转换为代码。
Cucumber的测试和Ansible的配置是用它们自己的专用语言编写的。Cucumber的测试被转换成可运行的代码或是运行时所需的数据结构,而Ansible规范总是被转换成由 Ansible 运行时处理的数据结构。
因此,RSpec和路由代码是被嵌入运行的代码的:它们对你的编码词汇表做了真正的扩展。Cucumber和Ansible 被代码读出来,然后转换成代码可以使用的某种形式。
我们将 RSpec和路由视为内部领域语言的范例,与之相对,Cucumber和Ansible采用的是外部语言。
一般来说,内部领域语言可以利用其宿主语言的特性:创建出来的领域语言更为强大,而且这种威力是毫无代价的。
内部领域语言的缺点是会受到宿主语言的语法和语义的限制。尽管有些语言在这方面非常灵活,你仍然不得不在想要的语言和可以实现的语言之间做出妥协。
外部语言没有这样的限制。只要为这种语言编写一个解析器就可以了。有时可以使用其他人做的解析器(就像Ansible使用YAML那样),不过这样一来,就又回到了折衷的做法。
编写解析器可能意味着要向你的程序添加新的库和工具。编写一个好的解析器并不是一件简单的工作。但是,如果你有一颗坚强的心,可以试试bison或ANTLR之类的解析器生成器,或是诸如PEG之类的解析框架。
我们的建议相当简单:花费的努力不要比节省下来的还多。编写领域语言会给项目增加一些成本,所以你需要确信省下的花销(在可预计的长期)足以抵消它。
通常,如果可以的话,就使用现成的外部语言(如YAML、JSON或CSV)。否则就试试内部语言。我们建议仅当应用程序的领域语言开放给用户来写的时候,才选择外部语言。
15 估算
在估算的过程中,你将会加深对程序所处世界的理解。
通过学习估算,把这项技能发展为对事物的数量级产生直觉,你将能展现出一种魔法般的能力,这种能力可以判别事情的可行性。当有人说“我们要通过网络将备份上传到亚马逊S3上”时,你直觉上就能判断这是否可行。在你编码的时候,能知道哪个系统需要优化,哪些放在那里就够了。
提示23 通过估算来避免意外
- 多精确才够
- 在某种程度上,所有的答案都是估算,区别仅在于一些比另一些更精确。所以当有人让你估算的时候,你要问自己的第一个问题是,答案会用在什么场合下。对方需要很高的精度,还是只要一个大约的估计?
- 挑选答案的单位来反映想要传达的精确性。
- 估算从何而来
- 所有的估算都是基于对问题的建模。但是在我们深入建模技术之前,必须提到一个基本的估算技巧,用它总能给出不错的答案:问问已经做过的人。
- 理解在问什么
- 所有的评估工作的首要部分都是建立对所问内容的理解。除了上面已经讨论过的精确度问题,你还需要掌握问题域的范围。范围通常是问题的隐含前提,你只是需要养成习惯,在开始猜测之前就加以考虑。很多时候,你选择的范围会成为给出的答案的一部分:“假设没有交通事故,车里也有汽油,那么我会在 20 分钟内抵达那里。”
- 对系统建模
- 建模是估算中很有趣的部分。当你理解了被问的问题时,就开始为之建立一个粗略的思维模型框架。如果是估计响应时间,那么模型可能涉及一个服务器,以及进来的流量的一些状况。对于一个项目,模型可能是开发组织在开发期间需要的每个步骤,以及关于系统可能如何实现的粗略图景。
- 建模的过程,从长远来看,既富创造性又实用。通常,在建模的过程中会发现一些表面上看不出来的潜在模式和过程。你甚至可能会去想重新审视最初的问题:“你要一个对做X的预估,然而这事很像一个X的变种Y。而Y用一半的时间就能完成,仅仅只比X少一个特性而已。”
- 建模会给估算过程引入不准确性。这不可避免,但也是有益的。你在用模型的简单性来换取精确性。在模型上加倍的努力可能只会换来精度上的微小提升。经验会告诉你何时应停止精炼。
- 把模型分解成组件
- 一旦得到了模型,就可以将其分解为组件。你需要发掘出描述这些组件如何交互的数学规则。有时候组件向最终结果贡献的值是累加上去的,但有些组件提供的则是乘法因子,它们会更复杂(例如那些模拟抵达节点的流量的组件)。
- 确定每个参数的值
- 一旦参数被分离,就可以将它们过一遍,为每个参数分配一个值。这一步通常会引入一些误差。但关键是找出哪些参数对结果影响最大,集中精力确保其近乎正确。一般来说,就重要性而言,把价值累加到结果的参数,明显低于对结果有乘法或除法效应的参数。将一条线路的速度提高一倍,会让一小时内接收的数据总量翻倍,而增加 5 毫秒的传输延迟则不会产生明显的影响。
- 你应该用一个合理的方法来计算这些关键参数。例如在队列的例子中,最好试着度量一下现有系统的实际事务到达率,或者找到一个类似的系统度量一下。类似地,你可以度量一下目前服务单个请求所需的时间,或是使用本部分中描述的技巧做一个估算。事实上,你经常会发现自己的估算是建立在其他次级估算的基础上的。这会是犯下最大错误的地方。
- 计算答案
- 只有在最简单的案例中,估算才会有单一的答案。你也许能高兴地说:“我能在 15分钟内走过5个街区。”然而随着系统变得越来越复杂,你会避免正面回答。做多组计算,不断改变关键参数的值,直到能确定哪些参数在真正地主导模型。电子表格对此大有助益。然后围绕下面这些参数来表述你的答案——“如果系统配有SSD及32GB内存,那么响应时间大约是四分之三秒;如果内存是16GB,那么响应时间大约是1秒。”(注意到没有,“四分之三秒”传达出了与750ms不同感觉的精度。)
- 在计算阶段,你可能会得到一些奇怪的答案。不要急于否定。如果计算是正确的,那么错的可能是你对问题的理解,或者模型是错的。这是有价值的信息。
- 记录你的估算能力
- 我们认为记录下你做过的估算是一个好主意,这样可以看到做过的估算的准确程度。如果一个全面评估涉及多项次级评估,那么也要记录这些次级评估。
- 当估算错误时,不要只是耸耸肩就走开。找出为什么结果偏离了你的猜测。也许是选择的一些参数与问题的实际情况不匹配,也许是模型出错。不管是什么原因,都要花点时间去查出到底是怎么回事。只要这样去做,下一次的估算就会更好。
估算项目进度
一般你会被要求估计完成某件事需要多长时间。如果这件“事情”很复杂,那么估算起来就很难。在本部分中,我们将研究两种减少不确定性的技巧。
- 粉刷导弹
- 人们在现实世界中所做的估算。它不会仅有一个数字(除非你强迫他们给你一个数字),而是由一系列的预案构成。
- 当美国海军需要计划北极星潜艇项目时,他们采用了这种评估方法,并称之为计划评审技术(Program Evaluation Review Techningue,PERT)。每个 PERT 任务都有一个乐观的、一个最有可能的和一个悲观的估算。任务被排列进一个相互依赖的网络中,然后使用一些简单的统计方法来确定整个项目可能的最佳和最差时间。像上面这样使用一个带范围的值是一个好方法,它能避免最常见的那些导致估算错误的因素:因为不确定而随便填一个数字。相反,PERT背后的统计数据为你分散了不确定性,使你能够更好地估算整个项目。然而,我们对此兴趣不大。人们倾向于为项目中所有任务做一面墙那么大的图表,并潜在地相信,仅仅因为使用了一个公式,就能得到一个准确的估算。这不太可能成功,因为迄今为止从未有人如愿。
- 吃掉大象
- 我们发现,确定一个项目的时间表的唯一方法,通常来自于在这个项目上获得的经验。只要重复下列步骤做增量开发,这未必是个悖论。
- 检查需求
- 分析风险
- 设计、实现、集成
- 和用户一起验证
- 在初始阶段,到底需要多少次迭代,每次迭代需要多长时间,对这些你可能只有一个模糊的概念。有些方法要求你在初始阶段就将其明确下来,但对几乎所有不太平凡的项目来说,这样做都是错的。除非你在做的东西和上一个非常类似,且有一个相同的团队,还必须使用相同的技术,否则都只是猜测。
- 因此,先完成初始功能的编码和测试,然后将其标记为第一次迭代的结束点。基于这个过程积累的经验,可以用来提炼最初对迭代次数及每次迭代要做些什么的猜测。一次次地迭代下去,提炼出的东西会变得更好,对进度的信心也会随之增长。这种评估工作通常在每个迭代周期的末尾团队进行回顾时完成。
- 这也是一个老笑话里所讲的,怎样吃掉大象:一次咬一口。
提示24 根据代码不断迭代进度表
这可能不受管理人员的欢迎,他们通常在项目开始之前就想要一个简单可靠的数字。你必须帮助他们去理解,进度是由团队、团队的生产力和环境综合决定的。明确了这一点,把提炼进度表作为每次迭代的一部分,你就可以估算出能力范围内最精确的进度安排。
开始给你的估算做一个记录。每次都跟踪一下估算的准确程度。如果误差超过了50%,试着找到估算错误的原因。
第3章 基础工具
每个制造者在开始他们的职业生涯时,都会准备一套精良的基础工具。木工可能需要一些尺子、量规,几把锯子,好的刨刀,精细的凿子,钻头和支架,木槌以及夹子。这些工具是精心挑选出来的,打算一直使用下去,不同工具的用途之间很少重叠——也许更重要的是,这些工具会越用越称手。
工具会放大你的才能。工具越好,同时你越知道怎样用更好,效率就越高。一开始一组基础的通用工具就够用了。随着经验的增长,伴随着各种特殊需求的出现,你会扩充你的工具组合。这是和工匠学的——定期给工具箱添加工具。要一直寻找更好的做事方法。如果感觉手头的工具搞不定遇到的问题,先记录下来,再去试试其他的工具,只要它足够强大,就可能对你有帮助——让需求来驱使你不断选购新的工具。
花点时间学习使用这些工具,某一天你会惊讶地发现,自己十指翻飞敲击着键盘,在下意识地处理文本。这些工具将成为双手的延伸。
16 纯文本的威力
作为务实的程序员,我们的基础材料不是木头或铁块,而是知识。我们把需求以知识的形式收集起来,然后在设计、实现、测试和文档中表达这些知识。我们相信,纯文本是将知识持久地存储下来的最佳格式。纯文本赋予了我们操作知识的能力,既可以用手工的方式,也可以用编程的方式进行操作,事实上任何工具都可以拿来一用。
大多数二进制格式的问题是,理解数据所需的上下文与数据本身是分离的。这是在人为地将数据与其含义剥离。数据可能因此被加密起来;缺少了解析它们的应用逻辑,数据变得毫无意义。然而用纯文本,就有了一种自解释的数据流,而它和创建它的应用程序是相互独立的。
提示25 将知识用纯文本保存
人类可读形式的数据与自描述数据,会比所有其他形式的数据,以及创建数据的应用程序,更有生命力,这毋庸置疑。只要数据还在,就有机会用到——即使其时产生数据的应用程序可能已失效很久。
17 Shell游戏
图形界面非常棒,对于一些简单的操作来说,干起来可以更快更方便。移动文件、阅读电子邮件和输入信息这些事情,你可能都想在一个图形环境下完成。但是,如果使用图形界面去完成所有工作,就会错失环境的全部能力。你将无法把常见的任务自动化,或是无法充分利用工具所能提供的强大功能。并且,你也无法通过组合你的工具来创建定制的宏工具。图形工具的好处在于WYSIWYG ——所见即所得;弱势之处是WYSIAYG——所见即全部。
提示26 发挥 Shell 命令的威力
就像木工定制他们的工作空间一样,开发人员也应该定制自己的Shell。最典型的莫过于改变所用终端程序的配置。
18 加强编辑能力
提示27 游刃有余地使用编辑器
以一整年为跨度,即使编辑效率只提高了4%,只要每周花在编辑上的时间有 20小时,你每年就能凭空多出一周时间。
如果你操作编辑器游刃有余,最主要的收益来自于变得“顺畅”——不必再把心思花在编辑技巧上面。从思考到将想到的东西呈现在编辑器中的整个过程,没有阻塞,一气呵成。思考变流畅,编程就会受益。
首先,编辑时要自省。每次发现自己又在重复做某件事情的时候,要习惯性地想到“或许有更好的方法”,然后找到这个方法。
一旦你发掘出一个新的有用的特性,需要尽快把它内化成一种肌肉记忆,这样在使用的时候就能不假思索。据我们所知,能做到这点的唯一方法只有不断重复。
当你在使用编辑器过程中遇到明显的限制时,可以四处找找有什么扩展可以解决问题。极有可能你并非这个功能的唯一需求者,如果幸运的话,有其他人已经发布过解决方案。
更进一步,深入研究一下编辑器的扩展语言。搞明白怎样用它来将一些重复工作自动化——通常也就是一两行代码的事情。
有时你还会走得更远,不知不觉就写出一个完整的扩展。那么,不妨发布出去:你需要它,其他人也会需要的。
19 版本控制
但版本控制系统所做的事情远不只撤销错误。一个好的 VCS能让你通过跟踪变化来回答诸如此类问题:这行代码是谁改的?当前版本和上周版本的差异在哪里?这个发布版中我们修改了多少行代码?这类信息,对跟踪 Bug、审核、性能以及质量这些目标来说,意义非常重大。
VCS 还能帮你标识出软件的不同发布版本。一经标识,就总是能回到特定的版本并重新生成它,而不受之后可能发生的更改的影响。
最后,版本控制系统允许两个或多个用户同时处理同一组文件,甚至对同一文件进行并发更改。当文件被发送回仓库时,系统再来处理这些更改的合并。尽管看起来有风险,但是在各种规模的项目中,这样的系统实际上都能很好地工作。
提示28 永远使用版本控制
即使你只有一个人且项目一周就会结束,即使它是一个“用完即弃的”原型,即使你操作的不是源码,永远都应如此。确保所有内容都在版本控制之下——文档、电话号码列表、供应商备忘录、Makefile 文件、构建和发布过程、整理日志文件的小shell 脚本——所有的一切。我们会将自己输入的所有内容(包括这本书的文本)都例行公事地提交到版本控制系统中。即使不是在开发项目,我们也会用一个仓库将日常事务保护起来。
20 调试
对于许多开发人员来说,调试是一个敏感的、情绪化的主题。你可能会遇到拒绝、指责、站不住脚的借口或习惯性的漠视,而不是把它当作一个有待解决的难题来攻克。要接受这样一个事实:调试只是在解决问题并为此攻关。
提示29 去解决问题,而不是责备
Bug 是你的错还是别人的错并不重要。无论是谁的错,问题仍然要你来面对。
在开始调试之前,正确的心态非常重要。你需要关闭许多平日用来自我保护的防御机制,排除可能面临的任何来自项目的压力,让自己放松下来。
提示30 不要恐慌
人们很容易陷入恐慌,尤其是当最后期限逼近,或是在老板或客户站在背后紧张凝视之下,拼命找出问题原因的时候。然而,这时非常重要的是要退后一步冷静思考。对于那些你觉得是 Bug引起的症状,认真想想,到底什么会导致它们那个样子。
调试时要注意不要短视。不要仅仅去纠正你所看到的症状:更有可能的是,实际的错误可能与你所观察到的尚有几步之遥,并且可能涉及许多其他相关的事情。永远要去发掘问题的根本原因,而不仅仅停留在问题的表面现象。
提示31 修代码前先让代码在测试中失败
有时,只要强制自己对暴露 Bug的环境进行隔离,就能获得对如何修 Bug 的洞察力。写测试这个动作,就会揭示出解决方案。
提示32 读一下那些该死的出错信息
要知道,把纸笔放在旁边会很有帮助,这样可以随时做笔记。特别是,当无意中发现一个线索,一番顺藤摸瓜后却发现问题不在这里时,如果之前没有记下从哪里开始的,可能会在找回源头上浪费很多时间。
有一个非常简单但特别有用的方法,可以用来找到问题的原因,那就是向其他人解释该问题。你找的人只须要越过你的肩膀看着屏幕并不断点头(就像一只橡皮鸭在浴缸里上下跳动),一句话都不用说。你一步一步地解释代码用来做什么,这一简单的做法常常能让问题跳出屏幕来暴露自己。
这听起来很简单,但是在向另一个人解释这个问题时,你必须明确地陈述自己检查代码时可能认为理所当然的事情。通过把这些假设用语言表达出来,你可能会突然对这个问题有了新的认识。如果你找不到一个人来听,那么用橡皮鸭、泰迪熊或盆栽植物也可以。
提示33 “select”没出问题
如果你“只改变了一个东西”,然后系统就不工作了,那么这个东西就最可能直接或间接地负有责任,不管看起来多么牵强。有时改变的东西超出了你的控制范围:更新操作系统、编译器、数据库或其他第三方软件的版本,可能会破坏以前正确的代码,因而出现新的 Bug。之前如果发现 Bug,你会想办法绕过去,但当这些 Bug 被修复后,你当初绕过 Bug的方案却不能用了。API 改了,功能变了;简而言之,这是一个全新的局面,你必须在这些新的条件下重新测试系统。因此,在考虑升级时,请密切关注时间表;有时等到下一次发布之后再做升级也许更合理。
提示34 不要假设,要证明
当你遇到一个意外的 Bug时,除了修复它,还需要确定为什么没有更早地发现这个错误。考虑一下,是否需要修改单元测试或其他测试,以让这些测试能够捕获到它。
如果修复这个 Bug花了很长时间,问问自己为什么。你能做些什么来让下次修复这个Bug 更容易呢?也许可以构建更好的测试钩子,或是编写一个日志文件分析器。
最后一点,如果 Bug是因为某人的错误假设造成的,那么就与整个团队讨论这个问题:如果一个人误解了,那么很可能很多人都误解了。
21 文本处理
这些语言是重要的赋能技术。运用它们,你可以快速地做出工具,或是为想法建立原型——而使用传统语言则可能需要五到十倍的时间。对于做一些实验性质的东西来说,这种放大效果至关重要。花30分钟尝试一个疯狂的想法,比花5个小时要好得多。花一天时间对项目的重要组件进行自动化是可以接受的;但要花上一周时间就未必了。
提示35 学习一门文本处理语言
22 工程日记
日记本有三大好处。
- 它比记忆更可靠。人们可能会问:“你上周打电话问的那个有电力供应问题的公司叫什么名字?”你只需翻回一页左右,说出名字和号码。
- 它为你提供了一个地方,用来保存与当前任务无关的想法。这样你就可以继续专注于正在做的事情,并知道这个伟大的想法不会被遗忘。
- 它就像一种橡皮鸭(在第96页讨论过)。当你停下来,把东西写上去的时候,大脑可能会换档,几乎就像在和某人说话一样——这是一个反思的好机会。你可能在开始做笔记的时候,突然意识到刚刚做的事情,也就是笔记的主题,是完全错误的。
还有一个额外的好处。你能时不时地回想起多年以前你在做什么,会想到那些人、那些项目,以及那些糟糕的衣服和发型。
第4章 务实的偏执
提示36 你无法写出完美的软件
务实的程序员则更进一步,他们连自己也不相信。既然没人能写出完美的代码,那么也包括自己在内。务实的程序员会为自己的错误建立防御机制。
23 契约式设计
伯特兰·迈耶(《面向对象软件构造》[Mey97])在 Eiffel 语言中发明了契约式设计的概念。这是一种简单但功能强大的技术,侧重于文档化(并约定)软件模块的权利和责任,以确保程序的正确性。什么是正确的程序?不多也不少,正好完成它主张要做的事情的程序。文档化及对主张进行检验是契约式设计(缩写为 DBC)的核心。
软件系统中的每一个函数和方法都力争有所作为。在开始做事之前,这个函数可能对世界的状态有一些期望;当结束时,或许它也能够对世界的状态做出一个陈述。迈耶将这些期望和主张描述如下。
- 前置条件
- 为调用这个例程,必须为真的是什么?例程的需求。一个例程永远不应该在前置条件被违反的时候被调用。传递良好的数据是调用者的责任(参见第108页的知识栏)。
- 后置条件
- 例程保证要做的是什么?例程完成时世界的状态。例程有后置条件这个事实,意味着能得出这样的结论——不允许无限循环。
- 类的不变式
- 从调用者的角度来看,类会确保该条件始终为真。在例程的内部处理期间,可以不遵守不变式,但是当例程退出并将控制权返回给调用者时,不变式必须为真。(注意,一个类不能给参与不变式的任何数据成员不受限制的写访问权限。)
例程和任何潜在调用者之间的契约因此可以被解读为
如果调用者满足了例程的所有前置条件,则例程应保证在完成时所有后置条件和不变式都为真。
如果任何一方未能履行契约,就会调用(之前已经同意的)补救措施——可能是抛出异常,或者程序终止。不管发生什么事,毫无疑问,不能履行契约都是一个 Bug。这是不应该发生的事情,也正缘于此,不应该使用前置条件来执行诸如用户输入验证之类的操作。
提示37 通过契约进行设计
在正交性中,我们建议编写“害羞”的代码。而在这里,强调的是“懒惰”的代码:在开始之前,对要接受的东西要求严格一点,并且尽可能少地对回报做出承诺。请记住,如果你订的契约是可以接受任何东西,并且承诺要回报整个世界,那么你就有很多代码要写!
在任何编程语言中,无论是函数式的、面向对象的,还是过程式的,DBC 都会迫使你去思考。
在函数式语言中,通常将状态传递给函数并接收更新后的状态。
DBC 和测试是关于正确性这个更宽泛话题的不同方法。它们都有价值,在不同的情况下各有用途。DBC 与特定的测试方法相比,有以下几个优点:
- DBC 不需要任何设置和模拟器
- DBC 定义出参数何时成立、何时失败的所有情况,而测试则一次只针对一种特定的情况
- TDD 及其他测试只发生在构建循环中的“测试环节”。但 DBC 和断言永远存在,无论在设计时、开发时、部署时,还是维护时
- TDD 在测试时并不重点考虑对代码中不变式的检查,更多的是以一种黑箱风格检查公共接口
- 与防御性编程相比,DBC 更高效(也更 DRY),因为在防御性编程中,每个人都必须校验数据,以防有人没有校验。
在编写代码之前,简单地列出输入域的范围、边界条件是什么、例程承诺要交付什么——或者更重要的是,没有承诺要交付什么——这些对编写更好的软件来说,是一个巨大的飞跃。不说清楚这些内容,就回到了巧合式编程(参见第204页的讨论),这是许多项目开始、结束、最终失败的地方。
对于不支持在代码中 DBC的语言,你或许只能止步于此——也不是太糟糕。DBC 毕竟是一种设计技术。即使没有自动检查,也可以将契约作为注释加入代码或是写入单元测试,并且仍然可以获得非常实际的好处。
一定不要把固定的、不可违背的需求,与那些仅仅可能是随着新管理层上任而改变的策略相混淆。这就是我们使用语义不变式这个术语的原因——它必须是事物意义的核心,而不受策略的影响(策略用于更动态的业务规则)。
当你发现一个需求符合这个标准时,确保它成为你维护的文档中众所周知的部分,无论是什么文档——不管是一式三份签署的需求文档中的带圆点的列表,还是每个人都能看到的公共白板上的大通告。尽量把它定义得清楚又无歧义。
24 死掉的程序不会说谎
人们很容易陷入“这不可能”的心态。我们大多数人编写的代码,都没有检查文件关闭操作是否成功,或者确认 trace语句是否按预期写进输出里。这些事情都有一个共性,似乎我们不需要这么干——在所有正常情况下,前面所讨论的那些代码都不会失败。但我们在防御性编码——要确保数据是我们想要的,确保产品中使用的代码就是我们以为的那些代码,我们会检查依赖项的正确版本是否已经加载。
所有的错误对你来说都是信息。你可以说服自己错误不可能发生,然后选择忽略它。但是务实的程序员会告诉自己,如果出现错误,就意味着已经发生了非常糟糕的事情。别忘了看那该死的错误信息(参见第93页的身处陌生之地的程序员)。
首先,应用程序代码并不会因为错误处理而黯然失色。其次,也许更重要,代码的耦合性更低。在冗长版的那个例子中,我们必须列出add_score_to_board方法可能抛出的每个异常。如果该方法的作者添加了另一个异常,则代码过时,难以察觉。对于更务实的第二个版本,新的异常会自动传播。
提示38 尽早崩溃
尽快检测问题的好处之一是,可以更早崩溃,而崩溃通常是你能做的最好的事情。
乔·阿姆斯特朗,Erlang 的发明者,《Erlang 程序设计》[Arm07]的作者,有一句反复被引用的话:“防御式编程是在浪费时间,让它崩溃!”在这些环境中,程序被设计成允许出故障,但是故障会由监管程序掌控。监管程序负责运行代码,并知道在代码出故障时该做什么,这可能包括在代码出错后做清理工作、重新启动等。当监管程序本身出错时会发生什么?它自己还有一个监管程序来管理这些事件,从而形成一种由监管程序树构成的设计。该技术非常有效,有助于解释这些语言在高可用性、容错性系统中的用法。
一旦代码发现本来不可能发生的事情已发生,程序就不再可靠。从这一时刻开始,它所做的任何事情都是可疑的,所以要尽快终止它。
一个死掉的程序,通常比一个瘫痪的程序,造成的损害要小得多。
25 断言式编程
提示39 使用断言去预防不可能的事情
无论何时,你发现自己在想“当然这是不可能发生的”时,添加代码来检查这一点。最简单的方法是使用断言。
断言对算法操作的检查也很有用。
不要使用断言来代替真正的错误处理。
如果我们为检测错误而添加的代码最终“滋生”了新的错误,那就很尴尬。如果对条件做评估本身有副作用,就可能发生这样的事情。
对于断言有一个常见的误解,大概是这样的:断言给代码增加了一些开销。因为它们在检查不应该发生的事情,所以只会被代码中的Bug 触发。一旦代码被测试过并发布出去,就不再需要断言了,应该关闭它们以让代码运行得更快。断言是一种调试设施。这里有两个明显错误的假设。首先,假设了测试可以发现所有的 Bug。实际上,对于任何复杂的程序,在所有执行代码的路径中,哪怕只是很小的一部分,都不太可能测试全。其次,乐观主义者忘记了程序是在一个危险的世界中运行的。在测试期间,老鼠不太可能会咬穿通信电缆,玩游戏的人也不大会耗尽内存,日志文件也不会写满存储分区。而一旦程序在生产环境中运行时,这些事情都可能发生。第一道防线是测试任何可能的错误,而第二道防线则是使用断言来检测你没想到的东西。
26 如何保持资源的平衡
无论在什么时候,我们写代码都要管理资源:内存、事务、线程、网络连接、文件、计时器——所有可用的数量有限的东西。大多数情况下,资源使用遵循一个可预测的模式:分配资源,使用它,然后释放它。
提示40 有始有终
分配资源的函数或对象,对释放资源应负有责任。
当有疑问时,缩小范围总是有好处的。
提示41 在局部行动
释放资源的顺序与分配资源的顺序相反。在这样的次序下,如果一个资源包含对另一个资源的引用,就不会让被依赖的资源提前释放。
在代码的不同位置,如果都会分配同一组资源,就始终以相同的顺序分配它们。这将减少死锁的可能性。
例如,你是怎样处理日志文件的?日志会不断创建数据并逐步耗尽存储空间。是否可以用合适的方法来滚动日志并将其清理干净?那些非官方调试文件被扔到哪里去了?如果日志被写入数据库,是否有类似的过程来废弃过期的日志?对于你创建出的占用有限资源的任何东西,请考虑一下如何保持它的平衡。
如果资源分配失败并引发异常,会发生什么情况?finally子句将截获该异常,并尝试释放一个从未分配的东西。
有时,资源分配的基本模式并不合适。这种情况通常出现在使用动态数据结构的程序中。一个例程会分配一个内存区域并将它链接到某个更大的结构中,在那里这块内存可能会停留一段时间。这里有一个技巧,可用来为内存分配建立一个语义不变式。你需要确定由谁来负责聚合的数据结构中的数据,以及当释放顶层结构时会发生什么。有三种主要的方案:
- 顶层结构同时负责释放它所包含的任何子结构。然后这些结构递归地删除它们包含的数据等。
- 顶层结构只做简单的释放。它所指向的每个结构(没有其他地方引用)都变得无处引用。
- 如果包含任何子结构,则顶层结构拒绝释放自己。
因为务实的程序员不相信任何人,包括自己。所以,写一些代码去实际检查资源有没有被恰当地释放,绝对是一个好主意。对于大多数应用程序,我们可以为每种类型的资源都做一个封装器,使用这些封装器去跟踪所有的分配和释放操作。在代码中的某些特定的位置,程序逻辑能指明资源应处于某种状态:利用封装器去检查是否是这样的。例如,长期运行的处理请求的服务程序,都可能在其主循环的顶部有一个单点,程序会在此等待下一个请求的到来。这就是一个好位置,可以在这里确保资源的用量并没有较上个执行循环不断递增。
27 不要冲出前灯范围
提示42 小步前进——由始至终
总是采取经过深思熟虑的小步骤,同时检查反馈,并在推进前不断调整。把反馈的频率当作速度限制,永远不要进行“太大”的步骤或任务。
越是必须预测未来会怎样,就越有可能犯错。与其浪费精力为不确定的未来做设计,还不如将代码设计成可替换的。当你想要丢弃你的代码,或将其换成更合适的时,要让这一切无比容易。使代码可替换,还有助于提高内聚性、解耦和DRY,从而实现更好的总体设计。
即使你对未来充满信心,但总有机会遇到黑天鹅。
提示43 避免占卜
很多时候,明天看起来会和今天差不多,但不要指望一定会这样。
第5章 宁弯不折
28 解耦
耦合是修改之敌,因为它将事情连接在一起,要改就得一起改。这使得修改变得更加困难:要么需要花上不少时间,弄清楚所有需要修改的地方到底有哪些,要么又会因为“仅仅只修改一处”而没有跟着改与之相耦合的地方,把时间花在想明白为什么会出问题上。
当设计桥梁的时候,你想让其保持自己的形状,需要它们具备刚性。但当设计未来会变化的软件时,想要的恰恰相反:你希望它是灵活的。为了更灵活,单个组件应该与尽可能少的其他组件耦合。
更糟糕的是,耦合有传递性:如果 A与B、C耦合,B与M、N耦合,C与X、Y耦合,那么A实际上与B、C、M、N、X及Y耦合。
提示44 解耦代码让改变更容易
再就是注意留心一些耦合的“症状”:
- 不相关的模块或库之间古怪的依赖关系
- 对一个模块进行的“简单”修改,会传播到系统中不相关的模块里,或是破坏了系统中的其他部分
- 开发人员害怕修改代码,因为他们不确定会造成什么影响
- 会议要求每个人都必须参加,因为没有人能确定谁会受到变化的影响
提示45 只管命令不要询问
这个原则说的是,不应该根据对象的内部状态做出决策,然后更新该对象。这样做完全破坏了封装的优势,并且在这样做时,也会把实现相关的知识扩散到整个代码中。
在每个应用程序中,都有一些通用的顶层概念。在这样的应用程序中,顶层概念包括客户和订单。将订单完全隐藏在客户对象中是没有意义的:它们有自己的存在价值。因此,我们完全可以创建出暴露订单对象的API。
得墨忒耳法则
在关于耦合的讨论中,人们经常提及得墨忒耳法则,或简称LoD。LoD是由伊恩·霍兰德在20世纪80年代末提出的一组参考指南。创建这组指南是为了帮助得墨忒耳项目的开发人员保持函数简洁和解耦。
LoD 说的是,定义在C 类中的函数只应该调用:
- C类其他实例的方法
- 它的参数
- 它所创建出来的对象的方法,包括在栈上和堆上的对象
提示46 不要链式调用方法
当你访问某样东西时,尽量不要超过一个“.”。
“一个点”规则有一个很大的例外:如果你链式调用的东西真的不太可能改变,那么这个规则就不适用。在实践中,应用程序中的任何内容,都应该被认为是可能发生改变的。第三方库中的任何东西都应该被认为是易变的,特别是如果已知该库的维护者在版本之间修改过API。
这并不是说管道不会引入耦合:它们也引入了耦合。管道中的函数所返回的数据格式必须与下一个函数所接受的格式兼容。
我们的经验是,这种形式的耦合对修改代码所造成的障碍,远远小于导致铁道事故的那种形式。
全局可访问的数据是应用程序组件之间耦合的潜在来源。每一块全局数据就好像让应用程序中的每个方法都突然获得了一个额外的参数:毕竟,全局数据在每个方法中都是可用的。
我们的经验是,重用可能不是创建代码时主要考虑的问题,但是探求代码的可重用性应该作为例行编码过程的一部分。当你使代码可重用时,就给了它干净的接口,将其与其他代码解耦。这允许你在提取一个方法或模块时,不需要同时牵扯出其他东西。如果代码使用了全局数据,则很难将其与其他部分分离。
当给使用全局数据的代码编写单元测试时,你将看到这个问题。你将发现自己需要编写一组设置代码来创建一个全局环境,以便让测试能够运行。
提示47 避免全局数据
如果你拥有的只是一个带有大量导出实例变量的单件,那么它仍然只是全局数据,只是用起来名字比较长而已。
任何可变的外部资源都是全局数据。如果应用程序使用了数据库、数据存储、文件系统、服务 API等,那么它就有落入全局化陷阱的风险。同样,解决方案是确保始终将这些资源包装在你所控制的代码之后。
提示48 如果全局唯一非常重要,那么将它包装到API中
耦合的代码很难变更:一个地方的修改可能会对代码的其他地方产生副作用,而且通常产生在难以找到的地方,以至于1个月后才会在生产环境中显露出问题。
让代码害羞一点:让它只处理直接知道的事情,这将有助于保持应用程序解耦,使其更易于变更。
29 在现实世界中抛球杂耍
事件表达出信息的可用性。它可能来自外部世界:用户点击了按钮,或是股票报价更新了。它也可能是内部的:计算的结果已经准备好了,搜索完成了。它甚至可以是像获取列表中的下一个元素这样简单的事情。
不管来源是什么,如果我们编写响应事件的应用程序,并根据这些事件调整程序的行为,那么这些应用程序将在现实世界中更好地工作。用户会发现它们更具交互性,应用程序本身也会更好地利用资源。
但我们如何编写这类应用程序呢?如果没有某种策略,我们很快就会陷入困惑,应用程序将是一堆紧密耦合的代码。
下面看看能帮助我们的四个策略。
1.有限状态机
2.观察者模式
3.发布/订阅
4.响应式编程与流
状态机基本上就是怎样处理事件的一份规范。它由一组状态组成,其中一个是当前状态。对于每个状态,我们列出对该状态有意义的事件。对于每个事件,我们定义出系统新的当前状态。
一个纯粹的FSM,就像我们刚才看到的,是一个事件流解析器。它唯一的输出是最终的状态。我们可以通过在某些转换上添加触发动作来增强它。
在观察者模式中,我们有一个事件源,被称为被观察对象;而客户列表,也即观察者,会对其中的事件感兴趣。
观察者根据其兴趣被注册到观察对象上,这通常由传递一个待调用的函数引用来实现。随后,当事件发生时,观察对象遍历它的观察者列表,并调用每个传递给它的函数。事件作为调用参数提供给函数。
但是观察者模式有一个问题:因为每个观察者都必须与观察对象注册在一起,所以它引入了耦合。此外,由于在典型的实现中,回调是由观察对象以同步的方式内联处理的,因此可能会导致性能瓶颈。
发布/订阅(pubsub)推广了观察者模式,同时解决了耦合和性能问题。
在 pubsub模式中,我们有发布者和订阅者。它们是通过信道连接在一起的。信道在单独的代码块中实现:有时是库,有时是进程,有时是分布式基础设施。所有这些实现细节对代码来说都是隐藏的。
每个信道都有一个名字。订阅者注册感兴趣的一个或多个具名信道,发布者向信道写入事件。与观察者模式不同,发布者和订阅者之间的通信是在代码之外处理的,并且可能是异步的。
pubsub 是一种很好的解耦异步事件处理过程的技术。它允许在应用程序运行时添加和替换代码,而无须更改现有代码。其缺点是,很难查看在一个重度使用 pubsub模式的系统中发生了什么:无法在查看发布者的同时立即看到有哪些订阅者涉及特定的消息。
与观察者模式相比,pubsub模式是一个通过用共享接口(信道)进行抽象来减少耦合的好例子。然而,它基本上仍然只是一个消息传递系统。
很明显,事件也可以用来在代码中触发响应,但是将它们“楔”进去并不一定容易。这时就该引入流这个概念了。
流让我们把事件当作数据集合来对待。这就好像我们有一个事件列表,当新事件到达时,列表会变长。它的美妙之处在于,我们可以像对待任何其他集合一样对待流:我们可以操作、合并、过滤,以及做我们所熟知的所有其他针对数据所做的事情。我们甚至可以将事件流和常规集合组合在一起。流可以是异步的,这意味着你的代码有机会按事件到来的方式去回应事件。
事件流通常以事件触发的形式增殖,这意味着导致其增殖的被观察对象可以并行运行。
事件到处都是。有些是显而易见的:一次按钮点击,一个计时器到期。有些则没那么简单:有人登录进来,文件中的一行匹配了一个模式。但是,无论事件源是什么,围绕事件编写的代码都比对应的线性代码更容易响应,解耦效果也更好。
30 变换式编程
所有程序其实都是对数据的一种变换——将输入转换成输出。然而,当我们在构思设计时,很少考虑创建变换过程。相反,我们操心的是类和模块、数据结构和算法、语言和框架。
我们认为,从这个角度关注代码往往忽略了要点——我们需要重新把程序视为从输入到输出的一个变换。当这样做的时候,许多以前操心的细节就消失了。结构变得更清晰,错误处理更加一致,耦合下降了很多。
提示49 编程讲的是代码,而程序谈的是数据
有时候,找到变换的最简单方法是,从需求开始并确定它的输入和输出。现在你已经将整个程序表示为函数。然后就可以找到从输入到输出的步骤。这是一个自顶向下的方法。
如果你有面向对象编程的背景,那么就会条件反射似的要求隐藏数据,并将数据封装在对象中。结果这些对象会来回折腾,改变彼此的状态。这就引入了很多耦合,因而也成为 OO系统难于更改的一个重要原因。
提示50 不要囤积状态,传递下去
在变换式模型中,我们将其颠倒过来。不要把数据看作是遍布整个系统的小数据池,而要把数据看作是一条浩浩荡荡的河流。数据成为与功能对等的东西:管道是一系列的代码→数据→代码→数据……数据不再和特定的函数组一起绑定在类定义中。相反,当应用程序将其输入转换为输出时,可以自由地表达自己的展开过程。这意味着我们可以极大地减少耦合:一个函数可以在任何地方使用(并重用),只要其参数与其他函数的输出相匹配。
到目前为止,我们的变换已经在一个不出错的世界中工作起来了。但是,又如何在现实世界中使用它们呢?如果我们只能建立线性链,那么怎样添加错误检查所需的所有条件逻辑?
有许多方法可以做到这一点,但是所有方法都依赖于一个基础约定:永远不在变换之间传递原始值。取而代之的是,将值封装在一个数据结构(或类型)中,该结构可以告知我们所包含的值是否有效。例如,在 Haskell中,这个包装器被称为 Maybe。在 F#和 Scala中是 Option。
我们面临的问题是,当错误发生时,我们不希望继续运行管道后面的代码,也不希望让后面的代码感知到错误正在发生。这意味着我们需要暂缓管道函数的运行,直到我们知道管道中先前的步骤已经完成。为此,需要将代码从函数调用改写为一个函数值,以供稍后调用。
将代码看作一系列(嵌套的)变换,可以为编程打开思路。这需要一段时间来适应,但是一旦你养成这个习惯,将发现代码变得更简洁,函数变得更短,而设计也变得更平顺。
31 继承税
继承首次出现于 1969年的 Simula 67中。对于在同一个列表中排入多种类型事件的问题,这曾是一个优雅的解决方案。Simula的方法使用的是一种被称为前缀类的东西。
Simula 程序员使用的心智模型是,link 类的实现及实例的数据被预先添加在 car这个类和 bicycle这个类的实现里。link 的部分差不多被看作是装载汽车和自行车的容器。
两种类型的继承(实际上有相当多的共同点)在接下来的几十年里发展起来。 Simula方法认为继承是一种将类型组合起来的方法,这种方法在 C++和 Java等语言中得到了延续。Smalltalk学派认为,继承是一种动态的行为组织,这在 Ruby和 JavaScript等语言中都可以看到。
就这样,到了今天,我们面对的是 OO开发者这一代人,他们使用继承有两个原因:一个是不喜欢拍键盘,另一个是喜欢类型。
那些不喜欢拍键盘的人,通过继承将基类的公共功能添加到子类中,来保护他们的手指:User类和 Product类都是 ActiveRecord::Base的子类。
那些喜欢类型的人,通过继承来表达类之间的关系:汽车是一种交通工具。
不幸的是,这两种形式的继承都有问题。
继承就是耦合。不仅子类耦合到父类,以及父类的父类等,而且使用子类的代码也耦合到所有祖先类。
有些人认为继承是定义新类型的一种方式。他们最喜欢的设计图表,会展示出类的层次结构。他们看待问题的方式,与维多利亚时代的绅士科学家们看待自然的方式是一样的,即将自然视为须分解到不同类别的综合体。
不幸的是,这些图表很快就会为了表示类之间的细微差别而逐层添加,最终可怕地爬满墙壁。由此增加的复杂性,可能使应用程序更加脆弱,因为变更可能在许多层之间上下波动。
然而,更糟糕的是多重继承问题。汽车可以是一种交通工具,但它也可以是一种资产、保险项目、贷款抵押品,等等。正确地对此进行建模需要多重继承。
提示51 不要付继承税
让我们推荐三种技术,它们意味着你永远不需要再使用继承:
- 接口与协议
- 委托
- mixin 与特征
接口与协议之所以如此强大,是因为我们可以将它们用作类型,而实现适当接口的任何类都将与该类型兼容。
提示52 尽量用接口来表达多态
接口与协议给了我们一种不使用继承的多态性。
继承鼓励开发人员创建这样的对象,其类拥有大量的方法。如果父类有 20 个方法,而子类只想使用其中的两个,那么其对象还是会将其他 18 个方法放在那里,并使其能被调用。类失去了对其接口的控制。
提示53 用委托提供服务:“有一个”胜过“是一个”
基本思想很简单:希望能够为类和对象扩展新的功能,但不用继承。那么就创建一组函数,给这个函数组起一个名字,然后用它去扩展一个类或对象。至此,你就创建出了一个新的类或对象,它组合了原始类及所有 mixin 的特性。在大多数情况下,即使无法使用要扩展的类的源码,也可以进行此扩展。
提示54 利用 mixin 共享功能
无论你的目的是共享类型信息、添加功能,还是共享方法,在不同的场景下,都会有一个方案更合适。与编程中的任何事情一样,选一个最能表达你意图的技术。
32 配置
如果代码依赖某些值,而这些值在应用程序发布后还有可能改变,那么就把这些值放在程序外部。当应用程序于不同的环境中运行,而且面对不同的用户时,将和环境相关、用户相关的值放在应用之外。通过这种方式来参数化应用程序,让代码适应其运行的环境。
提示55 使用外部配置参数化应用程序
许多框架和相当多的可定制程序,将配置直接保存在文件中,或放在数据库的表内。如果信息直接放在文件中,倾向于用一些现成的纯文本格式。目前,YAML和 JSON非常流行。有时,有些用脚本语言编写的应用程序会分出几个源码文件,专门用来包含配置数据。如果信息是结构化的,而且很可能被用户改变(例如税率),可能储存到数据库的表中更好。当然,你可以两者一起使用,根据配置信息的用途分开存放。
无论使用什么形式,配置都将作为数据结构被读入应用程序,这通常发生在应用程序启动时。一般来说,这种数据结构会被设计成全局的,这样考虑是因为可以让代码的每个部分都更容易获得配置的值。
我们希望你不是这样做的,而是将配置信息包装在一个(瘦)API后面。这将使代码从配置的呈现细节中解耦出来。
虽然静态配置很常见,但目前我们倾向于另一种做法。我们仍然希望配置数据保持在应用程序外部,但不直接放在文件中,也不放在数据库里;而是储存在一个服务 API之后。这样做有很多好处:
- 在身份认证和访问权限控制将多个应用程序的可见内容阻隔开的情况下,让多个应用程序可以共享配置信息
- 配置的变更可以在任何地方进行
- 配置数据可以通过专有 UI 维护
- 配置数据变得动态
最后一点,配置应该是动态的,这在我们转向高可用性应用程序时至关重要。为了改变单个参数就必须停下来重启应用程序,这样的想法已完全脱离当前的现实。使用配置服务,应用程序的组件可以注册所使用参数的更新通知,服务可以在配置被更改时发送包含了新值的消息。
无论采用何种形式,配置数据都会驱动应用程序的运行时行为。当配置值更改时,不需要重新构建代码。
不要因为懒,就放弃做决策,而把分歧做成配置。如果对某个特性应该这样工作还是那样工作真的存在争议,或者不知道是否应该让用户选择,那么就去找个方法试试,通过反馈来决定哪一种更好。
第6章 并发
在一个规模适度的系统中编写代码,完全不涉及并发方面的事情,几乎是不可能的。并发可能是外显的,也可能埋藏在库内部。如果希望你的应用程序能够在由异步构成的现实世界中打拼,那么并发性是一个必要条件:用户在交互中,数据在获取中,外部服务在调用中,所有这些都是同时进行的。如果强迫这个过程串行——一件事发生后才能进行下一件事,以此类推,则系统的反应会变得迟钝,运行代码的硬件也可能难以充分发挥自己的能力。
开发人员经常讨论代码块之间的耦合。他们指涉的是依赖关系,以及这些依赖关系是如何让事物变得难以变更的。但还有另一种形式的耦合。如果代码给几件事情强加一个顺序进来,而这个顺序对解决手头问题而言并非必需,就会发生时域耦合。
33 打破时域耦合
时间是软件架构中经常被忽略的一个方面。唯一让我们担心的是日程表上的时间,发布前还剩的时间——不过这不是我们这里想讨论的内容。我们讨论的是,时间在作为软件本身的设计元素时所担当的角色。时间对我们来说有两个重要的方面:并发性(在同一时刻发生的多件事情)以及次序(事情在时间轴上的相对位置)。
我们需要考虑并发性,并且考虑对时间依赖或顺序依赖解耦。通过这样做,我们可以在开发的许多领域中获得灵活性并减少任何基于时间的依赖:工作流分析、体系结构、设计和部署。结果将是系统更容易推理,潜在的响应更快、更可靠。
提示56 通过分析工作流来提高并发性
活动图由一组用圆角框表示的活动构成。从一个活动出发的箭头可以指向另一个活动(这个活动能在前一个活动完成后启动),也可以指向被称为同步条的粗实线。一旦指向同步条的所有活动都已完成,就可以处理所有离开同步条的箭头。如果一个活动没有被任何箭头指向,就可以在任意时间启动。
你可以在活动图中标识出能够并行和不能并行的活动,以此来最大化并行性。
Elixir语言编译器的工作方式,就是一个实践中的有趣例子。在编译器启动时,它会将正在构建的项目分解成不同模块,然后并行地编译每个模块。有时一个模块会依赖别的模块,在这种情况下,编译将暂停,直到被依赖模块的构建结果可用为止。当顶层模块编译完成时,就意味着所有依赖项都已编译完毕。最终结果是获得了一个能利用所有(处理器)核心的快速编译器。
34 共享状态是不正确的状态
提示57 共享状态是不正确的状态
简单而言,信号量是一个在同一时间只能让一个人持有的东西。你可以创建一个信号量,然后利用它来控制对其他资源的访问。
这种方法存在一些问题。可能最重要的一点是,这个方法只有在访问陈列柜的每个人都同意使用信号量时才有效。如果有人忘记使用(换句话说,一些开发人员编写的代码不符合约定),那么我们又会陷入混乱。
让资源具备事务性
当前的设计很糟糕,因为该设计将保护陈列柜访问权的责任委托给了使用它的人。让我们做出改变,将其调整为集中控制。要做到这一点,我们必须改变 API,以便服务员在单次调用中,既能检查派的数量又能获得一块派
务实的思路是“派加冰激凌本身就是一种资源”。将这段代码移到一个新模块中,这样客户接下来只需说“给我拿个派加冰淇淋”,要么成功,要么失败。
当然,在现实世界中可能会有许多这样的复合菜品,你不会想为每个菜品都编写新模块。你更需要的是某种形式的“菜单条目”,让这些条目来引用包含的配料,然后实现一个通用的 get_menu_item 方法以调配每个条目所需的资源。
提示58 随机故障通常是并发问题
35 角色与进程
- 角色是一个独立的虚拟处理单元,具有自己的本地(且私有的)状态。每个角色都有一个信箱。当消息出现在信箱中且角色处于空闲状态时,角色被激活并开始处理消息。处理完该条消息后,它将继续处理信箱中的其他消息,如果信箱是空的,则返回休眠状态。
- 在处理消息的过程中,一个角色可以创建其他角色,可以向其他认识的角色发送消息,也可以创建一个新的状态,用来在处理下一条消息时作为当前状态。
- 进程通常代表一种更通用的虚拟处理机,它一般由操作系统实现,可以让并发处理更容易。进程也能(根据约定)被约束为以角色的形式运转,我们在这里说的就是这类进程。
角色只会是并发的
有些事情,在角色的定义中是不会出现的:
- 没有一件事是可控的。接下来会发生什么,不会事先计划;信息从原始数据到最终输出的传输过程,也无法统筹安排。
- 系统中唯一的状态,保存在消息和每个角色的本地状态中。消息除了接收方可以读取,没有其他途径检查;本地状态在角色之外无法被访问。
- 所有的消息都是单向的——没有回应这个概念。如果希望角色能返回一个回应消息,则需要在发送给角色的消息中包含自己的信箱地址,然后角色(最终)会将回应作为另一条消息发送到该信箱。
- 角色会将每一条消息处理完,一次只会处理一条。
因此,角色以并发方式来异步地执行,并且不共享任何内容。如果有足够的物理处理器,就可以在每个处理器上运行一个角色。如果只有一个处理器,那么某些运行时可以处理角色之间的上下文切换。无论以哪种方式,在角色内运行的代码都是相同的。
提示59 用角色实现并发性时不必共享状态
在角色模型中,不需要为处理并发性编写任何特定代码,因为没有共享状态。对于业务从头到尾的逻辑,也没有必要以“做这个,做那个”的方式,将其显式地写在代码里,因为角色会基于收到的消息自己处理这些事情。
Erlang 语言及其运行时是一个角色模型实现的绝佳例子(虽然 Erlang的发明者并没有阅读关于角色的原始论文)。Erlang 把角色称为进程,但其不是常规的操作系统进程。相反,就和我们之前讨论过的角色一样,Erlang的进程是轻量级的(你可以在一台机器上运行数百万个进程),其通过发送消息进行通信。每一个进程都与其他进程相互隔离,进程之间没有状态共享。
此外,Erlang的运行时实现了一个监督系统,这个系统管理着进程的生命期,在出现故障时能重新启动一个进程或一组进程。Erlang还提供了代码的热加载机制:你可以在不停止正在运行的系统的情况下,替换该系统中的代码。Erlang系统运行着一些世界上最可靠的代码,号称有9个9的可用性。
36 黑板
这是一种放任自由的并发形式。侦探是独立的进程、代理人、角色等。有些人把事实保存在黑板上。另一些人则从黑板上摘取事实,可能将其合并或处理,并向黑板添加更多信息。黑板逐渐会帮助他们得出结论。
基于计算机的黑板系统,最初被用于人工智能的应用中,所解决的是一些大型和复杂的问题——语音识别、知识推理系统等。
提示60 使用黑板来协调工作流
在编写这一第二版图书时,许多应用程序都是使用小型的、解耦的服务构建起来的,所有这些服务都可通过某种形式的消息传递系统相互通信。这些消息传递系统(如Kafka和NATS)所做的远不只将数据从A发送到B。特别是,它们还能提供持久化特性(以事件日志的形式),以及通过模式匹配的形式来检索消息的能力。这意味着你可以将它们视作一个黑板系统和(或)一个能运行一组角色的平台。
第7章 当你编码时
传统观点认为,一旦项目到了编码阶段,就几乎只剩一些机械工作:只是把设计翻译成可运行的代码段而已。我们认为这种态度是软件项目失败的最重要的原因。这导致许多系统最终变得丑陋、低效、结构糟糕、不可维护,或者根本就是错误的。
37 听从蜥蜴脑
本能就是我们的无意识大脑对模式的一种直接反应,有些是天生的,有些是通过不断重复学习到的。当你作为程序员积累了经验后,大脑就会逐渐形成一层又一层的隐性知识:这样可以工作,那样不能工作,导致某种类型错误的原因,所有在日常生活中注意到的事情。这部分大脑会在你停下来和别人聊天时,按下保存文件的按键,即使你没有意识到正在这么做。
每个人都害怕空荡荡的屏幕——孤独的光标在闪烁,被无尽的虚无包裹。开始一个新项目(甚至是现有项目中的一个新模块)有可能让人不安。因此,我们中的许多人宁愿推迟迈出第一步。
我们认为造成这种情况的原因有两个,而二者的解决方案是相同的。
第一个原因是你的蜥蜴脑试图告诉你一些事情,就像是在感知面下潜藏着某种形式的疑虑。这是很重要的。
作为一名开发人员,你已经尝试过很多东西,并已了解哪些是有效的,哪些是无效的。你一直在积累经验和智慧。如果能感到一种挥之不去的疑虑,或在面对一项任务时感觉有些不情愿,那可能是那些经验试图和你说些什么——要注意听。你可能无法确切地指出哪里出了问题,但经过一段时间后,疑虑可能会变得实在,变成可以确定的东西。让直觉来提高你的绩效。
另一个原因则平淡无奇:你可能只是担心自己会犯错。
这是一种合理的恐惧。我们这些开发者在代码中投入了大量精力,因而会把代码中的错误看作是自身能力的反映。也许存在一些“冒名顶替症候群”的因素,让我们可能认为这个项目超出了能力范围。看不到通往终点的路,只好继续远行,直到最后被迫承认自己迷路。
提示61 倾听你内心的蜥蜴
首先,停止正在做的事情。给自己一点时间和空间,让大脑自我组织。远离键盘,停止对代码的思考,做一些暂时不需要动脑筋的事情——散步、吃午饭、和别人聊天,或是先睡一觉。让想法自己从大脑的各个层面渗透出来:对此不用很刻意。最终这些想法可能会上升到有意识的水平,这样你就能抓住一个“啊哈!”的时刻。
如果这不起作用,就试着把问题外化。把正在写的代码涂画到纸上,或者向你的同事(最好不是程序员)解释一下是怎么回事,向橡皮鸭解释一下也行。把问题暴露给不同部分的大脑,看看有没有一部分大脑能更好地处理困扰你的问题。我们已经记不清有过多少次这样的讨论,其中一个人向另一个人解释问题,说着说着突然叫道:“哦!当然应该这样!”然后停下来解决了问题。
但也许在试过这些方法后,还是无法脱困。那么就该行动了。我们需要告诉大脑:打算要做的事情,并没有那么重要。可以用做原型的方式来干这件事。
多年以后,我们发现了一种有效的脑力突破的方法——告诉自己该做原型了。如果你面对的是一个空白屏幕,那么就找找项目中你想探索的某些方面。也许你正在使用一个新的框架,并希望了解框架是如何进行数据绑定的。或者是一个新的算法,你想要探索一下算法如何在边缘情况下工作。或者你可能想尝试几种不同风格的用户交互。
如果你正在和现有的代码交战,那么就先把它藏在某个地方,然后做一些类似的原型。
执行以下操作。
1.在便签上写下“我正在做原型”,然后贴在屏幕的一侧。
2.提醒自己,原型注定要出问题。提醒自己,原型即使没有出问题也会被扔掉。这样做没有坏处。
3.在空编辑器窗口中,写一条注释,用一句话描述你想学点什么或做点什么。
4.开始编码。
如果你开始产生疑虑,就看看便签。
如果在编码的过程中,那个挥之不去的疑虑突然变成了实实在在的担忧,那么把它弄清楚。
如果在实验结束后仍旧不舒服,那就重新从散步、谈话和休息开始。
不过根据我们的经验,在第一个原型的某个时间点上,你会惊讶地发现自己正在随着音乐哼唱,享受着编写代码的愉快体验。紧张感会消失,取而代之的是一种紧迫感:让我们把这件事做好!
到了这个阶段,你知道该做什么。删除所有的原型代码,扔掉便签,用明亮的新代码填充空白的编辑器。
我们的大部分工作是处理现有的代码,这些代码通常是由其他人编写的。这些人的直觉和你不同,所以他们会做出不同的决定。但不一定更糟,仅仅是不同而已。
你可以只是机械地阅读他们的代码,慢慢啃,在看似重要的地方做笔记。这是一件苦差事,但很有用。
或者可以做一个实验。当你发现事情以一种奇怪的方式完成时,把它记下来。持续这样做,试着寻找模式。如果能看出是什么驱使他人以这种方式编写代码,理解代码这项工作会变得容易得多。你将能够有意识地应用他们那些心照不宣的模式。
你可能会学到一些新的东西。
38 巧合式编程
我们应该避免通过巧合编程,因为靠运气和意外来获得成功是行不通的,编程应该深思熟虑。
实现造成的偶然事件,指事情之所以这样,仅仅是因为代码当前的写法如此。你最终依赖的是,一些因文档中没有记载而未考虑到的边界条件,或是将错就错的结果。
不要假设,要证明。
提示62 不要依赖巧合编程
在所有层次上,人们都在头脑中使用许多假设——但是这些假设很少被记录下来,这些假设经常在不同的开发人员之间发生冲突。不以既定事实为基础的假设,是所有项目的祸根。
如果希望花费更少的时间来编写代码,就要在开发周期中尽可能早地捕获并修复错误,这样可以一开始就少犯错。只要我们在编程时能深思熟虑,便能看到效果:
- 时刻注意你在做什么。Fred是慢慢让事情失去控制的,直到最后,像第9页的青蛙一样被煮熟。
- 你能向一个更初级的程序员详细解释一下代码吗?如果做不到,也许正在依赖某个巧合。
- 不要在黑暗中编码。构建一个没有完全掌握的应用程序,或者使用一个并不理解的技术,就很可能会被巧合咬伤。如果不确定它为什么能用,就不会知道它为什么出错。
- 要按计划推进,不管这个计划是在脑子里,还是在鸡尾酒餐巾纸的背面,或是在白板上。
- 只依赖可靠的东西。不要依赖假设。如果你不知道某件事是否可靠,就要做最坏的打算。
- 将假设文档化。第104的话题23:契约式设计,可以帮助你在心中澄清设想,也可以帮助你与他人沟通。
- 不要只测试代码,还要测试假设。不要猜,去实际试一下。写一个断言来测试假设(参见第115页的话题25:断言式编程)。如果断言是正确的,那么说明你已经改进了代码中的文档。如果发现假设是错误的,那么你应该感到幸运。
- 为你的精力投放排一个优先级。要把时间花在重要的方面(事实上,这往往正是比较困难的部分)。如果根本原理或基础设施都会出问题,花哨的外表则更是不堪一击。
- 不要成为历史的奴隶。不要让现有的代码去支配未来的代码。如果不再合适,所有代码都可以替换。即使一个程序正在进展中,也不要让已经做完的事情限制下一步要做的事情——准备好重构(参见第216页的话题40:重构)。这个决定可能会影响项目进度。这里的假设是影响小于不进行更改造成的开销。
39 算法速度
大 O永远不会告诉你时间、内存或其他什么资源的真正开销数值:它只是告诉你数值会怎样跟着输入的量而变化。
O()符号不仅适用于时间,也可以用它来表示算法使用的任何其他资源。例如,它常常用来对内存开销建模(练习中有一个例子)。
O(1) 常量(访问数组中的元素,简单的代码段)
O(lg n)对数(二分查找)。对数的底无关紧要,所以它等价于 O(log n)
O(n) 线性(顺序查找)
O(n lg n)比线性糟糕一点,不过还能接受(快速排序及堆排序的平均时间)
O(n2) 平方(选择排序及插入排序)
O(n3) 立方(两个 n × n矩阵乘法)
O(Cn) 指数(旅行推销员问题,集合划分)
如果你在循环中嵌套另一个循环,算法就变成了 O(m × n),这里 m和 n分别是两层循环的上限。这通常出现在排序算法中,例如冒泡排序,外层循环每一轮检查数组中的一个元素,内层循环确定这个元素在排序结果中的位置。这类排序算法趋近于 O(n2)。
如果你的算法在循环中每轮将集合对分,那么就接近于对数 O(lg n)。对有序数列二分查找、遍历二叉树、找到机器字的最高位,都是 O(lg n)的。
如果算法会将输入分成两半来分开处理,最后再把结果合并起来,那么就是 O(n lg n)的。经典的例子是快速排序,这个算法会把数据分为两块,然后递归地对每块排序。虽然技术上说,其行为在处理有序输入时会退化为 O(n2),但快速排序的平均运行时间是 O(n lg n)。
提示63 评估算法的级别
如果你不确定代码会运行多久,或是不知道代码需要多少内存,那就跑跑看。不断改变输入数据的数量或其他会对运行时间造成很大影响的东西,然后把不同的量和对应的开销制成一张图,图上曲线的形状很容易理解。看看随着输入数量增加,曲线是向上走,还是呈直线,或是逐渐拉平?有三四个点就能看明白了。
还要考虑一下你写的代码本身。当 n较小时,一个简单的 O(n2)循环比一个复杂的 O(n lg n)算法表现得要好得多,在 O(n lg n)的内层循环非常昂贵时尤为如此。
提示64 对估算做测试
如果获得精确的计时结果比较困难,可以用代码分析器统计算法中不同步骤运行的次数,然后再根据输入的数据大小绘制出图表。
在投入宝贵的时间尝试改进算法之前,确保算法确实是瓶颈,总是最为可取。
40 重构
随着程序的演化,有必要重新考虑早期的决策,对部分代码进行返工。这个过程理所当然。代码需要演化;它不是一个静态的东西。
软件更像是园艺而非建筑——它更像一个有机体而非砖石堆砌。你根据最初的计划和条件在花园里种植很多花木。有些茁壮成长,另一些注定要成为堆肥。你会改变植物相对的位置,利用光和影、风和雨的相互作用。过度生长的植物会被分栽或修剪,那些不协调的颜色可能会转移到更美观的地方。你拔除杂草,给需要额外帮助的植物施肥。你不断地监测花园的健康状况,并根据需要(对土壤、植物、布局)做出调整。
代码进行重写、修订、结构调整的这一系列工作被称为重组。但是这些行动当中有一个子集已经成为了实践标准,它被称为重构。
马丁·福勒将重构 [Fow19]定义为一种:
重组现有代码实体、改变其内部结构而不改变其外部行为的规范式技术。
这一定义的关键部分是:
1.这项活动是有规范的,不应随意为之
2.外部行为不变;现在不是添加功能的时候
重构是一项日复一日的工作,需要采取低风险的小步骤进行,它更像是耙松和除草这类活动。这是一种有针对性的、精确的方法,有助于保持代码易于更改,而不是对代码库进行自由的、大规模的重写。
提示65 尽早重构,经常重构
随着时间的推移,代码中的附带损害有可能致命(参见第6页的话题3:软件的熵)。重构,和大多数事情一样,在问题很小的时候做起来更容易,要把它当作编码日常活动。你并不需要“用一周时间去重构”一块代码——那是在全面重写。即使重写这种程度的破坏性工作是必要的,也很可能无法立即完成。你要做的是,确保这个过程已被安排在日程表上,确保受影响代码的用户知道重写的计划,以及这么做会对他们有何影响。
重构的核心是重新设计。你或团队中的其他人设计的任何东西,都可以根据新的事实、更深的理解、更改的需求等重新设计。但是,如果你执拗地非要将海量的代码统统撕毁,可能会发现,自己所处的境地,比开始时更加糟糕。
显然,重构是一项需要慢慢地、有意地、仔细地进行的活动。马丁·福勒提供了一些简单技巧,可以用来确保进行重构不至于弊大于利:
1.不要试图让重构和添加功能同时进行。
2.在开始重构之前,确保有良好的测试。尽可能多地运行测试。这样,如果变更破坏了任何东西,都将很快得知。
3.采取简短而慎重的步骤:将字段从一个类移动到另一个类,拆分方法,重命名变量。重构通常涉及对许多局部进行的修改,这些局部修改最终会导致更大范围的修改。如果保持小步骤,并在每个步骤之后进行测试,就能避免冗长的调试。
如果不得不进行超过重构范围的工作,而且会以改变外部行为或接口收场,那么通过刻意破坏构建,让代码过去的客户无法通过编译,可能会有所帮助。这样做可以让你知道什么需要更新。下一次看到一段代码与它应该有的样子不符时,要把它修好。这其实是在控制疼痛——尽管现在很痛,但以后会痛得更厉害,那么就忍痛赶紧干完。
41 为编码测试
提示66 测试与找 Bug 无关
我们相信,测试获得的主要好处发生在你考虑测试及编写测试的时候,而不是在运行测试的时候。
提示67 测试是代码的第一个用户
测试所提供的反馈至关重要,可以指导编码过程。
TDD 的基本循环是:
1.决定要添加一小部分功能。
2.编写一个测试。等相应功能实现后,该测试会通过。
3.运行所有测试。验证一下,是否只有刚刚编写的那个测试失败了。
4.尽量少写代码,只需保证测试通过即可。验证一下,测试现在是否可以干净地运行。
5.重构代码:看看是否有办法改进刚刚编写的代码(测试或函数)。确保完成时测试仍然通过。
这里的想法是,这个循环周期应该非常短——只有几分钟的时间,这样就可以不断地编写测试,然后让它们工作。
当你不能理解整个问题时,就应小步前进,一次一个测试。这个想法经常被吹捧为 TDD 的一个优点。然而,这种方法可能会误导你,它鼓励人们专注于不断优化简单的问题,而忽略编码的真正动因。有一个发生于 2006 年的有趣案例,当时敏捷运动的领军人物罗恩·杰弗里斯开始写一个博客的系列文章。这个系列,记录了他尝试用测试驱动的方法,来开发解数独程序的过程。在写了五篇文章之后,他改进了底层棋盘的呈现方式,进行了多次重构,直到对对象模型满意为止。但之后他放弃了这个项目。按顺序阅读这些博客文章是很有趣的,可以看到一个聪明的人是如何被通过测试的喜悦套牢,开始被琐事分心。
在那计算科学稚嫩的童年,有两种设计学派:自上而下和自下而上。自上而下学派讲的是,应该从试图解决的整个问题开始,把它分解成几块。然后逐步拆分成更小的块,以此类推,直到最后得到小到可以用代码表示的块为止。
自下而上学派主张构建代码就像构建房子一样。他们从底层开始,生成一层代码,为这些代码提供一些更接近于目标问题的抽象。然后添加一层具有更高层次的抽象。这个过程会持续下去,直到所要解决问题的抽象出现,这里也就是最后一层:“去搞定它……”。
这两个学派实际上都没成功,因为它们都忽略了软件开发中最重要的一个方面:我们不知道开始时在做什么。自上而下学派认为可以提前表达整个需求,然而他们做不到。自下而上学派假设他们能构建出一系列的抽象,这串抽象最终会将他们带到一个单一的顶层解决方案,但是当不知道方向时,如何决定每一层的功能呢?
提示68 既非自上而下,也不自下而上,基于端对端构建
我们坚信,构建软件的唯一方法是增量式的。构建端到端功能的小块,一边工作一边了解问题。应用学到的知识持续充实代码,让客户参与每一个步骤并让他们指导这个过程。
作为对比,彼德·诺米格描述了一种感觉非常不同的替代方法:不是由测试驱动,而是——从对传统上如何解决这类问题(使用约束传播)的基本理解开始,然后专注于改进算法。他只用了十几行代码就实现了棋盘的呈现方式,而这直接来源于对符号的讨论。
硬件的芯片级测试大致相当于软件测试中的单元测试,即对每个模块单独进行测试,以验证其行为。一旦我们在受控(甚至人为的)条件下对模块进行了完整的测试,就能更好地了解模块在大范围内的反应。
我们喜欢将单元测试看作是在针对契约测试(参见第104页的话题23:契约式设计)。我们想要编写测试用例来确保指定的单元遵守了契约。这样做能告诉我们两件事:代码是否符合契约,以及契约是否具有我们所认为的含义。我们想通过范围很广的测试用例和边界条件来测试模块是否交付了它所承诺的功能。
提示69 为测试做设计
不要把临时(Ad Hoc)与“odd hack”搞混,临时测试发生在我们通过手动运行来捣鼓代码的时候。可能只是简单地加一句 console.log(),也可能是在调试器、IDE 或REPL 环境中交互输入的一段代码。
在调试完以后,需要把这个临时测试正式化。如果代码出过一次问题,就有再出问题的可能性。不要将创建出来的测试扔掉,把它添加到现有的单元测试库中。
在大多数情况下,测试先行,包括测试驱动设计,可能是最佳选择,因为它能确保测试的进行。但它也不是总那么方便和有效,所以在编码期间进行测试是一个很好的后备方案——编写一些代码,尽情修改,为它编写测试,然后继续在下一个部分如法炮制。最糟糕的做法基本可以统称为“以后再测”。开什么玩笑,“以后再测”实际上意味着“永不测试”。
测试也是与其他开发人员进行交流的一种方式,所以我现在还是会在与他人共享代码时为其编写测试,或给有外部依赖的事情写测试。
对待测试代码要像对待任何产品代码一样,保持解耦性、简洁性和健壮性,同时不要依赖于不可靠的东西(参见第204页的话题38:巧合式编程),比如 GUI 系统中某个部件的绝对位置,服务器日志中的准确时间戳,或是错误信息的精确措辞——对这类事情做检查会导致测试非常脆弱。
提示70 要对软件做测试,否则只能留给用户去做
42 基于特性测试
提示71 使用基于特性的测试来校验假设
这就是基于特性的测试既强大又给人挫折之处。说它强大是因为,只要建立一些规则来生成输入,设定好断言来验证输出,就可以任其发展。至于会发生什么,你并不完全知道——测试可能通过,断言也可能失败,又或者代码可能因无法处理所给定输入而完全失败。
挫折之处在于,确定失败的原因可能很棘手。
我们的建议是,当基于特性的测试失败时,找出传递给测试函数的参数,然后使用这些值创建一个单独的、常规的单元测试。单元测试为你做了两件事。第一,它使你可以将注意力集中在问题上,而避开所有那些基于特性的测试框架产生的对代码的额外调用。第二,单元测试可充当回归测试。因为基于特性的测试所传递的参数是随机生成的,不能保证在下一次运行测试时使用相同的值;而单元测试则能强制使用这些值,确保 Bug 不会通过。
单元测试是 API 的第一个客户。
这一点对基于特性的测试也同样成立,只是方式略有不同。基于特性的测试让你从不变式和契约的角度来考虑代码;你会思考什么不能改变,什么必须是真实的。这种额外的洞察力会对代码产生神奇的影响,可以消除边界情况,并突显使数据处于不一致状态的函数。
43 出门在外注意安全
在美好的旧日时光中,对这些内部错误做出评估就已经足够了。但时至今日,这仅仅才是开始,因为除了内部原因造成的错误,还需要考虑外部参与者是如何故意将系统搞砸的。也许你会抗议,“哦,没人会关心我这些代码,并不是什么重要的东西,甚至没人知道这台服务器……”无论是地球另一端的熊孩子、国家支持的恐怖主义,还是犯罪团伙、商业间谍,甚至是复仇心重的前任,都已就位并将你瞄准。对于未修补的、过时的系统,其在开放网络中的生存时间是以分钟计算的——甚至更少。
务实的程序员有相当多的偏执。我们知道自己有缺陷和限制,外部攻击者会抓住每个我们留下的漏洞去破坏系统。特定的开发和部署环境,将有其自己的围绕安全性的需求,但是你应该始终牢记一些基本原则:
1.将攻击面的面积最小化
2.最小特权原则
3.安全的默认值
4.敏感数据要加密
5.维护安全更新
代码的复杂性会让攻击面更大,会留下更多产生意料之外的副作用的机会。
永远不要信任来自外部实体的数据,在将其传递到数据库、呈现视图或其他处理过程之前,一定要对其进行消毒。
就其本质而言,世界上任何地方的任何用户都可以调用未经身份认证的服务,因此如不做任何的处理和限制,就至少立刻创造了一个拒绝服务(DOS)攻击的机会。
将授权用户的绝对数量保持在最小值。要淘汰不使用的、旧的或过时的用户和服务。
不要泄露信息,并确保所通报的数据适合该用户的权限。对潜在的危险信息,比如社会保险或其他政府颁发的身份识别号码,要做截断或混淆。
要确保任何“测试窗口”(在第228页讨论过)和运行时异常报告都已受到保护,不会被间谍看见。
提示72 保持代码简洁,让攻击面最小
另一个关键原则是,在最短的时间内使用最少的特权。换句话说,不要自动获取类似root 或 Administrator 这样的最高级别权限。如果真需要这么高级别的权限,那就去申请,获得权限后只做最少量的工作,然后迅速放弃权限,这样可以降低风险。
在应用程序或网站用户的设置里,默认值应该是最安全的。这些值可能不是对用户最友好或最方便的,但是最好让每个人自己为安全性和方便性之间的权衡做决定。
不要将个人身份信息、财务数据、密码或其他凭据,以纯文本的形式保存在数据库或其他外部文件中。如果数据被泄露,加密提供了额外的安全级别。
在版本控制中,我们强烈建议将项目所需的一切事项都置于版本控制之下。没错,几乎一切。但这里给出此规则的一个主要例外:
不要把保密内容、API 密钥、SSH 密钥、加密密码或其他凭据,和源码一起提交到版本控制中。
密钥和秘密需要单独管理——作为构建和部署的一部分,通常通过配置文件或环境变量来管理。
提示73 尽早打上安全补丁
历史上(到目前为止)最严重的数据泄露是由系统更新滞后造成的。
安全性的一个基本问题是,好的安全性常常与常识或惯例背道而驰。例如,如果你认为严格的密码要求会提高应用程序或网站的安全性,那么你就错了。
严格的密码策略实际上会降低安全性。以下针对一些非常糟糕的想法,提供一些 NIST 的建议:
- 不要将密码长度限制在 64 个字符以内。NIST 推荐 256 为最佳长度。
- 不要截断用户选择的密码。
- 不要限制特殊字符,比如 ;&%$#/。请参阅本部分前面关于 Bobby Tables 的说明。如果密码中的特殊字符会危及系统,那么你将面临更大的问题。NIST 表示接受所有可打印的ASCII 字符、空格和 Unicode。
- 不要向未经身份认证的用户提供密码提示,或提示输入特定类型的信息(例如,“你的第一只宠物叫什么名字?”)。
- 不要禁用浏览器中的粘贴功能。破坏浏览器和密码管理器的功能,并不能使系统更安全。实际上,它会促使用户创建更简单、更短、更容易破解的密码。出于这个原因,美国的 NIST和英国的国家网络安全中心都特别要求校验方允许粘贴功能。
- 不要强加其他组合规则。例如,不要强制要求任何特定的大小写混合、数字或特殊字符,或禁止重复字符,等等。
- 不要蛮横地要求用户在一段时间后更改密码。只有在有正当理由的情况下才这样做(例如,系统遭到了破坏)。
我们应该鼓励长的随机的密码,因为它有更高程度的熵。人为的限制局限了信息熵,助长了使用糟糕密码的习惯,让用户的账户很容易被接管。
一定要记住,当涉及密码学的问题时,常识可能会让你失望。当涉及加密时,第一条也是最重要的一条规则是,永远不要自己做。即使是对于密码这样简单的东西,常见的做法也是错误的(参见上一页的知识栏:密码的反模式)。一旦你进入了密码学的世界,即使是最小的、看起来最不起眼的错误,也会危及一切:你那聪明的、全新的、自制的加密算法,可能会在几分钟内被专家破解——不要自己做加密。
正如我们在其他地方所说的,只应依赖可靠的东西:经过良好审查、彻底检查、维护良好、经常更新、最好是开源的库和框架。
44 事物命名
我们为应用程序、子系统、模块、函数和变量起名字——不断地创造新事物并给它们命名。这些名字非常非常重要,因为它们透露出你的很多意图和信念。
我们认为,事物应该根据它们在代码中扮演的角色来命名。这意味着,无论何时,只要你有所创造,就需要停下来思考“我这一创造的动机是什么?”
这是一个强有力的问题,因为它把你从立即解决问题的心态中带出来,让你看到更大的图景。当我们考虑一个变量或函数的作用时,所考虑的是它的特别之处,它能做什么,它与什么相互作用。在很多时候,对于正要去做的事情,一旦我们怎么都想不出一个适合它的名字,往往就会幡然醒悟,意识到这件事情其实毫无意义。
大多数的计算机入门文章都会告诫你,永远不要使用单个字母的变量,如 i、j、k。我们认为在某种程度上这并不正确。
事实上,这取决于特定编程语言或环境所处的文化氛围。在 C 语言中,i、j、k 通常用作循环变量,s 用于字符串,等等。如果在这种环境中编写程序,那么你就会习以为常,违反这种规范反而不和谐(也就说明不对)。与此相对,在没有同样预期的其他环境中沿用该习惯也是错误的。
每个项目都有自己的词汇表:对团队有特殊意义的术语。“Order”对于开发在线商店的团队来说是一回事,而对于记录宗教团体的世系的应用程序来说,意味着完全不同的另一件事。重要的是,团队中的每个人都知道这些词的意思,并始终如一地使用它们。
一种方法是鼓励大量的交流。如果每个人都参与结对编程,并且频繁地交换结对,那么术语就会渗透性地传播开来。
另一种方法是使用项目术语表,列出对团队有特殊意义的术语。这是一个非正式的文档,可以在 wiki 上创建并维护,也可以将索引卡片挂在墙上。
过一段时间,项目术语将会有自己的生命。随着每个人都熟悉了这些词汇,就能够把这些术语用作简称,准确而简洁地表达许多意思。(这正是模式语言所指。)
提示74 好好取名;需要时更名
如果由于某种原因,无法改变现在这个错误名字,那么说明这里有一个更大的问题:对 ETC 的违背(参见优秀设计的精髓)。那么先修复这个更大的问题,再更换有问题的名字。让更名变得容易,并且经常去做。
第8章 项目启动之前
45 需求之坑
需求很少停留在表面。通常情况下,它们被埋在层层的假设、误解和政治之下。更糟糕的是,需求通常根本不存在。
提示75 无人确切知道自己想要什么
提示76 程序员帮助人们理解他们想要什么
典型的客户会带着需求来找我们。这种需求可能是战略性的,但更可能是战术性的:对当下面临的问题做一个回应。需求可能是对现有系统的变更,也可能是需要某些新东西。需求有时用业务术语表达,有时用的是技术术语。
新手开发人员经常犯的错误是,把这种对需求的声明照单全收,然后实现对应方案。
根据我们的经验,最初对需求的声明,往往并非绝对化的要求。客户可能没有意识到这一点,但一定希望你能一起去探索。
下面给出一个简单的例子。
你充当的角色是解释客户所说的话,并向他们反馈其中的含义。这既是一个演绎性过程,又是一个创造性过程:你会灵机一动,为一个更好的解决方案添砖加瓦,最终方案会比你或客户单独提出的方案更好。
需求是一个过程
提示77 需求是从反馈循环中学到的
你的工作是帮助客户理解他们所陈述需求的后果。你通过激发反馈来做到这一点,并让他们利用反馈来完善自己的想法。
在这种情况下,务实的程序员藉由“你是不是这个意思”这样的客户访谈来得到反馈。我们制造出展示模型和产品原型,并让客户先用用看。理想情况下,我们生产的东西足够灵活,经得起在与客户讨论过程中的修改;该我们对客户反馈的“这不是我的意思”做出回应了,就用这一句——“是不是更像这样”。
在了解系统真实使用方式的同时,你还会惊讶于一个小小请求的实际效果——“你工作的时候我可以在旁边坐一个星期吗”。它既有助于建立信任,也为我们与客户的沟通奠定了基础。但要记住不能碍事!
提示78 和用户一起工作以便从用户角度思考
是业务策略还是需求?这里有一个很微妙的区别,而它将对开发者产生深远的影响。如果需求被声明为“只有主管和人事可以查看员工记录”,那么开发者可能会为应用程序每次访问该数据编写一个显式的测试。但是,如果声明是“只有授权用户才能访问员工记录”,那么开发者可能会设计并实现某种访问控制系统。当策略改变时(往往会变的),只需要更新该系统的元数据。实际上,以这种方式采集需求,自然会导向一个通过良好分解来支持元数据的系统。
提示79 策略即元数据
针对更普遍的情况做实现,至于系统需要支持的那种特定类型的东西,只是通用实现在加入策略信息后的示例。
我们相信,最好的需求文档,或许也是唯一的需求文档,就是可以工作的代码。
但这并不意味着,你可以不记录对客户需求的理解就扬长而去。这只是意味着,这些文档不必交付:它们不是需要交给客户签字的东西;相反,只是帮助指导实现过程的路标。
客户之所以请程序员来,是因为程序员会对所有的细节和细微之处感兴趣,尽管客户的动机只是解决一个高阶的、有些模糊的问题。需求文档是为开发人员编写的,其中包含的信息和细微之处有时难以理解,并且常常让客户感到乏味。
应该用怎样的形式?我们喜欢可以写在真实(或虚拟)索引卡上的东西。这些简短的描述通常被称为用户故事。它们从使用某功能的用户角度,描述了应用程序的一小部分应该做什么。
当以这种方式编写需求文档时,需求可以放在一块板子上并四处移动,以展示其状态和优先级。
你可能认为,单张索引卡无法囊括实现应用程序组件所需的信息。你说的没错,但这正是目的之一——通过保持需求的简短陈述,鼓励开发人员去澄清问题。这样可以在创建每段代码之前,以及在创建期间,对客户和程序员之间的反馈过程进行强化。
生成需求文档的另一大危险是过于具体。好的需求是抽象的。就需求而言,最简单最能准确反映业务需求的语句是最好的。这并不意味着可以摸棱两可——必须将底层语义的不变式作为需求来紧抓不放,并将特定的或当前的工作实践作为策略记录下来。需求不是架构;需求无关设计,也非用户界面;需求就是需要的东西。
许多项目失败,都可以归咎于不断扩大涉及范围——也称为功能膨胀、特性泛滥或需求蠕变。
如果你在与客户一起工作时,一直通过持续反馈来进行迭代,那么客户对“特性又多了一个”产生的影响,将有切身体会。他们将看到另一张故事卡出现在面板上,继而帮你另选一张卡,以为进入下次迭代腾出空间。反馈是双向的。
创建并维护一张项目术语表,在上面记录项目中所有特定术语和词汇的定义。项目所有的参与者,包括最终用户和支持员工,都应该使用同一张术语表来确保一致性。这意味着术语表必须随处可访问,同时也说明了为什么需要在线文档。
提示80 使用项目术语表
46 处理无法解决的难题
解谜的奥妙在于确定真正的(而不是想象的)约束条件,在这个约束条件下找到解开的方法。有些约束条件是绝对的,有些其实是一些先入为主的观念。应该尊重那些绝对的约束条件,无论这些约束条件看起来多么令人反感或愚蠢。
但另一方面,正如亚历山大证实的,一些明显的约束条件并非真的约束条件——许多软件问题都很狡猾。
解决谜题的关键是,认识到你所受到的约束和你所拥有的自由度,因为认识到这些就会找到答案。这就是为什么有些谜题如此有效——我们太容易忽视潜在的解决方案。
提示81 不要跳出框框思考——找到框框
当面对一个棘手的问题时,把你面前所有可能的解决途径都列举出来。不要忽略任何东西,无论听起来多么无用或愚蠢。
对约束进行分类和排序。当木工开始一个项目时,他们先切出最长的木块,然后从剩下的木头中切出较小的木块。同样地,我们希望首先确定最具限制性的约束,然后让其余的约束适配它。
如果你就是不愿意让这个问题搁置一段时间,那么最好的办法就是找个人去解释一下这个问题。通常情况下,围绕它的简单谈论就可以让你分心,从而得到启迪。
在日常工作中,将什么行得通什么行不通反馈给大脑,是供养大脑的最好方法。
47 携手共建
与用户密切合作的建议贯穿本书;用户是你团队的一部分。在共同工作的第一个项目中,我们一起实践了现在被称为结对编程或群体编程的方法:一个人输入代码,而一个或多个团队成员一起评论、思考和解决问题。这是一种强大的合作方式,超越了没完没了的会议、备忘录和冗长的法律文件。
这就是我们所说的“一起工作”的真正含义:不仅仅是提问、讨论、做笔记,还要在真正编码的同一时刻提问和讨论。
如果你目前在个人独立编程,可以尝试一下结对编程。最少预留两周的时间,一次持续几个小时,因为一开始会感觉很奇怪。在需要集思广益、想出新点子,或诊断出棘手问题的时候,不妨尝试做一次群体编程。
与所有合作性事务一样,你需要对人的方面如同对技术方面一样有所把控。这里有一些用来启动的小技巧:
- 打造代码,而非打造自我。这与谁最聪明无关;我们都有许多闪光的瞬间,也有糟糕的时刻。
- 从小规模做起。只需要 4-5 人的群体,或者开始时只组成几对,做一些短期活动。
- 批评要针对代码,而不针对人。“让我们看看这一块”听起来比“你搞错了”好得多。
- 倾听他人的观点并试着理解。观点不同不是错误。
- 频繁进行回顾,为下一次做好准备。
提示82 不要一个人埋头钻进代码中
48 敏捷的本质
提示83 敏捷不是一个名词;敏捷有关你如何做事
我们觉得很多人已经忽视了敏捷的真正含义,因而希望看到人们回归到最基本的东西。
记住宣言中的价值观:
我们一直在实践中探寻更好的软件开发方法,身体力行的同时也帮助他人。由此我们建立了如下价值观:
- 个体和互动高于流程和工具
- 工作的软件高于详尽的文档
- 客户合作高于合同谈判
- 响应变化高于遵循计划
也就是说,尽管右项有其价值,我们更重视左项的价值。
如果有人向你兜售一些东西,而这些东西让你觉得右边的事情比左边的事情更重要,那么这样的人,对于我们和其他宣言作者重视的东西,显然不会认同。
没有人能告诉你该做什么。而我们打算告诉你的,是做事时该有的精神。它完全可以归结为如何处理不确定性。敏捷宣言建议你通过收集反馈并采取行动来做到这一点。所以下面是我们以敏捷方式工作的秘诀:
1.弄清楚你在哪里。
2.朝想去的方向迈出有意义的最小一步。
3.评估在哪里终结,把弄坏的东西修好。
重复这些步骤,直到完成。在每一件事的每个层面上递归地使用这些步骤。
你做出了一个改变,后来发现自己并不喜欢。根据清单上的第三步,我们必须能够修复我们破坏了的东西。为了使反馈循环更有效,修复必须尽可能地简单。如果不够简单,我们就只会耸耸肩,而不去修它。我们在第6页的主题3:软件的熵中讨论了这种效应。为了使整个工作敏捷起来,需要实践优秀的设计,因为优秀的设计使事情容易改变。如果它很容易改变,就可以在每个层面做调整,不会有任何犹豫。
这就是敏捷。
第9章 务实的项目
49 务实的团队
在我们看来,团队是小而稳定的实体。50个人就不算是团队,那是部落。如果团队的成员经常被分配到其他地方,相互之间缺乏了解,那么这也不是一个团队,他们只是暂时同在一个公交车站躲雨的陌生人。
务实的团队很小,充其量也就10-12 人左右。成员很少进出。每个人都很了解彼此,相互信任,互相依赖。
提示84 维持小而稳定的团队
质量是一个团队问题。即使是最勤奋的开发者,只要身处一个什么都不在乎的团队中,也会发现自己很难保持修复琐碎问题所需的热情。如果团队不鼓励开发者在这些修复工作上花费时间,那么问题就会进一步恶化。
质量只能来自团队每个成员的独立贡献。质量是内在的,无法额外保证。
要和这些现象做斗争。鼓励每个人积极监控环境的变化。保持清醒,对项目范围扩大、时间缩短、额外特性、新的环境——任何在最初的理解中没有的东西,都要留心。对新的需求要保持度量。团队不必对变化导致的失控心存抗拒——只需要知道变化正在发生就可以。否则,置身沸水的人就会是你。
如果团队对改进和创新是认真的,那么就需要将其排入日程表。“只要有空闲时间”就去做,意味着这件事永远不会发生。无论你处理事务用的是待办事项表、任务列表、流程表,还是什么别的工具,都不要将其仅用于功能开发。团队的工作不应仅致力于开发新功能,还可能包括:
旧系统的维护
虽然我们喜欢在新系统上工作,但是旧系统可能需要一些维护工作。我们遇到过这样的团队,他们试图把这项工作扔到角落里。如果团队被赋予这样的任务,那么就去做吧——真正地去做。
流程的反思与精炼
只有在你花时间观察周围,找出什么是有效的,什么是无效的,然后做出改变的时候,持续的改进才有可能发生。(参见第267页的话题48:敏捷的本质)。太多的团队忙于排水,而没有时间修补漏洞。把这件事排入日程表并加以解决。
实验新技术
不要仅仅因为“大家都在这么做”,或是你在会议上听到、网上看到了什么,就去采用新的技术、框架或库。做一个原型来慎重考察候选技术。将这项任务安排在时间表上,来尝试新事物并分析结果。
学习和提升技能
个人的学习和提高是一个很好的开始,但是很多技能在团队范围内传播时更有效。要制订计划去做,无论是非正式的午餐时间学习,还是更正式的培训课程。
提示85 排上日程以待其成
在外人看来,最糟糕的项目团队就是那些看起来闷闷不乐、沉默寡言的团队。他们的会议组织混乱,没有人愿意发言。电子邮件和项目文档一团糟:每一个都使用着不同的术语,没有哪两样东西看起来是相同的。
优秀的项目团队有独特的个性。人们期待与他们会面,因为知道他们准备得很充分,会让看到他们表现的每个人都心情愉悦。他们生成的文档是清晰、准确和一致的。团队用同一个声音说话,甚至可能还不乏幽默感。
有一个简单的营销技巧,可以帮助团队作为一个整体对外交流:创建一个品牌。当开始一个项目时,为它起一个名字,最好是一个稀奇古怪的名字。(过去我们在项目上曾用过捕食绵羊的鹦鹉、视错觉、沙鼠、卡通人物和神话中的城市等名字。)花30分钟想一个滑稽的标志,然后加以启用。与别人交谈时,要大方地使用团队的名字。听起来有点傻,但这给了团队一个赖以依托的身份,也将作品和一个值得纪念的世界关联在一起。
一个项目团队必须在项目的不同领域完成许多不同的任务,涉及许多不同的技术。理解需求、设计架构、为前端和服务器编码、测试,所有这些都必须进行。但有一个常见的误解是,这些活动和任务可以单独发生、相互隔离。不,这做不到。
使用曳光弹时,我们建议开发单独的特性,无论它们最初是多么小多么局限,都要贯穿整个系统。在团队中要实现这一点,意味着你需要拥有所有技能:前端、UI/UX、服务器、DBA、QA,等等,而且这些技术彼此之间也需要协调、融洽。使用曳光弹方法,可以非常快速地实现非常小的功能,并立即获得关于团队沟通和交付情况的反馈。这样就创建了一个环境,在你做出改变时,可以快速、轻松地调整团队和流程。
提示86 组织全功能的团队
有一个确保一致性和准确性的好方法,就是将团队所做的一切自动化。
记住团队是由个人组成的。赋予每个成员能力,让他们以自己的方式发光发热。要提供完善的架构来支持他们,并确保项目交付的价值。然后,像够好即可的软件中的画家那样,抵制住多画几笔的诱惑。
50 椰子派不上用场
原生岛民以前从未见过飞机,也没见过和这些陌生来客一样的人。这些陌生人给他们的家乡带来了难以置信的物质财富。他们操纵着机械大鸟,在被称为“跑道”的地方整日起降,并带来这些东西,用于回报当地人允许他们使用自己的土地。陌生人提到这可能与战争和冲突有关。直到有一天,一切都结束了,他们离开了,顺便带走了他们那些奇异的财富。
岛民们急于重现好运,他们用当地的材料重建了机场、控制塔和设备的复制品:使用的是葡萄藤、椰子壳、棕榈叶等。但是由于某种原因,尽管他们把一切都准备好了,飞机还是没有来。他们模仿的是形式,不是内容。人类学家称之为货物崇拜。
在很多时候,我们就是这些岛民。
人们容易受到诱惑,掉入货物崇拜的陷阱:通过投资去造出神器的外观,希望吸引来潜在有效的魔法。但与最初发生在美拉尼西亚的货物崇拜一样,用椰子壳制成的赝品是替代不了真机场的。
目前的趋势是采用成功公司的政策和流程,比如 Spotify、Netflix、Stripe、GitLab 这样的公司。每家公司对软件开发和管理都有自己独特的理解。但是考虑一下环境:你是否处于相同的市场,具有相同的约束和机会、相似的专业知识和组织规模、相似的管理和相似的文化?用户基础和需求是否相近?
别上当。特定的神器,以及浮于表面的结构、策略、流程和方法是不够的。
提示87 做能起作用的事,别赶时髦
用一个小团队或一大套人马来试验这个想法。保留那些看起来效果不错的部分,其他的都可以视为日常的开销和浪费,弃之无妨。没有人会对你的公司降低评级,仅仅因为它与 Spotify 或 Netflix 的运作方式不同。要知道,即便是这些公司,在自身的成长过程中,也并没有遵循目前的流程。几年之后,随着这些公司的成熟、转型及继续蓬勃发展,他们要做的事情还会发生变化。
这才是他们成功的真正秘诀。
软件开发方法论的目的是帮助人们一起工作。正如我们在敏捷的本质中所讨论的,在开发软件时,没有哪一个计划是可以照搬的,更别说另一家公司里某个人提出的一个计划。
我们的目标当然不是“使用 Scrum”、“进行敏捷”、“做到精益”或诸如此类的事情。我们的目标是交付可以工作的软件,让用户马上能获得新的功能。不是几周、几个月或几年以后,而是现在。对于许多团队和组织来说,持续交付感觉像是一个崇高的、无法实现的目标,特别是当你背负着一个将交付时间限制在几个月甚至几周的流程时。但和任何目标一样,最关键的是要保持瞄着正确的方向。
提示88 在用户需要时交付
过度投资于任何一种特定的方法,会让你对其他方法视而不见。当你习惯于一种方法时,很快就看不到其他的出路了。你已经僵化,变得不再能快速适应。
51 务实的入门套件
我们认为,应该从头开始:每个团队需要的最基本、最重要的元素是什么,而不去考虑方法、语言或技术栈。因此,务实的入门套件这个想法诞生了,它涵盖了三个关键且相互关联的主题:
- 版本控制
- 回归测试
- 完全自动化
提示89 使用版本控制来驱动构建、测试和发布
也就是说,构建、测试和部署通过提交或推送给版本控制来触发,并在云容器中完成创建。发布到交付阶段,还是生产阶段,可以通过在版本控制系统中打标记来指定。这样,发布就不再有那么强的仪式感,变成了日常生活中的一部分——这是真正的持续交付,没有绑定到任何一台构建机器或开发人员的机器上。
寻找 Bug 有点像用网捕鱼。我们使用精细的小渔网(单元测试)来捕捉小鱼,使用大的粗渔网(集成测试)来捕捉食人鲨。有时鱼会尽力逃脱,所以要修补任何发现的洞,希望这样能捕捉到越来越多在项目池中游来游去的滑溜溜的缺陷。
提示90 尽早测试,经常测试,自动测试
事实上,好项目的测试代码可能会比产品代码更多。生成这些测试代码所花费的时间是值得的。从长远来看,最终的成本会低得多,而且你实际上有机会生产出几乎没有缺陷的产品。
提示91 直到所有的测试都已运行,编码才算完成
自动构建过程要运行所有可用的测试。以“测试实际环境”为目标很重要,换句话说,测试环境应该与生产环境紧密匹配。任何缝隙都是 Bug 滋生的地方。
构建可能包括几种主要的软件测试类型:单元测试、集成测试、确认和验证,以及性能测试。
提示92 使用破坏者检测你的测试
如果你对测试非常认真,那么可以从源码树中分出一个单独的分支,有目的地引入Bug,并验证测试是否能够捕获到。在更高的层次上,可以使用像 Netflix 的 Chaos Monkey这样的东西来破坏(例如,“kill”)服务,以测试应用程序的韧性。
一旦你确信测试是正确的,并且正在进行寻找 Bug 的工作,那么如何知道是否已经对代码库进行了足够彻底的测试?
简单的回答是“无法知道”,永远也不会知道。你可以试试能在测试期间监视代码覆盖率的分析工具,并跟踪哪些代码行已经执行,哪些没有执行。这些工具帮助你大致了解测试有多全面,但是不要期望 100% 的覆盖率。
提示93 测试状态覆盖率,而非代码覆盖率
如果有某个 Bug 成了现有测试的漏网之鱼,那么就需要添加一个新测试,以保证下一次能将其捕获。
提示94 每个 Bug 只找一次
一个 Bug一旦被人类测试员发现,这就应该是它被该人类测试员发现的最后一次。要立即修改自动化测试,以便这个特定的 Bug,从此往后每次都被检查到——不能有任何例外,无论它多么琐碎,也无论开发者有多少抱怨,或是不停唠叨“哦,永远不会再发生了”。
无论你是使用rsync 和 ssh 这样简单的 shell 脚本,还是 Ansible、Puppet、Chef 或 Salt 这样功能全面的解决方案,都不要依赖任何手动干预。
提示95 不要使用手动程序
52 取悦用户
作为开发者,我们的目标是取悦用户。这就是我们身在其位的原因。不要为了数据就从他们身上深挖,不要数他们的眼球,不要掏空他们的钱包。撇开邪恶的目标不谈,只是及时交付能工作的软件,还远远不够。这本身无法取悦他们。
用户真正要的不是代码,他们只是遇到某个业务问题,需要在目标和预算范围内解决。他们的信念是,通过与你的团队合作,能够做到这一点。
那么,如何发掘他们的期望呢?问一个简单的问题:
这个项目在完成一个月(或是一年,不管多久)之后,你根据什么来判断自己已经取得成功?
你很可能会对最终答案感到惊讶。一个对产品推荐做改进的项目,实际上可能是根据客户留存率来判断的;合并两个数据库的项目,可能是根据数据质量来判断的,也可能是根据节省的成本来判断的。但是真正有意义的是这些对业务价值的期望,而不仅仅是软件项目本身。软件只是达到这些目的的一种手段。
既然你已经让项目背后的一些价值期望浮现出来,你就可以开始考虑如何实现这些期望:
- 确保团队中的每个人都清楚这些期望。
- 在做决定的时候,想想哪条路更接近这些期望。
- 根据期望严格分析用户需求。在许多项目中,我们已经发现,所陈述的“需求”实际上只是对用技术可以完成哪些工作的猜测——它实际上是一个业余的实现计划,只是伪装成需求文档。如果你能证明有方法会使项目更接近目标,那么就不要害怕,大胆提出改变需求的建议。
- 随着项目的进展,继续考虑这些期望。
提示96 取悦用户,而不要只是交付代码
如果你想取悦客户,就和他们建立起某种关系,这样即可积极地帮助他们解决问题。或许你的头衔只是“软件开发者”或“软件工程师”的某种变体,而事实上这个头衔应该是“解决问题的人”。这就是我们所做的,也是一个务实的程序员的本质。
我们在解决问题。
53 傲慢与偏见
务实的程序员不会逃避责任。相反,我们乐于接受挑战,并让自己的专长广为人知。如果我们正在负责一个设计,或一段代码,那么这是一份值得自豪的工作。
提示97 在作品上签名
保持匿名会滋生粗心、错误、懒惰和糟糕的代码,特别是在大型项目中——很容易把自己看成只是大齿轮上的一个小齿,在无休止的工作汇报中制造蹩脚的借口,而不是写出好的代码。
我们想看到你对所有权引以为豪——“这是我写的,我与我的作品同在”。你的签名应该被认为是质量的标志。人们应该在一段代码上看到你的名字,并对它是可靠的、编写良好的、经过测试的、文档化的充满期许。这是一件非常专业的工作,出自专业人士之手。
一个务实的程序员。
跋
对于我们交付的每一段代码,我们有义务问自己两个问题:
1.我已经保护好用户了吗?
2.我自己会用它吗?
提示98 先勿伤害
其次,有一个与恕道相关的拷问:我自己愿意成为这个软件的用户吗?我希望分享自己的详细信息吗?我希望自己的行踪被交给零售店吗?我愿意乘坐这辆自动驾驶汽车吗?做这件事我能心安吗?
有一些创造性的想法,开始打道德行为界限的擦边球,如果你参与了这类项目,就和出资者一样负有责任。
提示99 不要助纣为虐
正在为自己和子孙后代建设未来——这是你的职责所在,去创造一个让所有人心向往之的宜居未来。当你做的事情违背了这个理想时,要敢于承认,并有勇气说“不!”对可以拥有的未来充满憧憬,才有动力去创造它。即使是空中楼阁,也要每天为它添砖加瓦。
我们都有精彩的人生。