编程指南-全-
编程指南(全)
原文:Code Craft
译者:飞龙
第一部分。代码面
程序员编写程序。这不需要天才就能想明白。但有一个更微妙的区别:只有优秀的程序员才会习惯性地编写优秀的代码。糟糕的程序员……不会。他们制造出的混乱需要比编写时更多的努力来修复。
你更愿意成为哪种人?
代码工艺从代码面开始;这是我们最喜欢的地方。我们程序员只有在沉浸在编辑器中,一行行地敲打出完美形成和执行良好的源代码时,才会感到最快乐。如果我们周围的世界在一阵布尔逻辑的烟雾中消失,我们会非常高兴。遗憾的是,现实世界不会走开——它似乎不愿意将自己局限在自身之内。
在你精心编写的代码周围,世界正处于混乱的变化状态。几乎每个软件项目都以其变化性为特征:不断变化的需求、不断变化的预算、不断变化的截止日期、不断变化的优先级和不断变化的团队。所有这些因素都使得编写优秀的代码变得非常困难。欢迎来到现实世界。
优秀的程序员在不受干扰的情况下自然会编写整洁的代码。但他们也有一系列的战斗策略来帮助他们在前线编写健壮的代码 *。他们知道如何保护自己免受软件工厂残酷现实的侵害,并编写能够经受住变化旋风的代码。
这就是我们在这里要探讨的内容。本节深入探讨了痛苦而实用的代码构建细节,编写源代码语句的螺丝和螺母。你将学习策略来帮助你在波涛汹涌的软件开发海洋中保持浮力,并挑战你提高代码编写技能。
这些章节关注以下问题:
第一章
防御性编程:当世界与你为敌时,如何编写健壮的代码。
第二章
优秀的展示:为什么它很重要以及如何展示代码。
第三章
为程序的部分选择清晰的名称。
第四章
自我文档化的代码。当你无法写出一整本小说时,解释代码的实际策略。
第五章
编写最合适的代码注释的有效技巧。
第六章
处理错误:如何管理可能出错的操作,以及出错时应该做什么。
这些构成了在混乱世界中编写良好代码的道路;它们是坚实的代码编写技巧,应该成为第二本能。如果你不编写清晰、易懂、防御性、易于测试和易于维护的软件,那么当你应该为软件工厂接下来会抛出的东西做准备时,你会被繁琐的代码相关问题所困扰。
第一章。防御
健壮代码的防御性编程技巧
我们必须相互不信任。这是我们对抗背叛的唯一防御。
——田纳西·威廉姆斯
当我的女儿十个月大时,她喜欢玩木制积木。嗯,她喜欢和木制积木还有我一起玩。我会尽可能堆砌一个高塔,然后轻轻地推一下底部的积木,她就会让整个塔倒塌,并发出一声高兴的尖叫。我建这些塔不是为了它们的强度——如果那样做的话就毫无意义了。如果我真的想要一个坚固的塔,我会用完全不同的方式来建造。我会削平基础,从宽基开始,而不是仅仅快速堆叠积木,尽可能堆得高。
太多的程序员编写代码就像易倒的积木塔;轻轻一推底部,整个塔就会倒塌。代码层层叠加,我们需要使用确保每一层都稳固的技术,这样我们才能在此基础上构建。
向着优秀的代码迈进
代码看起来似乎能工作、正确的代码和好的代码之间有很大的区别。M.A.杰克逊写道:“软件工程师智慧的起点是认识到让程序工作与让它正确之间的区别。”(杰克逊 75)这里有一个区别:
-
写出大多数时候都能工作的代码很容易。你给它通常的输入集;它给出通常的输出集。但是给它一些意想不到的东西,它可能就会倒塌。
-
正确的代码不会倒塌。对于所有可能的输入集,输出都将正确。但通常所有可能的输入集非常大,难以测试。
-
然而,并非所有正确的代码都是好的代码——逻辑可能难以理解,代码可能很复杂,而且可能实际上无法维护。
根据这些定义,我们应该追求的是好的代码。它健壮、足够高效,当然,是正确的。工业强度的代码在接收到非寻常输入时不会崩溃或产生错误的结果。它还将满足所有其他要求,包括线程安全、时间约束和可重入性。
在自己舒适的家中编写这种好的代码是一回事,在一个精心控制的环境中。但在软件工厂的热潮中这样做就完全不同了,那里世界在你周围变化,代码库正在迅速演变,你不断地面临那些怪异的遗留代码——由早已消失的代码猴子编写的陈旧程序。当世界联合起来阻止你时,尝试编写好的代码!
在这个痛苦的环境中,你如何确保你的代码是工业强度的?防御性编程有所帮助。
虽然有许多构建代码的方法(面向对象的方法、基于组件的模型、结构化设计、极限编程等),但防御性编程是一种可以普遍应用的方法。它与其说是一种正式的方法论,不如说是一套非正式的基本指南。防御性编程不是万能的灵丹妙药,而是一种预防潜在编码问题的实用方法。
假设最坏的情况
当你编写代码时,很容易对它应该如何运行、如何被调用、有效的输入是什么等等做出一系列假设。你可能甚至都没有意识到自己已经做出了假设,因为这一切对你来说似乎都很明显。你可能会快乐地花几个月的时间编写代码,而这时这些假设在你的脑海中逐渐消失和扭曲。
或者,你可能会在产品即将在 10 分钟内出厂时,捡起一些旧代码进行关键的最后一刻修复。你只有足够的时间匆匆浏览其结构,你将假设代码是如何工作的。没有时间进行全面的文学批评,直到你有机会证明代码实际上正在做你所认为的事情之前,假设就是你拥有的全部。
假设会导致我们编写有缺陷的软件。做出假设很容易:
-
函数永远不会以那种方式被调用。我总是只会传递有效的参数。
-
这段代码将始终正常工作;它永远不会产生错误。
-
没有人会尝试访问这个变量,如果我只将其标记为仅限内部使用。
当我们进行防御性编程时,我们不应该做出任何假设。我们永远不应该假设它不可能发生。我们永远不应该假设世界会按照我们期望的方式运行。
经验告诉我们,唯一可以确定的事情是:你的代码将以某种方式,在某个时候出错。有人会做愚蠢的事情。墨菲定律是这样说的:“如果可以用错,它就会。”听听那个人的话——他是从经验中说的。1] 防御性编程通过预见这些事故,或者至少预先猜测它们——弄清楚代码每个阶段可能出错的地方,并加以防范,来防止这些事故的发生。
这是不是太偏执了?也许吧。但有点偏执并不会伤害到任何人。事实上,这很有道理。随着你的代码的发展,你将忘记最初做出的假设集合(真正的代码确实会发展——参见第十五章)。其他程序员不会了解你头脑中的假设,或者他们只是对你代码能做什么做出自己无效的假设。软件演变会暴露弱点,代码的增长会隐藏原始的简单假设。一开始有点偏执可以使代码在长期运行中更加健壮。
关键概念
不要假设任何东西。未写出的假设会不断导致错误,尤其是在代码增长时。
再加上这样一个事实:你和你用户都无法控制的事情也可能出错:磁盘满了,网络失败了,计算机崩溃了。坏事发生了。记住,实际上失败的不是你的程序——软件总是按照你告诉它的那样去做。真正的算法,或者可能是客户端代码,是引入系统错误的原因。
随着你编写更多的代码,并且以越来越快的速度工作,犯错的几率越来越大。如果没有足够的时间来验证每个假设,你就无法编写健壮的代码。不幸的是,在编程前线,很少有机会放慢速度,审视一下,仔细研究一段代码。世界变化得太快,程序员需要跟上。因此,我们应该抓住每一个减少错误的机会,而防御性实践是我们的主要武器之一。
^([1]) 爱德华·墨菲中士是一位美国空军工程师。他在发现一名技术人员系统地错误地将一整排设备颠倒连接后,提出了这个臭名昭著的法律。对称的连接器允许这种可避免的错误发生;之后,他选择了不同的连接器设计。
什么是防御性编程?
正如其名所示,防御性编程是谨慎、守卫的编程。为了构建可靠的软件,我们设计系统中的每个组件,使其尽可能自我保护。我们通过在代码中明确检查它们来打破未写明的假设。这是尝试防止,或者至少观察我们的代码以某种方式被调用时将表现出不正确行为的一种尝试。
防御性编程使我们能够及早发现小问题,而不是等到它们演变成大灾难时才被咬。过于常见的是,你会看到“专业”的开发者匆忙推出代码而不加思考。故事可能就像这样:

他们不断地被那些他们从未花时间验证的错误假设所困扰。这几乎不是对现代软件工程的宣传,但它却时常发生。防御性编程帮助我们从一开始就编写正确的软件,并摆脱编写它,尝试它,编写它,尝试它……的循环。使用防御性编程,故事看起来更像这样:

好吧,防御性编程并不能完全消除程序故障。但问题将变得不那么麻烦,更容易解决。防御性程序员捕捉到的是飘落的雪花,而不是被错误的大雪崩所掩埋。
防御性编程是一种预防方法,而不是一种治疗方法。将其与调试进行比较——在虫子咬人之后移除虫子的行为。调试完全是关于寻找治疗方法。
防御性编程不是什么
关于防御性编程有一些常见的误解。防御性编程不是:
错误检查
如果你的代码中可能出现错误条件,你应该检查它们。这不是防御性代码。这只是良好的实践——编写正确代码的一部分。
测试
测试你的代码不是防御性的。它是我们开发工作中的另一个正常部分。测试工具不是防御性的;它们可以证明代码现在是正确的,但不会证明它能够经受未来的修改。即使拥有世界上最好的测试套件,任何人都可以做出更改,并使其通过未经测试的审查。
调试
在调试期间,你可能需要添加一些防御性代码,但调试是在你的程序失败之后进行的。防御性编程是你为了防止程序一开始就失败(或者及早检测到失败,在它们以难以理解的方式显现之前,需要整夜调试)而采取的措施。
防御性编程真的值得麻烦吗?有赞成和反对的论点:
反对的案例
防御性编程消耗资源,包括你的和电脑的。
-
它会降低你代码的效率;即使是额外的少量代码也需要额外的执行。对于一个函数或类来说,这可能无关紧要,但当你有一个由 100,000 个函数组成的系统时,你可能会有更多的问题。
-
每项防御性实践都需要一些额外的工作。你为什么应该遵循其中任何一项?你已经有很多事情要做,对吧?只需确保人们正确使用你的代码。如果他们不这样做,那么任何问题都是他们自己的责任。
案例为
反驳论点是很有说服力的。
-
防御性编程可以为你节省大量的调试时间,让你有更多的时间去做有趣的事情。记住墨菲定律:如果你的代码可能被错误使用,它就会。
-
运行正常但稍微慢一点的代码,与大多数时候运行但偶尔会崩溃,散发出五彩斑斓的火花相比,要好得多。
-
我们可以设计一些防御性代码,在发布版本中物理移除,绕过性能问题。我们在这里考虑的大多数项目实际上并没有任何显著的额外开销。
-
防御性编程避免了大量安全问题——这是现代软件开发中的一个严重问题。更多内容将在后面讨论。
随着市场对更快、更便宜软件的需求增加,我们需要关注能够产生结果的技术。不要跳过前端额外的努力,这可以防止以后的痛苦和延误。
险恶的大千世界
有人说:“不要把可以由愚蠢解释的事情归咎于恶意。”^([2]) 大多数时候,我们是在防御愚蠢,防御无效和未经检查的假设。然而,确实存在恶意用户,他们会试图扭曲和破坏你的代码,以适应他们邪恶的目的。
防御性编程有助于提高程序安全性,防止这种故意的滥用。黑客和病毒编写者通常会利用马虎的代码来控制应用程序,然后编织他们想要的任何邪恶计划。在当今软件开发的世界中,这是一个严重的威胁;它对生产力、金钱和隐私的损失有着巨大的影响。
软件滥用者从利用小程序的小缺陷的机会主义者到花费时间故意试图非法访问你系统的硬核黑客都有。太多的无意识程序员为这些人留下了巨大的漏洞。随着网络化计算机的兴起,粗心大意的后果变得越来越严重。
许多大型开发公司终于开始意识到这个威胁,并开始认真对待这个问题,投入时间和资源进行严肃的防御性代码工作。实际上,攻击发生后很难再添加防御措施。我们将在第十二章中更详细地探讨软件安全。
防御性编程技术
背景介绍就到这里。这一切对在软件工厂工作的程序员意味着什么呢?
在防御性编程的范畴下,有许多常识性规则。人们通常在想到防御性编程时会想到断言,这是正确的。我们稍后会讨论这些。但还有一些简单的编程习惯可以极大地提高你代码的安全性。
尽管这些规则看起来是常识,但它们通常被忽视——这就是世界上大多数软件标准低的原因。只要程序员保持警觉并充分了解信息,更严格的安全和可靠的开发可以出人意料地容易实现。
接下来的几页列出了防御性编程的规则。我们将从宏观的角度开始,探讨高级防御技术、流程和程序。随着我们的进展,我们将填充更详细的细节,更深入地查看单个代码语句。其中一些防御技术是针对特定语言的。这是自然的——如果你的语言允许你自伤,你就必须穿上防弹鞋。
当你阅读这个列表时,评估一下自己。你目前遵循了多少条规则?你将现在采用哪几条?
采用良好的编码风格和可靠的设计
通过采用良好的编码风格,我们可以防止大多数编码错误。这自然与这一节的其他章节相吻合。简单的事情,比如选择有意义的变量名和恰当地使用括号,可以提高清晰度并减少错误未被注意到的可能性。
类似地,在深入代码之前考虑更大的设计规模是关键。“计算机程序的最佳文档是清晰的结构。”(Kernighan Plaugher 78)从一组清晰的 API 开始实现,一个逻辑的系统结构,以及定义良好的组件角色和责任,将避免在以后遇到麻烦。
不要急于编码
看到程序员快速编写函数,将其推入编译器检查语法,运行一次看是否工作,然后继续下一个任务的“击跑式编程”现象非常普遍。这种方法充满了危险。
相反,在编写每一行时都要思考。可能会出现哪些错误?你是否考虑了所有可能发生的逻辑转折?缓慢而细致的编程看似平凡,但实际上可以减少引入的错误数量。
关键概念
欲速则不达。在输入时始终仔细思考你所键入的内容。
某个 C 家族的陷阱会捕捉到快速编程者,那就是将==误写成=。前者是相等测试;后者是变量赋值。在没有帮助的编译器(或者关闭了警告)的情况下,不会有任何迹象表明程序行为不是你所期望的。
在匆忙进行之前,始终完成完成代码部分所涉及的所有任务。例如,如果你决定首先编写主流程,然后是错误检查/处理,你必须确保你有纪律去做这两件事。对于推迟错误检查并直接进入三个更多代码部分的主流程要非常小心。你打算稍后返回的意图可能是真诚的,但“稍后”可能很快就会变得非常晚,到那时你可能会忘记很多上下文,使得工作变得耗时且更加繁琐。(当然,那时可能还会有一些人为的紧急截止日期。)
纪律是一种需要学习和加强的习惯。每次你没有做正确的事情,你将来继续不做正确事情的可能性就更大。现在就做;不要把它留到撒哈拉沙漠的雨天。稍后做实际上需要更多的纪律!
信任无人
你的母亲曾告诉你不要和陌生人说话。不幸的是,良好的软件开发需要更多的怀疑和更少对人性本身的信任。即使是好意的代码使用者也可能在你的程序中引起问题;采取防御性措施意味着你不能信任任何人。
你可能会因为以下原因遇到问题:
-
真实用户意外地输入了无效的输入或错误地操作程序。
-
恶意用户试图有意识地引发不良的程序行为。
-
客户端代码使用错误的参数调用你的函数或提供不一致的输入。
-
操作系统环境未能为程序提供足够的服务。
-
外部库表现不佳,未能遵守你依赖的接口合同。
你甚至可能在某个函数中犯下愚蠢的编码错误,或者忘记了一些三年前的代码应该如何工作,然后错误地使用它。不要假设一切都会顺利,或者所有代码都会正确运行。在整个工作中设置安全检查。始终警惕弱点,并用额外的防御性代码来防范它们。
关键概念
不要相信任何人。绝对任何人——包括你自己——都可能将缺陷引入你的程序逻辑。在证明它们有效之前,对所有的输入和所有的结果都持怀疑态度。
为了清晰,而不是简洁,编写代码
当你可以选择简洁(但可能令人困惑)的代码和清晰(但可能令人厌烦)的代码时,使用看起来是意图的代码,即使它不那么优雅。例如,将复杂的算术运算分解成一系列单独的语句,以使逻辑更清晰。
考虑谁可能会阅读你的代码。它可能需要由初级程序员进行维护工作,如果他不能理解逻辑,那么他肯定会犯错误。复杂的结构或非同寻常的语言技巧可能证明了你对运算符优先级的百科全书式知识,但它实际上破坏了代码的可维护性。保持简单。
如果它无法维护,你的代码就不安全。在极端情况下,过于复杂的表达式可能导致编译器生成不正确的代码——许多编译器优化错误就是这样暴露出来的。
关键概念
简单是一种美德。永远不要使代码比必要的更复杂。
不要让任何人随意修改他们不应该修改的东西
内部的东西应该保持在内部。私有的东西应该被锁起来。不要在公共场合展示你代码的“脏衣服”。无论你多么礼貌地请求,如果有机会,人们 都会 在你不注意的时候摆弄你的数据,并且 会 尝试出于他们自己的原因调用“仅实现”例程。不要让他们这样做。
-
在面向对象的编程语言中,通过将其设置为私有来防止对内部类数据的访问。在 C++中,考虑使用“猫脸”(或 pimpl)惯用用法——这是一种常见的技巧,用于将类的内部结构排除在其公共头文件之外。(Meyers 97)
-
在过程式语言中,你仍然可以通过将私有数据封装在不可见类型后面,并为其提供定义良好的公共操作来使用面向对象(OO)封装概念。
-
将所有变量保持在最必要的紧密作用域内;当你不需要时不要全局声明变量。当它们可以是函数局部变量时,不要将它们放在文件作用域。当它们可以是循环局部变量时,不要将它们放在函数作用域。
说“何时”
何时进行防御性编程?你是当事情出错时开始吗?还是当你遇到你不理解的代码时?
不,这些防御性编程技术应该始终使用。它们应该成为第二天性。成熟的程序员已经从经验中学习——他们已经被咬了很多次,知道要设置合理的防护措施。
防御策略在你开始编写代码时更容易应用,而不是将它们添加到现有代码中。如果你试图在一天晚些时候强行加入这些内容,你将无法做到彻底和准确。如果你在出现问题后开始添加防御代码,你实际上是在进行调试——是反应性的,而不是预防性和主动性的。
然而,在调试过程中,甚至在添加新功能时,你会发现你想要验证的条件。添加防御代码总是好的时机。
全部启用警告进行编译
大多数语言的编译器在你伤害它们的感情时,会引用大量的错误信息。当它们遇到可能存在缺陷的代码时,如在使用 C 或 C++变量之前没有对其进行赋值时,它们也会发出各种警告。^[[3]) 这些警告通常可以单独启用和禁用。
如果你的代码充满了危险的结构,你会得到一页又一页的警告。遗憾的是,常见的反应是禁用编译器警告或者只是忽略这些信息。不要这样做。
始终启用你的编译器的警告。如果你的代码产生了任何警告,立即修复代码以平息编译器的尖叫。不要满足于在启用警告时无法静默编译的代码。警告存在是有原因的。即使你认为某个特定的警告不重要,也不要留下它,否则有一天它可能会掩盖一个真正重要的警告。
关键概念
编译器警告可以捕捉到许多愚蠢的编码错误。始终启用它们。确保你的代码在启用警告的情况下静默编译。
使用静态分析工具
编译器警告是你代码的有限静态分析的结果,这是一种在程序运行之前进行的代码检查。
可用的独立静态分析工具有很多,例如用于 C 语言的lint(及其更现代的衍生工具)和用于.NET 组件的 FxCop。你的日常编程流程应该包括使用这些工具来检查你的代码。它们能发现比单独的编译器更多的错误。
使用安全数据结构
或者,如果没有成功,请安全地使用危险的数据结构。
可能最常见的安全漏洞源于缓冲区溢出。这通常是由于对固定大小数据结构的粗心使用而触发的。如果你的代码在写入缓冲区之前没有先检查其大小,那么总是有可能写入到缓冲区的末尾之外。
这非常容易做到,就像这个小的 C++代码片段所展示的那样:
char *unsafe_copy(const char *source)
{
char *buffer = new char[10];
strcpy(buffer, source);
return buffer;
}
如果source中的数据长度大于 10 个字符,它的副本将超出buffer预留内存的末尾。然后可能发生任何事情。在最好的情况下,结果是数据损坏——其他数据结构的某些内容将被覆盖。在最坏的情况下,恶意用户可能利用这个简单的错误在程序堆栈上放置可执行代码,并使用它来运行他自己的任意程序,从而有效地劫持计算机。这类漏洞经常被系统破解者利用——这是严重的事情。
避免这些漏洞被咬很容易:不要编写如此糟糕的代码!使用更安全的数据结构,这些数据结构不允许你破坏程序——使用 C++的string类这样的管理缓冲区。或者系统地使用对不安全数据类型的安全操作。上面的 C++代码可以通过将strcpy替换为strncpy(一个大小受限的字符串复制操作)来得到保护:
char *safer_copy(const char *source)
{
char *buffer = new char[10];
`strncpy`(buffer, source, 10);
return buffer;
}
检查每个返回值
如果一个函数返回一个值,那么它有它的原因。检查这个返回值。如果它是一个错误代码,你必须检查它并处理任何失败。不要让错误默默地侵入你的程序;吞下错误可能导致不可预测的行为。
这同样适用于用户定义的函数以及标准库函数。大多数你发现的隐蔽错误都是在程序员未能检查返回值时出现的。不要忘记,一些函数可能通过不同的机制返回错误(即标准 C 库的errno)。始终在适当的级别捕获并处理适当的异常。
谨慎处理内存(以及其他宝贵资源)
要彻底,并在执行过程中释放你获取的任何资源。内存是这种被引用最多的例子,但并非唯一。文件和线程锁也是我们必须谨慎使用的宝贵资源。做一个好的管家。
不要忽视关闭文件或释放内存,因为你认为操作系统会在程序退出时清理你的程序。你真的不知道你的代码会运行多长时间,消耗所有文件句柄或消耗所有内存。你甚至不能确定操作系统会干净地释放你的资源——一些操作系统不会。
有一种观点认为:“不要担心释放内存,直到你知道你的程序首先能正常工作;只有那时才添加所有相关的释放。”只是说“不”。这是一种极其危险的做法。它会导致你在内存使用中犯许多错误;你不可避免地会忘记在某些地方释放内存。
关键概念
对待所有稀缺资源都要表示尊重。谨慎管理它们的获取和释放。
Java 和.NET 使用垃圾回收器为你完成所有这些繁琐的清理工作,因此你可以“忘记”释放资源。让它们掉到地上,因为运行时会不时地清理。这是一个美好的奢侈,但不要被虚假的安全感所迷惑。你仍然需要思考。你必须明确地删除你不再关心的对象的引用,否则它们不会被清理;不要意外地保留对象引用。较不先进的垃圾回收器也容易被循环引用(例如,A 引用 B,B 引用 A,但没有人关心它们)所欺骗。这可能导致对象永远不会被清理;这是一种微妙的内存泄漏形式。
在声明变量的地方初始化所有变量
这是一个清晰度问题。如果你初始化了变量,每个变量的意图都是明确的。依靠诸如“如果我没有初始化它,我就不关心初始值”这样的经验法则是不安全的。代码会演变。未初始化的值可能会在未来的某个时刻变成问题。
C 和 C++使这个问题更加复杂。如果你意外地使用了一个未初始化的变量,每次程序运行时你都会得到不同的结果,这取决于当时内存中的垃圾是什么。在一个地方声明一个变量,稍后分配它,然后更晚些时候使用它,这为错误打开了窗口。如果分配被省略,你将花费大量时间寻找随机行为。通过在声明时初始化每个变量来关闭这个窗口;即使值是错误的,行为至少是可预测地错误的。
更安全的语言(如 Java 和 C#)通过为所有变量定义一个初始值来避免这个陷阱。在声明变量时初始化变量仍然是良好的实践,这可以提高代码的可读性。
尽可能晚地声明变量
通过这样做,你将变量放置得尽可能靠近其使用位置,防止它混淆代码的其他部分。这也通过变量澄清了代码。你不必四处寻找变量的类型和初始化信息;附近的声明使其一目了然。
不要在多个地方重复使用相同的临时变量,即使每次使用都在逻辑上独立的区域。这会使代码的后期重构变得极其复杂。每次都创建一个新的变量——编译器会处理任何效率问题。
使用标准语言功能
在这方面,C 和 C++是噩梦。它们经历了许多不同的规范修订,其中一些较为隐晦的情况被留为特定实现的未定义行为。如今有众多编译器,每个编译器都有细微不同的行为。它们大多数是兼容的,但仍然存在很多可能导致错误的隐患。
明确定义你正在使用的语言版本。除非你的项目有要求(并且最好有一个很好的理由),不要依赖于编译器的怪异行为或语言的非标准扩展。如果语言中有未定义的区域,不要依赖于你特定编译器的行为(例如,不要依赖于你的 C 编译器将char视为signed值——其他人不会这样做)。这样做会导致非常脆弱的代码。当你更新编译器时会发生什么?当一个新的程序员加入团队而不理解这些扩展时会发生什么?依赖于特定编译器的怪异行为会导致以后出现真正微妙的错误。
使用良好的诊断日志工具
当你编写一些新的代码时,你通常会包含大量的诊断信息来检查正在发生的事情。这些诊断信息在事件发生后真的应该被移除吗?保留它们会在你需要重新访问代码时使生活变得更轻松,特别是如果它们可以在同时被选择性地禁用的话。
有许多诊断日志系统可供使用,以简化这一过程。许多系统可以在不需要时无额外开销地使用;它们可以被条件性地编译出来。
小心类型转换
大多数语言允许你将数据从一种类型转换为另一种类型(或转换)。这种操作有时比其他操作更成功。如果你尝试将 64 位整数转换为较小的 8 位数据类型,其他 56 位会发生什么?你的执行环境可能会突然抛出一个异常或默默地降低数据完整性。许多程序员没有考虑这类事情,因此他们的程序以不自然的方式运行。
如果你真的想使用类型转换,请仔细考虑。你对编译器说的是:“忘记你的类型检查:我知道这个变量的类型,你不知道。”你正在在类型系统中撕开一个大洞,然后直接穿过它。这是不稳定的地带;如果你犯任何错误,编译器会静静地坐在那里,低声咕哝,“我早就告诉你了。”如果你很幸运(例如,使用 Java 或 C#),运行时可能会抛出一个异常来让你知道,但这取决于你试图转换的确切内容。
C 和 C++在数据类型的精度方面特别模糊,因此不要假设数据类型可以互换。不要假设 int 和long具有相同的大小并且可以相互赋值,即使你在自己的平台上可以这样做。代码会迁移到不同的平台,但糟糕的代码会以糟糕的方式迁移。
细节说明
有许多低级别的防御性构建技术,这些都是合理的编码惯例和对现实世界健康怀疑的一部分。考虑:
提供默认行为
大多数语言都提供了switch语句;它们记录了default情况下的行为。如果default情况是错误的,请在代码中明确指出。如果没有发生任何操作,请明确在代码中说明——这样维护程序员才能理解。
同样,如果你编写了一个没有else子句的if语句,请停下来片刻,考虑你是否应该处理逻辑默认情况。
遵循语言习惯
这条简单的建议将确保你的读者理解你编写的所有代码。他们会做出更少的错误假设。
检查数值限制
即使是最基本的计算也可能导致数值变量溢出或下溢。请注意这一点。语言规范或核心库提供了确定标准类型容量的机制——使用它们。确保你知道所有可用的数值类型,以及每种类型最适合什么。
确认每个计算都是合理的。例如,确保你不会使用会导致除以零错误的值。
const-正确
C/C++程序员应该对此非常警觉——这将使生活更加容易。尽可能地将一切设置为const。它做两件事:const修饰符充当代码文档,并且const允许编译器发现你犯的愚蠢错误。它阻止你修改受限的数据。
约束
我们已经考虑了我们编程时做出的假设集合。但我们如何将这些假设物理地纳入我们的软件中,使它们不再是即将出现的隐性问题?只需编写一些额外的代码来检查每个条件。这段代码充当每个假设的文档,使其明确而不是隐含。^[[4]) 在这样做的时候,我们正在将程序功能和行为的约束编码化。
如果约束被违反,我们希望程序做什么?由于这种约束将不仅仅是简单的可检测和可纠正的运行时错误(我们本应该已经检查并处理这些错误),它必须是程序逻辑中的缺陷。程序的反应可能性很少:
-
对问题视而不见,并希望不会因此发生任何问题。
-
现场罚款并允许程序继续(例如,打印诊断警告或记录错误)。
-
直接进入监狱;不要通过(例如,立即以控制或不受控制的方式终止程序)。
例如,用设置为零的字符串指针调用 C 的strlen函数是不合法的,因为指针将被立即解引用,所以后两种选择是最可能的候选。最合适的做法可能是立即终止程序,因为解引用空指针可能导致在未受保护的操作系统上发生各种灾难。
在许多不同的场景中都会使用约束:
前置条件
这些是在进入代码段之前必须成立的条件。如果前置条件失败,那是因为客户端代码中的错误。
后置条件
这些必须在代码块离开后仍然成立。如果后置条件失败,那是因为供应商代码中的错误。
不变量
这些是在程序执行达到特定点时始终成立的条件:在循环迭代之间、方法调用之间等等。不变量的失败意味着程序逻辑中的错误。
断言
关于程序在特定时间点的任何其他状态声明。
这里列出的前两项在没有语言支持的情况下实现起来很令人沮丧——如果一个函数有多个退出点,^([5]) 那么插入后置条件会变得混乱。Eiffel 支持核心语言中的前置和后置条件,并且还可以确保约束检查没有副作用。
虽然有些繁琐,但用代码表达的良好约束可以使你的程序更清晰、更易于维护。这种技术也被称为设计合同,因为约束在代码的不同部分之间形成了一个不可变的合同。
要约束的内容
你可以用约束来保护多种不同的问题。例如,你可以:
-
检查所有数组访问都在范围内。
-
在解引用指针之前断言指针不是零。
-
确保函数参数有效。
-
在返回之前对函数结果进行合理性检查。
-
在操作对象之前证明对象的状态是一致的。
-
保护代码中任何可能写入注释我们不应该到达这里的地方。
这两个示例特别关注 C/C++。Java 和 C#有它们自己的方式来避免核心语言中的一些这些陷阱,其他语言也是如此。
你应该进行多少约束检查呢?每行都进行检查有些过于极端。就像许多事情一样,正确的平衡随着程序员经验的增长而变得清晰。是过多还是过少更好?过多的约束检查可能会掩盖代码的逻辑。 "可读性是程序质量的最佳单一标准:如果一个程序易于阅读,那么它可能是一个好程序;如果它难以阅读,那么它可能不是一个好程序。" (Kernighan Plaugher 76)
实际上,将前置和后置条件放入主要函数以及将不变量放入关键循环中就足够了。
移除约束
这种类型的约束检查通常只在程序构建的开发和调试阶段需要。一旦我们使用约束来确信(无论正确与否)程序逻辑是正确的,我们理想上会移除它们,以避免不必要的运行时开销。
多亏了现代技术的奇迹,所有这些都完全可能。C 和 C++ 标准库提供了一个通用的机制来实现约束—assert。assert 作为过程防火墙,测试其参数的逻辑。它被提供为开发者的警报,以显示程序的不正确行为,并且不应允许在面向客户的代码中触发。如果断言的约束得到满足,则执行继续。否则,程序将终止,并产生类似以下错误信息的消息:
bugged.cpp:10: int main(): Assertion "1 == 0" failed.
assert 被实现为一个预处理宏,这意味着它在 C 中比在 C++ 中更自然。有一些更符合 C++ 风格的断言库可用。
要使用 assert,你必须 #include <assert.h>. 你可以在你的函数中编写类似 assert(ptr != 0); 的内容。预处理器的魔法允许我们通过向编译器指定 NDEBUG 标志来在生产构建中删除断言。所有 asserts 都将被移除,并且它们的参数将不会评估。这意味着在生产构建中 asserts 完全没有开销。
与其说断言 应该 完全移除,不如说只是使其非致命,这是一个有争议的问题。有一种观点认为,在移除它们之后,你正在测试的是 完全不同的 代码片段.^([6]) 另一些人认为,断言的开销在发布构建中是不可接受的,因此必须消除。(但人们多久会分析执行来证明这一点?)
无论哪种方式,我们的断言都不能有任何副作用。例如,如果你错误地编写了:
int i = pullNumberFromThinAir();
assert(i = 6);**// hmm - should type more carefully!**
printf("i is %d\n", i);
断言在调试构建中显然永远不会触发;其值是 6(对于 C 来说足够接近 true)。然而,在发布构建中,assert 行将被完全移除,printf 将产生不同的输出。这可能是产品开发后期出现微妙问题的原因。保护调试代码中的错误非常困难!
容易想象出断言可能产生更微妙副作用的情况。例如,如果你 assert(invariants());,而 invariants() 函数有副作用,那么很难发现。
由于断言可以在生产代码中移除,因此仅使用 assert 进行约束测试至关重要。真正的错误条件测试,如内存分配失败或文件系统问题,应在普通代码中处理。你不会希望从程序中编译掉这些内容!合理的运行时错误(无论多么不希望)应通过无法移除的防御性代码来检测。
Java 有一个 类似 的断言机制^([7])。它可以通过 JVM 上的控制来启用和禁用,并且会抛出异常(java.lang.AssertionError),而不是导致程序立即终止。.NET 在框架的 Debug 类中提供了断言机制。
攻击性编程?
最好的防御就是进攻。
—谚语
在撰写这一章时,我 wonder,防御性编程的反面是什么? 当然是进攻性编程!
我认识一些人,你可以称他们为进攻性程序员。但我想这不仅仅是对着电脑咒骂,从不洗澡那么简单。
逻辑上讲,进攻性编程方法会积极尝试破坏代码,而不是防御问题。也就是说,积极攻击代码而不是保护它。我称之为测试。正如我们将在第 132 页的"谁,什么,何时,为什么?"中看到的那样,当正确执行时,测试对你的软件构建有极其积极的影响。它极大地提高了代码质量,并为开发过程带来了稳定性。
我们都应该成为进攻性程序员。
当你发现并修复一个故障时,一个好的做法是在修复故障的地方插入一个断言。这样你就可以确保你不会再次被咬。至少,这会作为警告标志提醒未来维护代码的人。
C++/Java 编写类约束的常见技术是为每个类添加一个名为bool invariant()的单个成员函数。(自然,这个函数应该没有副作用。)现在可以在每个调用此不变量的成员函数的开始和结束处放置一个assert。显然,构造函数的开始或析构函数的结束不应有断言。(出于明显的原因。)例如,一个circle类的不变量可能检查radius != 0;这将是一个无效的对象状态,可能导致后续计算失败(可能是因为除以零错误)。
^([2]) 一些历史学家将这句话归功于拿破仑·波拿巴。现在有一个 guy,他对防御之道颇有了解。
^([3]) 许多语言(如 Java 和 C#)将此视为错误。
^([4]) 虽然如此,这并不能取代编写良好的文档。
^([5]) 关于函数是否应该有多个出口点,存在神学上的争论。
^([6]) 实际上,在软件开发和发布构建之间可能会有更多变化——例如编译器优化级别和调试符号的包含。这两者都可能对执行产生微妙的影响,并可能掩盖其他错误的迹象。即使在开发的最早期阶段,也应该与开发和发布构建一样进行测试。
^([7]) 它是在 JDK 1.4 中添加的,在早期版本中不可用。
简而言之
为围攻取水,加强你的防御!揉泥,踩灰浆,修补砖墙!
--纳胡姆 3:14
编写不仅正确而且优秀的代码很重要。它需要记录所有做出的假设。这将使其更容易维护,并且会减少错误。防御性编程是一种预期最坏情况并为此做好准备的方法。这是一种防止简单错误变成难以捉摸的 bug 的技术。
在防御性代码旁边使用编码约束会使你的软件更加健壮。像许多其他良好的编码实践(例如——见第 138 页的"测试类型")一样,防御性编程是关于明智地(并且尽早)花一点额外的时间,以便在以后节省更多的时间、精力和成本。相信我,这可以拯救整个项目免于毁灭。
| 好程序员…… | 坏程序员…… |
|---|
|
-
关心他们的代码是否健壮
-
确保在防御性代码中明确捕捉到每个假设
-
希望对垃圾输入有明确的行为定义
-
在编写代码时仔细思考他们所写的代码
-
编写能够保护自己免受他人(或他们自己)愚蠢行为的代码
|
-
不愿意思考他们的代码中可能出错的地方
-
发布可能失败的代码以供集成,并希望其他人会将其解决
-
将有关他们代码如何使用的的重要信息锁在他们脑海中,随时可能丢失
-
对他们所编写的代码思考不多,导致软件不可预测且不可靠
|
另请参阅
第八章
进攻性编程——无需多言。
第九章
当错误突破你精心设置的防线时,你需要一个策略来收拾它们。
第十二章
防御性编程是编写安全软件系统的一项关键技术。
第十九章
你必须记录预先条件和后续条件;否则,任何人如何知道它们存在?如果你指定了任何约束,则可以添加防御性代码来断言它们。

激发思考
在第 463 页的"附录 A"部分可以找到对这些问题的详细讨论。
沉思
-
你可以有太多的防御性编程吗?
-
你是否应该在代码中为每个找到和修复的错误添加断言?
-
断言是否应该在生产构建中条件性地编译为空?如果不是,哪些断言应该保留在发布构建中?
-
异常是否是比 C 风格断言更好的防御性屏障?
-
预先条件和后续条件的防御性检查应该放在每个函数内部,还是围绕每个重要的函数调用?
-
约束是完美的防御工具吗?它们有什么缺点?
-
你可以避免防御性编程吗?
-
如果你设计了一种更好的语言,防御性编程仍然有必要吗?你该如何做到这一点?
-
这是否表明 C 和 C++因为存在许多问题表现的地方而存在缺陷?
-
-
你不需要担心编写防御性代码的代码类型是什么?
个人化
-
你在键入每个语句时考虑得有多仔细?你是否不懈地检查每个函数的返回代码,即使你确信一个函数不会返回错误?
-
当你记录一个函数时,你是否声明了前置和后置条件?
-
它们在函数功能的描述中是否总是隐含的?
-
如果没有前置或后置条件,你是否明确地记录了这一点?
-
-
许多公司只是口头上提到防御性编程。你的团队推荐它吗?看看代码库——他们真的这样做吗?约束在断言中是如何编码的?每个函数中的错误检查有多彻底?
-
你是否足够偏执?在过马路之前你是否会两边都看?你是否吃你的绿色蔬菜?你是否检查代码中的每一个潜在错误,无论可能性有多小?
-
完全做到这一点有多容易?你是否忘记了考虑错误?
-
有没有方法可以帮助你写出更全面的防御性代码?
-
第二章 最佳计划
源代码的布局和展示
停止仅仅根据外表进行评判,做出正确的判断。
--约翰 7:24
编码风格一直是,现在是,并将继续是程序员(专业、业余和学生的)之间圣战的课题——不幸的是,激烈的分歧降级为仅仅互相叫骂。我会让你看看你的愚蠢括号该放哪里。
我曾经工作的第一家公司启动了一个过程来定义其内部编码标准。这些指南本应涵盖几种语言,定义共同的约定和最佳实践。几个月后,编译指南的小组仍在争论 C 语言中括号应该放在哪里。我不确定是否有人最终遵循了产生的标准。
为什么人们对此如此激动?正如我们将看到的,展示方式极大地影响了代码的可读性——没有人愿意与难以阅读的代码一起工作。展示也是一个非常主观和个人化的事情——你可能不喜欢让我兴奋的风格。熟悉带来舒适,而陌生的风格会让你感到紧张。
程序员对代码充满热情,因此展示方式会引发强烈的情感。
这有什么大不了的?
代码的布局和展示在现代大多数编程语言中都是一个问题。允许个人艺术表达的格式自由在 20 世纪 60 年代初随着 Algol 语言的出现而流行起来;之前可用的 Fortran 版本在格式上更为受限。从那时起,很少有语言偏离了这种自由形式的处理方法。
代码展示风格控制着许多事情;大括号的位置是最明显的^([1]),也许是最有争议的问题。代码风格的更广泛方面,如函数和变量命名的约定,与其他编码关注点(例如程序结构,例如不要使用 gotos,或只编写单入口、单出口函数)相结合,以规定你编写程序的风格。总的来说,这构成了你的编码标准。
虽然在定义代码展示格式时有很多个人选择要做,但所有这些都是审美上的。按照定义,展示没有任何语法或语义意义;编译器会忽略它。
然而,代码的展示方式对代码质量有着实际的影响。程序员会根据代码的布局来解读其含义。它可以阐明并支持你的代码结构,帮助读者理解正在发生的事情。或者它可能会造成混淆、误导并隐藏代码的意图。不管你的程序设计得多好;如果它看起来像一团糟,那么使用起来将会很不愉快。但糟糕的格式化不仅会使代码难以追踪;它甚至可能实际上隐藏了错误。作为一个简单的例子,考虑以下 C 代码:
int error = doSomeMagicOperation();
if (error)
fprintf(stderr, "Error: exiting...\n");
exit(error);
布局显示了作者想要发生的事情,但当他看到代码实际运行时,他会感到惊讶。
由于我们是有责任感的工匠,致力于高质量的代码,我们努力追求清晰的展示。软件开发中已经有很多障碍;我们不应该让基本的代码展示成为其中之一。
^([1]) 大括号是花括号(即{和})的常见名称,这在 C 风格编程语言中非常常见。
了解你的受众
要编写有效的源代码,了解你向谁展示代码非常重要。如果你要让人困惑,你最好知道谁应该得到道歉。实际上,我们的源代码有三个受众:
我们自己
我的书法如此糟糕,有时甚至我自己都读不懂。除非我专注于清晰地写作,否则它几乎毫无用处。代码也是如此。你必须能够立即阅读你所写的内容,也许在多年后当你再次回到它时也是如此。谁能预料到会回到古老的(相对而言)COBOL 代码去修复 Y2K 错误?
编译器
编译器不在乎你的代码看起来如何,只要它没有语法错误。代码的意图完全被忽略。你可以写详细的注释来解释你希望函数做什么,但编译器不会告诉你指令是否实际上做了你注释中说的。只要它是有效的代码,你的开发环境就会满意。
其他人
这是最重要的受众,但往往是最少被考虑的。
所以你在一个团队中工作,但你是唯一会看到你代码片段的人,对吧?错。事情永远不会是这样。
你在家编写一些代码以娱乐自己;没有人会看到它。你不需要担心让它整洁,对吧?不,你不需要;但这对你有何好处?你并没有在培养让你成为专业人士的技能。这是在一个没有外部压力的项目上练习真正良好纪律的完美机会。这是一个养成良好习惯的机会。如果你在这里失败了,你在“真实”项目上没有纪律难道不是令人惊讶的吗?
你的源代码是一份文档,描述了你正在创建的程序。它需要清晰地供任何可能回来查看它的人阅读。这包括审计(代码审查)你所做的工作的人以及后来维护它的人。对那些不得不照顾你代码的人来说要友好——想象一下你自己站在他们的位置上。
我们根据我们的受众调整演示风格元素。受众如何影响我们布局代码?令人惊讶的是,我们最不关心的是编译器。它的任务是忽略所有不必要的空白,并专注于解释我们的语法。演示不是关于语法意义的,编译器可以处理我们抛给它的任何古怪布局。
相反,我们使用布局来强调代码对人类读者的逻辑结构。这是关于沟通的,越清晰越好。
关键概念
了解你的源代码的真实受众:其他程序员。为他们写作。
什么是好的演示?
正如你所见,好的演示不仅仅是整洁。整洁的代码确实给人留下高质量的印象,但代码可以是既整洁又具有误导性的。我们追求的是清晰的布局;代码结构必须通过缩进策略来增强,而不是被其隐藏。如果某个控制流程必然是复杂的,布局应该帮助你阅读代码。(如果你编写了一个不必要的复杂控制流程,你应该立即更改它。)
我们的代码布局必须传达意义,而不是隐藏意义。我建议以下作为衡量演示风格质量的好指标。
一致
缩进策略必须在整个项目中保持一致。不要在源文件中间改变风格。这不仅看起来不专业,还可能造成混淆,给人留下你的源文件实际上并不相关的印象。
单个演示规则应该是内部一致的。在大括号、括号等不同情况下的位置都应该遵循单一约定。缩进空格的数量始终相同。
Kernighan 和 Ritchie——C 的创始人——在强调良好缩进的重要性后说:“花括号的位置不太重要,尽管人们对此有着强烈的信念。我们已经选择了几种流行风格中的一种。选择一种适合你的风格,然后始终如一地使用它。”(Kernighan Ritchie 88)
传统
最好是采用目前在行业中广泛使用的其中一种主要风格,而不是发明你自己的缩进规则。你可以确信它对阅读你代码的其他人来说是可访问的。而且你不太可能让人感到恶心。
简洁
你能简洁地描述你的缩进策略吗?想想看。如果你这样做,除非某种情况,在这种情况下,如果你满足条件 X,那么你这样做;否则,你做另外一件事,这取决于……
某人最终可能需要扩展你编写的代码,并且应该以相同的方式进行扩展。如果不容易理解,那么这真的是一种有用的展示风格吗?
花括号准备
为了说明展示对源代码的影响以及选择特定风格所涉及的权衡,本案例研究调查了一个重要的与 C 相关的布局问题。通过观察这个简单区域的变化,我们将看到展示有多么重要,以及它对你的代码有多么深远的影响。
花括号定位 是花括号语言的一大关注点,尽管它实际上只是整个代码布局问题的一小部分。作为最直接可见的元素,它产生了大约 80% 的争议。其他语言也有它们自己的类似布局关注点。
有许多传统的花括号定位风格。你选择哪种取决于你的审美观、你编码的文化以及你习惯的什么。不同的风格在不同的环境中是合适的——考虑杂志文章与源代码编辑器(参见第 28 页的“展示得当”)。你可能更喜欢缩进风格,但在杂志上你被迫使用 K&R 以最大化利用印刷页面。
K&R 花括号风格
K&R 风格是最古老的风味,由 C 的创始人 Kernighan 和 Ritchie 在他们的书《C 程序设计语言》中确立。因此,它通常被认为是“原始且最佳”的。它是由在小型屏幕上显示尽可能多的信息的需要所驱动的。它可能是 Java 代码的主要风格。
int k_and_r() {
int a = 0, b = 0;
while (a != 10) {
b++;
a++;
}
return b;
}
优点
-
占用空间小,因此你可以一次在屏幕上显示更多的代码
-
关闭的花括号与匹配的语句对齐,因此你可以向上扫描以找到被终止的结构
缺点
-
花括号没有对齐,所以很难在视觉上匹配它们
-
你可能不会注意到如果开括号超出了页面右边
-
代码语句看起来非常密集
缩进花括号风格
更宽敞的方法是所谓的“缩进”风格(有时称为“Allman”风格)。这是我个人最喜欢的风格。
int exdented()
{
int a = 0, b = 0;
while (a != 10)
{
b++;
a++;
}
return b;
}
优点
-
清晰且不杂乱的结构
-
由于开括号是独特的,因此更容易扫描代码;这使得每个代码块更加明显
缺点
-
占用更多的垂直空间
-
当你有包含仅有一个语句的大量块时看起来很浪费
-
对于一些黑客来说,太像 Pascal 了
良好的展示
你如何展示代码取决于它将被阅读的上下文。比你想的上下文要多。当你阅读一些代码时,重要的是要欣赏推动其展示的力量。常见的代码栖息地包括:
源编辑器
这通常是代码的自然栖息地。它引发了程序员自动思考的所有展示问题。代码通常在计算机屏幕上阅读,通常是在某个专门的编辑器或 IDE 中。你通过滚动或导航到文件中特定感兴趣的地方。这是一个交互式世界——大多数情况下,你阅读代码是为了进行修改。这意味着代码必须具有可塑性。
编辑器可能有水平滚动条以处理长行,或者可能限制页面宽度并将它们换行。通常有语法着色来帮助理解。当你输入时,编辑器会为你执行一些格式化工作。例如,它智能地将光标定位在新行上。
已发布代码
除非你生活在一个孤独、孤立的小世界里,否则你将定期阅读已发布的代码。有大量的论坛:书籍和杂志中的列表、库文档中的片段,甚至新闻组中的帖子。这些格式是为了清晰,但也倾向于更紧凑的表示,因为空间并不便宜。行被垂直压缩以在短空间内放入最多的代码,并且它们被水平压缩以适应狭窄的打印边距。
这类代码往往省略错误处理以及与示例主要思想无关的任何内容。它只用于传达一个观点,而不是详尽无遗。
你可能永远不需要为这种媒体编写代码,但你肯定会看到很多(至少你在这本书中阅读代码片段)。你需要了解权衡和与正常代码的差异,这样你就不会无意中养成任何坏习惯。
打印输出
当你打印项目代码时,会遇到新的问题。列宽成为问题。你应该在打印前重新格式化,缩小页面并处理小字体,还是随意换行?没有语法着色来增强展示(除非你足够富有,可以拥有彩色打印机以及所有墨水),因此混乱的注释或被大注释块禁用的代码突然变得不那么明显。
虽然你可能永远不会打印出源代码的一页,但这些是你应该考虑的有效问题。
缩进括号风格
较不常见但仍然使用的是缩进风格。在这里,括号与代码缩进。它也被称为Whitesmith风格,因为早期 Whitesmith C 编译器的示例代码使用了它。
int indented()
{
int a = 0, b = 0;
while (a != 10)
{
b++;
a++;
}
return b;
}
优点
将代码块链接到包含它们的括号
缺点
许多人不喜欢他们的模块与他们的支架相连接
其他支架样式
还有其他的。例如,GNU风格介于缩进和展开之间;括号放置在每个缩进级别之间的一半。还有混合风格;Linux 内核编码风格是 K&R 的一半,展开的一半。大多数 C#程序员也结合布局风格。如果你真的很古怪,你会喜欢这个:
int my_worst_nightmare()
{
int a = 0, b = 0;
while (a != 10) {
b++;
a++;
}
return b;
}
我看到了很多类似的超现实代码,我确信如果你尝试,你也能创造出同样噩梦般的规模。
关键概念
识别你选择的语言的常见代码布局风格,并熟悉与每个风格一起工作。欣赏它们的优缺点。
一种风格统治一切
在看到构成良好编码风格的因素、它所管辖的内容以及为什么它是必要的之后,你现在必须实际上选择一个。这就是战斗开始的地方。一个展示宗教的追随者与下一个布道者的冲突,导致程序员内战。但工匠从这些琐事中退后一步,采取更平衡的观点。
只要你的写作风格是好的,那就不重要是什么风格。而且争论它也没有意义。有不止一种好的风格;每种风格的质量和适用性将取决于上下文和文化。
关键概念
选择一种单一的良好编码风格,并坚持下去。
可以说,如果你的语言标准定义了唯一的正确展示风格,世界将会变得更好。毕竟,所有的代码看起来都会一样。争论将停止,我们都会转向更有用的事情。你可以拿起任何人的代码,立即掌握它。听起来很不错,不是吗?
反论是竞争是一件好事。如果我们有一个单一的垄断编码风格,谁又能说它是最好的呢?通过有多种编码风格,我们被鼓励去思考和改进我们应用风格的方式。这鼓励风格指南的改进。结果是:它让我们写出更好的代码。
然而,这种论点并不是允许你按照自己的特定风格编码的许可证。记住,好的展示是传统的——一个读者预期的布局。
常见编码标准
一些著名的编码标准通常被使用。
Indian Hill
这份著名文件的完整标题是“Indian Hill 推荐的 C 风格和编码标准”。它与美洲原住民站在土堆上无关;相反,它来自著名的 Indian Hill AT&T Bell 实验室。
GNU
GNU 编码标准非常重要,因为它们影响了大多数常用的开源或免费软件。你可以在 GNU 项目的网站上找到它们(www.gnu.org)。
MISRA
英国汽车工业软件可靠性协会(MISRA)为用 C 语言编写安全关键嵌入式软件定义了一套知名的标准。它包括 127 条指南,并且存在一些工具可以用来验证你的代码是否符合这些指南。这些指南更多地关注语言使用而不是代码布局。
项目 foo
世界上几乎每个项目都有自己的专属编码风格。只需进行一番搜索,你就能找到成千上万种。例如,Linux 内核有自己的指导方针,Mozilla 项目也是如此。
房屋风格(以及它们的应用)
许多软件公司都有一个内部(房屋)编码风格,它定义了代码的展示规则等。但为什么要麻烦——任何用良好风格编写的代码都易于阅读和维护。如果没有人会感到难以遵循,我们真的需要这个额外的官僚层级吗?
房屋风格确实重要且有用,原因有很多。如果每个人都唱同一首赞美诗(或许应该是在同一个赞美诗上写),那么所有源代码都将保持一致和统一。这有什么价值?它提高了代码质量,并使软件开发更安全。以下是方法:
-
任何在组织外部发布的代码都将整洁有序,看起来经过深思熟虑。一个项目中存在许多相互冲突的风格看起来既粗心又不够专业。
-
由于有共同的习语和方法论,公司可以确信程序是按照一定标准编写的。这并不能保证代码质量良好,但它确实有助于防止编写出糟糕的代码。
-
它弥补了工具的不足;不同方式设置的 IDE 之间会相互斗争,拆分代码,并通常破坏布局。一个标准提供了一个公平的起点(以及所有程序员共同的敌人)。
-
能够立即识别你同事代码的形状并快速进行适当的维护更改的吸引力是显而易见的。这节省了阅读时间,因此也节省了公司的钱。
-
由于程序员不会不断地重新格式化代码以适应他们个人的审美偏好,你的版本控制历史非常有用。如果弗雷德将伯特的代码格式化为“他的”风格,那么当你稍后查看差异时会发生什么?许多差异工具相当原始,现在会显示大量琐碎的空白和括号差异。
这些房屋编码标准是好事。即使你并不完全同意它们规定的规则——例如,如果你的缩进策略在你看来更漂亮、更容易理解——那也无关紧要。大家共享相同风格的好处超过了你需要遵守的负担。如果你不同意标准,你仍然应该按照它来工作。
关键概念
如果你的团队已经有了编码标准,那么就使用它。不要使用你自己的个人风格。
你可能会惊讶地发现,你的编码风格中有多少是从熟悉和实践中培养出来的。如果你使用公司风格一段时间,它很快就会变得习以为常,看起来非常正常。
如果你正在处理来自公司外部的代码,并且不符合你的公司风格,会发生什么?在这种情况下,编写符合该源文件现有风格的代码更有意义。(这就是为什么写一个易于遵循的风格很重要。)唯一的其他实际选择是将文件(以及任何其他文件)转换为你的公司风格。对于大多数现实世界的项目来说,这种做法并不可行,尤其是如果你不断地收到外部源代码更新。
遵守给定文件或项目的风格,在不冲突的情况下遵守你的公司风格,并牺牲你的个人偏好。不过,不要盲目地放弃你的风格;理解权衡利弊。如果你的公司没有公司风格,那就争取一个……。

制定标准
你被分配了一个任务,即制定一个目前尚无的代码展示风格。祝你好运!你可以确信,每个人都会对应该包含哪些内容有自己的看法,并且没有人会对最终结果完全满意。技术人员就是这样有帮助。
制定编码标准是一项微妙的工作,应该谨慎而坚定地处理。为什么?向一群程序员发布命令既不会使你或你的标准受欢迎。但如果你不强调它的重要性,程序员就不会接受它,并会继续以他们独特的方式编码。
这项任务的难度取决于团队中的人:
-
有多少程序员
-
他们作为个人是如何编码的
-
他们的编码风格有多相似
-
他们是否真的想要一个标准
-
他们是否愿意改变自己的风格
如果他们的编码风格已经相当相似,那么这项工作就会变得轻松。如果它们差异很大,你将面临一段颠簸的旅程。虽然人们很少就最佳风格达成一致,但他们通常会同意某些风格比其他风格更好。你必须旨在提供一套足够详细的布局指令,同时尽可能满足尽可能多的程序员——并且产生一些有助于他们作为团队更好地工作的东西。以下是针对这项艰巨任务的一些建议:
这究竟是为了什么?
首先要有一个明确的工作范围——编码标准是仅针对你的直接团队、部门还是整个公司?这将在你开发和实施它时产生重大差异。
记住:一个好的个人风格不一定适合整个程序员的团队。你正在创造的东西不应该只是满足你个人的审美偏好;它应该是一个能够统一团队代码并避免常见问题的标准。在制定标准的过程中,要牢记这个目标。
确定你打算达到的详细程度。这是否只是一个代码布局文档,还是也会涉及语言使用问题?最好是保持简单:写一个用于展示的文档和一个用于语言使用的不同文档。
获得支持
让团队中的每个人都参与进来,这样他们才会拥有它。如果程序员觉得自己做出了贡献,他们更有可能遵循标准。
-
在开始工作之前,让每个人都同意需要一个标准。确保团队理解代码一致性的好处和临时代码展示的危险。
-
如果你有多于几个程序员,不要试图通过委员会来设计标准。好吧,除非你首先把办公室里所有的锐器都藏起来。选择一个小团队来完成这项工作。
当标准接近完成时,与采用者小组进行审查。确保你有一个能够做出最终决定的主席,否则,一切都会因为 15 个程序员的宗教争论而停滞不前。
产出成果
最终产品应该是一个易于访问的文档,而不仅仅是一套模糊的共识惯例。你应该能够以后参考这个文档,并将新来者引向它。文档包含了一组规则,也许还有对更具争议性决策的说明。
标准化最佳实践
确保标准体现了团队当前的最佳实践——让他们知道他们正在做正确的事情。如果没有突然出现的东西,他们更有可能接受它。然而,如果你包括团队经验之外的随机惯例,他们可能会反抗。
关注重点
将你的精力集中在真正重要的事情上,这将使你的团队代码得到最大的改进。如果你只使用 C 语言,就不要试图为 C、C++和 Java 制定一个展示标准。
避免热点
如果这些罕见但繁琐的情况实际上不会带来太多差异,就留给个人品味。如果人们真的对if语句中拆分行的布局感到过分激动,那就放弃,让他们做他们想做的事情。
不要过于限制;如果违规行为有正当理由,允许规则被违反。
分步骤进行
一个明智的方法是逐步发展你的风格。首先就花括号布局和缩进大小达成一致。仅此而已。这已经足够困难了!一旦你有了这个基础,进步将会容易得多;任何变化都只是更多相同的事情。在某个时候,添加新规则可能不再值得,因为代码已经足够规范。
制定采用计划
明确了解如何采用这个编码标准。要现实。人们必须对它感到满意,否则他们不会使用它。采用必须基于某种形式的多数规则;如果弗雷德仍然认为当其他人都能妥协时,他的switch语句看起来更好,那么就“太糟糕了,弗雷德”。不过,不要被诱惑让它成为一个民主过程,那根本行不通。
不要用标准威胁人们,也不要因为不使用它而诱导惩罚。那不会受欢迎。相反,提供激励——即使只是代码审查中的公开赞誉。
最终,标准的采用取决于其引入的权威性。要么程序员自己授权它,要么过程得到管理层的支持。否则,就是浪费时间。
这听起来像是在试图说服一群小学生和睦相处、友好玩耍吗?有趣,不是吗……然而,你将跋涉过宗教的泥潭,在另一边出现,带着一种真正能改善你团队代码的房屋风格。一旦伤口愈合,这一切都将是值得的。
正义之战?
结束战争最快的方式就是输掉它。
--乔治·奥威尔
在代码布局上进行圣战是不切实际且浪费时间的行为;有更多重要的事情需要我们关注。但要注意——代码布局并不是编程社区中唯一的烫手山芋。你可以将其扩展到包括编辑器、编译器、方法、唯一真正的语言,^([2]) 以及更多。
这些小骚动已经持续了多年。它们将继续进行。而且没有人会永远赢。没有人会设法确立正确的答案,因为没有正确答案。这些争论只是一个人试图将自己的特定(精心形成的)观点强加给他人,反之亦然的机会。毕竟,我的观点必须是正确的,因为它是我的。这就像尝试编织意大利面——一开始很有趣,但很混乱,完全没有意义。通常只有不成熟的程序员才会参与。(老手们已经争论过了。)
要学习的关键点是:圣战是徒劳的。作为一名专业人士,你应该从这样的小争执中退出来。当然,要有经过教育的个人观点,但不要傲慢地假设它是正确的。
关键概念
圣战:说不。不要参与。走开。
^([2]) 这让我想起了几年前参加的一个 C/C++编程会议。一位演讲者展示了他的一项发现,即使用 Pascal 而不是 C,你可以得到更少的错误(更容易修复),而在 C++中,最难以修复的错误和数量众多的错误发生。反应是热烈的——每个人的羽毛都竖起来了!
简而言之
没有什么比成功的表象更成功。
--克里斯托弗·拉斯 ch
展示是区分好代码和坏代码的关键特征之一。程序员从代码的外观中汲取了很多东西,所以担心布局是正确的。在可能存在的任何公司编码标准指导下,能够敏感地布局代码以实现最大清晰度是一项重要的技能。
寻找战斗
代码布局不是程序员之间发生争执的唯一借口。有许多宗教主题,为了你的血压,你最好巧妙地避开。小心以下:
我的操作系统比你的好
. . . 因为它可以从手表扩展到外星母船,每个时代只需要重启一次,并且大多数操作只需要一个两个字母的命令。
但我的比你的好,因为你永远不会看到任何一段使用它的文本,它色彩搭配得体,而且连瞎猴子都能操作。任何你不能用它做到的事情,在大多数文明国家都是非法的。
我的编辑器比你的好
. . . 因为它能够识别超过一百万种不同的语法方案,可以编辑用象形文字写的文件,并且它的 400 个操作都可以用不到 10 个同时按键完成。你可以在桌面上使用它,从命令行,通过调制解调器,通过上升的主干道,以及通过 128 位加密的烟雾信号。
但我的比你的好,因为它可以与我的内衣集成,并且在我自己还没想出来之前就知道我想写什么。
我的语言比你的好
. . . 因为它实现了大多数主要政府的人工智能,并且足够聪明,能够将随意的手势解释为有意义的指令序列。
但我的比你的好,因为它允许你写俳句,并且用空白字符的组合来编码信息。
有理由假设经过精心布局的代码已经被精心设计。更有理由假设展示粗糙的代码并没有得到很多关注。但这个故事不仅仅是关于格式化源代码。
除了展示技巧之外,还有一些态度将好程序员和差程序员区分开来。道德很简单:避免制造空洞的言论。电脑会为你做这件事(我们不需要办公室供暖,因为我们的设备会散发出大量的热量)。了解你喜欢什么,并准备好为之辩护,以表达你的观点——但不要假设你必须赢,或者你必须是对的,而且无论如何都不要傲慢地做自己想做的事情。
| 好程序员 . . . | 差程序员 . . . |
|---|
|
-
避免无意义的争论,并对他人的观点敏感
-
足够谦逊,知道他们并不总是对的
-
了解代码布局如何影响可读性,并努力追求尽可能清晰的代码
-
即使与个人偏好相矛盾,也会采用房屋风格
|
-
闭关自守且固执己见——我的观点是正确的
-
就最琐碎的事情与人争论;这是证明他们优越性的机会
-
没有一致的个性化编码风格
-
在他人自己的风格中践踏他们的代码
|
参见
第三章
编码规范可能规定你如何创建名称。
第四章
良好的展示是编写自文档化代码的关键。
第五章
描述我们如何编写注释;一些注释的使用与源代码布局相关。

开始思考
关于这些问题的详细讨论可以在第 466 页的"附录 A"部分找到。
仔细思考
-
你是否应该调整旧代码的布局以符合最新的代码风格?这是代码重格式化工具的有价值用途吗?
-
一个常见的布局约定是在固定数量的列中拆分源行。这种做法的优点和缺点是什么?它有用吗?
-
一个合理的编码规范应该有多详细?
-
对风格的偏离有多严重?为了不遵守它,应该截掉多少肢体?
-
一个标准可以过于详细和限制性吗?如果它这样做会发生什么?
-
-
当定义一个新的展示风格时,需要多少项或案例来布局规则?还需要提供哪些其他展示规则?列出它们。
-
哪个更重要——良好的代码展示还是良好的代码设计?为什么?
个人化
-
你是否以一致的风格写作?
-
当你与其他人的代码一起工作时,你采用哪种布局风格——他们的还是你自己的?
-
你的编码风格有多少是由编辑器的自动格式化决定的?这是采用特定风格的一个充分理由吗?
-
-
制表符:它们是魔鬼的作品,还是自切片面包以来最好的东西?解释原因。
-
你知道你的编辑器是否自动插入制表符吗?你知道你的编辑器的制表符停止位置吗?
-
一些非常受欢迎的编辑器使用制表符和空格的混合缩进。这会使代码的维护性降低吗?
-
一个制表符应该对应多少个空格?
-
-
你有偏好的布局风格吗?
-
用一系列简单陈述来描述它。要完整。例如,说明你如何格式化
switch语句和拆分长行。 -
需要多少个语句?这是你预期的吗?
-
你的公司是否有编码规范?
-
你知道它在哪吗?它是如何宣传的?你读过它吗?
-
如果是:它好吗?进行诚实的批评,并将你的评论反馈给文档所有者。
-
如果不是:它应该吗?(证明你的答案。)是否存在一个普遍的未写成的代码风格,每个人都采用?你能推动标准的采用吗?
-
-
是否有多个标准被使用,也许每个项目一个?如果是这样,项目之间是如何共享代码的?
-
-
你遵循过多少种不同的布局风格?
-
你觉得哪个最舒服?
-
哪个定义得最严格?
-
有链接吗?
-
第三章。名字的意义
赋予事物有意义的名称
当我使用一个词时,Humpty Dumpty 以一种相当轻蔑的语气说,它意味着我选择的意思——不多也不少。
--刘易斯·卡罗尔
古代文明知道,给某物命名就是对其拥有权力。这不仅仅是对占有的一种简单主张。有些人对名字的力量深信不疑,以至于他们永远不会将自己的名字告诉陌生人,因为他们害怕陌生人可能会用它来对他们造成伤害。
名字意味着很多。你可能不会因为它们而生活在恐惧中,但不要低估名字的力量。一个名称描述了:
身份
名字是我们对身份概念的基本组成部分。历史上有很多例子——甚至在公元前 2000 年之前,就有反映特定情况的具有意义的地名和被命名的孩子的圣经例子。在大多数文化中,女性在结婚时更改姓氏仍然是一种惯例,尽管一些女性选择不这样做的事实表明她们对自己的名字赋予了重要的意义。
行为
一个名称不仅促进身份,还暗示行为。显然,名称并不规定一个对象做什么,但它将影响你如何与之互动以及外界如何解释它。我们永远不会固定一个对象只有一个名称。在不同的环境中,我以不同的名字为人所知:我妻子叫我的名字,^([1]) 我女儿们知道我的名字,我在聊天室中使用的昵称,等等。这些名字表明了与我以及我扮演的角色不同的关系和互动。
识别
一个名称将某个事物标记为一个独特的实体。它将其从虚幻的概念提升到明确的现实。在有人给电起名之前,没有人会理解它是什么,尽管他们可能通过观察闪电或本杰明·富兰克林的演示而对它的效果有一些模糊的认识。一旦命名,它就成为了可以识别的独立力量,因此更容易进行推理。巴斯克文化认为,给某物命名证明了它的存在:Izena duen guzia omen da ——有名字的东西是存在的。(库兰斯基 99)
今天,命名已经成为一个价值数百万美元的产业,被小型公司、最大的跨国公司以及介于两者之间的所有企业所使用(成功率各不相同)。为了推出、重新命名和宣传产品,这些组织需要更新、更吸引人的名称。这些名称有助于建立产品和服务意识。
显然,名字具有极其重要的意义。
作为程序员,我们在命名我们的构造时拥有这种巨大的权力。一个命名不当的实体不仅可能是不方便的;它可能是误导的,甚至可能是危险的。作为一个非常简单的例子,考虑以下 C++代码:
void checkForContinue(bool weShouldContinue)
{
if (weShouldContinue) abort();
}
参数名称显然是一个谎言,或者至少它的意义与你的预期相反。该函数将不会按预期执行,结果,你的程序将崩溃——一个由单个错误命名的变量引起的相当糟糕的结果。
棍子和石头可以打断我的骨头,但名字永远不会伤害我。这不是真的。
为什么我们应该好好命名?
我们需要仔细考虑我们赋予事物的名称。记住,编写源代码全部关于清晰的沟通。一个名称创建了一个理解、控制和掌握的渠道。适当的命名意味着知道名称就是知道对象。
好的名称真的很重要。人脑一次只能同时处理大约七条信息(尽管我相信我有一些缺陷插槽,减少了这种容量)。将所有关于程序的信息塞进你的大脑已经很困难了;我们不应该添加复杂的命名方案或要求晦涩的引用,使这项任务更加困难。
清晰的命名是精心制作的代码的一个显著特征。命名事物的能力是代码工匠的重要技能——他会努力编写易于阅读的代码。
关键概念
学会透明地命名事物——对象的名称应该清楚地描述它。
^([1]) 这取决于她当时的心情是好是坏!
^([2]) 这被称为米勒数,以乔治·A·米勒的心理研究命名。(Miller 56)
我们要命名什么?
在本章中,我们将花一些时间思考,作为程序员,我们如何命名以及如何命名。首先:什么? 在编写代码时,我们最常命名的事物是:
-
变量
-
函数
-
类型(类、
enums、structs、typedefs) -
C++命名空间和 Java 包
-
宏
-
源文件
这个列表绝对不是详尽的——我们还会为其他更高级别的实体赋予有意义的名称:状态机的状态、消息协议的部分、数据库元素、应用程序可执行文件等等。但这六个就足够开始了。
命名游戏
如何命名?每个这些项目的命名技术将取决于你正在遵循的任何编码标准。然而,尽管标准可能要求某些命名约定,但它不会足够具体,以指导程序每个部分的适当命名。
为了命名得好,在你为它想出一个名称之前,确切地知道你要命名的内容是至关重要的。如果你不知道你要命名的内容、它的用途以及它为什么存在,你怎么能给它一个有意义的名称?坏名字往往是理解不足的迹象。
关键概念
良好的命名关键在于准确理解你要命名的内容。只有在这种情况下,你才能给出一个有意义的名称。如果你不能为某物想出一个好名字,你真的了解它吗,甚至它是否应该存在?
在我们详细探讨我们创建的具体名称类别之前,了解推动我们选择名称的驱动力以及什么构成一个好名称是很重要的。接下来的几节将解释一个好名称的特质。
描述性
显然,一个名称必须是描述性的。这就是你用它来描述某物的目的。然而,常见到一些令人困惑的标识符,它们与它们描述的数据几乎没有相似之处。
即使是一个准确的名字也可能有限制。人们常常坚持他们对一个概念的最初印象,尽管有关于不要以貌取人的谚语。因此,通过仔细的命名传达正确的第一印象是很重要的。从没有经验的读者的角度选择名称,而不是从你内部的知识角度。
有时找到一个好的描述是困难的。如果你想不出一个好的名称,那么你可能需要改变你的设计。这是一个可能表明某些事情可能出错的迹象。
技术正确
现代编程语言对我们如何命名事物施加了一些规则。大多数允许大小写敏感的名称,不允许空白字符(空格、制表符、换行符),并且允许仅限字母数字字符加上某些符号(如下划线)。如今,标识符的长度没有明显的限制.^([3]) 尽管许多语言允许使用 Unicode 标识符,但出于简单起见,仍然常见的是从 ISO8859-1(ASCII)字符集中选择。
可能还有其他技术限制。C/C++标准保留了特定的名称范围:你不应该使用任何以str开头后跟小写字母或以下划线开头的全局标识符,或者在任何名为std的命名空间中的任何内容。了解这些类型的限制很重要,这样我们才能编写健壮、正确的代码。
习语性
仅仅因为一种语言允许某些字符组合并不意味着它们自动就是好的名称。清晰的名字遵循读者期望的约定:语言的习语。就像在自然语言中流利地使用取决于理解其习语一样,在编程语言中使用习语性用法也需要流利。
一些语言有单一的、常见的命名约定——庞大的 Java 库建立了一种难以忽视的先例——而 C 和 C++的收敛程度较低。有几个文化,每个文化都有自己的特点;标准库使用一种约定,Windows Win32 API 使用另一种。
关键概念
了解你语言的命名规则。但更重要的是,了解你语言的习语。常见的命名约定有哪些?使用它们。
适当
一个适当的名称在几个方面都取得了良好的平衡:
长度
要创建清晰、描述性的名字,我们必须使用自然语言单词。程序员有内置的缩写和缩短这些单词的冲动,但这会导致混乱、杂乱无章的名字。如果名字的含义不明确,那么名字的长短无关紧要。a 不能作为 apple_count 的现实替代。
关键概念
命名时,优先考虑清晰度而非简洁性。
然而,短变量名(甚至是一个字母)有其合理性:作为循环计数器。在小循环中,变量名如loop_counter不仅过于冗长,而且很快就会变得令人厌烦。
关键概念
理解短名和长名之间的权衡——它们如何取决于变量的使用范围。
语气
名字的语气**很重要。就像粗鲁的笑话不适合葬礼一样,不恰当的名字会破坏你代码的专业性。这是认真的吗?是的——愚蠢的名字会让读者怀疑原作者的能力。
避免使用像blah或wibble这样的玩笑名字,或者更大的技术陷阱foo和bar。它们很容易悄悄进入,虽然一开始可能很有趣,但后来只会造成混淆。(这些名字的对象通常是快速临时修补,寿命超过了预期。)显然,专业意味着在命名时不要使用咒骂。
思考
那么,所有这些foo和bar是怎么回事呢?这些词是一种技术幽默,毫无意义,却又充满目的。它们通常用作占位符来表示任意事物。你可能这样写:对于某个变量 foo,增加它的值 ++foo;。
这些词通常成系列出现。有几个变体系列,但你将看到 foo、bar 和 baz `相当普遍。接下来是什么可能取决于命运的无常或你偏爱的任何技术传说。
这些词的词源有争议。有些人将其追溯到二战时期的军队俚语 FUBAR(Mucked Up Beyond All Repair)。不用说,你永远不应该在生产代码中使用这些名字。
关键概念
始终第一次就给事物起一个好名字。
^([3]) 请注意,C 语言的老版本将外部唯一链接限制在前六个字符,并且大小写不一定重要。当你编写代码时,你需要确切地了解你的代码目标是什么。
坚果和螺栓
以下章节将探讨如何命名我们之前列出的每一类项目。即使你已经编程多年,这也是对命名约定广泛范围的 useful 回顾。
命名变量
如果一个变量不是一个电子实体,它将是你可以用手拿的东西,是物理对象的软件等价物。反映这种特性的名称通常将是名词。例如,GUI 应用程序中的变量名可能是ok_button和main_window。即使不对应现实世界对象的变量也可以给予名词名称;考虑elapsed_time或exchange_rate。
如果一个变量不是一个名词,它通常会被“名词化”成动词,例如,count。数值变量的名称描述了值的解释,如widget_length。布尔变量的名称通常是条件语句的名称,这是自然的,因为值将是真或假。
对于装饰成员变量以表明它们是成员而不是普通局部变量或(邪恶的)全局变量,面向对象的语言有许多约定。这是一种温和的匈牙利命名法,一些程序员认为它很有用.^([1]) 例如,C++成员通常以下划线开头,以下划线结尾,或以m_开头。第一种方法不太受欢迎,因为它有一定的风险,并且令人不快.^([5]) 此外,前导或尾随的下划线使得变量看起来不太自然。
一些程序员会在指针类型后加上像_ptr这样的后缀,在引用类型后加上像_ref这样的后缀。这是匈牙利命名法的另一种微妙渗透,而且它是多余的。变量是指针的事实隐含在其类型中。如果你的函数如此之大,以至于你认为这种装饰是有用的,那么它可能太大了!
另一种常见的变量命名实践是使用缩写作为简洁、有意义的名称。例如,你可能这样声明一个变量:SomeTypeWithMeaningfulNaming stwmn(10);。如果使用的范围较小,这种名称可能比冗长的变体更清晰。
区分类型名和变量名的约定通常是最好的。类型名通常以大写字母开头,而变量名则以小写字母开头。这样,看到变量声明如下:Window window;并不罕见。
关键概念
采用有助于区分变量名和类型名的命名约定。
匈牙利命名法
匈牙利命名法是一种有争议的命名约定,它通过在名称中编码有关变量或函数类型的信息,相信这将使代码更易于阅读和维护。它起源于 20 世纪 80 年代的微软,在公司的公共 Win32 API 和 MFC 库中得到广泛应用,这也是它受欢迎的主要原因。
它被称为匈牙利命名法,因为它是匈牙利程序员查尔斯·西莫尼(Charles Simonyi)首创的。它也被称为这样,因为变量名看起来就像是用匈牙利语写的:非 Windows 程序员会被像lpszFile、rdParam和hwndItem这样的超现实名称搞糊涂。
有许多细微不同且不完全兼容的匈牙利符号方言,这并没有帮助解决问题。
命名函数
如果一个变量像你可以用手拿的东西,那么 函数 就是你要做的——你不想永远拿着它。由于函数是一个动作,它的名字将最合理地是(或者至少包括)一个动词。以名词为名的函数不会很清晰;例如,apples() 这个函数做什么?它是返回苹果的数量,还是将某物转换为苹果,或者是从无到有地制造苹果?
有意义的函数名应避免使用单词 be、do 和 perform。这些是初学者试图有意识地包含动词(这个函数做XXX ...)的经典陷阱。它们只是噪音,不会给名字增加任何价值。
函数应该始终从用户的角度命名,将所有内部实现细节整洁地隐藏起来。(这就是函数的目的——它是一种压缩和抽象的层次。)幕后它是否在列表中存储元素,是否通过网络进行调用,或者构建一台新计算机并在其上安装文字处理器?如果用户只看到函数 count apples,那么这个函数应该被命名为 countApples()。
关键概念
从外部视角命名函数,使用动作短语。描述逻辑操作,而不是实现。
你可能唯一选择打破这条规则的时间是对于简单的查询函数,这些函数请求信息。对于这些访问器,你可以合理地根据请求的数据来命名函数。例如,参见本章第 9 个问题的答案 "Mull It Over" 部分,在第 478 页。
当你编写一个函数时,它应该有良好的文档(无论是规范还是使用某种文献编程方法)。然而,函数名仍然应该是函数所做事情的清晰陈述;它是函数契约的一部分。void a() 这个函数做什么?它可以是任何事情。
大小写约定
大多数语言禁止我们在标识符中使用空格和标点符号,因此我们采用一种将多个单词连接起来的约定。这些大小写约定引发了与永恒的圣洁编辑战一样多的程序员争吵。在现代代码中,你会看到一些常见的做法:
camelCase
camelCase 在 Java 语言库中广泛使用,也在许多 C++ 代码库中使用。之所以称为 camelCase,是因为其首字母大写类似于骆驼的驼峰,可能最早在 20 世纪 70 年代初的 Smalltalk 中使用。
ProperCase
这与 camelCase 非常相似,唯一的区别是第一个字母也大写。有时也称为 PascalCase。通常这两种约定会一起使用。例如,Java 类名使用 ProperCase,成员使用 camelCase。Windows API 和 .NET 方法使用 ProperCase。
使用下划线
这种风格的倡导者是 C++标准库的实现者(看看std命名空间中的所有名称)和 GNU 基金会。
还有许多其他形式。你能想到多少种?你可以从将正确的大小写与下划线混合开始,或者完全省略大写字母。
命名类型
你可以创建哪些类型取决于你使用的语言。C 语言提供了typedef,它是其他类型名称的同义词。你使用它们来提供更简单、更方便的名称。因此,一个typedef应该有一个清晰的名称。即使它只是一个函数体内的局部typedef,也应该有一个描述性的名称。
Java、C++和其他面向对象的语言深刻地基于新类型的创建(类)。C 语言也允许你定义复合类型,称为struct。就像好的变量和函数名对代码的可读性至关重要一样,好的类型名也是至关重要的。然而,对于命名类并没有太多严格的启发式规则,因为不同的类服务于不同的目的。
-
一个类可能描述了一些有状态的数据对象。在这种情况下,它的名称可能是一个名词。
它可能是一个函数对象(一个函数式对象)或实现某些虚拟回调接口的类。在这里,名称可能是一个动词,可能包括一个公认的设计模式的名称。(Gamma 等人 94)
-
如果类是两者的组合,那么它可能很难命名,并且可能设计得不好。
接口类(例如,具有纯虚拟函数的抽象 C++类或 Java 和.NET 中的
interface)通常根据接口功能命名。像Printable和Serializable这样的名称很常见。.NET 添加了一个匈牙利标记,将所有接口名称前缀为I,结果产生像IPrintable这样的名称。
之前,我们讨论了在函数名中要避免的词汇;这里也有类似的陷阱。例如,DataObject是一个糟糕的名称:这个类可能确实包含数据,并且显然将被用来创建对象——这不需要重申。
关键概念
避免在名称中使用冗余的词汇。特别是,在类型名称中避免以下词汇:class、data、object 以及type。
确保你描述的是数据类别而不是实际对象。这是一个微妙但重要的区别。
不良命名类别
一个糟糕的类名可能会真正让程序员感到困惑。我曾经参与过一个包含状态机实现的程序。由于某些历史原因,每个状态的基础类被命名为Window,而不是像State这样的合理名称。这非常令人困惑,并且当一些程序员第一次看到它时,会让他们感到困惑。更糟糕的是,命令模式的基础类被命名为Strategy,但实际上并没有实现策略设计模式。要弄清楚发生了什么从来都不容易。更好的命名会为代码的逻辑提供一个清晰的路径。
命名命名空间
你会给专门用于整理名称的东西起什么名字?C++和 C#的namespace和 Java 的package就像袋子,主要起分组机制的作用。
它们还用于防止名称冲突。当两个程序员用相同的名称创建不同的事物,并且他们的代码被粘合在一起时,会发生什么任何人都可以猜测。最好的情况是代码无法链接;最坏的情况是会发生各种运行时破坏。将项目放入不同的命名空间可以避免污染全局命名空间的风险。这使得它们成为有价值的命名工具。
但仅凭命名空间本身并不能防止冲突;你的utils命名空间仍然可能与别人的utils冲突。为了解决这个问题,我们采用了一种命名方案。Java 定义了一个包名称的层次结构,就像互联网域名一样嵌套——你将代码放入你自己的唯一命名的包中。这巧妙地避免了冲突的问题。如果没有这样的约定,命名空间可以减少问题发生的可能性,但并不能消除。
当为命名空间选择名称时,选择一个描述内容关系的名称。如果它们都是库接口的一部分,那么就使用库的名称。如果内容是更大系统的一个单独部分,那么选择一个描述这个部分的名称;UI、filesystem或controls都是好的名称。不要选择一个冗余地暗示一组项目的名称——controls_group是一个不好的名称。
关键概念
为命名空间和包命名时,应反映其内容的逻辑关系。
命名宏
宏是 C/C++世界的核桃敲击大锤。它们是用于基本文本的搜索和替换工具,不尊重作用域或可见性。它们很无礼。然而,有些核桃没有它们是无法敲开的。
宏具有非常显著的影响,因此有一个非常成熟的传统,即以最明显的方式命名宏:使用大写字母。务必遵循这一点,不要将任何其他名称完全大写。这使得宏像突出的拇指一样显眼,这正是它们的目的。
由于它们是简单的文本替换工具,因此给宏命名时,要足够独特,以免在代码的其他地方出现。否则,将会发生混乱和破坏。
一个独特的文件或项目名称前缀在这里会有所帮助。宏名称PROJECTFOO_MY_MACRO比MY_MACRO更安全。
关键概念
C/C++中的宏总是大写以使其突出,并且命名仔细以避免冲突。永远不要大写其他任何内容。
命名文件
你的源文件名称可以对编码的容易程度产生真正的影响。一些语言有严格的文件名要求——Java 源文件名必须与包含的公共类名称相对应。另一方面,C 和 C++则比较宽松,没有任何限制。⁶
为了使选择文件名变得简单明了,每个文件应包含一个单一的概念单元。将更多内容放入一个文件中,从长远来看可能会带来麻烦。将你的代码拆分成尽可能多的文件;这不仅会使它们更容易命名,而且会减少耦合,使项目的结构更加清晰。
定义小部件接口的 C/C++文件应该命名为widget.h,而不是widget_interface.h、widget_decls.h或任何其他变体。你应该按照惯例,为每个widget.h配对一个匹配的widget.cpp或widget.c(参见第 50 页的"ALL THAT ENDS WELL"),该文件实现widget.h声明的任何内容。共享的基本名称将它们在逻辑上联系起来。这是既明显又符合惯例的。
在命名文件时,还有很多其他微妙但重要的问题:
-
注意大小写。一些文件系统无法正确处理大小写,在查找文件名时忽略大小写。但是,当移植到大小写确实重要的平台时,除非你仔细观察了大小写,否则你的代码将无法编译。也许避免被绊倒的最简单方法就是强制所有文件名都使用小写;正如他们所说,如果你不能做得好,那就小心点。(当然,这不会适用于 Java,Java 为其类和接口使用 PascalCase 命名风格。)
-
由于同样的原因,如果你的文件系统认为
foo.h和Foo.h是不同的文件名,不要利用这一点。确保同一目录下的文件名在大小写之外还有所不同。 -
如果你在一个项目中混合使用多种语言,不要在同一个目录中创建
foo.c、foo.cpp和foo.java。这会变得很混乱——哪个文件用于创建名为foo.o的目标文件,哪个用于创建名为foo的可执行文件? -
尽量确保你创建的所有文件都有独特的名称,即使它们分布在不同的目录中。这样做可以更容易地推断出哪个文件是哪个。当你
#include "foo.h"时,很明显你指的是哪个头文件。如果有两个文件具有相同的名称,那么对于代码库的新手来说可能会感到困惑。随着系统的增长,这个问题会变得更加严重。一种有效的方法是在逻辑文件名中添加一些路径信息。安排你的文件,以便你可以不混淆地包含
library_one/version.h和library_two/version.h。
文件命名对编码的便捷性有严重影响。我曾经在一个 C++项目中工作,其中大多数文件名与类名完全匹配;类Daffodil在Daffodil.h中定义(为了保护无辜者,已更改名称)。然而,一些文件以略微不同的方式命名,通常是缩写,所以HerbaciousBorder被放在HerbBdr.h中。这使得找到正确的文件名来#include变得复杂且耗时。更不用说,Daffodil类的所有实现并不一定都在Daffodil.cpp中——其中一些可能在一个共享的FlowerStuff.cpp中,也许还在Yogurt.cpp中,没有任何合理的解释。正如你可以想象的那样,这使得找到特定的代码片段变得像噩梦一样。源代码浏览器在这种情况下有所帮助,但它们不能替代清晰、命名良好的代码。
^([1]) 当然,这种命名约定对类的公共 API 没有任何影响,因为你的所有成员变量都是私有的,对吧?
^([5]) 你不能以下划线开头,后面跟着大写字母的全局标识符。古老的 C 命名规则提出了许多这样的奇怪要求。
^([6]) 除了操作系统或文件系统强加的之外。
一个玫瑰,无论叫什么名字
命名游戏比你想象的要复杂得多,命名代码片段有很多考虑因素。主要的命名原则有哪些?
要发明一个好的名称,请遵循以下步骤:
-
保持一致性
-
充分利用内容
-
利用名称的优势
皆大欢喜
选择后缀对于文件命名至关重要。Java 的构建系统坚持要求源文件以.java结尾。C 和 C++编译器对后缀不敏感,但将头文件命名为.h是一种普遍的约定,不这样做就像是在你的眼睛上扎针。我们确实对缺乏严格定义感到一些痛苦;C++实现文件名有几种约定,如常见的后缀.C、.cc、.cpp、.cxx和.c++. 较不常见,但仍然存在,是 C++头文件后缀为.hpp。你的选择可能取决于编译器、个人偏好和/或编码标准。一致性是关键;选择一个文件后缀方案并始终如一地使用它。
我甚至在一个不支持文件名后缀的平台工作过。确定文件类型是一项复杂且混乱的业务。
保持一致性
这可能是最重要的命名原则。要保持一致性——不仅在你自己的工作中,而且在公司范围内的实践中。如果一个类的接口看起来像这样,我对其质量没有信心:
class badly_named : public MyBaseClass
{
public:
void doTheFirstThing();
void DoThe2ndThing();
void do_the_third_thing();
};
当很多人一起工作时,很容易得到这样的代码——就像随机数生成器一样内部不一致。这通常是更严重问题的症状——可能是程序员没有尊重他们同时工作的代码的基本设计。这就是强制编码标准和中心设计文档可以大有帮助的地方。
命名一致性不仅限于大小写和格式,还在于你创建名字的方式。一个名字建立了一个隐含的隐喻。在整个程序或项目中,这些隐喻应该是统一的。你的命名方法应该是全面的。
关键概念
选择一个一致的命名约定,并且始终如一地使用它。
通过一致的命名,我们得到直观的代码,因此更容易工作,更容易扩展,也更容易维护。从长远来看,管理成本会大大降低。
利用上下文
每个名字在上下文中阅读时都应该完美合理。一个名字只会在其上下文中被阅读,所以你可以删除所有重复上下文信息的冗余部分。我们追求简洁、描述性的名字,没有不必要的负担。
这种上下文信息可能来自:
作用域
事物要么存在于顶级、全局作用域中,要么存在于某个命名空间、类或函数中。选择一个在该作用域中合理的名字。作用域越小、越具体,在其中创建名字就越容易,读者理解这个名字真正含义也就越容易。如果一个函数在Tree类中计算树上的苹果数量,那么它不需要被命名为countApplesInTree()。它的完全限定名将是一个明确的描述:Tree::countApples()。将事物放在你能够的最小(因此是最具描述性)的作用域中。
法语,像大多数其他罗曼语族的语言一样,有两种形式的“你”:tu和vous。你使用哪一个取决于你与你要称呼的人的熟悉程度。同样,你给变量取的名字可能取决于你使用它的上下文。你可能会在函数的公共声明中看到不同命名的变量,而在函数实现中则不同。
类型
每件事都有类型,你将知道那个类型是什么。一个名字不需要重申这个类型信息。(重申这一点正是匈牙利命名法的用途,也是为什么它经常受到诟病的原因。)
一个缺乏经验的程序员可能会将他的地址string变量命名为address_string。这个_string后缀有什么好处?没有,所以去掉它吧。
关键概念
一个名字所需的细节取决于其上下文。在命名时利用上下文信息。
利用名字的优势
名字中蕴含着力量——这种力量让你能够比仅仅使用语言的语法表达得更多。想想你如何可以使用类似的名字通过共同的词缀来分组事物。或者考虑如何通过在名字中包含这些信息来暗示函数参数是输入还是输出。
简而言之
在你的名字中,我会寄予希望,因为你的名字是好的。
--诗篇 52:9
我们的祖先早已知道,优秀的程序员也知道:给事物命名至关重要。好的命名不仅仅是为了美观,它们还能传达关于代码结构的更多信息。它们是帮助理解性和可维护性的基本工具。
我们之所以用高级语言编写代码,主要目的是为了沟通,而这种沟通是面向代码读者——其他程序员——而不是编译器。糟糕的命名可能会误导。名字确实有力量,经验丰富的程序员在命名代码的任何部分时都理解所涉及的权衡。
通用的“应该做”和“不应该做”的事项
我们可以将本章中的许多建议归纳为一些通用的“应该做”和“不应该做”的事项。不要创建以下类型的名字:
晦涩难懂
你可以通过多种方式创建难以解释的名字。首字母缩略词和缩写可能会显得相当随机,单个字母的名字也过于神奇。
冗长
避免简短的名字,但也不应该创建一个名为the_number_of_apples_before_I_started_eating的变量。这既不实用也不幽默。
不准确或误导
虽然看起来很明显,但请确保你的命名准确无误。如果某个东西与列表无关,就不要称其为widget_list。如果某个东西是组件的容器,就不要称其为widget。
拼写错误会引发一片混乱:我以为这个变量叫做 ignoramus,但我到处都找不到它。哎呀,它拼错了 ignoramous。唉。
含糊不清或模糊
不要使用可能被解释为几种不同含义的名字。除非非常清楚它代表什么,否则不要使用像data或value这样毫无希望模糊的名字。除非你真的需要,否则避免使用模糊的temp或tmp。
不要通过大小写或单个字符的变化来区分名字。要警惕听起来相似的名字。
不要随意创建与外部作用域中某个东西具有相同名称的局部变量。
过于可爱
那些性感的缩写,难以记住的巧妙缩写,以及对数字的解释性使用都应该避免。i18n,这是国际化的常见缩写,对于外行人来说听起来像是无意义的。
另一方面,应该创建清晰、具体、简洁、准确且无歧义的名字。应该使用常见的术语和参考框架。使用问题域中的词汇,并借鉴描述性设计模式的名字。(Gamma 等人 94)
| 优秀的程序员 . . . | 糟糕的程序员 . . . |
|---|
|
-
认识到命名的重要性,并尊重它们。
-
考虑命名,并为他们创建的每一件事选择合适的名称
-
在名称长度、清晰度、上下文等方面保持平衡
-
保持对整体图景的观察,这样它们的命名可以在整个项目(或多个项目)中保持一致
|
-
对他们代码的清晰度不太关心
-
产生一次性代码,这种代码易于编写但思考不足
-
忽略语言的天然习语
-
命名不一致
-
不要从整体上思考,未能考虑他们的代码片段如何融入整体
|
另请参阅
第二章
讨论编码标准,这些标准可能会指导你在命名时的选择。还讨论了圣战,匈牙利命名法无疑是其起因之一。
第四章
好的命名不能替代文档齐全的代码——但它们是代码文档的一个基本组成部分。

激发思考
以下问题的详细讨论可以在第 474 页的"附录 A"部分找到。
深思熟虑
-
这些是好的变量名称吗?用是(解释原因,以及上下文),否(解释原因),或无法判断(解释原因)来回答。
-
int apple_count -
char foo -
bool apple_count -
char *string -
int loop_counter
-
-
在什么情况下这些是合适的函数名称?你可能会期望哪些返回类型或参数?哪些返回类型会使它们变得没有意义?
-
doIt(...) -
value(...) -
sponge(...) -
isApple(...)
-
-
命名方案应该优先考虑代码的易读性还是易写性?你将如何使其中之一变得容易?
-
你会写多少次同一块代码?(想想看。)你会读多少次?你的答案应该能给出一些关于相对重要性的指示。
-
当命名约定冲突时,你会怎么做?比如说,你正在编写驼峰式 C++代码,并且需要进行 STL(使用下划线)库的工作。处理这种情况的最佳方法是什么?
-
-
在你需要给出有意义的循环计数器名称之前,循环应该有多长?
-
在 C 语言中,如果
assert是一个宏,为什么它的名称是小写的?我们为什么应该给宏起名,使其突出? -
遵循你语言的标准库命名约定的优缺点是什么?
-
你能否用完一个名称?在许多不同的函数中重复局部变量名称是否可以?使用会覆盖(并隐藏)全局名称的局部名称是否可以?为什么?
-
描述匈牙利命名法的机制。这种命名约定的优缺点是什么?它是否在现代代码设计中有一席之地?
-
我们看到许多包含作为获取器和设置器的成员函数的类;读取和写入某些属性的值。这些函数的常见命名约定是什么,哪一个最好?
个人化思考
-
你在命名方面有多擅长?你已经遵循了多少这样的启发式方法?你是否会自觉地思考你的命名和这类规则,或者你只是自然而然地做它?在哪些方面你可以改进?
-
你的编码标准是否提到了命名?
-
它是否涵盖了我们在这里讨论的所有情况?是否足够充分?它是有用的,还是只是表面的?
-
在编码标准中,有多少命名细节是合适的?
-
-
你最近遇到的最糟糕的名字是什么?名字是如何误导你的?你会如何修改它们以避免未来的混淆?
-
你是否需要在平台之间移植代码?这对文件名、其他名称和整体代码结构有何影响?
第四章 THE WRITESTUFF
编写“自文档化”代码的技术
对写作的真正认真是两个绝对必要条件之一。不幸的是,另一个是天赋。
——欧内斯特·海明威
现代自组装(flat-pack)家具非常出色,即使是经验丰富的木匠也会感到敬畏和困惑。通常,它们设计得很巧妙,最终会变成你期望的样子。
在组装时,你必须依赖提供的说明——没有它们,你将建造出更多像现代艺术而不是家具的东西。说明的质量极大地影响了施工的难易程度。糟糕的说明会让你大汗淋漓,咒骂不已,并不断地拆解那些本不应该连接在一起的木块。
真遗憾,他们不再像以前那样制造东西了。
源代码也面临着类似的问题。诚然,他们不再像以前那样制造它了,但没有人真的那么喜欢穿孔卡片或 COBOL。更重要的是,没有好的说明来解释代码是如何组合在一起的,使用一些程序可能会让你大汗淋漓,咒骂不已,并不断地拆解那些本不应该连接在一起的代码片段。
创建好的代码意味着创建良好文档化的代码。我们编写代码的原因是为了传达一组清晰的指令——不仅是对计算机,也是对那些后来必须修复或扩展这些指令的可怜傻瓜。现实世界中的代码永远不会被写下来然后被遗忘。它将在软件产品的整个生命周期中被修改、扩展和维护。为此,我们需要说明,用户指南——文档。
关于编写代码的常见智慧是,你应该要么写大量的文档关于代码,要么在代码中写大量的注释*。
这两个想法都是无意义的。大多数程序员都讨厌文字处理器,对写太多注释感到厌烦。编写代码确实是一项艰苦的工作。编写文档不应该比这更辛苦。在软件工厂的热潮中,任何需要额外工作的事情往往都不会做。或者如果做了,也往往做得不好。
我见过软件系统依靠设计规范、实现笔记、维护指南和风格指南来支撑。不出所料,这种代码真的很令人厌烦。所有这些辅助文档的问题在于:
-
我们不需要额外的工作去做。编写文档需要花费很多时间;阅读它也是如此。程序员更愿意把时间花在编程上。
-
所有这些单独的文档都必须与任何代码更改保持更新。在一个大型项目中,这是一项巨大的工作量。常见的替代方案(从不更新任何文档)会导致危险的不准确和误导性信息。
-
一大堆文档难以管理。很难找到正确的文档,或者找到文档中可能分散在几个地方的信息。就像代码一样,文档也需要受到版本控制的约束,你必须确保你阅读的是与你在工作的源代码版本相对应的文档版本。
-
重要信息分散在单独的文档中很容易被忽略。如果它不在代码旁边,而且没有有用的提示,事情就会被忽视。
关键概念
不要编写需要外部文档支撑的代码。这是脆弱的。确保你的代码在没有外部文档的情况下也能读得清楚。
常见的替代方案——用详细的代码注释来编写代码文档——可能同样糟糕,甚至更糟。大量的详细注释会阻碍好代码。你最终会写出格式糟糕的文档,而不是一个好的程序。
我们如何避免这个噩梦?我们编写自文档代码。
自文档代码
这听起来是个好主意,不是吗?但什么是自文档代码?这个程序是自文档的:
10 PRINT "I am very small and very pointless"
20 GOTO 10
虽然这并不是什么值得骄傲的事情。一个更复杂、更有用的自文档程序需要大量的技能。计算机程序往往比编写它们更难阅读。任何使用过 Perl 的人都会理解这一点;它被描述为终极的一次性编写语言。确实,旧的 Perl 代码可能真的难以理解,但你可以在任何语言中编写晦涩的代码,而且这并不需要太多的努力。
完全且正确描述你代码的唯一文档就是代码本身。这并不意味着它是可能的最佳描述,但往往,它就是你所拥有的唯一文档。
因此,你应该尽一切可能使其成为好的文档,任何人都能阅读的文档。出于必要性,代码是除了作者之外更多的人必须能够理解的东西。编程语言是我们的交流媒介。清晰的沟通至关重要。有了清晰性,你的代码质量会提高,因为你不太可能犯错误(因为错误更明显),维护代码也更便宜——学习它所需的时间更少。
自文档化的代码易于阅读。它本身就可以理解,无需依赖外部文档。我们可以通过许多方式提高代码的清晰度。一些技术非常基础,自从我们学习编程以来就被我们熟知。其他技术则更为微妙,需要经验。
关键概念
编写易于人类阅读的代码。简单。编译器能够应对。
这里有一个例子,一个简单的函数,它离自文档化最远。你认为它做什么?
int fval(int i)
{
int ret=2;
for (int n1=1, n2=1, i2=i-3; i2>=0; --i2)
{
n1=n2; n2=ret; ret=n2+n1;
}
return (i<2) ? 1 : ret;
}
不要以貌取书……
一个自文档化的代码文件读起来就像一本好的参考书。这样的书结构严谨,分章节,布局合理。它从前到后、从上到下自然阅读,但也可以作为参考书直接查阅。我们的代码应该就是这样。让我们比较一下这些部分:
引言
一本书的引言解释了书中的内容,设定了基调,并解释了它如何融入更大的图景。源文件应该以代码注释头部开始。它解释了文件的内容,并指定了源文件属于哪个项目。
目录
尽管有人认为文件头部应该包含所有包含的函数列表,但我强烈建议不要这样做。它会迅速过时。然而,你可以使用大多数现代编辑器或 IDE 列出文件的内容(所有类型和类、函数、变量),为特定代码片段提供有用的指导。
章节
本书分为几个部分。源文件也可能分为主要章节;可能一个文件包含几个类或函数的逻辑组。这就是防波堤注释发挥作用的地方。过度的 ASCII 艺术通常是一件坏事,但这类注释有助于逻辑上分割文件,便于导航。
然而,要注意。在一个源文件中放入太多东西不是一个好主意。简单的文件/类一对一对应是最好的。大型、多用途的文件难以理解,也很难导航。(如果这条建议让你有太多的源文件,那么你需要改进更高层次的代码结构。)
章节
一本书的每一章都是一个自包含且命名良好的部分。源文件通常包含多个命名良好的函数。
段落
在每个函数中,你将把代码分组为语句块。初始变量声明将位于一个逻辑块中,通过空白行与后续代码隔开(嗯,至少在较老的 C 代码中是这样的)。这并不是一个语法问题,而是一种有助于你阅读代码的布局。
句子
句子自然对应于每个单独的代码语句。
交叉引用和索引
再次强调,这并不是你源文件标记的一部分,但一个好的编辑器或 IDE 将提供交叉引用功能。学习如何使用它们。
这是一个有趣的类比,但它对编写代码有什么区别呢?许多优秀的书籍写作技巧可以转化为优秀的代码编写技巧。学习它们可以使你的代码更具可读性。将代码分成部分、章节和段落。使用布局来强调代码的逻辑结构。使用简单、简短的代码语句——就像简短的句子一样,它们更易读。
这是一个现实例子;无数百万行生产软件中的代码看起来都像这样,而一线程序员因此遭受痛苦。相比之下,下面的代码确实是自文档化的。你只需阅读第一行就能大概了解它做了什么。
int fibonacci(int position)
{
if (position < 2)
{
return 1;
}
int previousButOne = 1;
int previous = 1;
int answer = 2;
for (int n = 2; n < position; ++n)
{
previousButOne = previous;
previous = answer;
answer = previous + previousButOne;
}
return answer;
}
你应该注意关于那个函数的一件事——注释的缺失。没有注释也很明显发生了什么。注释只会增加更多需要阅读的内容。它们会成为不必要的噪音,并使函数在未来更难维护。这很重要——因为即使是最小、最漂亮的函数也需要后续维护.^([1])
^([1]) 你是否弄清楚了这个第一个示例做了什么?这两个函数都在计算斐波那契数列中的值。你更愿意阅读哪一个?
自文档化代码的技巧
传统上认为编写自文档化代码需要添加大量的注释。良好的注释当然是一项重要的技术,但其中还有更多。事实上,我们应该通过编写不需要注释的清晰代码来积极避免注释。
以下章节列出了重要的自文档化代码技巧。你会注意到,它们与本书第一部分其他章节的内容相似。这并不完全令人惊讶——优秀的代码有许多重叠的特征;一种技术的益处将在代码质量的多个方面体现。
编写具有良好展示的简单代码
展示对代码的清晰度有巨大的影响。深思熟虑的布局传达了代码的结构;它使函数、循环和条件语句更清晰。
-
使你的代码中的“正常”路径明显。错误情况不应该混淆正常的执行流程。你的if-then-else结构应该有序排列(即,始终将“正常”情况放在“错误”情况之前,或者反之亦然)。
-
避免过多的嵌套语句。它们会导致需要长时间解释的复杂代码。常识认为每个函数应该只有一个并且只有一个出口点;这被称为单入口单出口(SESE)代码。但实际上,这对可读代码来说过于严格,会导致深层嵌套。我更喜欢我们之前看到的
fibonacci示例,而不是这种 SESE 变体:int fibonacci(int position) { int answer = 1; if (position >= 2) { int previousButOne = 1; int previous = 1; for (int n = 2; n < position; ++n) { previousButOne = previous; previous = answer; answer = previous + previousButOne; } } return answer; }为了一个额外的
return语句,我宁愿避免这种无谓的嵌套——它使得函数的阅读变得非常困难。函数逻辑中间的return是可疑的,但顶部的简单短路对函数的可读性有很大帮助。 -
谨慎优化代码,使其不再是基本算法的清晰表达。永远不要优化代码,除非你已经证明它确实是程序功能可接受的瓶颈。只有在那时才进行优化,并清楚地注释正在发生的事情。
选择有意义的名字
所有变量、类型、文件和函数名都应该是有意义的,而不是误导性的。一个名字应该忠实描述其所代表的内容。如果你不能有意义地命名某物,那么你真的理解它在做什么吗?你的命名方案应该是连贯的,以便没有令人不快的惊喜。确保变量只用于其名字所暗示的内容。
好的命名可能是我们避免无谓注释的最好方式。它们是我们在代码中接近自然语言表达性的东西。
分解为原子函数
你将代码分割成函数以及给这些函数命名的做法,要么可以为代码增加意义,要么完全剥夺其意义。
-
一个函数,一个动作。把这当作你的座右铭。不要编写复杂的函数,既做咖啡,又擦鞋,还猜测你最初想到的数字。在一个函数中,只做一项动作。选择一个明确解释该动作的名字。好的名字意味着不需要额外的文档。
-
最小化任何意外的副作用,无论它们看起来多么无害。它们需要额外的文档。
-
保持简短。简短的功能易于理解。如果你将复杂算法分解成带有描述性名字的小块,你就可以理解它,但如果它是一页上杂乱无章的代码,你就不能。
选择描述性类型
尽可能地,使用可用的语言特性来描述约束或行为。例如:
-
如果你正在定义一个永远不会改变的值,强制它为常量类型(在 C 中使用
const)。 -
如果一个变量不应该包含负值,使用无符号类型(如果你的语言提供了的话)。
-
使用枚举来描述一组相关的值。
-
选择合适的类型。在 C/C++中,将大小放入
size_t变量中,将指针运算的结果放入ptrdiff_t变量中。
命名常量
遇到一些看起来像 if (counter == 76) 的代码,会让你感到困惑。数字 76 有什么神奇的意义?那个测试的意图是什么?
这些所谓的魔法数字是邪恶的。它们隐藏了意义。编写
const size_t bananas_per_cake = 76;
...
if (count == bananas_per_cake)
{
// make banana cake
}
要清晰得多。如果你在代码中大量使用常数76(抱歉,bananas_per_cake),你将获得额外的好处:当你需要更改香蕉与蛋糕的比例时,你只需要进行一次代码更改,而不是在项目中为每个76执行错误易发的搜索和替换。
关键概念
避免使用魔法数字。使用有良好命名的常量代替。
这同样适用于常量字符串和数字。质疑代码中 任何 文本的用途,尤其是当你多次使用它时——你能使用一个更易于维护的命名常量吗?
强调重要代码
让重要内容与平凡内容区分开来。将读者的注意力引向正确的位置。有许多编码机会可以做到这一点。
例如:
-
有助于排序类中的声明。公共信息应该放在前面,因为这是类用户需要看到的内容。将私有实现细节放在最后,因为它们对大多数读者来说不太重要。
-
在可能的情况下,隐藏所有非必要信息。不要让全局命名空间充斥着不必要的冗余。在 C++ 中,你可以使用 pimpl 习语来隐藏类的实现细节(Meyers 97)。
-
不要隐藏重要的代码。每行只写一个语句,并保持每个语句简单。你 可以 写出非常巧妙的
for循环,将所有逻辑放在一行中,并用一系列逗号分隔,但这不容易阅读。不要这样做。 -
限制嵌套条件语句的数量。如果不这样做,重要条件的处理将被一系列的
if和花括号所隐藏。
关键概念
确保所有重要的代码都突出显示,并且易于阅读。隐藏客户端受众不关心的任何内容。
分组相关信息
在一个地方呈现所有相关信息。否则,你不仅会让读者跳过许多障碍,而且还需要他通过 ESP 知道障碍在哪里。单个组件的 API 应该在一个文件中呈现。如果有如此多的相关信息,以至于一起呈现变得混乱,那么质疑代码的设计。
在可能的情况下,通过语言结构对项目进行分组。在 C++ 和 C# 中,我们可以在 namespace 内部对项目进行分组。Java 提供了 package 作为分组机制。相关的常量值可以在 enum 中定义。
关键概念
有意地将信息分组在一起。使用语言特性来明确这种分组。
提供文件头
在文件顶部放置一个注释块来描述其内容和所属的项目。这只需要一点努力,但可以产生很大的影响。当有人来维护该文件时,他们会很好地了解可以期待什么。
这个头文件可能很重要:大多数公司出于法律原因要求每个源文件都包含一个可见的版权声明。文件头通常看起来像以下这样。
/*********************************************************
* File: Foo.java
* Purpose: Foo class implementation
* Notice: (c) 1066 Foo industries. All rights reserved.
********************************************************/
适当处理错误
在最合适的环境中处理任何错误。如果有磁盘 I/O 问题,你应该在访问磁盘的代码中处理它。也许处理这个错误意味着向更高层次抛出一个不同的错误(如“无法加载文件”异常)。这意味着在程序的每个级别,错误都是对在那个环境中问题的准确描述。不要在用户界面代码中处理硬盘损坏——这没有意义。
自文档化代码有助于读者理解错误从何而来,它的含义以及它对当时程序的影响。
关键概念
不要返回无意义的错误。在每种情况下呈现适当的信息。
编写有意义的注释
正如你所见,我们试图通过使用其他隐式代码文档技术来避免编写注释。然而,一旦你写出了尽可能清晰的代码,你需要注释剩下的部分。清晰的代码包含适量的注释。这个适量的注释是多少?
关键概念
只有在你无法以其他方式提高代码清晰度时才添加注释。
首先考虑所有这些其他技巧。更改名称或添加新的从属函数会使代码更清晰并避免注释吗?
自我提升
你如何提高编写自文档化代码的能力?让我们回到书籍写作领域,寻找一些线索。
提高写作技能的简单原则是:如果你读得多,你将成为一个更好的作家。批判性地阅读知名作者的作品会教你什么有效,什么无效。你将学会新的技巧和习语,以丰富你的工具箱。
同样,如果你阅读了很多代码,你将成为一个更好的程序员。如果你沉浸在 良好 的代码中,你很快就能在远处嗅出糟糕的代码。海关官员每天看到那么多护照,伪造的护照就像一个明显的痛处。即使是巧妙的模仿也会变得明显。当你对警告信号敏感时,糟糕的代码会变得更加引人注目。
通过这次经历,你自然会发现自己开始在代码中使用良好的技巧。你将开始注意到你编写糟糕的代码时;这会让人感到不舒服。
实际的自文档化方法
我们将通过比较两种特定的代码文档方法来结束本章。记住,这些方法是在我们刚刚看到的技巧之后出现的。Kernighan 和 Plaugher 说:“不要文档化糟糕的代码——重写它。”(Kernighan Plaugher 78)
文档化编程
文档化编程 是一种极端的自文档化代码技术,由著名的计算机科学家 Donald Knuth 提出。他写了一本以这个名称描述它的书。(Knuth 92)它是对传统编程模型的激进替代,尽管有些人认为 Knuth 职业生涯中的文档化编程阶段是一个大而遗憾的偏离。即使它不是编码的“唯一正确方式”,我们仍然可以从中学到一些东西。
文献化编程背后的想法很简单:你不是编写程序,而是编写文档。文档语言与编程语言紧密绑定。你的文档主要是对正在编程的什么的描述,但碰巧也能编译成那个程序。源代码是文档,反之亦然。
文献化的程序几乎像是一个故事一样编写;对于人类读者来说,它很容易跟随,甚至可能读起来很有趣。它不是为语言解析器排序或约束的。这不仅仅是一个带有反转注释的语言;这是一种反转的编程方法。文献化编程是一种全新的思维方式。
Knuth 最初在名为WEB的系统中混合了 TeX(一种文档排版标记语言)和 C。文献化编程工具解析程序文件,并生成格式化的文档或可以输入传统编译器的源代码。
当然,这只是另一种编程技术,就像结构化编程或面向对象编程一样。它不能保证高质量的文档。也就是说,这始终取决于程序员。然而,文献化编程将重点转向编写程序的描述,而不仅仅是编写实现它的代码。
文献化编程在产品的维护阶段真正发挥其作用。有了高质量的(以及数量的)文档直接在手,维护源代码变得容易得多。
文献化编程有许多有用的特性:
-
文献化编程将重点重新放在编写文档上。
-
这让你在编写解释和论证的同时,以不同的方式思考你的代码。
-
当你对代码进行更改时,你更有可能更新文档,因为它们方便地位于附近。
-
你可以保证整个代码库只有一个文档。你总是能够查看你正在工作的代码的正确版本——它就是你在工作的代码。
-
文献化编程鼓励包含通常在源代码注释中找不到的项目。例如:算法描述、正确性证明以及设计决策的合理性论证。
然而,文献化编程并不是万能的灵丹妙药。它有一些严重的缺点:
-
文献化的程序更难编写,因为大多数程序员并不觉得这样做很自然。我们往往不把代码看作是需要格式化的打印文档。相反,我们心理上模拟控制流和交互对象。
-
需要额外的编译步骤,这使得文献化的程序在操作时速度较慢。目前还没有真正好的工具支持。
处理文学程序相当困难,因为编译器需要提取所有程序片段并按正确顺序重新组装它们。虽然按照任何顺序编写文档很方便,但 C 语言对代码的顺序有相当具体的要求;例如,
#includes必须首先出现。这导致了一些实际上的妥协。 -
你可能会记录一些实际上并不需要文档的代码。而另一种情况,即不记录大量简单代码,也经常发生。这已经不再是一个好的文学程序;你甚至可以不费那个功夫。
当所有内容都在被记录时,你可能会在所有噪音中错过一些重要的文档。
-
Knuth 讨论了程序员作为散文家的观点。许多程序员可能连一篇论文都写不出来,但他们能写出最精致的代码。也许这些人是对规则的例外,但并不是每个优秀的程序员都能成为有能力的文学程序员。
-
将文档与代码紧密关联可能会出现问题。你可能已经冻结了代码以进行主要发布——不允许有任何更改——但你仍然需要更新文档。修改文档意味着修改源代码。现在你有了同一代码库的可执行版本和文档版本,你必须将它们结合起来:这是一个管理的噩梦。
后续章节将讨论软件规范;文学编程如何与规范相关?文学程序永远不会取代描述需要完成的工作的功能规范。然而,应该可以从这样的规范中开发出一个文学程序。文学程序实际上更多的是传统代码与设计和实现规范的结合。
文档工具
存在一种编程工具,它介于文学编程方法和编写外部规范之间。这些工具通过提取特别格式化的注释块来从你的源代码中生成文档。自从 Sun 将 Javadoc 作为 Java 平台的核心组件引入以来,这种技术变得特别流行。所有的 Java API 文档都是由 Javadoc 生成的。
为了准确了解其工作原理,我们将通过一个示例来探讨。确切的注释格式可能不同,但为了记录一个 Widget 类,你可能需要写一些像这样的内容:
**/**
* This is the documentation for the Widget class.
* The tool knows this because the comment started
* with the special '/**' sequence.
*
* @author Author name here
* @version Version number here
*/**
class Widget
{
public:
**/**
* This is the documentation for a method.
*/**
void method();
};
文档工具将解析你项目中的每个文件,提取文档,构建一个包含所有信息的交叉引用数据库,并输出一个包含这些信息的漂亮文档。
你几乎可以记录你写的任何代码:类、类型、函数、参数、标志、变量、命名空间、包等等。有设施可以捕获大量信息,包括以下能力:
-
指定版权信息
-
记录创建日期
-
交叉引用信息
-
标记旧代码为已弃用
-
提供简短的摘要以供快速参考
-
提供每个函数参数的描述
可用的文档工具很多,既有开源的也有商业的。我们已经提到了 Javadoc;其他流行的工具包括 C#的 NDoc 和优秀的 Doxygen (www.doxygen.org)).
这是一种出色的文档方法,允许您在不编写单独规范的情况下,以合理的详细程度记录代码。您还可以轻松地在源文件中阅读文档,这可能会非常有帮助。
文档工具提供了许多好处:
-
与文献化编程一样,这种方法鼓励您编写文档并保持其更新。
-
获取可编译的代码不需要单独的步骤。
-
这种方法更自然,不需要大规模的调整或陡峭的学习曲线。虽然代码可以用来生成文档,但您不需要人为地使代码看起来像一本书,也不必担心繁琐的文本布局问题。
-
文档工具支持丰富的搜索、交叉引用和代码大纲功能。
然而,了解基于注释的代码文档的后果是很重要的:
-
与文献化编程不同,它实际上仅适用于 API 文档,而不是内部代码文档。您必须在语句级别使用常规注释。
-
由于文档注释的间隔,很难快速浏览源文件并了解其内容概览。您必须使用工具的概览输出。这可能格式得很好,但在您沉浸在代码编辑器世界中时查看可能不方便。
关键概念
使用文献化的文档工具自动从您的代码生成文档。
虽然这是一种编写文档的强大方式,但您仍然可以使用它编写出糟糕的文档。以下是一些有助于正确编写文档的有用启发式方法:
-
对于每个公开可见的项目,写上一句或两句描述;不要过度使用大量的文本。一大堆散文读起来很慢,而且难以更新。不要犹豫不决。
-
如果不清楚变量或参数的用途,请对其进行文档说明,但如果它们的名称已经很明显,则无需文档化。如果它们不增加任何价值,您不需要记录每一个细节。工具的输出仍然会包含这些项目,只是没有文本说明。
-
如果函数的一些参数用于输入,而另一些用于输出,请在它们的描述中明确这一点。很少有语言提供语法机制来表达这一点,因此您必须明确地记录它。
-
记录任何函数的前置条件和后置条件,可能抛出的异常以及函数的任何副作用。
简而言之
写作的技巧是创造一个其他人在其中可以思考的语境。
--Edwin Schlossberg
我们编写代码主要是为了沟通。没有文档的代码是危险的,几乎无法沟通。这是一个高维护问题。差的文档也好不到哪里去,要么误导读者,要么导致一个依赖外部解释的脆弱程序。
对于一段代码,我们通常拥有的唯一文档就是代码本身。使代码自我文档化并易于阅读可以在一定程度上解决这个问题。自我文档化的代码并非神奇出现,你必须仔细思考。结果是看起来像很容易编写的代码。
文档化编程是一种(相当极端)编写自我文档化代码的方法。另一种不那么极端的方法是使用文档工具。这些工具可以非常容易地生成 API 文档,但它们并不一定取代所有书面规范。
| 好程序员…… | 坏程序员…… |
|---|
|
-
努力编写清晰、自我文档化的代码
-
尽量编写最少的文档
-
考虑那些将维护他们代码的程序员的需求
|
-
为他们编写难以理解的意大利面代码而感到自豪
-
尽量避免编写任何文档
-
不关心更新文档
-
思考,“如果对我来说编写困难,那么对其他人来说也应该难以理解。”
|

参考信息
第三章
好的命名是编写自我文档化代码的有力工具。
随口一提
当你确实需要编写注释时,这是正确的方式。
第十九章
代码应该自我文档化,但出于许多原因,我们仍然需要单独的规范。
激发思考
在第 480 页的"附录 A"部分可以找到对这些问题的详细讨论。
深思熟虑
-
将相关的代码分组可以使它们之间的关系变得清晰。我们如何进行这种分组?哪些方法最能体现这些关系?
-
我们应该避免在我们的代码中使用魔法数字。零是魔法数字吗?你应该怎么称呼代表零的常量值?
-
自我文档化的代码很好地利用上下文来传达信息。展示你是如何做到这一点的,并给出一个特定名称在不同函数中导致不同解释的例子?
-
对于一个新手来说,能否完全理解一些自我文档化的代码是现实的吗?
-
如果代码确实是自我文档化的,那么还需要多少其他文档?
-
为什么除了原作者之外,更多的人必须理解任何一段代码?
-
这个简单的 C 语言bubblesort函数可能需要一些改进。它具体有哪些问题?请写一个改进的、自我文档化的版本。
void bsrt(int a[], int n) { for (int i = 0; i < n-1; i++) for (int j = n-1; j > i; j--) if (a[j-1] > a[j]) { int tmp = a[j-1]; a[j-1] = a[j]; a[j] = tmp; } } -
与代码文档工具一起工作会引发一些有趣的问题。你对这些有什么看法?
-
当你审查文档时,你应该进行代码审查,查看源文件中的注释,还是进行规范审查,查看生成的文档?
-
你将协议和其他非 API 问题的文档放在哪里?
-
你是否文档化了私有/内部函数?在 C/C++ 中,你将这种文档放在头文件还是实现文件中?
-
在一个大型系统中,你应该创建一个单一的、大的 API 文档,还是创建几个较小的文档,每个区域一个?每种方法的优点是什么?
-
-
如果你正在处理一个没有良好文档的代码库,并且需要修改或添加新的方法或函数,提供有文档注释是否是一个好主意,还是应该保持它们未文档化?
-
能否编写自文档化的汇编代码?
个人化
-
你认为你遇到的最佳文档化的代码是什么?是什么让它如此?
-
这段代码是否有大量的外部规范?你阅读了多少?你如何确保在不阅读所有内容的情况下对代码有足够的了解?
-
你认为其中有多少是由于作者的编程风格造成的,又有多少是由于他或她遵循的任何 house style 或指南?
-
-
如果你使用多种语言编写文档,你的文档策略在每种语言中是如何不同的?
-
在你编写的最后一段代码中,你是如何使重要的内容突出显示的?你是否适当地隐藏了私有信息?
-
如果你在一个团队中工作,其他人多久会来问你某个功能是如何工作的?你能通过更好的文档来避免这种情况吗?
第五章。一个简单的评论
如何编写代码注释
注释是免费的,但事实是神圣的。
--查尔斯·普雷斯蒂奇·斯科特
注释很像意见。你可以自由地发表它们,但仅仅因为你发表了,并不意味着它们是正确的。在本章中,我们将花一些时间思考编写这些细节。编写注释比你想象的要复杂得多。
很可能在你被教授编程时,你首先学到的是如何编写注释。你被告知注释有助于代码的可读性,你很可能被鼓励写很多注释。但在这个游戏中,我们需要更多地考虑质量而不是数量。注释是我们的生命线,记忆的提醒,以及代码的指南。我们应该以应有的尊重对待它们。
我将我的语法高亮代码编辑器的注释设置为绿色显示。这样,当我加载源文件时,我就能立即感受到代码的质量以及它将有多容易工作。在正确的模式中散布的绿色比例让我对世界感到满意。相反,我会去厨房喝一杯浓咖啡,然后再继续。
注释可以使代码从糟糕变为优秀,从复杂难懂的逻辑泥潭变成清晰易懂的算法集。但让我们不要过分强调这一点——还有比注释更重要的事情要正确处理。当你真正写出了优秀的代码时,你的注释就像是蛋糕上的糖霜,精致地放置以增添美观和价值,而不是随意涂抹以掩盖所有的裂缝和瑕疵。
好的注释是一种避免让代码令人畏惧的策略。注释不是一种魔法添加剂,可以转变糟糕的代码为甜美的代码。
什么是代码注释?
不要跳过这一节!诚然,这是一个令人痛苦的开端。我们都知道代码注释是什么,对吧?但它的哲学性比你想象的要强。
从语法上讲,注释是编译器会忽略的源代码块。你可以放任何你喜欢的进去,比如你孙子的名字或者你最喜欢的衬衫的颜色;编译器在愉快地解析文件时,对此不会眨一下眼。[¹]
从语义上讲,注释是泥泞的小径和明亮的公路之间的区别。注释是对其所在代码的注释。你可以用它作为高亮工具,使特定的问题区域突出,或者作为头文件中的文档媒介。你可能使用注释来描述算法的形状,以帮助维护程序员(可能是未来的你),或者标记每个函数之间的空间,帮助你更快地浏览源文件。
注释的目标是面向人类读者,而不是计算机。在这个意义上,注释是编程墙中最以人为本的砖块。它们是精心塑造的砖块,而不是结构性的轻质砖。如果我们想提高注释的质量,我们需要看看并解决人类在阅读代码时真正需要的东西。
代码注释不是你应该放入代码中的唯一文档。注释不是规范。它们不是设计文档。它们不是 API 参考。[²] 然而,它们是一种非常有价值的文档形式,始终会物理地附加到代码上(除非有人恶意按下 DELETE 键)。它们的紧密邻近性意味着它们更有可能被更新,更有可能在上下文中被阅读。这是一个内部文档机制。
作为负责任的程序员,我们有责任做好注释。
[1]) 当然,处理注释的东西会根据你使用的语言类型而有所不同。在 C/C++中,庞大的预处理器野兽在编译阶段开始之前吞噬注释。在其他语言中,编译器本身在标记源代码时会丢弃注释。在解释型语言中,你的密集注释可能会减慢执行速度,因为解释器必须跳过你所有孙子的名字。
^([2]) 嗯,除非你使用一种文献编程工具,这在第 66 页的"文献编程"中讨论过。
注释看起来是什么样子?
嗯,它们是绿色的,对吧?至少对我来说是这样的。
C 语言的注释在/*和*/之间成块出现,可以跨越任意多行。C++、C99、C#和 Java 增加了//之后的单行注释。其他语言提供了类似的块和行注释功能,但语法不同。
再次强调,这是基础性的主题。但是,不同的注释标记经常以微妙不同的方式使用。我们将随着讨论的进行看到例子。然而,任何巧妙利用微妙语法差异的注释方案都应该谨慎对待。
需要多少注释?
精力充沛的写作是简洁的。
--威廉·斯特伦克
我们需要关注注释的质量,而不是数量,因此,我们写的注释内容比注释的数量更重要。下一节将讨论这一点。
学生程序员被教导要写注释,而且要写很多。但是,注释过多确实存在——你可以在密集的文字森林中隐藏重要的代码部分。当你不得不花更多的时间阅读复杂的注释段落而不是实际需要阅读的代码时,代码质量就会受到影响。
我把这种技能比作成为一名优秀的音乐家。在乐队中演奏不是关于你能在每一个可能的机会制造多少噪音。你演奏乐器越多,整体的声音就越复杂,音乐就越糟糕。同样,过多的注释会使代码变得混乱。优秀的音乐家不需要思考,我应该何时停止演奏,让其他人有机会? 优秀的音乐家只在真正能增加价值的时候演奏。美在于空间。我们只有在真正需要的时候才应该写注释。
关键概念
学会写足够的注释,但不要过多。重视质量,而不是数量。
阅读你注释的人也能阅读代码,所以尽量在代码本身中尽可能多地记录,而不是在注释中。毕竟——注释有说谎的恶习。考虑你的代码语句是注释的第一层,并使它们自我说明。
编写得好的代码实际上不需要注释,因为一切都应该不言自明。像f()和g()这样的函数名大声呼唤着需要注释来描述它们,但someGoodExample()根本不需要注释。你可以看到它是一个好的示例函数名。
关键概念
花费你的时间编写不需要大量注释支撑的代码。
你写的注释越少,你写出糟糕注释的机会就越小。
我们在注释中放什么?
写作的高效源泉是明智的思维。
--贺拉斯
坏的注释比没有注释更糟——它们会误导读者。那么,你应该在注释中写些什么呢?以下是一些基本步骤来提高注释内容的质量:
解释为什么,而不是如何
这是一个关键点,所以请读这段话两遍。然后吃掉这一页。你的注释不应该描述如何程序工作。你可以通过阅读代码来看到这一点。毕竟,代码是代码如何工作的最终描述。而且它已经被写得清晰易懂,不是吗?相反,你应该专注于描述为什么某物被写成这样,或者下一个代码块最终实现了什么。
不断检查你是否在写/* 从 GlbWLRegistry 更新 WidgetList 结构 */或/* 缓存小部件信息以供以后使用 */。它们可能等同于同一件事,但后者传达了代码的意图,而前者只是告诉你它在做什么。
当你维护一段代码时,它存在的原因为什么会改变得比如何实现这个目的要少,这使得这种注释的维护要容易得多。
关键概念
好的注释解释的是为什么,而不是如何。
你也可以用注释来解释你为什么做出了特定的实现选择。如果你有两个可能的实现策略,而你决定选择其中一个,那么考虑是否值得添加一个注释来解释这个理由。
不要描述代码
无价值的描述性注释可能是明显的:++i; // increment i。它们也可能更微妙:一个关于复杂算法的冗长注释,然后是算法的实现。除非你正在记录一个没有它就无法理解的非常复杂的算法,否则没有必要费力地将代码用英语重新表述。那时,你可能更应该担心重写算法而不是注释。
关键概念
遵守黄金法则:一个事实——一个来源。不要在注释中重复代码。
不要替换代码
如果你看到一条注释表明某些内容可以通过语言本身强制执行(例如,// 这个变量应该只由类 foo 访问),那么考虑用具体语法来表示它。
如果你发现自己写了很多注释来解释一个复杂算法的工作原理,那就停下来。首先,为自己尝试记录正在发生的事情而鼓掌。但随后考虑是否可以通过更改代码或算法来使其更清晰。
-
也许你可以将代码拆分成几个命名良好的函数,以反映程序逻辑。
-
不要写注释来描述变量的用途;重命名变量。你本来要写的注释通常会告诉你变量的名字应该是什么!
-
如果你正在记录一个始终应该成立的条件,也许你应该写一个断言。
-
记住,你不需要过早地优化(从而使代码晦涩难懂)。
关键概念
当你发现自己正在写密集的注释来解释你的代码时,退一步。有没有更大的问题要解决?
保持实用性
一条好的注释通常需要经过几次迭代才能提升到质量等级,就像代码一样。确保你的注释:
记录意外情况
如果有任何代码片段是不寻常的、意外的或令人惊讶的,请用注释记录下来。当你后来回来,忘记了所有关于问题的事情时,你会感谢自己。如果有具体的解决方案,比如针对操作系统的问题,那么在注释中提及这一点。
这的另一面是,你不需要记录明显的事情。记住:不要重复代码!
说出真相
当注释不是注释时是什么时候?当它是谎言时。好吧,你永远不会故意写谎言,但很容易无意中引入错误信息,尤其是在修改已经注释过的代码时。后来的代码更改很容易使注释不准确;“与注释一起工作”在第 84 页描述了应对这种情况的策略。
有价值
一些俏皮而隐晦的注释可能很有趣,而且可能很小,但不要把它们放进去。它们会妨碍视线并造成混淆。避免使用粗俗的言语、只有你自己能理解的内部笑话,以及不必要的批评性评论——你永远不知道你的代码在一个月或一年后会在哪里,所以不要写那些可能会让你以后尴尬的注释。
清晰
你的注释是用来注释和解释代码的。不要含糊其辞。尽可能具体(而不必为每一行写一篇论文)。如果有人阅读你的注释后仍然不知道它的意思,那么你就已经使代码更糟并减慢了他们的理解速度。
可理解
你不需要在写的每个注释中都写完整的、语法正确的英语句子。然而,注释必须是可读的。单词的可爱缩写通常只会让读者感到困惑——尤其是如果英语不是他们的第一语言。
关键概念
思考你在注释中写的内容;不要不用大脑就打字。在代码的上下文中再次阅读它。它包含正确的信息吗?
一个战争故事
我曾经为一家拥有混合程序员的公司做过一些咨询工作:一些是英语母语者,一些是希腊语母语者。希腊人都能说一口流利的英语,但没有任何一个英语母语者能说希腊语(这并不奇怪)。
其中一位希腊程序员用希腊语写注释,当被礼貌地要求改变这种做法时,他拒绝了。英语程序员无法阅读这些注释,因为它们对他来说,字面上就是全希腊语!
避免干扰
注释的作用是阐明周围的代码,因此我们必须避免任何分散注意力的东西。注释应该只增加价值。避免包含以下内容的注释:
过去
我们不需要记录我们过去是如何做的。版本控制系统会做这件事。我们不需要在注释中看到旧的代码重现,也不需要看到对旧算法的描述。
不想要的代码
不要通过在注释中包围代码来删除代码。这很令人困惑。即使在指挥官风格(不穿裤子,没有调试器,没有printf)调试时,也不要在注释块中隐藏你需要删除的代码。使用 C 的#ifdef 0 ... #endif或一些等效的结构。这些结构可以嵌套,并且意图更清晰(特别是如果你忘记回来整理的话)。
ASCII 艺术
避免使用 ASCII 艺术图片或任何试图以巧妙方式突出代码的东西。例如,这是一个坏主意:
aBadExample(n, foo(wibble));
`// ^^^ // My favorite // function`
在具有可变宽度显示字体的编辑器中,这不会有意义。注释不应该增加维护工作量!
块结束
一些程序员会在每个控制块的末尾添加注释,例如在if语句的闭合括号后放置// end if (a < 1)。这是一种多余的注释形式;在真正理解之前需要将其过滤掉。块的底部应该与顶部在同一页上可见,代码布局应该清楚地显示其开始和结束。应避免所有额外的冗词。
在实践中
以下示例说明了这些注释原则。考虑以下 C++代码片段。不考虑惯用语的批评,它根本不清楚发生了什么。
for (int i = 0; i < wlst.sz(); ++i)
k(wlst[i]);
不好。这里有一些改进的空间,所以让我们改进。通过应用合理的布局规则和添加一些注释,可以使代码不那么晦涩:
`// Iterate over all widgets in the widget list`
for (int i = 0; i < wlst.sz(); ++i)
{
`// Print out this widget`
k(wlst[i]);
}
更好!现在代码片段的意图完全清晰。尽管如此,我仍然不完全满意。有了适当的功能和变量名,我们不再需要任何注释,因为代码本身就说明了:
for (int i = 0; i < widgets.size(); ++i)
{
printWidget(widgets[i]);
}
注意,我没有将i重命名为更长的名字。它是一个具有非常小作用域的循环变量。将其命名为loopCounter可能是过度设计,并且可能会使代码更难阅读。
我们最终没有注释并不令人惊讶。记住 Kernighan 和 Plaugher 的建议:“不要记录糟糕的代码——重写它。”(Kernighan Plaugher 78)
关于美学的注释
你无疑已经听到过人们虔诚地宣扬如何格式化注释。我不会规定一种真正的格式化方式(没有这样的事情),但有一些重要的方面需要考虑。将这些视为根据你个人品味而不是作为严格规定的指南。
一致性
所有的注释都应该清晰且一致。选择一种特定的方式来布局你的注释,并在整个文档中使用它。每个程序员对美学的理解都不同,所以选择对你来说合适的方式。确实使用现有的风格指南,或者检查(好的)现有的代码,并遵循你看到那里的风格。
注释写作中的小格式问题可能看似微不足道——例如,每个注释是否应该以大写字母开头?然而,如果你的所有注释都是随机大写的,这会传达出代码缺乏连贯性的感觉,就像程序员在构建它时并没有真正仔细思考一样。
清晰的块注释
语法高亮编辑器很棒,因为它们有助于使注释突出。但不要过于依赖它们。你的代码可能来自单色打印件或在没有语法着色的编辑器中查看。注释工作仍然应该易于阅读。
一些策略可以在这里有所帮助,特别是关于块注释。将开始和结束标记(例如,C 和 C++中的/*和*/)放在它们自己的行上,使它们更加突出。在块注释的左侧放置一个边距字符也有助于使其看起来像是一个单独的项目:
`/* * This is much more readable * as a block comment in the midst * of a whole pile of code */`
这比替代方案要好得多:
`/* a comment that might span a few lines but without any margin character. */`
至少,对齐注释文本,使其不是参差不齐的一团糟。
缩进注释
注释不应该横穿代码并打断逻辑流程。保持它与周围代码相同的缩进级别。这样,注释看起来就像适用于代码的正确级别。我总是不得不仔细地看这样的代码:
void strangeCommentStyle()
{
for (int n = 0; n < JUST_ENOUGH_TIMES; ++n)
{
`// This is a meaningful comment about the next line.`
doSomethingMeaningful(n);
`// But frankly, it's confusing the pants off of me.`
anotherUsefulOperation(n);
}
}
在没有花括号的循环中(这本身就不是个好主意),不要在单个循环体语句之前放置注释——这可能导致各种灾难。如果你想在其中添加注释,请将整个内容用花括号括起来。这是一个更安全的策略。
行尾注释
大多数注释都是单独成行的,但有时一个简短的单一行注释可以跟随一个代码语句。在这种情况下,将注释间隔开来,清楚地将其与代码区分开来是一个好的做法。例如:
class HandyExample
{
public:
... some nice public stuff ...
private:
int appleCount; `// End-of-line comments:`
bool isFatherADustman; `// Make them stand out`
int favoriteNumber; `// from the code`
};
这是一个使用注释布局来改善代码外观的好例子。如果每行末尾的注释直接跟在变量声明之后,它们看起来会参差不齐、杂乱无章,需要更仔细地阅读。
帮助你阅读代码
注释通常写在与它们描述的代码上方,而不是下方。这样,源代码就像一本书一样向下阅读。注释的作用是为读者准备即将到来的内容。
使用空白字符,注释有助于将代码分成“段落”。注释引入几行,解释它们打算实现的内容;然后是代码,接着是一个空行,然后是下一个块。这是一个如此常见的约定,以至于在注释之前有一个空行的注释感觉像是一个段落的开始,而夹在两个代码行之间的注释则更像是一个括号中的语句或脚注。
关键概念
注释是代码叙述的一部分。以自然的方式使用它们。
选择一种低维护性风格
选择一种低维护性的注释风格是明智的,否则你会在应该编写代码的时候浪费时间调整注释。
一些 C 语言程序员在左边缘创建带有星号列的注释块和右边缘带有星号的列。可以说这看起来非常漂亮,但调整这种边距内的段落文本所需的工作量是巨大的。当你本可以继续处理下一个任务时,你必须手动重新对齐右侧的所有星号。如果程序员使用了制表符,那么事情变得更糟:如果有人使用不同大小的制表符打开文件,他会想知道原始程序员在做什么——所有的星号看起来都非常丑陋且排列不当。
我们上面看到的行尾注释是一个需要一些努力的对齐示例。你愿意投入多少工作量取决于你。在美观的源代码和维护工作量之间总是有一个平衡。我想我更倾向于一点努力而不是丑陋的代码。
防波堤
注释通常被用作代码各部分之间的防波堤。这就是人们艺术感发挥作用的时刻;程序员使用不同的方案来区分主要注释(这是代码的新部分)和次要注释(这描述了函数的几行代码)。实现多个类的源文件可能在每个主要部分之间有类似的内容:
`/************************************************************************** * class foo implementation **************************************************************************/`
一些程序员在函数之间插入大块的注释艺术。有些人使用长单行注释作为规则。我只是在每个函数之间放置几行空白。如果你的函数非常大,以至于你需要视觉线索来看到它们的开始和结束,那么你需要修改你的代码。
避免使用这些大规则来强调每个可见的注释。否则,就没有什么会被强调。良好的缩进和结构,而不是令人印象深刻的 ASCII 艺术,应该将代码分组在一起。
话虽如此,精心挑选的防波堤注释可以帮助你快速在文件中导航。
标志
注释也可以用作代码中的内联标志。存在许多常见的约定。你会在仍在进行中的文件中看到//XXX、//FIXME或//TODO散布其中。好的语法高亮编辑器默认会突出显示这些注释。XXX用于标记有问题的代码或需要重工作的内容。TODO通常标记了以后返回的功能缺失部分.^([3]) FIXME表示已知有问题的内容。
文件头部注释
每个源文件都应该以一个描述其内容的注释块开始。这只是一个快速概述,一个前言,提供一些你总是希望一打开文件就显示的基本信息。如果存在这样的标题,那么任何打开文件的程序员都会对内容有信心;它表明文件是经过深思熟虑创建的,而不是仅仅作为一个新代码的垃圾场。
关键概念
为每个源文件提供一个注释序言。
有些人主张这个标题应该提供一个列表,列出文件中定义的每个函数、类、全局变量等等。这是一个维护灾难;这样的注释会迅速过时。这个文件标题应该包含的信息是文件的目的(例如,实现 foo 接口)和一个描述所有权和复制权利的版权声明。
如果在构建过程中自动生成了源文件,那么你必须安排这个文件接收一个注释标题,清楚地(用大写字母)说明它的来源。这将防止有人错误地编辑它,结果在下次构建时内容被重新生成。
标题不应包含容易过时的信息,如作者、修改者或文件最后修改的日期。这可能不会经常更新,并且会误导。版本控制会告诉你这些信息。它也不需要包含描述所有修改的源文件历史。这些信息存在于你的源控制系统中,不需要在这里重复。此外,如果你必须滚动 10 页的修改历史才能到达代码的第一行,那么文件就会变得难以工作。因此,一些程序员将其放在文件末尾,但这仍然会使文件变得不合理地大,加载缓慢,并且难以工作。
恰当的注释
我们在本章中关注的是代码注释,我们实际上在源代码中输入的内容。但不同类型的注释在邻近的牧场中觅食:
签入/签出注释
你的版本控制系统维护着项目生命周期中每个文件的修改历史。它将元数据与每个版本关联——至少是程序员提供的提交注释。如果它跟踪当前正在使用的文件,它也可能记录签出注释。你使用这些注释来描述你正在更改的内容,作为对后世的记录。
这样的注释非常有价值,应该仔细创建。它们应该是:
-
简短的(这样你可以快速浏览所有修改的日志)
-
准确的(不要弄错信息,否则历史就毫无价值)
-
完整的(这样你可以在不手动diff版本的情况下看到文件中发生的一切)
记录更改了什么和为什么,而不是如何更改。你可以使用文件修订差异来找出你如何修改了代码。
这就是关于过去的注释应该放的地方。这也是放置错误跟踪引用的正确位置。不要诱惑将属于这里的资料放入源代码注释中。记住:一个事实——一个来源。
README 文件
这些是存在于源代码文件旁边的目录中的纯文本文件。它们是有用的文档,介于正式规范和代码注释之间。它们通常包含实用信息,可能是关于每个文件做什么,或者关于文件层次结构的结构;它们基本上是简短的笔记。
README 文件往往要么是杂乱无章且思考不周,要么是维护不善且过时——这真是个遗憾。当你遇到一个 README 文件时,你自然会打开它,看看它包含哪些有用的信息。README 的存在表明有人在收集源文件时思考过;有一些值得记录的东西,有一些值得说的东西。
^([3]) 在使用TODO注释时要小心。你可能更愿意抛出一个TODO异常,这样就不会被忽略。这样,如果你忘记实现缺失的代码,你的程序将以定义良好的方式失败。
与注释一起工作
注释是在编写代码时使用的方便工具。但要注意不要滥用它们。
帮助你编写程序
一种常见的编写常规方法是在注释中首先构建其结构,然后填充每个注释行下面的代码。如果你这样工作,完成之后,你应该问问自己,剩余的注释是否仍然有用。根据刚才讨论的标准进行评估,并在必要时进行修改或删除。不要只是留下它们就继续前进。
另一种方法是手写编写新的常规程序,然后添加任何必要的注释。危险在于你可能会忘记完成这项工作,或者你可能不会写出最好的注释——现在几乎太清楚代码是如何工作的了。经验丰富的程序员会边走边注释。实践表明,你应该使用多少注释。
不要害怕使用我们之前看到的标志,如TODO,作为给自己标记的标记。这将避免忘记解决那些讨厌的小尾巴的尴尬。你可以轻松地在整个代码库中搜索这些注释,以找出还需要完成什么。
错误修复通知
一种常见但值得怀疑的注释实践是在修复错误的地方放置通知。你可能会在函数中间遇到这样的注释:
`// <bug reference> - changed to use blah.foo2() // method because blah.foo() didn't handle <some // condition> properly`
blah.foo2();
尽管这些注释的初衷是好的(帮助你了解开发过程中的变化),但它们往往弊大于利。要理解真正的问题,你不得不在你的错误跟踪系统中查找故障,并提取文件的上一版本来调查发生了什么变化。很少的错误修复需要这种阅读,所以新来者可能可以生活在无忧无虑的无知中。这些注释在开发的后期阶段和维护期间大量出现,并在源代码中散布旁白、过时信息和执行主线的干扰。
当你进行一个非显而易见的修复时,插入一个注释是有道理的——以防止后来修订代码的人重新引入错误。然而,在这些精心挑选的案例中,你实际上是在记录意外而不是放置一个错误修复通知。
关键概念
注释应该活在现在,而不是过去。不要描述已经改变的事情,或者告诉某物曾经做过什么。
注释腐烂
注释会腐烂。好吧,所有粗心维护的代码都会腐烂,获得丑陋的瑕疵,并失去原始的整洁设计。然而,注释似乎比任何其他代码腐烂得更快。它们与它们描述的代码脱节。这可能会非常令人烦恼。
一个战争故事
我曾经处理过一个包含注释“功能 A 和 B 尚未实现”的代码段。我需要这两个功能,所以我创建了它们。只有在完成之后,我才意识到功能 B已经实现了——我白费了力气——而且功能 A 是多余的,因为 B 的实现已经处理了它。如果做这个的程序员移除了错误的注释,我就不会浪费那么多工作了。
简单的解决方案是这样的:当你修复、添加或修改任何代码时,修复、添加或修改其周围的任何注释。不要只是摆弄几行代码然后继续。确保任何代码更改都不会使注释变成谎言。相应的,我们必须使注释易于更新,否则它们就不会被更新。注释必须与它们的代码部分清晰相关,不要放在晦涩难懂的位置。
关键概念
当你修改代码时,保持其周围的注释不变。
另一个坏习惯是留下注释掉的代码块。当你一年后回来,或者当任何其他程序员偶然发现它们时,这会令你困惑。如果你遇到一个注释块中的代码,你会想知道为什么它在那里。这是一个从未完成的修复吗?它仍然是一个正在进行的工作吗?这段代码从未工作过吗?其余的代码功能是否完整?
要么留下一个说明你为什么注释掉代码的便条,要么完全删除它——你总是可以从源代码控制系统中恢复它。即使你认为你只是暂时移除了一些代码,也给自己留个便条;你可能会忘记完成它。
维护和无用注释
当你浏览一个旧代码库时,最好不要删除你发现的任何无用注释,除非它们是直接危险的。把它们留作未来维护程序员的警告——它们提供了对周围代码(缺乏)质量的宝贵见解。当然,如果你实际上正在尝试改进那块代码,那么在改进过程中就重新编写注释!如果你发现一个确实是事实错误或误导性的注释,那么你应该在维护代码的过程中重新编写它。
学习有趣的区域标志,如XXX,并以尊重和谨慎的态度对待它们。同时注意那些被注释掉的输出语句。这些都是过去这里存在问题的明确迹象——要小心处理代码!
注意注释腐烂。仅仅因为注释说“这定义在 foo.c 中”并不意味着它现在是这样。始终相信代码,怀疑注释。
In a Nutshell
主要写作是说已经看到的东西,这样它就永远不需要再说了。
--Delmore Schwartz
我们写了很多注释。那是因为我们写了大量的代码。学会写正确的注释类型非常重要,否则我们的代码可能会因为不适当和过时的注释而变得难以维护。
注释的重要性不亚于它们所注释的代码——你不能通过注释使糟糕的代码变得好。你的目标应该是无需注释的自文档化代码。
| 好程序员…… | 坏程序员…… |
|---|
|
-
尽量写一些真正优秀的注释
-
写出解释为什么的注释
-
专注于编写好的代码,而不是大量注释
-
写出有意义的、合理的注释
|
-
无法区分好注释和坏注释
-
写出解释如何的注释
-
如果注释只对自己有意义,那就没关系
-
用许多注释来加强糟糕的代码
-
在源文件中填充冗余信息(修订历史等)
|

另请参阅
第二章
代码布局和展示方案将影响你如何布局你的注释。
第三章
自我注释代码的另一个方面:选择好的名称。
第四章
讨论了自文档化代码,这是一种使大量注释变得不必要的策略。还描述了文学编程技术。
第十八章
版本控制系统保存文件历史,因此你不需要在注释中解释它。
激发思考
在第 485 页的"附录 A"部分可以找到对以下问题的详细讨论。
思考
-
在以下类型的代码中,注释的需要和内容可能会有何不同:
-
机器语言(低级汇编语言)
-
Shell 脚本
-
单文件测试框架
-
大型 C/C++项目
-
-
你可以运行工具来计算你的源代码行中有多少是注释。这些工具有多有用?这种对注释质量的衡量有多准确?
-
如果你遇到一些难以理解的代码,哪种方法更好地增加可读性:添加注释来记录你认为正在发生的事情,还是用更具描述性的名称重命名变量/函数/类型?哪种方法最有可能更容易?哪种方法更安全?
-
当你用代码注释块记录 C/C++ API 时,它应该放在声明函数的公共头文件中,还是放在包含实现的源文件中?每个位置的优缺点是什么?
个人化
-
仔细看看你最近工作的源文件。检查你的注释。它真的好吗?(我打赌当你阅读代码时,你会发现自己做了一些修改!)
-
你如何确保你的注释真正有价值,而不仅仅是只有你自己能理解的个人杂谈?
-
你合作的人是否都按照相同的标准,以大约相同的方式进行注释?
-
谁最擅长写注释?你为什么这么认为?谁最差?这与这些个人的一般编码质量有多少相关性?
-
你认为任何强加的编码标准能提高你团队编写的注释质量吗?
-
-
你是否在每个源文件中包含历史记录信息?如果是的话:
-
你是否手动维护它?为什么,如果你的版本控制系统会自动为你插入这些信息?历史记录是否保持得特别准确?
-
这真的是一种明智的做法吗?这种信息需要多频繁?为什么将它放在源文件中比放在另一个单独的机制中更好?
-
-
你是否在其他人代码中添加你的首字母或以其他方式标记你做出的注释?你是否给注释加日期?你什么时候以及为什么这么做——这是否是一种有用的做法?有人找到别人的首字母和日期标记是否曾经有用过?
第六章 TO ERR IS HUMAN
处理不可避免的事情——代码中的错误条件
我们知道,避免错误的唯一方法是检测它,而检测它的唯一方法是能够自由地询问。
--J. Robert Oppenheimer
在生活的某个时刻,每个人都会有这样的顿悟:世界不会像你期望的那样运作。我的一个一岁的朋友汤姆在爬一个四倍于他身高的椅子时学到了这一点。他期望能到达顶部。实际的结果让他惊讶:他最终掉进了一堆家具下面。
世界是破碎的吗?它是错误的吗?不。在过去几百万年中,世界一直在快乐地沿着自己的道路前进,并且看起来在可预见的未来将继续如此。是我们的期望是错误的,需要调整。正如他们所说:坏事会发生,所以处理它。我们必须编写处理真实世界及其意外方式的代码。
这尤其困难,因为世界大部分像我们期望的那样运作,不断地让我们陷入一种虚假的安全感。人类的大脑被设计来应对,有内置的安全措施。如果有人堵住了你的前门,你的大脑会处理这个问题,你会在撞上意外的墙之前停下来。但是程序并不那么聪明;我们必须告诉它们哪里有砖墙,以及它们撞上时应该做什么。
不要假设你的程序中的所有内容都会一直顺利运行。世界并不总是按照你预期的那样运作:你必须处理代码中所有可能出现的错误条件。这听起来很简单,但这个陈述会导致无尽的痛苦。
它从何而来
预期意外的事情,显示出一种彻底的现代智慧。
--奥斯卡·王尔德
错误会发生,并且将会发生。几乎所有操作都可能产生不良结果。它们与有缺陷程序中的 bug 不同,因为你在事先就知道错误可能会发生。例如,你想要打开的数据库文件可能已经被删除,磁盘可能随时会满,你的下一次保存操作可能会失败,或者你正在访问的 Web 服务可能目前不可用。
如果你没有编写代码来处理这些错误条件,你几乎肯定会遇到一个bug;你的程序不会总是按照你的意图工作。但如果错误很少发生,它可能是一个非常微妙的错误!我们将在第九章中探讨错误。
错误可能由于一千种原因之一发生,但它将落入以下三个类别之一:
用户错误
愚蠢的用户错误地操作了你的可爱程序。也许他提供了错误输入或尝试了绝对荒谬的操作。一个好的程序会指出错误并帮助用户纠正它。它不会侮辱他或以难以理解的方式抱怨。
程序员错误
用户按下了所有正确的按钮,但代码却出了问题。这是其他地方的一个错误的后果,是程序员引入的错误,用户对此无能为力(除了尝试在未来避免它)。这种错误(理想情况下)永远不会发生。
这里有一个循环:未处理的错误会导致 bug。而这些 bug 可能会导致你的代码其他地方出现进一步的错误条件。这就是为什么我们认为防御性编程是一个重要的实践。
例外情况
用户按下了所有正确的按钮,程序员也没有出错。命运的变幻无常的手指介入了,我们遇到了无法避免的事情。也许网络连接失败了,我们用完了打印机墨水,或者硬盘空间已经满了。
我们需要一个明确定义的策略来管理代码中每种类型的错误。错误可能通过弹出消息框检测并报告给用户,或者可能由中间层代码层检测并通过程序方式通知客户端代码。在这两种情况下,原则是相同的:无论是人类选择如何处理问题还是你的代码做出决定——有人负责承认并采取行动处理错误。
关键概念
认真对待错误处理。你代码的稳定性依赖于它。
错误由从属组件引发并向上传递,由调用者处理。它们以多种方式报告;我们将在下一节中探讨这些内容。为了控制程序执行,我们必须能够:
-
当出现问题时抛出错误
-
检测所有可能的错误报告
-
适当地处理它们
-
传播我们无法处理的错误
错误很难处理。你遇到的错误通常与你当时所做的事情无关(大多数属于“异常情况”类别)。它们也令人厌烦——我们希望专注于我们的程序应该做什么,而不是它可能出错的方式。然而,如果没有良好的错误管理,你的程序将会脆弱——建立在沙子上,而不是岩石上。一旦有风或雨的迹象,它就会倒塌。
错误报告机制
有几种常见的策略用于将错误信息传播到客户端代码。你可能会遇到使用其中每一种的代码,所以你必须知道如何使用每种方言。观察这些错误报告技术之间的比较,并注意哪些情况需要哪种机制。
每种机制对错误的局部性有不同的影响。如果错误在创建后不久被发现,则错误在时间上是局部的。如果错误在实际上表现的地方非常接近(甚至就是)被识别,则错误在空间上是局部的。一些方法专门旨在减少错误的局部性,使其更容易看到正在发生的事情(例如,错误代码)。其他方法旨在扩展错误的局部性,以便正常代码不会与错误处理逻辑纠缠在一起(例如,异常)。
最受欢迎的报告机制通常是架构决策。架构师可能会认为定义一个同质的异常类层次结构或一个共享原因代码的中央列表来统一错误处理代码很重要。
不报告
最简单的错误报告机制是不麻烦。在你想让你的程序以奇特和不可预测的方式行为并随机崩溃的情况下,这效果非常好。
如果你遇到错误并且不知道如何处理它,盲目地忽略它不是一个可行的选择。你可能无法继续函数的工作,但如果没有履行你的函数合同就返回,将会使世界处于未定义和不一致的状态。
关键概念
永远不要忽略错误条件。如果你不知道如何处理问题,向调用代码发出失败信号。不要把错误扫到地毯下,寄希望于最好的结果。
忽略错误的替代方案是在遇到问题时立即终止程序。这比在代码中处理错误要容易,但几乎不是一个经过良好设计的解决方案!
返回值
下一个最简单的机制是从你的函数返回成功/失败值。布尔返回值提供了一个简单的“是”或“否”答案。更高级的方法列举所有可能的退出状态,并返回相应的 原因码。一个值表示 成功;其余的表示许多不同的中止情况。这种枚举可以在整个代码库中共享,在这种情况下,你的函数返回可用值的子集。因此,你应该记录调用者可以期待的内容。
当这种方法对于不返回数据的程序有效时,将错误码与返回数据一起返回会变得混乱。如果 int count() 沿着链表向下遍历并返回元素数量,它如何表示列表结构的损坏?有三种方法:
-
返回一个包含返回值和错误码的复合数据类型(或称 元组)。这在流行的类似 C 的语言中相当笨拙,并且很少见。
-
通过函数参数传递错误码。在 C++ 或 .NET 中,此参数将通过引用传递。在 C 中,你会通过指针直接访问变量。这种方法既丑陋又难以理解;没有语法方式可以区分返回值和参数。
-
或者,预留一个返回值范围以表示失败。例如,
count可以指定所有负数作为错误原因码;它们本来就没有意义。负数是这种选择的常见选择。指针返回值可以赋予一个特定的无效值,按照惯例是零(或NULL)。在 Java 和 C# 中,你可以返回一个null对象引用。这种技术并不总是有效。有时很难预留一个错误范围——所有返回值都同样有意义且同样可能。它还会产生副作用,减少成功值的可用范围;使用负值将可能的正值减少了一个数量级.^([1])
错误状态变量
此方法试图管理函数返回值与其错误状态报告之间的竞争。而不是返回一个原因码,函数设置一个共享的全局错误变量。在调用函数之后,你必须检查这个状态变量以确定是否成功完成。
共享变量减少了函数签名中的混淆和杂乱,并且根本不会限制返回值的数据范围。然而,通过单独通道发出的错误信号更容易被忽略或故意忽视。共享的全局变量还有讨厌的线程安全问题。
C 标准库使用errno变量采用这种技术。它具有非常微妙的语义:在使用任何标准库功能之前,你必须手动清除errno。没有任何东西会设置成功值;只有失败会触及errno。这是常见的错误来源,它使得调用每个库函数变得繁琐。更糟糕的是,并非所有 C 标准库函数都使用errno,因此它并不一致。
这种技术与使用返回值在功能上是等效的,但它有足够的缺点,以至于让你避免使用它。不要以这种方式编写自己的错误报告,并且在使用现有实现时要极其小心。
异常
异常是管理错误的语言功能;并非所有语言都支持异常。异常有助于区分正常的执行流程和异常情况——当函数失败且无法履行其合同时。当你的代码遇到它无法处理的问题时,它会立即停止并抛出一个异常——一个表示错误的对象。然后,语言运行时会自动向上回溯调用栈,直到找到一些异常处理代码。错误会落在那里,由程序来处理。
有两种操作模型,它们通过处理异常后发生的事情来区分:
终止模型
在捕获异常的处理程序之后继续执行。这种行为由 C++、.NET 和 Java 提供。
恢复模型
执行从异常抛出的地方恢复。
前者模型更容易推理,但它不提供最终控制权。它只允许错误处理(当你注意到错误时可以执行代码),而不是故障纠正(修复问题并再次尝试的机会)。
异常不能被忽略。如果没有被捕获和处理,它将传播到调用栈的顶部,通常会导致程序立即停止。语言运行时会自动清理,在回溯调用栈时。这使得异常成为一个比手工错误处理代码更整洁、更安全的替代方案。然而,通过糟糕的代码抛出异常可能会导致内存泄漏和资源清理问题。²]你必须小心编写异常安全的代码。侧边栏将更详细地解释这意味着什么。
异常安全性的快速浏览
弹性代码必须是异常安全的。无论遇到什么异常,它都必须正确工作(对于正确的定义,我们将在下面探讨),无论代码本身是否捕获任何异常。
异常中立的代码将所有异常传播到调用者;它不会消费或改变任何东西。这对于像 C++模板代码这样的通用程序是一个重要的概念——模板类型可能会生成各种异常,而模板实现者可能不理解。
异常安全性有几个不同的级别。它们是用调用代码的保证来描述的。这些保证是:
基本保证
如果一个函数中发生异常(由你执行的操作或另一个函数的调用引起),它将不会泄露资源。代码状态将是一致的(即它仍然可以正确使用),但它不一定留下一个已知的状态。例如:一个成员函数应该向容器中添加 10 个项目,但异常通过它传播。容器仍然可用;可能没有插入对象,可能所有 10 个都插入了,或者可能每隔一个对象就添加了一个。
强保证
这比基本保证要严格得多。如果一个异常在你的代码中传播,程序状态将完全保持不变。没有任何对象被改变,没有全局变量被更改,什么都没有。在上面的例子中,容器中没有插入任何内容。
无异常保证
最终的保证是最为严格的:即一个操作永远不能抛出异常。如果我们是异常中立的话,那么这就意味着该函数不能做任何可能抛出异常的其他事情。
你提供哪种保证完全取决于你。保证越严格,代码的可重用性就越广。为了实现强保证,你通常需要一系列提供无异常保证的函数。
最值得注意的是,你写的每个析构函数必须遵守无异常保证^([3])。否则,所有的异常处理赌注都失效了。在异常存在的情况下,对象析构函数会自动在栈回溯时被调用。在处理异常时抛出异常是不允许的。
处理异常的代码与引发异常的代码是不同的,并且可能相隔很远。异常通常由面向对象的语言提供,其中错误由异常类层次结构定义。处理器可以选择捕获一个相当具体的错误类(通过接受一个叶类)或一个更一般的错误类别(通过接受一个基类)。异常在向构造函数中传递错误信号时尤其有用。
异常不是免费的;语言支持会带来性能惩罚。在实践中,这并不显著,并且仅在异常处理语句周围表现出来——异常处理器减少了编译器的优化机会。这并不意味着异常有缺陷;与不做任何错误处理相比,它们的成本是合理的!
信号
信号是一种更极端的汇报机制,主要用于执行环境向运行中的程序发送的错误。操作系统会捕捉到许多异常事件,例如由数学协处理器触发的浮点异常。这些定义良好的错误事件通过信号传递给应用程序,这些信号会中断程序的正常执行流程,跳转到指定的信号处理函数。你的程序可能在任何时候接收到信号,代码必须能够应对这种情况。当信号处理函数完成后,程序将在被中断的地方继续执行。
信号是软件中断的等效物。这是一个 Unix 概念,现在在大多数平台上都提供了(基本版本是 ISO C 标准[ISO99]的一部分)。操作系统为每个信号提供了合理的默认处理程序,其中一些什么都不做,而另一些则通过整洁的错误消息终止程序。你可以用你自己的处理程序来覆盖这些处理程序。
定义好的 C 信号事件包括程序终止、执行挂起/继续请求以及数学错误。一些环境通过许多更多的事件扩展了基本列表。
^([1]) 如果你使用了unsigned int,那么可用的值的数量将增加 2 的幂次方,重用signed int的符号位。
^([2]) 例如,你可以在分配了一块内存后,在异常传播过程中提前退出。分配的内存将会泄漏。这类问题使得在异常面前编写代码变得复杂。
^([3]) 至少在 C++和 Java 中是这样的。C#愚蠢地将~X()称为析构函数,尽管它实际上是一个伪装的终结器。在 C#析构函数中抛出异常有不同的含义。
错误检测
你如何检测错误显然取决于报告它的机制。在实践中,这意味着:
返回值
你通过查看函数的返回码来确定函数是否失败。这种失败测试与调用函数的行为紧密相关;通过调用函数,你隐式地检查了它的成功。你是否有必要处理这些信息取决于你自己。
错误状态变量
在调用函数之后,你必须检查错误状态变量。如果它遵循 C 的errno操作模型,你实际上不需要在每次函数调用后都测试错误。首先重置errno,然后连续调用任意数量的标准库函数。之后,检查errno。如果它包含错误值,那么这些函数中的某一个失败了。当然,你不知道哪个失败了,但如果你不在乎,那么这是一种简化的错误检测方法。
异常
如果异常从一个从属函数中传播出来,你可以选择捕获并处理它,或者忽略它并让异常向上传播一个级别。只有当你知道可能会抛出哪些类型的异常时,你才能做出明智的选择。你只有在它被记录(并且你信任该记录)的情况下才会知道这一点。
Java 的异常实现将此类文档放置在代码本身中。程序员必须为每个方法编写一个异常规范,描述它可以抛出什么;这是函数签名的一部分。Java 是唯一强制执行这种方法的主流语言。你不能泄露不在列表中的异常,因为编译器执行静态检查以防止这种情况发生.^([4])
信号
检测信号只有一种方法:安装一个处理程序。没有义务。你也可以选择不安装任何信号处理程序,并接受默认行为。
当各种代码片段在一个大系统中汇聚时,你可能需要以多种方式检测错误,甚至在单个函数内部也是如此。无论你使用哪种检测机制,关键点如下:
关键概念
永远不要忽略任何可能报告给你的错误。如果存在错误报告通道,它存在是有原因的。
总是编写错误检测脚本是良好的实践——即使错误对其他代码没有影响。这使维护程序员清楚,你知道一个函数可能会失败,并且已经有意选择忽略任何失败。
当你让异常在你的代码中传播时,你并不是在忽略它——你不能忽略异常。你是在允许它被更高层次处理。在异常处理方面,这种哲学相当不同。关于如何记录这一点最合适,你应该写一个简单的try/catch块来重新抛出异常,你应该写一个注释声称代码是异常安全的,还是你应该什么都不做?我倾向于记录异常行为。
^([4]) C++也支持异常规范,但将其使用留作可选。出于性能和其他原因,避免使用它们是惯例。与 Java 不同,它们在运行时强制执行。
处理错误
热爱真理,宽恕错误。
--伏尔泰
错误会发生。我们已经看到了如何发现它们以及何时这样做。现在的问题是:你对它们怎么办?这是难点。答案在很大程度上取决于情况以及错误的严重性——是否有可能纠正问题并重试操作,或者是否可以继续进行。通常没有这样的奢侈;错误甚至可能预示着结束的开始。你能做的最好的事情是在其他事情出错之前清理并迅速退出。
要做出这种决定,你必须了解情况。你需要了解一些关于错误的关键信息:
错误来源
这与将要处理的地方截然不同。源是核心系统组件还是外围模块?这种信息可能包含在错误报告中;如果没有,你可以手动找出。
你试图做什么
什么引发了错误?这可能为任何补救措施提供线索。错误报告很少包含这类信息,但你可以从上下文中找出被调用的函数。
为什么出错
问题的本质是什么?你需要确切地知道发生了什么,而不仅仅是错误的一般类别。错误操作完成了多少?全部或没有都是很好的答案,但通常,程序会在这两种状态之间处于某种不确定的状态。
错误发生时
这是错误在时间上的局部性。系统刚刚失败,还是问题已经存在两小时终于被感觉到?
错误的严重性
一些问题比其他问题更严重,但一旦检测到,一个错误就等同于另一个错误——你不理解和管理问题就无法继续。错误严重性通常由调用者根据恢复或绕过错误的难易程度来确定。
如何修复它
这可能很明显(例如,插入软盘并重试)或不是(例如,你需要修改函数参数以确保它们一致)。更常见的是,你必须从其他信息中推断出这种知识。
给定这种信息深度,你可以制定一个策略来处理每个错误。忘记为任何潜在的错误插入处理程序将导致错误,并且可能是一个难以练习和难以追踪的错误——所以仔细思考每个错误条件。
当处理错误
何时处理每个错误?这可以与检测时间分开。有两种不同的观点。
尽可能早
在检测到每个错误时立即处理。由于错误是在其源头附近处理的,因此你保留了重要的上下文信息,使得错误处理代码更加清晰。这是一种众所周知的自文档化代码技术。在错误源头附近管理每个错误意味着在无效状态下通过更少的代码进行控制。
这通常是返回错误代码的函数的最佳选择。
尽可能晚
或者,你可以尽可能晚地推迟错误处理。这认识到检测错误的代码很少知道如何处理它。它通常取决于它被使用的上下文:在加载文档时报告的缺失文件错误可能在查找首选项文件时被静默吞没。
异常非常适合这种情况;你可以将异常传递到每一层,直到你知道如何处理错误。这种检测和处理之间的分离可能更清晰,但它可能会使代码更复杂。当你最终处理错误时,并不明显你是有意推迟错误处理的,而且也不清楚错误是从哪里来的。
理论上,将“业务逻辑”与错误处理分开是很好的。但通常你无法做到,因为清理必然与那种业务逻辑交织在一起,而且将两者分开编写可能更加复杂。然而,集中式的错误处理代码有优势:你知道在哪里查找它,并且可以将中止/继续策略放在一个地方,而不是分散在许多函数中。
托马斯·杰斐逊曾说过,“拖延胜于错误。”这里有一定的道理;错误处理的实际存在比处理错误的时间更重要。尽管如此,选择一个折衷方案,既足够接近以防止模糊和脱离上下文的错误处理,又足够远离以不使正常代码被迂回路径和错误处理死胡同所混淆。
关键概念
一旦你足够了解错误以正确处理它,就立即在最适合的上下文中处理每个错误。
可能的反应
你已经捕获了一个错误。你准备处理它。接下来你打算做什么?希望是进行正确的程序操作。虽然我们无法列出所有可能的恢复技术,但以下是一些常见的反应供你考虑。
日志记录
任何合理规模的项目都应该已经采用了日志记录设施。它允许你收集重要的跟踪信息,并且是调查棘手问题的入口点。
日志存在是为了记录程序生活中的有趣事件,以便你深入了解其内部工作原理并重建执行路径。因此,你遇到的所有错误都应该详细记录在程序日志中;它们是所有事件中最有趣和最有说明性的。目标是捕捉所有相关信息——尽可能多的上一列表中的信息。
对于可能预测灾难性灾难的非常隐蔽的错误,让程序“打电话回家”——发送其自身的快照或错误日志的副本给开发者进行进一步调查,可能是个好主意。
日志记录之后你要做什么是另一回事。
报告
一个程序只有在没有其他事情可做时才应该向用户报告错误。用户不需要被成千上万的小块无用信息轰炸,或者被一大堆无意义的问题困扰。只有在真正关键的时候才进行交互。当你遇到可恢复的情况时,不要报告。无论如何,记录下事件,但对此保持沉默。如果你认为将来他们可能会关心,提供一种机制让用户能够阅读事件日志。
有些问题只有用户才能解决。对于这些问题,立即报告问题是一个好的做法,以便给用户最好的机会来解决问题或者决定如何继续。
当然,这种报告取决于程序是否是交互式的。深度嵌入的系统预计可以自行处理;在洗衣机上弹出对话框是很困难的。
恢复
有时候,你唯一能做的就是立即停止。但并非所有错误都意味着灾难。如果你的程序保存文件,有一天磁盘会满,保存操作会失败。用户期望你的程序继续愉快地运行,所以要做好准备。
如果你的代码遇到错误并且不知道如何处理它,向上传递错误。很可能调用者有恢复的能力。
忽略
我只包括这一点以示完整。希望到现在你已经学会了蔑视忽略错误的建议。如果你选择忘记处理它,只是继续交叉手指,那么祝你好运。大多数软件包中的大多数错误都来自这里。忽略可能引起系统行为异常的错误不可避免地会导致数小时的调试。
然而,你可以编写代码,当出现错误时,你可以什么也不做。这是明显的矛盾吗?不。可以编写代码来处理不一致的世界,面对错误时可以正确继续——但这通常会很复杂。如果你采用这种方法,必须在代码中明确表示。不要冒险让它被误解为无知和不正确。
关键概念
忽略错误并不能节省时间。你将花费更多的时间来解决糟糕的程序行为的原因,而不是编写错误处理程序。
传播
当一个从属函数调用失败时,你可能无法继续,但你可能不知道还能做什么。唯一的选择是清理并向上传播错误报告。你有选择。传播错误有两种方式:
-
导出你被提供相同的错误信息(返回相同的原因代码或传播异常)。
-
重新解释信息,向更高一级发送更有意义的消息(返回不同的原因代码或捕获并封装异常)。
问问自己这个问题:错误是否与通过模块接口公开的概念相关?如果是这样,传播相同的错误是可以的。否则,将其重新塑造成适当的光环,选择在模块接口上下文中合理的错误报告。这是一种良好的自文档化代码技术。
代码影响
给我看看代码! 让我们花些时间调查我们代码中错误处理的影响。正如我们将看到的,编写不扭曲和扭曲底层程序逻辑的良好错误处理并不容易。
我们将要查看的第一段代码是一个常见的错误处理结构。然而,它并不是编写容错代码的特别明智的方法。目标是依次调用三个函数——每个函数都可能失败——并在过程中执行一些中间计算。找出这个结构的问题:
void nastyErrorHandling()
{
if (operationOne())
{
... do something ...
if (operationTwo())
{
... do something else ...
if (operationThree())
{
... do more ...
}
}
}
}
从语法上讲,代码没问题;代码会正常工作。但从实际维护的角度来看,这种风格并不令人愉快。你需要执行的操作越多,代码嵌套得越深,阅读起来就越困难。这种错误处理方式很快就会导致条件语句变得一团糟。它并不能很好地反映代码的行为;每个中间计算都可以被认为是同等重要,但它们被嵌套在不同的层级。
构建错误消息
不可避免地,你的代码会遇到用户必须解决的错误。人工干预可能是唯一的选择;你的代码不能自己插入软盘或打开打印机。(如果它能,你将发大财!)
如果你打算向用户抱怨,有几个一般性要点需要记住:
-
用户思考方式与程序员不同,所以以他们期望的方式呈现信息。当显示磁盘上的可用空间时,你可能会报告
磁盘空间:10K。但如果空间已满,零可能会被误读为正常——并且用户将无法理解为什么当程序说一切正常时,他无法保存文件。 -
确保你的消息不要太晦涩。你可能能理解它们,但你的电脑盲奶奶能理解吗?(即使你的奶奶不会使用这个程序——几乎肯定会有智力较低的人会使用。)
-
不要展示无意义的错误代码。没有用户知道面对
错误代码 707E时该怎么做。然而,将这些代码作为“附加信息”提供是有价值的——它们可以被引用给技术支持,或者在网络上更容易地搜索到。 -
区分严重错误和普通警告。在消息文本中包含这些信息(可能使用
Error:前缀),并在消息框中使用伴随图标强调。 -
只有当用户完全理解每个选择的后果时,才提出问题(即使是一个简单的问题,如继续:是/否?)。如果需要,解释它,并清楚地说明每个答案的后果。
你向用户展示的内容将由界面约束和应用程序或操作系统风格指南决定。如果你的公司有用户界面工程师,那么这是他们的职责来做出这些决定。与他们合作。
我们能避免这些问题吗?是的——有几个替代方案。第一个变体简化了嵌套。它在语义上是等价的,但它引入了一些新的复杂性,因为流程控制现在依赖于一个新的状态变量ok的值:
void flattenedErrorHandling()
{
bool ok = operationOne();
if (ok)
{
... do something ...
ok = operationTwo();
}
if (ok)
{
... do something else ...
ok = operationThree();
}
if (ok)
{
... do more ...
}
if (!ok)
{
... clean up after errors ...
}
}
我们还增加了一个在出现任何错误后进行清理的机会。这足以清理所有失败吗?可能不够;必要的清理可能取决于我们在闪电击中之前在函数中走了多远。有两种清理方法:
-
在每次可能失败的运算之后进行一点清理,然后提前返回。这不可避免地导致清理代码的重复。你完成的工作越多,需要清理的也就越多,因此每个退出点都需要逐渐进行解包。
如果我们示例中的每个操作都分配了一些内存,每个提前退出的点都必须释放迄今为止所做的所有分配。越往里,释放的次数就越多。这将导致一些相当密集和重复的错误处理代码,使得函数变得更大,更难以理解。
-
将清理代码一次性写在函数的末尾,但要写成只清理脏东西的样子。这样更整洁,但如果你在函数中间不小心插入了一个提前返回,清理代码就会被绕过。
如果你不太关心编写单入口,单出口(SESE)函数,接下来的例子就消除了对单独控制流变量的依赖.^([5]) 尽管如此,我们再次失去了清理代码。简单性使得这成为对实际意图的更好描述:
void shortCircuitErrorHandling()
{
if (!operationOne()) return;
... do something ...
if (!operationTwo()) return;
... do something else ...
if (!operationThree()) return;
... do more ...
}
这种短路退出与清理要求的结合导致以下方法,尤其是在底层系统代码中尤为常见。有些人提倡这是被诟病的goto的唯一有效用途。我仍然不这么认为。
void gotoHell()
{
if (!operationOne()) goto error;
... do something ...
if (!operationTwo()) goto error;
... do something else ...
if (!operationThree()) goto error;
... do more ...
return;
error:
... clean up after errors ...
}
你可以使用 C++中的资源获取即初始化(RAII)技术,如智能指针,来避免这样的糟糕代码。(Stroustrup 97)这有一个好处,就是提供异常安全性——当异常提前终止你的函数时,资源会自动释放。这些技术避免了我们上面看到的大多数问题,将复杂性转移到单独的控制流中。
使用异常的相同示例如下(在 C++、Java 和 C#中),假设所有从属函数都不返回错误代码,而是抛出异常:
void exceptionalHandling()
{
try
{
operationOne();
... do something ...
operationTwo();
... do something else ...
operationThree();
... do more ...
}
catch (...)
{
... clean up after errors ...
}
}
这只是一个基本的异常示例,但它展示了异常可以多么整洁。良好的代码设计可能根本不需要try/catch块,如果它确保没有资源泄漏并将错误处理留给更高层次。但遗憾的是,面对异常编写好代码需要理解本章范围之外的原理。
^([5]) 虽然这显然不是 SESE,但我认为先前的例子也不是。只有一个退出点,在最后,但人为的控制流模拟了提前退出——它几乎可以有多个退出。这是一个很好的例子,说明了受规则如 SESE 的约束可能导致糟糕的代码,除非你仔细思考你在做什么。
撒野
我们已经忍受了别人的错误很长时间了。是时候扭转局面,扮演坏人了:让我们引发一些错误。在编写函数时,会发生错误的事情,你需要通知你的调用者。确保你这样做——不要默默地吞下任何失败。即使你确信调用者面对问题不知道该怎么办,它必须保持知情。不要编写撒谎并假装正在做它没有做的事情的代码。
应该使用哪种报告机制?这主要是一个架构选择;遵守项目约定和通用语言习惯。在具有此功能的语言中,通常倾向于使用异常,但只有当整个项目使用它们时才使用。Java 和 C#实际上让你别无选择;异常深深地埋藏在它们的执行运行时中。C++架构可能选择放弃此功能,以与没有异常支持的平台或与旧 C 代码接口的便携性。
我们已经看到了从从属函数调用中传播错误的策略。我们在这里的主要关注点是报告执行过程中遇到的新问题。你如何确定这些错误是你的事,但在报告它们时,考虑以下因素:
-
你是否首先适当地清理了?可靠的代码不会泄露资源或使世界处于不一致的状态,即使在发生错误的情况下,除非它是真的不可避免的。如果你做了这两件事中的任何一件,你必须仔细记录。考虑如果这个错误再次发生,你的代码下次被调用时会发生什么。确保它仍然可以工作。
-
不要在错误报告中向外界泄露不适当的信息。只返回调用者理解并可以采取行动的有用信息。
-
正确使用异常。不要为不寻常的返回值抛出异常——罕见但不是错误的案例。只使用异常来表示函数无法满足其合同的情况。不要非习惯性地使用它们(即用于流程控制)。
-
如果你正在捕获在程序执行过程中不应该发生的错误,即真正的编程错误,考虑使用断言(见第 16 页的“约束”)。异常也是这个问题的有效选择——一些断言机制可以在触发时配置为抛出异常。
-
如果你可以在编译时提前提取任何测试,那么就这样做。你越早检测和纠正错误,它造成的麻烦就越少。
-
让人们难以忽视你的错误。给半点机会,有人会糟糕地使用你的代码。异常在这方面很好——你必须故意行动才能隐藏异常。
你应该注意哪些类型的错误?这显然取决于函数正在做什么。以下是在每个函数中你应该进行的通用错误检查清单:
-
检查所有函数参数。确保你得到了正确和一致输入。根据你的合同写得有多严格,考虑使用断言。 (提供错误的参数是违法的吗?)
-
检查在执行过程中的有趣点是否满足不变性。
-
在使用之前,检查所有来自外部源的数据的有效性。文件内容和交互式输入必须是合理的,不能有缺失的部分。
-
检查所有系统和其他从属函数调用的返回状态。
规则的一个例外
异常是一个强大的错误报告机制。使用得当,它们可以极大地简化你的代码,同时帮助你编写健壮的软件。然而,如果落入错误之手,它们则可能成为致命的武器。
我曾经在一个项目中工作,程序员们习惯于通过抛出异常来打破 while 循环或结束递归,将其用作非局部 goto。这是一个有趣的想法,当你第一次看到它时,它有点可爱。但这种行为不过是滥用异常:这不是异常习惯用法所用的。由于维护程序员没有理解通过一个复杂、神奇终止的循环的控制流,导致了一个以上的关键错误。
遵循你语言的习语,不要为了追求花哨而编写代码。
错误管理
将错误抛出和处理统一的原则是,无论错误在哪里显现,都要有一个一致的策略来处理失败。这些是管理程序错误发生、检测和处理的一般性考虑:
-
避免可能导致错误的事情。你能做的是保证一定能成功的事情吗?例如,通过事先预留足够的资源来避免分配错误。有了确保的内存池,你的程序不会受到内存限制。当然,这只有在你知道你需要多少资源的情况下才会有效,但你通常都知道。
-
定义程序或例程在异常情况下的预期行为。这决定了代码需要有多健壮,因此决定了你的错误处理应该有多彻底。一个函数能否默默地生成错误的输出,遵循历史上的 GIGO 原则?^([6])
-
明确定义哪些组件负责处理哪些错误。在模块的接口中明确指出。确保你的客户端知道什么总是会工作,什么可能会有一天失败。
-
检查你的编程实践:你什么时候编写错误处理代码?不要推迟到以后;你可能会忘记处理某些事情。不要等到开发测试揭示了问题才编写处理程序——这不是一种工程方法。
关键概念
现在就编写所有错误检测和处理代码,就像你编写可能失败的代码一样。不要推迟到以后。如果你必须邪恶并推迟处理,至少现在就编写检测框架。
- 当捕获错误时,你是否发现了症状或原因?考虑你是否已经发现了需要在此处纠正的问题根源,或者你是否发现了早期问题的症状。如果是后者,那么不要在这里编写大量的处理代码,而应该将其放入更合适(更早)的错误处理程序中。
^([6]) 即,垃圾输入,垃圾输出——给它垃圾,它会高兴地吐出垃圾。
简而言之
犯错误是人类的天性;悔改是神圣的;坚持是魔鬼般的。
--本杰明·富兰克林
人类会犯错误(但计算机似乎也很擅长这一点)。处理这些错误是神圣的。
你写的每一行代码都必须通过适当的和彻底的错误检查和处理来平衡。没有严格错误处理的程序将不会稳定。总有一天会发生一个难以捉摸的错误,程序将因此崩溃。
处理错误和失败情况是项艰巨的工作。它使编程陷入现实世界的平凡细节。然而,这是绝对必要的。你编写的代码中高达 90%处理的是异常情况。(Bentley 82)这是一个令人惊讶的统计数据,因此编写代码时预期将更多精力投入到可能出错的事情上,而不是那些可能正确的事情上。
| 优秀的程序员... | 次要的程序员... |
|---|
|
-
将他们的良好意图与良好的编码实践相结合
-
在编写主要代码的同时编写错误处理代码
-
在他们编写的代码中,彻底地处理,覆盖每一个错误可能性
|
-
以随意的方式编写代码,既不考虑他们正在做什么,也不进行审查
-
忽略在编写代码时出现的错误
-
最终会进行漫长的调试会话来追踪程序崩溃,因为他们从未首先考虑错误条件。
|
参见
第一章
在上下文中处理错误是许多防御性编程技术之一。
第四章
自文档化的代码确保错误处理是代码叙述的组成部分。
第九章
未处理的错误条件将表现为代码中的错误。这是如何消除它们的。(最好一开始就避免它们。)

激发思考
在第 487 页的"附录 A"部分可以找到对这些问题的详细讨论。
仔细思考
-
返回值和异常是否是等效的错误报告机制?证明它。
-
你能想到哪些不同实现的元组返回类型?不要局限于单一编程语言。使用元组作为返回值的优缺点是什么?
-
不同语言中的异常实现有何不同?
-
信号是一种老式的 Unix 机制。现在我们有像异常这样的现代技术,它们仍然需要吗?
-
错误处理的最佳代码结构是什么?
-
你应该如何处理在错误处理代码中发生的错误?
个性化
-
你当前代码库中的错误处理有多彻底?这如何有助于程序的稳定性?
-
你在编写代码时自然地考虑错误处理,还是觉得它是一种干扰,更愿意稍后再回来处理?
-
去查看你最近(合理大小)编写的或参与工作的函数,并对代码进行仔细审查。找出每一个异常情况及潜在的错误情况。这些中有多少在你的代码中得到了处理?
现在让其他人来审查它。不要害羞!他们发现了更多吗?为什么?这对你正在工作的代码有什么启示?
-
你觉得使用返回值还是异常来管理错误条件更容易,更合理吗?你确定你知道编写异常安全代码涉及哪些内容吗?
第二部分. 代码的秘密生活
这一节探讨了开发代码的艺术和工艺——编程生活的日常活动。尽管这些话题并不是严格保密的秘密,但你很少听到专家的讨论或看到很多关于它们的写作。即便如此,掌握每一项实践对于编写好的程序至关重要;代码工匠对这些主题有全面的理解。
我们将探讨:
第七章
对我们行业工具的调查以及如何使用它们。
第八章
任何代码在未经证明适合其用途;未经测试之前都是不完整的。在这里,我们探讨如何进行测试的技术。
第九章
应对不可避免的事情:如何在你的代码中找到并移除错误。
第十章
"构建"代码:将源代码转换为可执行程序的过程。
第十一章
对代码优化的细节进行探讨。是什么、为什么、何时以及如何。
第十二章
软件安全这个棘手的话题——如何保护你的代码免受恶意滥用和恶意攻击。
这些是代码构建的基本方面。在软件工厂的压力和时间限制下,这些不仅仅是基本技能——它们是生存策略。随着经验的积累,它们变得习以为常,这样你就可以把宝贵的时间集中在更紧迫的问题上:你下一个系统的架构、客户不断变化的需求,以及谁会为你拿下一杯浓缩咖啡。
第七章. 程序员的工具箱
使用工具构建软件
对于我们所有人来说,那些超越我们自身能力的艺术手段是危险的。
--J.R.R. 托尔金
要成为一个高效的工匠,你需要一套好的工具。水管工工具箱的内容将支持他在遇到任何任务时,否则你下次水龙头爆裂时就不会再找他。
这些工具的存在和质量同样至关重要;一个优秀的工匠可能会因为工具的糟糕而受挫。如果压缩阀坏了,无论你的水管工多么出色,到处都会漏水。
当然,是你对这些工具的使用让你区别于其他工匠。工具本身并不能完成任何事情。在动力工具出现之前,木匠完全能够制作出精美的家具。工具更为基础,但他们对工具的熟练使用产生了美丽的东西。
同样适用于编程。要做好工作,你需要一套合适的工具支持;你对其有信心、知道如何使用,并且适合你将要遇到的工作。这需要一位技艺高超的工匠、好工具,以及对这些工具的精通,才能编写出优秀的代码。
这是严肃的事情。你如何使用你的工具可以让你成为一个真正高效的程序员。在极端情况下,这些工具可能提供决定项目成功或失败的捷径。软件工厂的持续节奏意味着你应该紧紧抓住任何能帮助你更快、更可靠地产生更好代码的东西。
其他章节涵盖了与特定工具相关的问题。在这里,我们将探讨软件工具的整体问题。编程是一个无法没有工具的学科。从日常到日常,我们使用工具而不多加思考,就像你理所当然地使用罐头开启器一样——只要它工作就好,但一旦它出问题(或者你需要打开一个形状奇特的罐头)你就陷入了困境,不管罐头开启器多么花哨。
什么是软件工具?
我们使用各种工具来构建软件;它们是构建程序的程序——如果这不算太哲学的话。我们用来创建软件的每一件事都是以某种形式存在的工具。有些工具帮助你编写代码。有些帮助你编写好的代码。有些帮助你整理你刚刚创建的代码混乱。
它们形状各异,大小不同,工作方式也不同。显然,它们所在的平台和环境是一个因素,但它们在以下方面也存在差异:
复杂性
一些工具是功能丰富且可配置性极强的高级环境。有些则是针对单一任务的微小实用程序。每种方法都有其优缺点:
-
当你终于学会如何让它同时煮咖啡并给你带来甜甜圈时,一个功能丰富的工具很酷。但如果许多神奇的功能使其难以使用,那么它就变得不那么有帮助了。
-
简单的工具更容易学习;它们做什么很明显。你最终会拥有很多这样的工具,每个任务一个。但如果你将它们串联起来,会有很多界面点,因此它们并不总是无缝协作。
不同的工具有不同的范围,从非常具体的任务(搜索文件中的文本字符串)到整个项目(协作项目管理环境)。
使用频率
有些工具被不断使用;我们无法没有它们生活。而有些工具则很少被使用,但当你需要它们时却非常宝贵。
界面
一些工具拥有相当漂亮的图形用户界面(GUI)。有些则更为基础,由命令行界面(CLI)驱动,并将输出定向到文件。你更喜欢哪一种取决于你的大脑是如何连接的以及你习惯了什么。
Windows 实用程序通常具有图形界面,没有命令行访问。标准的 Unix 实用程序则相反,这使得它们更容易通过脚本自动化并集成到更大的工具中。界面改变了你利用工具力量的方式。
集成
一些工具可以集成到更大的工具链中,通常被包含在图形化的集成开发环境(IDE)中。独立的命令行实用程序倾向于生成适合作为其他工具输入的纯文本输出,主要作为数据过滤器。
单一的 GUI 界面可以非常舒适地使用,并且集成可以使你极其高效。另一方面,它们需要花费时间来设置,就像你希望的那样,而且它们很少提供比手动命令行工具更全面的功能。但尽管它们非常强大,离散的 Unix 工具都有不同的难以理解的界面,这使得它们难以使用。
成本
有许多优秀的免费工具.^([1]) 然而,你得到的往往是你所付出的。免费工具通常具有较差的文档、较少的支持或较小的功能集。但这并不总是如此。一些免费工具远优于它们的商业版本。
你可以为任何类型的工具支付你想要的任何费用,但更高的价格标签并不保证更好的产品。我曾使用过一些极其昂贵的工具,它们的表现极其糟糕。这引出了……
质量
有些工具非常好。有些工具非常糟糕。我有几个关键的工具,我愿意永远不再看到它们;它们能完成工作,但只是勉强,并且永远处于崩溃的边缘。但如果没有它们,我就无法编写我需要付费的代码。我有多经常被诱惑自己重写它们?我可以继续做梦。
你将根据这些特性选择工具,做出适当的妥协。虽然习惯于你常用的工具集、学习它并使用它提高生产力很重要,但避免对其产生宗教般的执着。大多数 Windows 用户讨厌 Unix 风格的开发,而 Unix 黑客看不起 Windows 程序员,因为他们无法处理命令行。克服它。
我挑战你尝试在一个合理的大型项目中在一个不同的环境中工作。这将帮助你完全理解什么是好的工具链,并帮助你获得真正的“世界观”软件工具。
^([1]) 在软件世界中,“免费”有两个含义:免费就像啤酒(这个工具获取不会花费你任何费用)和免费就像言论(开源软件,你可以查看和修改其代码)。哪个“免费”更重要取决于你有多理想主义。参见第 361 页的“LICENSES”。
为何要担心工具?
没有核心软件工具集就无法创建程序;你会被困在没有编辑器或编译器的情况下。还有一些工具你可以没有,但仍然非常有用。为了提高你的生产力、代码质量和工艺,关注你目前正在使用的工具,并了解它们真正能做什么是很好的。
当你了解你的工具如何工作以及为哪种工作使用哪种工具时,你就能更好地产生正确工作的代码——并且更快地完成它。更聪明的工具使用会使你成为更聪明的程序员。
关键概念
深入了解你常用的工具。花一点时间掌握它们,很快就能得到回报。
让我们明确我们为什么实际上使用工具:工具不是为我们做工作——它们使我们能够做工作。软件的质量始终由其程序员的技能决定。下次当你编译器吐出错误信息时,提醒自己这一点。是你写了代码,笨蛋!
程序员在选择和使用工具方面有着截然不同的态度。这背后可能有一些深层的心理原因——可能与你是否是邪恶天才有关。在遇到一个新的大任务时:
-
一些程序员费力地手动完成它。
-
其他程序员编写一个脚本语言工具来自动完成工作。
-
其他人在寻找一个预先编写的工具来做这项工作,花费数小时。
给定一个可能解决问题的工具:
-
一些程序员不断尝试,直到得到接近他们想要的结果。
-
其他程序员仔细阅读文档,找出确切可以做什么,然后才开始使用它。
哪种方法才是正确的?嗯,这取决于。成为一名成熟程序员的组成部分是理解不同情况需要不同的解决方案,并针对不同的工作应用正确的工具。每个人都是不同的,每个人工作方式也不同——你的同事可能使用你最喜欢的工具之外的工具来提高生产力。但如果你看到有人每天都在手动将 C 代码转换为汇编代码,你会质疑他的理智。
实际上投资你的时间和金钱在工具上。考虑你将如何使用一个工具。只有在这样做所需的时间会得到回报时,才去寻找或编写一个新工具。不要花一周时间编写一个每月只能为你节省一小时的工具。但确实花一周时间编写一个每天能为你节省一小时的工具。
关键概念
采取务实的软件工具方法——只有在它们能让你生活更轻松时才使用它们。
功率工具
由于编程和工具密不可分,为了成为一名超级程序员,你需要成为一名超级工具用户。这意味着什么?
首先,了解周围有哪些工具是很重要的。在下一节中,我们将列出每个程序员都应该随身携带的常见工具列表。你不需要了解市场上每个工具;毕竟,这会让晚宴上的谈话变得极其乏味。仅仅了解存在的工具的一般类别,而不是具体产品,是向前迈出的重要一步。这将帮助你选择是找到一个特定任务的工具,自己编写工具,还是手动完成这项任务。
抽出时间来获取信息。查看你可以在哪里获得这些工具——有一些商店专门销售软件工具,互联网上也有大量的下载网站。也许你已经有了一些安装好的工具,但从未使用过,或者你没有意识到它们有多有用。了解工具能为你做什么;这将帮助你更好地使用工具。
关键概念
了解可用的工具种类。确保你知道在哪里可以获取它们,即使你现在不需要它们。
准备尝试新的工具,并花时间学习它;这是一种健康的态度。如果你开始一个新的项目,迁移到新的平台,遇到新的问题,或者发现你的旧工具已经过时,你可能被迫寻找新的工具。但不要等到被推动——确保现在,你正在使用你能得到的最好的工具。
投入一部分时间来磨练你的工具技能——就像你花时间阅读技术书籍或杂志,或者参加专业培训课程一样。这些东西很重要,所以相应地投资于它们。
这里有一些简单的步骤,帮助你成为工具高手。对于你软件构建武器库中的每一件武器 . . .
理解它能做什么
了解功能集——它 真正 能做什么,而不是你认为它 应该 能做什么。即使你不知道如何榨取每一滴好处(可能你需要查找更神秘的命令行参数),了解它的能力将是有帮助的。
工具 不能 做的事情有哪些?也许它不支持其竞争对手提供的某些功能。了解这些限制,这样你就知道何时寻找更好的替代品。
学习如何驾驭它
即使你在运行工具时没有产生错误,并不意味着它已经 完全 做了你想要它做的事情。你必须知道如何正确使用它,并自信地相信你可以让它按照你的意愿行事。
这个工具如何融入整个工具链?这将影响你如何使用它。例如,Unix 工具可以通过 管道 连接在一起,作为顺序过滤器——将小型单个工具拼接成更大的实用工具.^([2]) 了解如何利用每个工具的力量,以及了解它们如何相互协作,可以提高你的工具使用水平。
确定使用每个工具的最佳方式——这不一定是通过直接调用它或在 GUI 界面中点击某个地方。它可以自动触发吗?编译器通常是通过构建系统调用的,而不是手动调用。
了解它适合哪些任务
了解每个工具如何在其他可用工具的上下文中发挥作用。例如,我可以在我的文本编辑器中设置按键记录宏,这样我就可以在重复操作上节省时间。其中一些更改也可以使用魔法 sed 调用完成。^([3)] 然而,在这种情况下使用按键宏会更好——我已经在使用编辑器了,所以触发它们会更快。
你可能不知道如何使用 yacc,^([4)) 但如果你需要编写解析器,知道它的存在会为你节省大量的精力。
关键概念
使用正确的工具来完成正确的任务。不要用大锤砸核桃。
检查它是否正常工作
每个人在某个时候都会成为糟糕工具的受害者。你的代码可能无法工作,但无论你如何搜索错误行为,都没有解释。在绝望中,你会测试随机的事情——检查风向是否正确,灯具是否固定正确。几个小时后,你会发现一个不稳定的工具正在做奇怪的事情。
编译器可能会生成错误的代码。构建系统可能会出错。库中可能存在错误。在你拔掉太多自己的头发之前,学习如何在明显失败之前进行检查。
能够访问你工具的源代码对于诊断你遇到的问题非常有帮助,这允许你确切地了解工具正在做什么。这可能是你选择工具集的决定性因素。
明确的途径来获取更多信息
你不必知道所有的事情。诀窍是知道有人知道!
查找工具的文档在哪里。谁提供支持?如何获取更多信息?寻找手册、发布说明、在线资源、内部帮助文件和手册页。了解它们的位置以及如何按需访问它们。在线版本是否有有用的搜索工具和良好的索引?
了解新版本何时出现
工具似乎以惊人的速度发展——在这个行业中,技术变化很快。有些工具的发展速度比其他工具快得多。你刚刚安装了最新的 widgetizer,作者就发布了带有更长红色条纹的新版本。
了解你使用的工具非常重要,这样你就不会过时,最终得到一个可能存在错误且不受支持的工具套件。但这样做应该谨慎;不要盲目追求最新版本。前沿技术可能会带来痛苦!
新版本可能会有新的错误和更高的价格。如果它们提供了显著的修复并且已被证明是稳定的,就采用升级。先测试一下——在旧代码上对新的工具进行合理性检查,以确保它表现良好。
关键概念
跟上你工具的最新发展,但不要随意升级。
^(2)) 如果你对这个不太了解,我强烈建议你阅读相关内容。Unix 命令man bash是一个很好的起点;在手册页中搜索pipelines。
^([3]) sed 是一个流编辑命令行实用程序,将在下一节中解释。
^([4]) 解析器生成器。不用担心——稍后也会解释。
哪些工具?
软件开发工具种类繁多。多年来,它们被开发出来以满足特定的需求,这些需求经常出现。当一项任务被多次执行时,你可以确信有人已经为它编写了一个工具。
你的工具包具体包含什么将取决于你的工作领域。嵌入式平台可用的工具通常不如桌面应用程序丰富。以下我们将考虑常见组件。有些非常明显;有些则不那么明显。
虽然我们将分别查看每种工具类别,但不要忘记现代 IDE 将这些不同的程序集成到一个单一、流畅的界面中。这无疑很方便,但了解每个工具独立存在的意义很重要,原因如下:
-
你将知道如何充分利用每个可用的功能。
-
你将知道你的集成开发环境(IDE)缺少哪些有用的功能。
大多数 IDE 都是模块化的——你可以用更好的替代品替换一个组件,并插入那些出厂时不可用的功能。了解周围有哪些工具种类,你将提高你的 IDE 体验。
源代码编辑工具
陶艺家的媒介是粘土;雕塑家的,是石头;程序员的,是代码。这是我们工作的基本东西,因此选择优秀的工具来帮助我们编写、编辑和调查源代码非常重要。
源代码编辑器
编辑器可能是你最重要的工具,甚至比编译器更重要。编译器面对的是计算机,而编辑器面对的是你。而你才是驾驶员。这就是你将在编程生活中花费大部分时间的地方,所以选择一个好的编辑器,并学会如何真正地使用它。使用文本编辑器提高生产力将极大地改善你的编码方式。
关键概念
你选择的代码编辑器至关重要:它对你的编码方式有着巨大的影响。
“真正的源代码编辑器”是一个古老的争论,这里不需要再引起波澜,但你应该选择一个你感到舒适并且能满足你需求的编辑器。仅仅因为编辑器嵌入在你的可视化 IDE 中,并不意味着它就是最适合你的编辑器。另一方面,你可能会发现将其集成是一个巨大的好处。对于源代码编辑,我要求我的编辑器至少具备以下功能:
-
综合语法高亮显示(支持许多语言——因为我使用许多语言)
-
简单的语法检查(例如,突出显示不匹配的大括号)
-
良好的增量搜索功能(一种在输入时搜索的交互式查找形式)
-
键盘宏录制
-
高度可配置
-
在我使用的每个平台上都能工作
我的要求和编辑器的选择可能与你不同,但这似乎是一个相当合理的最重要的功能列表。我不介意花点时间学习如何充分利用所有这些功能。如果它能让我更有效率,那就值得了。
根据你正在进行的类型的工作,你可能会发现其他类型的编辑器很有用。有二进制文件编辑器(通常以十六进制显示文件内容;它们通常被称为十六进制编辑器)和针对特定文件格式的编辑器,例如 XML 文件编辑器。
Vim 和 Emacs 是臭名昭著的 Unix 编辑器,现在几乎可以在任何平台上找到(可能甚至包括你的电烤箱)。这些与 IDE 捆绑的默认编辑器形成对比。
源代码操作工具
Unix 哲学的特点是拥有大量的小型命令行工具。每个工具都有对应的 GUI 环境,但它们通常没有这么强大或容易组合。GUI 版本的学习要简单得多。
以下 Unix 命令提供了强大的机制来调查和修改源代码:
diff
比较两个文件并突出显示它们之间的差异。基本的 diff 将输出到控制台,但更复杂的图形版本也存在。甚至还有编辑器允许你在比较的文件上工作,将它们并排显示,并随着你输入更新差异。奇特的 diff 可以一次性比较三个文件。
sed
代表流编辑器。Sed 逐行读取文件,应用指定的转换规则。Sed 可以用作重新排序项目、全局搜索和替换工具,或者将模式插入到行中。
awk
想象一下加强版的 sed。Awk 是另一个可以处理文本文件的匹配程序。它为此任务实现了一个完整的编程语言,因此你可以编写相当高级的 awk 脚本来执行复杂的操作。
grep
在文件中搜索字符模式。这些模式由正则表达式描述,这是一种允许通配符字符和灵活匹配标准的迷你语言。
find/locate
这些工具有助于在文件系统中查找文件。它们可以通过名称、日期或其他许多标准来追踪它们。
这些只是冰山一角,还有许多其他工具。例如,wc执行单词/字符计数。对于更多宝石,请查看sort、paste、join和cut。
源代码导航工具
真正的大型项目拥有像城市一样的代码库。甚至城镇规划者也不太了解每一条后街。少数出租车司机知道最佳路线。普通市民对自己的社区相当熟悉。游客一离开公交车就会迷路。
有一种工具可以帮助你深入理解和映射代码,进行简单的搜索、导航和交叉引用。一些工具会生成调用图树,这样你可以看到控制流在系统中的流动。它们可能会生成图形映射或与你的编辑器集成,提供自动完成、函数调用帮助等。这对于大型代码库或进入一个已经建立的项目非常有价值。
一些免费可用的工具的好例子是 LXR、Doxygen 和备受尊敬的 ctags。
版本控制
在这里我们不会过多地讨论源代码控制工具,因为我们在第 351 页的"源代码控制"中已经涉及了它们。简单来说:你必须使用一个,否则可能会被迫强行截肢。
源代码生成
许多工具会自动生成源代码。有些很好;有些让我感到害怕。
一个例子是 yacc,一个 LALR(1)^([5])解析器生成器。你定义输入语法规则,然后使用它来生成可以解析符合这些规则的正确输入的程序。它会生成一个 C 代码解析器,并提供钩子供你在解析项目时添加功能。Bison 是一个类似工具。
有一种代码生成工具类别可以帮助你设计用户界面,生成工作后端代码。这些工具特别用于像 MFC 这样的复杂 GUI 工具包。如果一个库需要工具来做这么多基础工作,那么这表明该库本身可能过于复杂(或从根本上来说是损坏的)。小心行事!
编写大量脚手架代码的向导应该也受到谨慎对待。在开始修改之前,你必须真诚地理解生成的代码,否则你可能会被自己的无知所咬。如果你在修改任何生成的代码后重新运行向导,所有的手动编辑都将被无声地覆盖。哎呀。
你甚至可以编写自己的脚本来生成重复的代码段。有时这表明你的代码可能设计得更好。有时它确实是正确的技术方法。在过去,我编写了 Perl 脚本来自动生成代码。编写了生成器后,我信任它生成的代码。另一位程序员可能会怀疑地看着它,就像任何其他代码向导一样。
源代码美化器
这些工具使源代码格式统一,创建了一个统一的最低公倍数布局。我真诚地认为它们带来的麻烦比它们的价值大——它们可以破坏与它们修复的一样多的重要和有用的格式。
代码构建工具
我们不想整天盯着漂亮的源代码。有趣的部分是让它做些事情。我们做这件事太频繁了,以至于我们理所当然地认为这些工具都会正常工作,而没有去思考幕后的情况。
编译器
除了源代码编辑器之外,这是最常用的软件工具。编译器将你的源代码转换为可执行文件,这样你就可以惊叹于你的程序失败的方式。由于这个工具经常被使用,因此确保你能正确地使用它很重要。你真的了解它所有的选项和功能吗?许多公司都有一个专门的buildmaster,确保构建工具被正确使用,但这并不是不了解你的编译器的借口。
-
你是否了解应该采用哪种级别的优化以及这可能会如何影响生成的代码?这很重要——在其他事情中,它将决定代码在调试器中的运行方式有多么令人惊讶,甚至可能激活哪些编译器错误!
-
你是否在所有警告都开启的情况下编译?真的没有借口不这样做(也许只有当你维护的是已经充满警告的遗留代码时)。警告突出显示潜在的错误,它们的缺失让你对代码有额外的信心。
-
编译器默认是符合标准的吗?C++ ISO 标准是(ISO 98),1999 年的 C 标准是(ISO 99),Java 语言由(Gosling et al. 00)定义,C#由 ISO 标准定义。(ISO 05)。编译器是否有任何非标准扩展;如果是,你知道它们是什么以及如何避免它们?
-
它是否为正确的 CPU 指令集生成代码?当你只会在最新的英特尔芯片上运行时,你可能会在生成 386 兼容的代码。让你的编译器输出尽可能合适的代码。
我需要工具……
你需要执行一项任务。这是一项枯燥的任务。它是重复的。这是那种计算机应该做得更好的事情;它将减少错误,减少乏味,并且更快。这正是计算机被发明出来的原因!你如何找出是否有东西可以为你完成这项工作?
-
如果它在这个列表中提到,你已经知道有工具可用。
-
如果它不在列表中,但你确信你不是第一个遇到这种问题的人,那么可能有一个工具“某处”可以帮到你。你可能会对快速网络搜索带来的随机程序感到惊讶。
-
如果你的问题看起来很独特,你可能不得不为它编写自己的程序。参见第 126 页的“自己动手做”了解更多信息。
当寻找一个工具时,尽可能多地获取建议:
-
向团队中的其他人询问他们是否有任何经验。
-
在网上搜索,并阅读适当的新组。
-
去工具供应商那里。
在可用的工具选择中,你需要根据我们在第一部分看到的准则做出明智的选择。为了做出这个决定,你必须确立你的需求。工具是否免费重要吗?或者,现在能获取它更重要吗?它是否应该对团队中的每个人来说都易于使用?你将多久使用一次——它是否足以证明其费用合理?
交叉编译器针对的是与开发机器不同的平台。这主要用于编写嵌入式软件(毕竟,在洗碗机上运行 Visual C++是很困难的)。
编译器是更大工具链的一部分,包括链接器、汇编器、调试器、性能分析器和其他对象文件操作工具。
一些流行的编译器包括 gcc、微软的 Visual C++和博兰德的 C++ builder。
链接器
链接器与编译器紧密相关。它将编译器输出的所有中间对象文件粘合在一起,形成一个单一的、可执行的代码块。C 和 C++链接器与编译器结合得如此紧密,有时同一个可执行文件会执行这两个任务。对于 Java 和 C#,链接器与运行时环境相关联。
当使用你的链接器时,确保你知道:
-
它是否剥离了二进制文件?也就是说,它是否移除了调试符号,如变量和函数的名称?这些可以被调试器用来显示有用的诊断信息,但它们也可能显著增加可执行文件的大小,并使它们加载变慢。
-
它是否消除了重复的代码部分?
-
你能让它输出库对象而不是可执行文件吗?你对库有什么控制权——你能让它静态地或动态地加载吗?
构建环境
整个构建环境不仅仅是编译器和链接器。我们使用的构建工具是 Unix 的 make 程序或 IDE 的构建部分。它们自动化了编译过程。许多开源 Unix 项目使用 autoconf 和 automake 工具来简化构建。
学习如何充分利用你的集成构建环境,但不要以牺牲了解如何使用每个单独的构建工具为代价。我们将在第十章(第十章:杰克建造的代码"中更详细地探讨它们。
调试器
拥有一个高质量的调试器并理解如何使用它可以在追踪令人惊讶的行为上为你节省数小时的开发时间。它允许你调查程序中的执行路径,中断程序,调查变量值,设置断点,并通常剖析运行中的代码。这比在程序中散布printf日志语句要复杂一个数量级!
gdb是 GNU 的开源调试器;它已被移植到几乎每个可想象的平台。ddd是它的一个成熟的图形界面。每个 IDE 和工具链都有自己的调试器。
性能分析器
当你的代码运行速度不令人满意时,使用此工具。性能分析器测量运行代码的各个部分并确定瓶颈。它用于找到合理优化的目标;有了它的结果,你不会浪费精力加速很少执行的代码。
代码验证器
代码验证器分为两种类型:静态和动态。前者以类似编译器的方式处理代码,检查你的源文件以识别可能的问题区域和语言使用上的缺陷。lint是一个著名的例子;它在 C 中执行一系列常见编码错误的静态检查。其大部分功能已集成到现代编译器中,但仍有一些独立的工具可用于额外的检查。
动态验证器在代码编译时对其进行修改和测量,然后在运行时执行检查。内存分配/边界检查器是一个很好的例子——它们确保所有动态分配的内存都得到适当的释放,并且数组访问不会超出范围。⁶ 这些工具可以节省寻找难以捉摸的错误所需的大量时间。在大多数情况下,它们比调试器更有用,因为它们像预防机制而不是治疗手段:它们会在程序崩溃之前找到错误。
性能工具
这些工具执行代码检查,通常是静态分析器的一种形式(尽管也存在动态度量工具)。它们对你的代码质量进行统计分析。虽然统计数据很容易误导,但这些工具可以有效地突出最脆弱的区域。这些信息可以帮助你选择代码审查的具体目标。
指标通常按函数基础收集。最基本的指标是代码行数,其次是注释与代码的比例。这两者都不能真正告诉你什么特别有用的信息,但有许多更有趣的指标。循环复杂度是考虑决策点和潜在控制流数量的代码复杂度的度量。高循环复杂度意味着难以理解的代码,这更可能是不稳定且容易隐藏错误的。
反汇编器
这可以深入到可执行文件中,让您检查机器代码。调试器确实包含这种支持,但高级反汇编器可以尝试在没有符号存在的地方重建代码,生成对二进制程序文件的高级语言重新解释。
故障追踪
一个好的故障追踪系统提供了一个共享数据库,用于跟踪系统中发现的错误。它允许同事报告故障、查询、分配或评论它们,并最终将故障标记为已修复。这是确保产品质量的必要工具——您需要系统地管理故障,否则它们会从您手中溜走,您会发布一个有缺陷的产品。在回顾项目历史时捕获和存储这些信息也是有用的。
语言支持工具
要用高级语言编写,您需要大量的支持。语言实现提供了使编码成为可能所需的一切,使其比在机器代码的沼泽中挣扎更容易。
语言
语言本身*就是工具。一些语言提供了其他语言中不存在的功能。这些差距可以通过运行在程序源上的单独工具来填补。例如,C 语言备受诟病的预处理器可以非常有用,其他语言也有文本处理包。通用的代码功能(如 C++的模板)和前/后条件检查是其他类似有用的语言工具。
拥有一系列语言技能是非常有价值的。了解它们之间的差异,它们适合的任务以及它们的弱点。然后您可以选择任何给定任务的最佳语言。
关键概念
学习几种语言;每种语言都会教会你不同的解决问题的方法。把它们当作工具,并为每个任务选择最合适的语言。
运行时和解释器
大多数语言没有必要的运行时支持是无法使用的。解释型语言依赖于它们的解释器(或虚拟机),但直接编译的语言仍然依赖于它们的支持库。这些库通常与语言本身紧密相连,因此两者无法分离。
就像你可以选择不同的编译器一样,你可能能够选择具有不同特性的不同语言运行时。
Java 的 JVM(Java 虚拟机)是一种常见的语言解释器。C++标准库支持该语言,为一些核心语言特性提供默认处理程序。同样,C#语言依赖于.NET 环境的运行时支持。
组件和库
是的,这些也是工具!重用软件组件和寻找执行所需功能的库可以避免重新发明轮子。一个好的库可以提高生产力,就像任何其他软件工具一样。
这些库的范围各不相同——有些是整个操作系统的庞大抽象层,而有些则只做非常简单的工作,提供一种谦逊的日期类。它们负责处理细节,隐藏复杂性,这样你就不必担心它。你不必花时间编写、测试和调试自己的版本。
当今的所有语言都提供了一定程度的库支持。C++ STL 是一个功能强大的可扩展库的绝佳例子。Java 语言和.NET 环境提供了比你能挥动的棍子还要多的标准库。许多第三方库存在,既有商业的也有免费的。
杂项工具
故事还没有结束。你还会遇到更多工具。"见 Also"(ch06s08.html "See Also")在第 127 页指出,我们将在其他地方讨论软件工具。
以下是一些其他有趣的工具种类。
文档工具
良好的文档是无价的;它是精心设计的代码的关键部分。各种工具可以帮助你编写它,无论是在源代码本身还是在单独的文档中(我在第 66 页的"实用自我文档方法"中描述了一些工具)。永远不要低估一个好的文字处理器的重要性。
文档不仅需要阅读,还需要编写。良好的在线帮助系统(辅以高质量的书籍)至关重要。
ROLLING YOUR OWN
当你找不到适合工作的工具,而且手动完成将花费很长时间时,自己编写工具("ROLLING YOUR OWN")并没有什么不妥。实际上,如果这项任务会反复出现,短期的工具开发可能会在长期内为你节省数小时。
有些任务天生比其他任务更适合使用工具。确保你尝试的是现实可行的,并检查这种努力是否是成本效益的投资。
这些是创建工具的常见方法:
-
以新的方式组合现有工具,通常使用 Unix 管道机制,也许需要写一点连接胶水。你可以将复杂的命令行咒语放入shell 脚本(或在 Windows 环境中是批处理文件),这样你就不必每次都输入它们。
-
使用脚本语言。大多数小型自建工具都是用某种形式的脚本语言编写的,通常是 Perl。它们使用起来既快又简单,同时足够强大,可以提供编写工具所需的支撑。
-
从头开始创建一个完整的程序。你只有在它是一个你将反复使用的严肃工具时才真正想这么做。否则,这种努力可能是不合理的。
在编写工具时考虑:
-
观众——工具需要多么精致?一些粗糙的边缘是否可以接受?如果只有你和另一个技术专家在使用它,你可以应对。如果其他更细腻的人可能有一天需要它,也许你应该优雅地给它加上装饰。
-
你能否扩展现有的工具(将其命令包装起来,或者可能为它创建一个插件)?
项目管理
管理和工作协作工具允许你根据时间表报告和跟踪工作,管理故障,并监控团队绩效。根据管理工具的范围,谦逊的程序员可能不需要接近它。但更异类的系统可能成为项目活动的中心枢纽,吸引所有用户。
^([5]) 一种晦涩的技术专家(和无聊)的说法,指的是相对复杂的语法。
^([6]) 更具社会责任感的语言,如 Java,在语言设计中避免了这类问题。
简而言之
给我们工具,我们将完成这项工作。
--温斯顿·丘吉尔爵士
工具使软件开发成为可能。好的工具使它变得容易得多。
一定要评估你使用的工具集。你真的知道如何正确使用它们吗?有没有你本应拥有的缺失工具?你是否从你所拥有的工具中获得了最大价值?
工具的价值永远取决于其用户。谚语“拙匠常怪工具不利”包含了很多真理。糟糕的程序员会编写糟糕的代码,无论他们使用多少工具。事实上,工具可以帮助产生极其糟糕的代码。培养对工具箱的专业、负责任的态度将使你成为一个更好的程序员。
| 好程序员…… | 次程序员…… |
|---|
|
-
更愿意学习一次如何使用合适的工具,而不是一次又一次地重复乏味的工作
-
理解不同的工具链模型,并对每个都感到舒适
-
使用工具使他们的生活更轻松,但不要成为它们的奴隶
-
将他们使用的每件事都视为一个工具,一个可替换的实用工具
-
他们是高效的,因为使用他们的工具对他们来说就像第二本能一样
|
-
知道如何使用一些工具,并以它们为标准看待每个问题
-
害怕花时间去学习新工具
-
开始使用一个开发环境,现在虔诚地使用它,从未尝试过或调查过替代方案
-
当他们遇到一个有价值的新工具时,不要将其添加到他们的工具箱中
|
参考内容
第十章
软件构建过程是由工具驱动的。想象一下手动编译代码!
第十三章
包含一个讨论特定设计工具的部分。
第十八章
一章专门讨论版本控制工具的使用。

激发思考
关于这些问题的详细讨论可以在第 491 页的"附录 A"部分找到。
深思熟虑
-
对于开发团队中的每个人来说,使用相同的 IDE 是否更重要,还是每个人选择最适合他们的 IDE 更重要?不同的人使用不同的工具有什么影响?
-
任何程序员应该拥有的最小工具集是什么?
-
命令行工具或基于 GUI 的工具哪个更强大?
-
有没有不是程序的构建工具?
-
工具最重要的因素是什么?
-
互操作性
-
灵活性
-
定制化
-
权力
-
易用性和学习
-
个性化
-
你工具箱中的常用工具是什么?你每天都用哪些?你每周用几次?你偶尔才用哪些?
-
你对如何使用它们的了解程度如何?
-
你是否从每个工具中获得了最大收益?
-
你是如何学会使用它们的?你是否花时间提高使用它们的技能?
-
这些是你能使用的最好的工具吗?
-
-
你的工具有多新?如果它们不是最新的尖端版本,这有关系吗?
-
你是否更喜欢集成工具集(如可视化开发环境)还是离散的工具链?另一种方法的优点是什么?你对这两种工作方式有多少经验?
-
你是“默认丹”还是“调整汤姆”?你接受编辑器的默认设置,还是将其调整到极致?哪种方法是“更好”的?
-
你是如何确定软件工具的预算的?你如何知道一个工具是否物有所值?
第八章. 测试时代
测试代码的黑色艺术
测试一切。保留好的。
--帖撒罗尼迦前书 5:21
随意编写尽可能多的代码——你可以确信的一件事是:它第一次肯定不会完美工作。无论你花了多少时间精心设计它;软件错误有一种令人毛骨悚然的能力,能够渗透到任何程序中。你编写的代码越多,引入的错误就越多。你写得越快,引入的错误就越多。我还没有遇到一个真正多产的程序员能够创造出几乎无错误的代码。
我们该如何解决这个问题?我们测试我们的代码。我们这样做是为了找出任何存在的问题,一旦我们修复了它们,我们就使用测试来保持对代码质量的信心,因为我们继续对其进行修改。无论你认为你是一个多么好的程序员,发布未经测试的软件都是自杀。未经测试的软件注定会失败;测试是我们工艺的一个基本部分。太多的软件工厂低估了彻底测试的重要性,或者试图在软件发货前的最后一刻将其压缩进去。这是显而易见的。
测试不是开发过程末尾的一项任务,用于证明你的最终程序是好的。如果你只是试图做这件事,你将产生非常糟糕的代码。测试是一种核心构建技术。只有通过测试,你才能证明每一块代码都能正常工作,这然后告诉你何时完成。否则你怎么能知道呢?为什么这么多软件工厂认为他们可以不进行适当的测试就逃脱呢?
条款和条件
术语 bug 非常具有表现力,但非常不精确。很容易随意使用词汇而不真正理解它们的含义。使用更具体的术语有助于我们定义我们在做什么。这些定义受到了 IEEE 文献(IEEE 84)的启发:
错误
一个 错误 是你做错的事情。它是一种特定的人类行为,导致软件中包含 故障。例如:忘记检查你的代码中的条件(比如在索引 C 数组之前检查数组的大小)是一个错误。
故障
一个 故障 是一个错误的后果,体现在软件中。我犯了一个错误,这导致了代码中的故障。起初,这是一个 潜在 的问题。如果我所写的代码从未被执行,那么这个故障将永远不会有机会引起问题。如果执行经常通过有缺陷的代码,但从未以触发故障的特定方式执行,我们就永远不会注意到有故障。
这个微妙之处使得调试变得极其困难。一条有缺陷的代码行可能看起来在多年内都很正常,然后有一天它会导致你见过的最奇怪的系统崩溃;你不会怀疑这个老代码,因为它已经可靠了这么久。
你可能在代码审查中会发现一个故障,但你不能从运行中的程序中识别出一个故障。
故障
当遇到时,一个故障可能会导致 故障。它可能不会。故障,故障的表现,是我们真正关心的。这可能是我们唯一会注意到的事情。故障是程序操作与其要求、预期行为之间的偏离。这就是我们接近哲学的地方。如果一棵树在森林中倒下,它会发出声音吗?如果运行中的程序没有执行 bug,错误仍然是故障吗?这些定义有助于回答这些问题。
bug
术语 bug 是一种口语,通常用作 故障 的同义词。根据传说,第一个计算机 bug 是一个 实际 的虫子。它是由海军上将 Grace Hopper 在 1947 年于哈佛发现的。一只被困在 Mark II Aiken Relay Calculator 的两个电继电器之间的蛾导致整个机器关闭。
现实检查
两个简单的问题什么是测试?和你为什么要测试?看起来很明显。然而,往往在生产的适当阶段没有进行足够的软件测试,或者没有进行测试。良好的测试是一种技能。实际上进行一些测试比许多程序员所达到的要多;仅仅提到测试就足以让大多数程序员大汗淋漓。“测试最重要的规则是去做。”(Kernighan Pike 99)
测试与调试是两个不同的独立活动,尽管它们的界限模糊,两者经常被混淆在一起。测试是证明软件中存在或不存在错误的方法论过程。调试是追踪这种错误行为原因的行为。测试导致调试,调试导致修复,修复导致更多测试(我们再次测试以证明修复有效)。
关键概念
测试不是调试。不要混淆这两个概念。它们需要不同的技能。确保你知道你在测试和调试的时候。
如果你编程得好,你将进行大量的测试而不是调试。这就是为什么这一章在调试章节之前的原因。
在整个软件开发过程中,各种事物都会被测试:
-
大量的文档将经过一个测试阶段(更常见的是称为审查过程)。这样做可以确保,例如,需求规范正确地模拟了客户的需求,功能规范实现了需求规范,各种子系统规范足够完整以实现功能规范,等等。
-
自然地,实现代码在开发者的机器上被测试。它被测试在几个层面上,从逐行测试每个函数的编写,到测试单个模块,到集成测试,当代码部分被粘合在一起时。
-
最后,最终产品将被测试。虽然这一级别的测试将(或应该)间接测试所有已开发的代码组件,但这不是这些测试的重点。在这里,我们关心的是程序作为一个整体是否按指定的工作。
产品测试可能涉及许多事情。最重要的是,它们检查系统是否按预期工作。它们还检查它是否正确安装(如果它是包装好的 PC 软件)以及它是否可用。
这是 QA 部门执行的那种测试。这个部门的任务是理解产品应该如何工作,并确保它确实如此,同时满足为其设定的任何质量标准。
在本章中,我们将关注中间点——作为软件开发者,我们如何测试我们的代码。其他测试活动是大型且独立的主题,超出了本书的范围。
质量保证
QA:质量保证。听起来很痛苦,不是吗?但谁或什么是它?这个名字既给了一群软件工厂居民,也给了开发实践。为了正确理解 QA,重要的是将俚语和误解与真实定义分开。
人们错误地将 QA 与测试捆绑在一起,但两者有显著区别。测试旨在检测错误行为,即软件与其规范不符;它实际上是检测。真正的 QA 是预防。它确保我们的流程和开发实践将产生高质量的软件。测试只是 QA 的一部分——软件质量不仅包括低错误率。这意味着软件按时、按预算交付,并满足所有要求和期望(这两个不一定相同)。遗憾的是,今天软件工厂中仍然没有很多高质量的软件。
谁负责软件质量?一个组织的测试部门(通常称为 QA 部门)是一群致力于产品测试的人。他们有权决定你的程序是否足够好以发布。这是质量拼图中的重要一块,但不是全部。开发过程中的每个人都参与生产高质量的软件——这不是代码完成后可以附加的东西。
监控软件质量的职责通常落在执行产品测试的同一组人身上。否则,整体质量保证(QA)是项目经理的责任,而测试人员则负责进行测试。
^([1]) 因为,显然,正确的行为已经事先仔细指定了,对吧?
谁,什么,何时,为什么?
为了使我们的软件测试有效,我们需要了解为什么我们要测试,谁来进行测试,测试内容是什么,以及何时进行测试。
为什么进行测试
作为软件开发人员,我们的测试程序存在几个原因:帮助我们找到错误并修复它们,并确保相同的错误不会在后续版本中再次出现。
注意,测试永远不会揭示错误的缺失,只会揭示其存在。如果你的测试没有发现任何错误,这并不意味着它们不存在;只是意味着你还没有找到它们。
关键概念
测试只能发现错误的存在。它不能证明错误的不存在。不要被通过一系列不充分的测试的代码所误导,从而产生错误的安全感。
开发周期末的软件测试可能还有另一个动机。除了验证软件组件是正确的且不包含错误之外,你可能还需要验证它——确保它满足最初设定的要求——以证明它足够好以发布。验证是验收测试的一种形式。
谁进行测试
编写源代码的程序员有责任测试他或她编写的代码。每天早上对着镜子将这句话倒过来文身,并凝视 10 分钟。
太多的开发者,由于软件工厂的考验而感到失望,草率地编写代码并发布给 QA 部门,而没有自己进行测试。这是不负责任和不专业的。从长远来看,这会比你正确测试花费更多的时间和精力。在产品中发布未经测试的代码是愚蠢的,向 QA 部门提供未经测试的代码几乎同样糟糕。它的工作就是测试,但测试的是产品,而不是你新编写的代码。它可能会发现你留下的愚蠢的编码错误,可能以隐晦和看似无关的方式表现出来;但它的任务是寻找更基本的错误,这些错误在之前无法被发现,而不是清理马虎程序员的遗留问题。
关键概念
你必须测试你编写的每一行代码。不要期望其他人会为你做这件事。
测试涉及的内容
在编写软件时,我们创建单个函数、数据结构和类,并将它们粘合在一起形成一个工作系统。我们主要的测试策略是通过编写更多代码来测试所有这些代码,并通过编写测试代码来验证其行为。这形成了一个围绕测试对象的框架,对其进行刺激、戳击和驱动,以激发其响应并检查其响应是否正确。
我们为系统的每个级别编写测试代码,测试每个重要的类和函数,直至由这些较小部分组成的超结构。对于每个测试,你必须清楚以下内容:
-
你正在测试的确切代码片段。清晰的模块和定义良好的边界有助于此;接口是你的测试点。模糊或复杂的接口会使测试变得模糊和复杂。
-
你正在使用的测试方法(参见第 138 页的"测试类型]) 因此,尽早开始测试代码至关重要——在(或可能在)严肃的软件开发期间。敏捷程序员普及的测试驱动开发方法主张将测试作为核心构建技术;你在编写被测试的代码之前编写测试代码!
关键概念
有效的代码测试应尽早开始,这样你就可以在错误最无害时捕捉到它们。你可以在编写代码之前编写测试!*
这是一个关键点,并且将这一点吸收到你的编程习惯中至关重要。对于你写的每一行代码,立即编写一个测试。或者先编写测试。证明你的代码是可行的,这样你就知道可以安全地继续前进。如果你在这个时候不编写测试,你将留下未经证明、可能存在错误的代码。这会破坏你的代码库的稳定性:当你遇到错误时,你不知道是哪一段代码(自从你上次编写测试以来积累的大量代码)导致了问题。所以你最终会陷入调试器,这是巨大的时间浪费。
事后编写测试意味着你将从一个距离测试——要么太晚,当你已经忘记了代码应该做什么,要么作为测试一个单独代码模块的结果。这不会是一个有效的测试。你也更有可能忘记编写测试。
这种测试策略有深远的影响:当你开始思考编写一些代码时,你必须同时考虑测试它。这将塑造你设计代码的方式,使其变得更好;我们将在第 144 页的"为测试而设计"中看到原因。
每当你发现一个成功绕过现有测试的错误时,你必须在你测试套件中添加一个新的测试(在责备自己最初没有注意到它之后)。新的测试将有助于证明你的错误修复是正确的。它还将捕捉到任何后来再次出现的相同错误;错误可能会意外地复活——这通常发生在你的代码后来被修改时。
关键概念
为每个发现的错误编写一个测试。
因此,我们尽早编写测试,但我们多久运行一次?尽可能频繁地运行,如果不是更频繁的话(使用计算机支持)。我们运行测试的次数越多,我们检测到问题的可能性就越大。这体现在一种持续集成策略中(参见第 190 页的"自动构建"),并开始展示为什么程序性测试(易于重复运行)如此强大。
关键概念
尽可能频繁地运行你的测试。
^([2]) 更多关于错误成本的信息,请参阅第 157 页的 "失败的经济学"。
测试并不难……
除非你做得不好,否则这真的很困难。但这确实需要深思熟虑的努力。为了测试特定代码片段是否工作,你需要一个测试框架来演示:
-
对于所有有效的输入,都生成了正确的输出。
-
对于所有无效输入,都生成了适当的失败行为。
这听起来可能并不严重,但对于除了最简单的函数之外的所有函数,全面进行这种测试实际上是不切实际的。有效的输入集通常非常大,不可能单独测试每个输入。您必须选择一组较小的代表性输入值。无效输入集几乎总是比有效输入集大得多,因此您必须选择一些代表性的错误值。
为了说明这一点,这里有两个例子。第一个函数很容易测试:
bool logical_not(bool b)
{
if (b)
return false;
else
return true;
}
有效输入集的大小为两个,没有无效输入。这意味着函数的测试框架很简单。它可能看起来像这样:
void test_logical_not()
{
assert(logical_not(true) == false);
assert(logical_not(false) == true);
}
函数并没有做什么特别令人兴奋的事情。现在考虑以下函数(让我们暂时不要评论它的优雅)。测试它有多难?
int greatest_common_divisor(int a, int b)
{
int low = min(a, b);
int high = max(a, b);
int gcd = 0;
for (int div = low; div > 0; --div)
{
if ((low % div == 0) && (high % div == 0))
if (gcd < div)
gcd = div;
}
return gcd;
}
这仍然是一小段代码,但由于以下原因,测试它要困难得多:
-
尽管只有两个参数,但有效的输入集非常大。你不可能测试所有可能值的组合;这将花费很长时间.^([3]) 向函数添加更多参数会使这个问题呈指数级增长。
-
它包含一个循环。任何形式的分支(包括
for循环)都会增加复杂性和更多潜在的错误。 -
有几个条件语句。现在你必须安排运行代码,以检查每个条件组合的每一侧是否工作。
这只是一个单一的小函数。你注意到那里的错误了吗?你能找到它吗?如果你能找到,就有 10 分和一颗金牌.^([4])
关键概念
很容易相信你读到的代码,并相信它是正确的。当你刚刚编写了一些代码时,你会读你所打算写的,而不是你实际写的。学会多看一眼——带着怀疑的眼光读所有代码。
这三个问题并不是软件测试变得困难的唯一原因。还有许多其他方法可以增加测试的复杂性。
代码大小
代码越多,潜在的错误空间就越大,必须追踪的执行路径就越多,以检查其有效性。
依赖关系
测试一小段代码应该是容易的。但如果测试工具在执行任何操作之前必须附加其余的代码库,那么编写任何测试都变得非常痛苦(并且耗时),因为很难协调所有附加的代码组件。这是一个不可测试的设计的例子。我们将在稍后(在“为测试而设计”第 143 页)探讨解决方法。
下面的两个部分也是代码之间依赖关系的例子。
外部输入
任何对外部系统状态的依赖本质上都是另一个输入。与函数参数不同,很难安排这些外部输入采取某些测试值。共享的全局变量不能随意设置值,否则会损害运行程序的其他部分。
外部刺激
代码可能会对除了函数调用之外的其他刺激做出反应。当它们可能异步(在任何时间)发生,并且以任何频率发生时,这尤其麻烦。
-
一个类可以响应来自系统其他部分的回调,这些回调可能随时出现。
-
硬件接口代码对物理设备状态的变化做出反应。
-
与其他系统的通信可能需要任何长度的时间。物理连接容易受到干扰,因此它们可能会退化,网络连接也可能不可靠。
-
用户界面代码由用户的鼠标手势驱动。在测试条件下,很难在物理上自动化 GUI。
这些条件在人工测试环境中很难模拟,并且它们可能对时间非常敏感(例如,鼠标双击的速度或硬件生成的中断频率)。
一些外部影响是未计划的:内存可能不足,磁盘空间可能耗尽,网络连接可能失败。你必须确保你的代码在所有现有的环境条件下都是健壮的。
线程
多个控制线程使得测试更加复杂,因为并发代码可能以任何任意的顺序交织在一起。执行路径的复杂交互意味着任何给定的测试运行可能永远无法重复。导致死锁或饥饿的线程故障可能难以触发,但一旦出现,它们会引发严重问题。
程序的线程行为在真正的并行多处理器系统上与在单处理器时间切片环境下的模拟并发行为不同。
进化
软件会进化。这种进化往往会导致测试失败。如果需求没有确定下来,你的早期测试在你交付时可能已经无效,因为 API 已经改变,功能完全不同,而且由于开发从未长时间停止,因此可能没有创建完整的测试套件。
我们需要在我们自己的代码和任何我们依赖的外部代码中都有稳定的接口。在现实世界中,这是一个不切实际的理想——代码永远不会静止——因此我们必须编写小型、可塑的测试,这些测试可以很容易地与代码一起修改。
硬件故障
故障存在于硬件和软件中。在嵌入式环境中工作通常更容易遇到硬件错误,因为你更接近底层。硬件故障可能比诊断和修复它们困难一个数量级;它们很少可重复,你自然会首先怀疑你的软件。
糟糕的故障模式
代码可能会以多种令人兴奋和奇特的方式崩溃。程序故障不仅会导致不正确的输出——还有更多需要应对的问题:无限循环、死锁、饥饿、程序崩溃、操作系统锁定,以及其他潜在故障会露出它们丑陋的脑袋,使测试变得多样化和令人兴奋。病态的软件故障甚至可能导致硬件损坏!^([5]) 编写一个测试框架来检查那个。
编写测试框架不是一件小事。当组件粘合在一起并开始相互依赖时,软件的复杂性会呈指数级增长。所有这些问题都会使你的生活变得非常复杂。这时,不仅困难,而且在技术上不可能编写出能够全面测试软件的框架。没有足够的时间和资源来生成所有必要的测试数据,以及运行软件对所有输入和刺激的集合。暴力方法迅速变得不切实际,似乎更方便忽略测试,只希望没有错误。
无论你测试得多仔细,你仍然无法生产出无故障的软件——编写测试代码与编写常规代码一样困难,并且需要同样的技能。一些错误不可避免地会从最严格的测试中漏出(研究表明,最仔细测试的软件每 1000 行代码仍然包含 0.5 到 3 个错误)。(Myers 86)在现实世界中的测试很少能证明软件是坚不可摧的——仅仅证明它是足够的。
考虑到这一点,我们需要关注那些可能捕捉到大多数软件缺陷的关键测试,以实现最有效的测试。我们稍后会看到如何选择这些测试。
^([3]) 输入值越高,for 循环所需的时间就越长。假设一个 int 是一个 32 位值(意味着有 2⁶⁴ 种输入组合)并且你有一个性能良好的机器(让我们假设每个函数调用将花费一毫秒——那可是一个相当不错的处理器缓存),暴力测试将需要近 6 亿年!而且这还没有打印出任何测试结果……
^([4]) 查看本章第一个 "Mull It Over" 问题的答案(第 494 页)以了解它是什么。
^([5]) 这不是玩笑。68000 处理器有一个未记录的停止并引发火灾指令——一个快速循环地址线的总线测试操作,导致电路板过热并着火。
测试类型
软件测试有很多不同的种类,没有一种是比其他更好的。每种方法都是从不同的方向接近代码,并且会捕捉到不同类别的错误。所有这些都是必需的。
单元测试
术语单元测试通常用来表示测试代码的模块(比如库、设备驱动程序或协议栈层),但它实际上描述的是对原子单元的测试:每个类或函数。
单元测试是在严格隔离的情况下进行的。与单元接口的任何不受信任的外部代码都被替换为存根或模拟器——这确保你只捕获这个单元的缺陷,而不是由外部影响引起的缺陷。
组件测试
单元测试的进一步发展,这验证了一个或多个单元组合成一个完整的组件。通常这就是人们所说的单元测试。
集成测试
这测试了组件在系统中组合在一起时的组合,确保它们能够正确互联。
回归测试
这是对软件或其环境进行修复或修改后的重新测试。你运行回归测试以确保软件仍然像以前一样工作,并且你的修改没有在过程中破坏任何东西。当你与脆弱的软件一起工作时,一个地方的变化可能会在其他地方引起奇怪的故障。回归测试有助于防止这种情况发生。
确定需要多少重新测试可能很困难,尤其是在开发周期的后期。自动测试工具对于这种类型的测试特别有用。我将在第 144 页的"看!没有手!"中详细讨论这一点。
负载测试
你执行负载测试以确保你的代码可以处理预期的数据量。编写生成良好答案的代码很简单,但及时这样做是另一回事。这可能会揭示与系统效率相关的问题,可能是由于缓冲区大小不正确、内存使用不当或数据库设计不足。负载测试检查程序是否“按预期扩展”。
压力测试
压力测试在短时间内向代码内投掷大量数据,以查看它会如何反应。它与负载测试类似,通常用于高可用性系统。压力测试检查系统的特性:它对过载的容忍度。负载测试是为了证明代码可以满足其预期的需求;压力测试确保它不会在受到真正的打击时只是堆叠在一起。代码不必一直完美工作;它只需要优雅地失败并很好地恢复。
压力测试有助于确定软件的容量——在它崩溃之前你能施加多大的压力。这在线程或实时系统中尤为重要。
Soak 测试
Soak 测试与压力测试类似。重点是长时间在高负载下运行——几天、几周甚至几个月,以识别在执行了大量操作后出现的任何性能问题。Soak 测试揭示了可能被忽视的故障:可能导致程序崩溃的小内存泄漏或随着内部数据结构逐渐变得碎片化而导致的性能下降。
可用性测试
确保你的软件可以被短视的仓鼠轻松使用。有各种形式的最终用户测试,通常在可用性实验室中进行,条件非常受控和脚本化。我们还在实地试验中测试软件,将其置于真实世界环境中,看看用户有什么看法。
ALPHA, BETA, GAMMA . . .
关于alpha和beta测试呢?它们是常见的术语,但并不完全等同于我们在这里讨论的其他测试。它们更侧重于最终产品测试,而不是特定代码片段的实现。尽管如此,它们仍然值得解释。
幸运的是,这些术语没有正式的定义。每家公司都会对其alpha或beta状态的软件有自己的看法。据你所知,alpha 软件可能是由柠檬果冻制成的,在暴露于光线时会爆炸。Alpha 或 beta 软件通常作为预览版对外发布,这是一个早期收集反馈和建立信心的机会。
这些是对这些术语的常见解释:
Alpha 软件
这是第一个“代码完成”阶段。它可能仍然存在许多、许多错误,并且完全不可靠。Alpha 软件提供了对最终产品将如何的很好展示,如果你能忽略明显的缺陷。
Beta 软件
超过 alpha 阶段,beta 软件基本上没有错误;剩余的问题非常少。它离最终产品不远。Beta 测试(即测试beta软件)用于最终发布候选版本,以解决剩余的问题。Beta 测试通常涉及真实世界的实地试验。
发布候选版本
这是正式软件发布前的最后阶段。候选版本在正式发布前会经过验证和保证测试(验证)。
如果 alpha 和 beta 版本进入外部世界,它们可能有一些形式的限制(例如时间限制的操作)。发布候选版本是“纯”构建,没有任何这些限制。
当我们编写单元和组件测试时,有两种主要方法来设计测试用例:黑盒和白盒测试。
黑盒测试
这也被称为功能测试。黑盒测试将实际功能与预期功能进行比较。测试者不知道代码的内部工作原理;它被视为一个黑盒。设计者和测试者可以是独立的。
黑盒测试不关心是否每行代码都被测试,只关心它是否符合软件的规格——即如果你把正确的东西放入盒子的一个端,正确的东西就会从另一个端出来。因此,如果没有明确的规格和文档化的 API,设计黑盒测试是非常困难的。
黑盒测试用例可以在软件规格完成后设计。它们依赖于规格首先正确,并且在测试设计之后没有发生根本性的改变。
白盒测试
这也被称为结构测试。它是一种基于代码覆盖的方法。每一行代码都会被系统地审查以确保正确性。在你之前无法看到黑盒内部的情况下,你现在可以并且确实可以看到。因此,白盒测试有时被称为玻璃盒测试。它实际上只关注测试生成的代码行,并且不能保证它们符合其规格。
白盒测试有静态和动态两种方法。静态测试不运行代码;相反,它被检查并逐步执行以确保它代表一个有效的解决方案。动态测试运行代码,并关注路径和分支测试——尝试访问每一行代码并执行每一个决策。这可能需要修改代码以强制控制流下某些路径。这种修改可能比为所有行为组合设计测试用例要容易。^([7])
白盒测试既费时又比黑盒测试昂贵得多;因此,它做得很少。在计划白盒测试之前,需要完成代码。黑盒测试通常在白盒测试开始之前进行。这个阶段的失败代价更大。你将不得不编写修复代码,再次进行黑盒测试,然后设计和运行新的白盒测试。
存在工具可以对代码进行仪器化并测量测试覆盖率。没有工具支持,白盒测试可能会让你的头爆炸。
黑盒测试关注的是遗漏(软件遗漏了部分指定的行为)的故障,而白盒测试发现的是错误(实现的部分有缺陷)。为了完全测试一个软件单元,需要同时进行黑盒和白盒测试。
测试时间
这些测试方法在不同的开发阶段被使用。以下表格说明了这一点,显示了在每个阶段哪些测试最重要。
| 开发阶段 | 是否适合黑盒或白盒测试? | 此开发阶段的常见测试方法 | 谁执行测试? |
|---|---|---|---|
| 收集需求 | 黑色 | 设计的黑色盒测试 | 开发者,QA |
| 代码设计 | 黑色 | 设计的黑色盒测试 | 开发者,QA |
| 代码构建 | 黑色,白色 | 单元,组件,回归 | 开发者 |
| 代码集成 | 黑色,白色 | 组件,集成,回归 | 开发者 |
| 预发布状态 | 黑色,白色 | 回归,负载,压力,浸泡,可用性 | 开发者,QA |
| 测试候选状态 | 黑色,白色 | 回归,负载,压力,浸泡,可用性 | QA |
| 发布候选 | 黑色,白色 | 回归,负载,压力,浸泡 | QA |
| 发布 | 黑色,白色 | 现在太晚了…… | 用户(祝你好运) |
^([6]) 然而,这并不一定是一个好主意——程序员通常是编写他或她创建的代码单元测试的最佳人选。
^([7]) 如果你修改了源代码,那么你实际上并没有测试最终的可执行文件,这是令人担忧的。
选择单元测试用例
如果测试是必要的,但全面测试是不可能的,你必须明智地选择最有效的测试集。为此,你需要一个深思熟虑且有条理的计划。你可以采取一种散弹枪的方法——只是把代码靠在墙上,然后用手头的任何东西向它射击……

这样你可能会发现一些缺陷。但没有一个合理的、分阶段的测试方法,你永远不会得到那些能让你对代码有充分信心的质量测试。与其使用散弹枪,不如拿起一把带有精确瞄准器的步枪,对代码进行仔细瞄准,击中经过良好判断的目标,以查看其表现如何。
你瞄准哪里?你如何确定要发射的测试数据?由于你不能尝试每个可能值,你需要选择一些相关的输入。你必须选择最有可能揭示软件缺陷的测试,而不是运行只是重复展示相同几个问题的测试。
关键概念
编写一套全面的测试套件,每个测试用例都针对代码的不同方面进行测试。十五次重复展示相同错误的测试不如十五次展示十五种不同错误的测试有用。
要做到这一点,你必须了解你那部分代码的需求。除非你知道它应该做什么,否则你无法编写准确的测试用例。它可能做得非常出色,但做的事情是错误的。
在进行黑盒测试时,一些测试用例将包括:
一些良好输入
选择一些精心挑选的良好输入以确保软件在正常情况下能正常工作。
覆盖所有有效的输入值范围;包括一些中间值,一些来自可接受输入下限附近的值,以及一些来自上限的值。
一些不良输入
同样重要的是一定数量的精心选择的不良输入。这确保了软件的鲁棒性,并且不会对无效输入给出误导性的答案。
你必须考虑各种不良数据,包括:
-
数值上过大或过小的值(处理负值常常被忽视)
-
过长或过短的输入(字符串长度是一个经典例子——尝试发送一个空字符串看看会发生什么,或者尝试不同大小的数组和列表)
-
数据值内部不一致(这具体意味着什么将取决于函数的合约;可能它期望值按照某种顺序)
边界值
测试所有边界情况——它们是错误来源的丰富资源。确定最高和最低的有效输入,或者自然输入边界在哪里(可能是在行为改变的地方)。对于这些位置中的每一个,测试代码在以下位置的行为:
-
边界值本身
-
高于它的值
-
低于它的值
这确保了你的软件能够在边缘正确运行,并且当预期时能够准确放弃。
边界测试可以捕捉到过于简单的错误,比如输入>而不是>=,或者循环计数基数错误(你是从零开始计数还是从一?)。所有三个边界测试都是必要的,以检查这些类型的错误。
随机数据
测试随机生成的输入数据集以避免猜测。这是一个出人意料有效的测试策略。如果你能编写一个自动测试框架,它可以重复生成并应用随机数据,你就有很大机会发现你从未考虑过的微妙错误。
零
如果输入是数值型的,总是要测试零的情况。由于某种原因,程序员们往往没有正确考虑零,这是他们推理中的一个盲点。
C/C++指针通常被赋予零值以表示未设置或未定义。尝试将零指针抛向你的代码,看看它是否正确反应。在 Java 中,你可以发送null对象引用以产生类似的效果。
测试设计
你能编写的单元测试质量很大程度上取决于你要测试的接口质量。当你的代码被深思熟虑地编写,并且专门设计以适应检查和验证时,测试会更简单。你通过构建清晰的 API、减少对其他代码的依赖,并打破与其他组件的硬编码链接来实现这一点。这样,将组件放入测试环境并刺激它就变得容易了。相反,如果它与代码的其他部分紧密相连,你必须将所有这些代码拖入测试环境,并安排它们以适当的方式与你的单元交互。这并不总是容易,有时甚至不可能,这限制了可能测试的范围。
关键概念
设计你的代码以便于测试。
这个规则有一个有用的副作用:当你为可测试性结构化代码时,你将按照合理、可理解和可维护的方式进行结构化。你会减少组件耦合并增加内聚性。你会使其更加灵活、易于使用,并更容易在不同配置中进行连接。你的代码会更好。
由于你已经很好地进行了测试,代码更有可能是正确的。
你必须从一开始就为测试而设计。你不可能轻易地返回到旧组件并在其上安装一个“可测试”的接口。如果大量其他代码依赖于现有的接口,那么这样的修改很困难。记住:如果你在编写代码的同时编写单元测试,你最有可能会设计出真正可测试的代码。
一些简单的设计规则可以导致高度可测试的代码:
-
让每一部分代码都自成体系,不要对外部世界有未记录的、脆弱的依赖。不要将链接硬编码到系统的其他部分;依赖于可以被系统组件或测试模拟器实现的抽象接口。
-
不要依赖于全局变量(或单例对象,它们是全球变量的薄薄外皮)。将这些状态收集在作为参数传递的共享结构中。
-
限制代码的复杂性;将其分解成小、可理解、易于测试的块。
-
使代码可观察,这样你就可以看到它在做什么,查询内部状态,并确保它按预期运行。
看!不需要动手!
你不可能整天都在你的测试机器上转动手柄。手动一个接一个地调用测试不是我认为的伟大的一天编程。重复回归测试会很快变得无聊。这不仅会无聊,而且会慢、低效,并容易出错。黄金测试规则很简单:自动化。
关键概念
尽可能自动化你的代码测试。这比手动运行测试更快、更简单,而且更安全:测试更有可能定期运行。
如果测试在没有干预的情况下运行,它们可以作为构建过程的一部分触发验证阶段。在你玩一些新构建的软件之前,你会知道单元测试已经自动运行并通过;你确信没有愚蠢的编程错误,并且任何新的工作都没有破坏旧代码。
关键概念
将单元测试自动作为构建过程的一部分运行。
你可以将你的单个测试代码片段组合在一个自动的脚手架中,该脚手架协调测试执行并在一个地方收集测试结果。这个工具包监控哪些测试已经完成;更复杂的测试工具包会维护随时间推移的测试结果历史。有许多这样的流行工具,如 JUnit,这是一个常见的 Java 单元测试框架。
在回归测试期间,高度自动化变得尤为重要。如果你对代码进行了修改,并想确保你没有意外破坏任何东西,你可以自动运行整个测试集;最终会弹出是或否的答案。当然,回归测试结果只与放入框架中的测试一样好。
自动化确实是稳健代码开发的基本概念。如果你目前还没有一套自动化的单元测试,作为代码库的持续回归测试,那么请获取一套。你的工作质量将迅速提高。
可惜,并非所有测试都可以自动化。单元测试库函数相对容易;自动测试用户界面非常困难。你如何模拟鼠标点击,检查文本字符串的乌尔都语翻译,或确保正确的声音剪辑正在播放?
失败的面孔
我们的最大荣耀不在于从未跌倒,而在于每次跌倒后都能重新站起来。
——孔子
当你的测试发现程序故障时,你会怎么做?在你匆忙调试之前,退后一步,描述问题。这在你不打算(或没有时间)立即修复它时尤为重要。遵循以下步骤,确定故障的性质,以便你或任何其他开发者稍后可以尝试解决它。
-
注意你当时试图做什么,以及哪些操作触发了故障。
-
再试一次。发现问题是否可重复,它出现的频率,以及它是否与同时进行的任何其他活动相符。
-
详细描述故障。要非常具体。包括以下内容:
-
问题背景
-
可以复制它的最简单步骤
-
关于可重复性和发生频率的信息
-
软件的版本、确切的构建号以及所使用的硬件
-
可能与之相关的任何其他信息
-
-
记录下来。不要丢失!即使是一个你打算自己修复的简单编码错误(参见“你能管理它吗?”),也要将其信息放入故障跟踪系统中。
-
编写最简单的测试框架来演示故障,并将其添加到自动测试套件中。这将确保故障不会被丢失或忽略,一旦最终修复,就不会在开发过程中再次出现。
记住,测试不是调试——这些步骤不是调试!你并没有试图揭示失败的原因,或窥视代码,只是为了收集足够的信息,向其他开发者描述问题。
我们最喜欢的故障类型是可重复的故障。真的——我们喜欢反复崩溃的代码:问题容易复制;因此,追踪故障和证明你已经修复它都很简单。糟糕的故障是不规则的,甚至是随机的,因此很难描述。需要很长时间才能显现并依赖于风速的故障是一场噩梦。
你能管理它吗?
为了发现错误,你必须有系统性和条理性的方法。在管理和处理它们时,你也必须保持系统性和条理性。在发布代码(或将其检查到源代码控制中)之前,你将是唯一可能被其缺陷困扰的人。但是一旦它离开你的照顾,代码就获得了自己的生命。不再只有你会关心它的缺陷。随着更多玩家加入游戏,规则也会改变:
-
程序员会在代码层面发现问题——在自己的代码和其他人的代码中。
-
代码集成器会在组件粘合在一起时发现错误。
-
质量保证部门在测试产品时会发现错误。
在许多人发现许多问题的同时,其他人正在尝试进行修复,因此必须有一个良好的管理程序。否则,结果将是一团糟,开发工作将崩溃在每个人的头上。
故障跟踪系统
我们在管理故障中的关键武器是一个故障跟踪系统。这个工具是一个专门的数据库,对参与测试过程的每个人都是可见的。
随着错误的发现和处理,此数据库会更新以反映软件的状态。在此过程中,故障跟踪工具成为项目故障管理流程的一个组成部分。一般执行的操作包括:
报告故障
当你发现一个错误时,通过创建一个故障报告在数据库中为它创建一个新条目。它现在成为故障俱乐部的正式成员,拥有自己的个人会员编号。这个参考编号在未来的使用中可以唯一地识别它。现在这个错误不能被忽视。在软件发货之前,它必须得到解决。
创建报告还会提醒团队中的其他人这个错误已被发现;当他们遇到它时,他们不需要输入相同的信息。
分配责任
这标志着为特定人员关注的故障报告。它定义了谁负责修复(或确保有人修复)每个问题。如果没有这种所有权的概念,每个程序员都会认为其他人会修复这个错误,而错误会通过缝隙蔓延。
优先处理报告
故障跟踪系统允许你标记哪些故障是最重要的。一个可重复的启动崩溃显然比一个偶尔向右移动一个像素的按钮严重得多。
通过区分阻止运行的故障和小的烦恼,开发者可以规划他们的工作并选择哪些故障需要首先修复。该工具可能支持各种严重程度级别——从关键故障到中等至低优先级问题,再到功能请求。
标记为已修复
开发者会在修复完成后这样做。这并不会关闭故障报告,而是将其放在待验证的堆栈上。提交报告的人负责测试修复是否正确,尽管他可以委托这项任务。显然,修复不应该由制作它的人来验证。
关闭报告
一旦验证,报告可以关闭,变成一个遥远的记忆(也许是一个项目统计数字)。
可能存在其他导致报告关闭的场景——问题可能根本不是故障,可能是系统的特性,甚至可能是完全有效的行为。测试人员也会犯错误。
你可以选择不关闭你不想处理的报告,而是将其推迟,将故障标记为在以后的软件版本中修复。
查询数据库
你可以查询故障跟踪系统以获取信息:
-
自然地,你可以生成一个所有待处理故障报告的列表,按软件版本、分配人、优先级或任何其他方式排序。
-
你可以发现哪些故障已被分配给你。
-
你可以生成一份报告,列出每个软件版本中已修复的故障。这有助于准备发布说明。
-
你还可以查看项目统计信息——在开发过程中报告了多少个故障,修复了多少个,以及关闭率与生成率的比率。以图形方式呈现,这可以很好地展示软件的进展情况。
修改条目
你可以打开报告并更改其中包含的信息。这包括:
-
添加任何新发现信息的注释
-
将包含示例输出的日志文件附加到说明问题
-
将报告标记为另一个故障的重复项,以防止以后产生混淆
可用的故障跟踪工具有很多,包括商业版本和免费版本,如作为 Mozilla 项目一部分开发的流行 Bugzilla 系统。
缺陷审查
在产品开发的后期,随着发布截止日期的日益临近,缺陷审查会议成为生活的一部分,大约每周举行一次。这些审查在功能完成但所有缺陷都得到解决之前进行——这是开发过程的漫长冲刺阶段。它们为所有感兴趣的相关方提供了项目进度的概述,帮助规划剩余的修复工作,并引导软件向发布迈进。
这些会议由各种各样的人参加:
-
负责该产品的软件开发者。(毕竟,他们将进行修复。)
-
来自测试团队的代表,他们将解释故障的背景并确保缺陷审查的方向正确。(通常情况下,他们的责任是召集会议。)
-
产品经理,他们将获得进度概述并做出责任在此处停止的决定。
-
商业和市场团队成员,他们是必须销售这个充满错误的产品的人。(他们对每个错误重要性的观点有助于决定哪些需要修复,哪些可以扫进数字地毯下。)
从错误跟踪工具生成一份未解决的错误报告清单,并在会议中依次讨论每个错误。如果需要,测试或开发团队成员可以提供额外信息,然后根据问题的商业重要性做出决定。讨论那些讨厌的持续存在的错误,并报告修复进度。如果工作进展缓慢,可能会做出增加额外资源的决定。
由于有这么多人,会议可能会迅速偏离轨道,需要一个意志坚定的主席来保持讨论的焦点和简洁。主题是错误报告以及如何处理它们,而不是具体的代码修复。程序员喜欢谈论技术并试图在会议中解决每个问题。这不是讨论技术的地方.^([8])
^([8]) 成功会议的策略在 340 页的"MEETING YOUR FATE"中描述。
简而言之
测试对于生产高质量的软件至关重要。一般来说,测试越多越好——尽管测试的质量将反映在最终产品的质量上。糟糕的测试将捕获很少的错误,结果将是一个有缺陷的软件发布。
我们在开发的各个阶段进行测试,从单个函数,到组件集成,再到最终组装的程序。在每一个阶段,你必须采用一种系统的方法来寻找和管理软件错误。
每个程序员都有责任测试自己的代码。QA 部门已经有很多问题要处理,除了你的有缺陷的代码。你不能在开发结束时进行测试并添加软件质量——它必须从一开始就设计,测试与代码并行开发并运行。
| 优秀的程序员 . . . | 次要的程序员 . . . |
|---|
|
-
为他们所有的代码编写测试(甚至可能在编写代码之前)
-
在微观层面进行测试,这样宏观层面的测试就不会受到愚蠢的编码错误的阻碍
-
关心产品质量并对其负责,在总的测试工作中扮演自己的角色
|
-
不要认为测试是软件开发的重要和不可或缺的部分——那是别人的工作
-
将未经测试的代码发布给 QA 部门,并在测试发现错误行为时感到惊讶
-
通过太晚发现问题使他们的生活更加复杂——测试不够早,然后被一系列难以定位的错误所打击
|
参考资料列表
第九章
当你发现错误时该做什么——定位和修复错误的流程。
第二十章
代码审查是一种测试技术——一种静态代码分析的手动形式。

激发思考
关于这些问题的详细讨论可以在第 494 页的“附录 A”部分找到。
深思熟虑
-
为本章前面提到的
greatest_common_divisor代码示例编写一个测试框架。尽可能使其详尽。你包含了多少个单独的测试用例?-
这些中有多少通过了?
-
有多少失败了?
-
使用这些测试,识别任何故障并修复代码。
-
-
电子表格应用程序和自动飞机驾驶员的测试有何不同?
-
你是否应该测试你编写的所有测试代码?
-
程序员的测试与 QA 部门成员的测试有何不同?
-
是否有必要为每个函数编写测试框架?
-
测试驱动开发鼓励你在编写任何代码之前先编写测试。你应该编写什么样的测试?
-
你是否应该编写 C/C++测试来检查对
NULL(零)指针参数的处理?这种测试的价值是什么? -
你早期的代码测试可能不在最终平台上——你可能还没有访问权限。现在推迟测试直到你确实有一个目标测试平台,还是现在就全力以赴?
如果代码打算在不同的环境中运行(可能在高容量服务器上,或者某些嵌入式设备上),你怎么能确保你的测试是具有代表性的和足够的?
-
你怎么知道你已经完成测试可以停止了?多少是足够的?
个性化
-
你为多少代码编写了测试?你对这个结果满意吗?你的测试是构建过程中的自动化部分吗?你对剩余的代码进行什么样的测试?这是否足够?你将如何处理这个问题?
-
你与 QA 部门的人的关系有多好?你认为你在他们中的个人声誉如何?
-
你在代码中发现错误时的通常反应是什么?
-
你是否为每个发现的代码问题提交故障报告?
-
项目工程师预计要进行多少测试?
第九章。寻找错误
调试:出错时该做什么
我并没有失败。我只是找到了一万种行不通的方法。
--托马斯·爱迪生
没有人是完美的。嗯,除了我。整天,我必须坐下来处理别人代码中的繁琐问题。测试部门发现我们的软件在这样那样的情况下会崩溃。所以我翻遍整个系统,找出程序员弗雷德三年前做错了什么,修补它,然后发回给他们再次破坏。
当然,你不会看到我犯那种基本的错误——绝对不可能。我的代码滴水不漏。完美无瑕。低脂无胆固醇。我从不写一行代码而不经过细致的计划,我不会在没有考虑到可能出现的所有特殊情况的情况下完成代码语句,而且我打字如此小心,以至于我从未在if语句中将=误写成==。
完全无瑕疵,就是我。真的。
嗯,也许并不完全是这样。
生活的真相
我认为没有人会坐下来向新程序员解释生活的真相。是这样的,儿子。有鸟有虫。哦,还有虫子。虫子是构建软件不可避免的阴暗面,这是一个简单的生活事实。悲哀,但却是真的。整个部门,甚至整个行业,都存在来管理它们。
我们都知道发布软件中故障的普遍存在。为什么错误会如此频繁且大规模地出现?这都归结于人性。程序是由人类编写的。人类会犯错误。他们因为各种原因(或借口)犯错误,因为他们对正在工作的系统了解不够,或者因为他们没有正确理解他们正在实施的内容,但更常见的是,他们因为不够注意自己在做什么而犯错误。大多数错误都是由于疏忽。我曾经看到一个非常简单的例子;在家里一起玩:
-
从橡子中生长出来的树被称为……
-
青蛙发出的声音是……
-
火焰升起的蒸汽被称为……
-
鸡蛋的蛋白被称为……
那是蛋黄,对吧?想想看。如果你没有被那个所迷惑,那么你很可能只是因为刚刚警告了你才注意到的。(无论如何,给自己加一分。)但是告诉我,谁会在你即将编写可能存在缺陷的代码时提醒你?如果这样的人存在,他应该得到一生的布朗尼点。
作为程序员,我们都是软件糟糕状态的罪魁祸首。我们都有罪。我们是学会忍受这种罪恶感,还是采取一些措施?有两种类型的反应。第一种是这不是错误,这是功能学派:只是编造一个借口并忽略它。出现了一个错误,我们就用伟大的哲学家巴特·辛普森的话来回应:“我没做。没有人看到我这么做。你不能证明任何事情!”(辛普森 91)我们责怪编译器的怪癖、操作系统的缺陷、随机的气候变化,以及有自己思想的电脑。或者,就像我在开头段落中提到的,我们责怪其他人。一件特氟龙雨衣是编程的好工具。
然而,我们实际上应该遵循第二个学派,即承认软件错误并非完全不可避免的学派。许多无意的错误可以被捕捉甚至预防,作为负责任的程序员,我们应该采取措施这样做。防御性编程和合理的测试是我们的主要武器。在本章中,我们将探讨在错误意外通过时应该采用的良好调试技术。
恶魔的本质
与流行观点相反,"bug"这个术语在计算机出现之前就已经在使用了。在 19 世纪 70 年代,托马斯·爱迪生谈论了电路中的虫子。哈佛大学马克 II 型艾肯继电器计算器的故事记录了第一个记录的计算机虫子。1945 年,计算机的早期,当时它们占据了整个房间,一只蛾飞了进去,并设法卡在一些电路中,导致系统故障。他们将蛾贴在日志簿上,写道:"首次发现虫子实际案例"。为了永存,它被保存在史密森尼博物院。
错误是坏消息。但它们究竟是什么呢?我们在第 130 页的"术语和条件"中概述了这些事物的正确命名法。识别我们遇到的错误种类,了解它们的产生、生存和灭绝方式是值得的。
从 1,000 英尺的高度看
软件错误可以分为几个广泛的类别,了解这些类别将帮助我们推理它们。有些错误比其他错误更难找到,这与它们的类别有关。退后一步,从远处眯着眼睛看,这三个类别的错误就会出现:
编译失败
当你花费了很长时间编写的代码无法编译时,这真的很令人烦恼。这意味着你将不得不回去修复一个令人厌烦的小错误或参数类型不匹配,然后等待编译器再次运行,你才能开始真正的工作——测试你的作品。令人惊讶的是,这可能是你能得到的最好的错误类型。为什么?简单来说,因为它最容易检测和修复。这是最直接和最明显的。¹
检测错误所需的时间越长,修复它们的成本就越高;这在第 157 页的"失败的经济学"中有演示。你越早发现并修复每个错误,你就能越快继续前进,它们造成的麻烦和成本就越少。编译失败很容易注意到(或者更确切地说,很难忽视),并且通常很容易修复。你必须处理完它们才能运行程序。
大多数时候,编译失败将是一个愚蠢的语法错误或简单的疏忽,比如调用函数时参数数量或类型错误。失败可能是由于 makefile 中的错误,可能是链接阶段错误(可能是缺少函数实现),甚至可能是构建服务器磁盘空间不足。
运行时崩溃
在修复了编译错误之后,一个可执行文件就出现了,你高兴地运行它。然后它崩溃了。你可能会发誓并嘟囔着关于随机宇宙射线的事情。在经历了第 60 次崩溃之后,你可能会威胁要把电脑扔出窗外。这类错误比编译错误更难处理,但它们仍然相对简单。
这是因为,就像编译错误一样,它们是显而易见的。你不能与一个死去的程序争论。你不能假装崩溃是一个“特性”。当它踢开桶子,离开了它的肉身时,你可以退后一步,开始思考你的程序哪里出了问题。你会有一些线索(是什么输入序列导致了崩溃以及崩溃前它做了什么),并且你可以使用工具来发现更多信息(稍后会更详细地介绍)。
意外行为
这是真正棘手的一个——当你的程序不是在努力向上爬,而是在渴望 fjords。突然它做了错误的事情。你期望看到一个蓝色方块,却出现了一个黄色三角形。代码继续快乐地前进,完全不顾你的挫败感。是什么导致了黄色三角形的出现?程序是被一支激进的游击队 COM 对象推翻了吗?这几乎肯定是在代码深处的一个逻辑问题,它执行了半小时。祝你好运找到它。
一个失败可能是由有缺陷的单行代码引起的,或者它可能只有在几个相互连接的模块最终粘合在一起且假设不完全匹配时才会显现出来。
地面视角
如果我们稍微靠近一点,仔细观察运行时错误,更多的错误分组就会变得清晰。以下是按痛苦程度排序的列表,从刺痛到斩首。
语法错误
虽然这些错误大多数在构建时会被编译器捕获,但有时语言语法错误会悄悄溜过而未被察觉。它们会产生奇怪和意外的行为。在类似 C 的语言中,语法错误通常会是以下之一:
-
在条件表达式中将
==错误地当作=或将&&错误地当作& -
忘记分号或在错误的位置添加分号(经典位置是在
for语句之后) -
忘记用大括号括起一组循环语句
-
不匹配的括号
避免被这类错误绊倒的最简单方法就是保持所有编译器警告开启;现代编译器会对许多这类问题抱怨不已。
关键概念
构建代码时,请确保所有编译器警告都已开启。这样可以在问题发生之前将其突出显示出来。
失败的经济学
调试的艺术与上一章的主题——测试你的代码密切相关。测试会暴露出需要调试的错误。我已在两个单独的章节中涵盖了这些主题,因为它们是不同的学科。然而,这两个结合在一起对于可靠的软件开发是基本的。

软件工厂的紧张节奏要求快速且低成本地产生代码。这种匆忙导致软件项目充满错误,并且因此交付得非常晚。晚交付的软件是一个大问题——它不仅令人尴尬和不便;它可能给任何公司带来灾难。
事实上,你忽略测试的时间越长,允许错误存在的时间越长,情况就越糟——这张图说明了错误在开发过程中逐渐升级的影响。它显示了发现和修复错误的平均成本相对于其在生产阶段被发现的阶段。 (Boehm 81)
正如你所见,成本随着时间的推移而急剧上升(请注意,成本轴是对数刻度)。更糟糕的是,我们离项目截止日期越近,我们进行彻底测试的时间就越少。即将到来的截止日期的压力使得调试变得更加困难——在压力之下,你甚至更有可能在每次修复中引入新的错误。
为了保护自己并避免大量的调试压力,尽早彻底测试你的代码。尽快消除你发现的任何错误,在它们有机会造成重大麻烦之前。为此有既定的方法——看看测试驱动开发,它是敏捷软件开发的一个组成部分。
构建错误
虽然这不是一个运行时错误本身,但构建错误可能只会在运行时显现出来。要保持警惕,并且无论你认为你的构建系统有多好,都要始终怀疑它。在当今这个开明的时代,你不太可能遇到编译器错误。然而,你并不总是运行你认为自己构建的代码。
我已经遭遇过几次这种情况:构建系统未能重新构建程序或共享库(可能是因为 makefile 没有包含足够的依赖信息,或者旧的执行文件有一个错误的最后修改时间戳)。每次我测试我的修改时,我仍然在不知不觉中运行着旧的错误代码。有几种方法可以混淆构建系统,但最糟糕的是当你没有注意到它失败——就像一个麻风病肢体。
弄清楚这一点可能需要相当长的时间。因此,当你对正在发生的事情感到任何疑虑时,彻底清理你的项目并从头开始重建是明智的。这应该会清除任何潜在的构建系统问题.^([2])
基本语义错误
大多数运行时错误都是由于非常简单的错误导致的不正确行为。使用未初始化的变量是一个经典的例子,并且可能很难追踪;程序的行为将取决于变量使用的内存位置中之前存在的垃圾值。有时程序会正常运行;另一次它可能失败。其他基本语义错误包括:
-
比较浮点变量是否相等^([3])
-
编写不处理数值溢出的计算
-
隐式类型转换的舍入误差(丢失
char的符号是常见的) -
声明一个
unsigned int foo,后来写入if (foo < 0)——哎呀!
这种类型的语义错误通常可以通过静态分析工具捕获。
语义错误
这些隐蔽的错误不会被检查工具捕获,因此很难识别。语义错误可能是一个低级错误,比如在错误的地方使用了错误的变量,没有验证函数的输入参数,或者循环出错。它也可能是一个更高层次的错误:调用 API 错误或不保持对象内部状态的一致性。许多与内存相关的错误都落在这个类别中——由于它们能够扭曲和破坏你的运行代码,使其以完全不可预测和不合理的方式运行,因此它们可能非常难以找到。
程序通常会表现出奇怪的行为。唯一的安慰是它们正在做我们告诉它们做的事情。
最好的运行时失败类型是可重复的。如果它们可以重复,那么编写测试和追踪原因就更容易了。那些不一定总是发生的失败往往是由内存损坏引起的。
挖战壕的视角
现在我们已经把事情整理成整洁的小盒子,让我们直接深入看看一些常见的语义错误类型:
段错误
也称为保护错误,段错误是由于访问了未分配给程序使用的内存位置而引起的。这会导致操作系统终止你的应用程序并产生某种形式的错误消息,通常带有有用的诊断信息。
这可以通过涉及指针的打字错误或糟糕的指针算术轻易触发。一个常见的导致段错误的 C 语言打字错误是scanf("%d", number);。在number之前缺少&使得scanf试图将数据写入由(垃圾)内容引用的内存位置,然后——噗——程序在一缕烟雾中消失。如果你真的很不幸,那么number恰好持有等于有效内存地址的值。现在你的代码将继续像什么都没发生一样运行,直到你刚刚覆盖的内存被使用,你的命运就掌握在上帝手中。
内存越界
这些错误是由于写入了为你的数据结构分配的内存之外的区域,无论是数组、向量还是其他自定义结构。当你将值写入广阔的蓝色空间时,你可能会破坏程序其他部分的数据。如果你运行在一个未受保护的操作系统上(在嵌入式环境中更为常见),你甚至可能会篡改另一个进程或操作系统的数据。哎呀。
内存越界是一个常见问题,难以检测;通常的症状是在越界发生很久之后出现的随机意外行为,可能是在成千上万条指令之后。如果你很幸运,内存越界击中了无效的内存地址,你会得到一个段错误,这是很难忽视的。尽可能使用安全的数据结构来保护自己,避免这种灾难的可能性。
内存泄漏
这些是那些没有垃圾回收机制的语言中的常见威胁。⁴ 当你需要内存时,你必须礼貌地向运行时请求(在 C 中使用malloc或在 C++中使用new)。然后你必须有礼貌地在使用完毕后归还(分别使用free和delete)。如果你粗心大意地忘记了释放内存,你的程序会逐渐消耗越来越多的计算机稀缺资源。一开始你可能不会注意到,但随着内存页面在磁盘之间来回移动,你的计算机的响应速度会逐渐下降。
与此相关的其他两类错误:释放内存块过多次,导致不可预测的环境故障,以及未能仔细管理其他稀缺资源,例如文件句柄或网络连接。(记住:任何你手动获取的东西都必须手动释放。)
内存耗尽
这始终是一个可能性,就像耗尽文件句柄或任何其他受管理资源一样。这可能很少见(现代计算机有如此多的内存,这种情况怎么可能发生?),但这不能成为忽视失败可能性的借口。只有马虎的代码才会失败,并且当在受限情况下运行时,它将表现出非常脆弱的行为。因此,你应该始终验证内存分配或文件系统调用的返回状态。
一些操作系统从不从内存分配调用返回失败——每个分配都返回一个指向已保留但未分配的内存页面的指针。当程序最终尝试访问这个页面时,操作系统机制会拦截访问,然后真正为该页面分配内存,恢复正常的程序操作。这一切都进行得很好,直到可用的内存最终耗尽。然后你的程序将收到错误信号——在相关分配发生很长时间之后。⁵
数学错误
这些错误以多种形式出现:浮点异常、不正确的数学构造、溢出/下溢,或者可能失败的表达式(例如,除以零)。甚至尝试输出一个float,但通过printf("%f")传递一个int,也可能导致你的程序因数学错误而崩溃。
程序挂起
这些问题通常是由不良的程序逻辑引起的;最常见的是设计不当的终端情况导致的无限循环。在多线程代码中,我们也会看到死锁和竞态条件,以及等待永远不会发生的事件的事件驱动代码。然而,通常很容易中断正在运行的程序,查看代码卡住的地方,并确定挂起的原因。
不同的操作系统、语言和环境以不同的方式报告这些错误,使用不同的术语。一些语言通过不提供可能导致自己受伤的功能来避免整个类别的错误。例如,Java 没有指针,并且它会自动检查你进行的每一次内存访问。
^([1]) 假设你有一个合理的构建环境,当遇到错误时会停止,并提供一些合理的诊断信息。
^([2]) 这假设你信任你的build clean功能。为了真正彻底,删除整个项目并重新检出。或者,手动删除所有中间对象文件、库和可执行文件。对于大型项目,这两种选择都非常繁琐。这就是生活。
^([3]) 你无法有意义地做到这一点;浮点运算太近似,无法提供任何指示的精确比较。
^([4]) 在具有垃圾回收机制的语言中,也有可能发生内存泄漏。将两个对象引用相互传递,然后同时释放它们。除非你有一个高级的垃圾回收器,否则它们将永远不会被清理。
^([5]) 至少在耗尽虚拟内存地址空间之前,这对于 Linux 来说肯定是正确的。在这种情况下,malloc可能会返回 0,但系统在你有机会注意到之前可能已经崩溃了。
消灭害虫
在你的软件中清除错误是困难的。你必须发现一个错误,诊断问题,消除所有不希望的行为的痕迹,确保错误没有在其他地方繁殖,并且在执行所有这些操作时尽量不要破坏代码。仅第一步,找到错误,就是一个大麻烦:人类在写作时会犯错误,但在阅读时也会犯同样的错误。当检查我的散文或我的代码时,我会自然地阅读我打算写的,而不是我真正写的。有缺陷的代码并不明显。编译器并没有多大帮助;事实上,它非常挑剔。它只能产生确切你所要求的结果,而不是你所希望的结果。
一些程序员引入的错误比他们的同行少得多(多达 60%),可以更快地找到和修复错误(仅需 35%的时间),并且在这样做时引入的错误也更少。(Gould 75)他们是如何做到这一点的呢?他们天生就能更加专注于任务,并且能够关注他们所编写的代码的微观层面,同时仍然保持对整体图景的关注。
这就是调试的艺术;它是一个非常需要学习的技能。经验教会你如何成为一个有效的调试者。而且,这是我们都会有很多经验去做的事情。
调试时最重要的规则是:用你的大脑。思考。考虑你在做什么。不要盲目地乱砍代码,直到看起来有东西能工作。
关键概念
始终遵循调试的金科玉律:用你的大脑。
消灭害虫有两条路:一条是快速而粗糙的低效之路,另一条是神学上正确的高效之路。我们必须意识到它们两者;有时低效之路看起来像是一个很好的捷径,但实际上会更慢,有时高效之路需要更多的努力去遵循,而实际上并不需要那么多。
低效之路
错误实际上很简单。原因很明显。你不需要太多思考,对吧?有时快速调整确实能取得效果;几个简单的测试可以快速定位问题。所以这样做是合理的吗?也许吧,但不要陷入认为它每次都会奏效的陷阱。太多的程序员试图通过摆弄、调整、戳戳和捅捅代码来修复错误,而没有真正思考他们在做什么。结果很少是有用的——他们只是用无数的其他错误掩盖了原始问题。
如果你确实决定做一些快速而粗糙的试探,为自己设定一个明确的时间限制。不要整个早上都花在“再试一次”的方法上。时间限制一到,就遵循这里提出的更系统的方法。
关键概念
为“无结构”调试设定一个合理的时间限制,如果你没有成功,就转而采用更系统的方法。
如果你的猜测奏效,并且你确实找到了错误,重新启动你的思考机制。查看第 167 页的“如何修复错误”,并仔细、周到地进行更改。即使错误很容易找到,修复方法也不一定那么明显。
高效之路
一种更好的调试技术更加有系统和深思熟虑。它认识到消除错误有两个不同的方面:找到导致错误的原因和修复这个错误。
每个都提出了需要克服的挑战和需要解决的问题。很容易忘记后者,并假设一旦你找到了错误,修复它就会很容易且明显。不要相信这一点。我将在后面的章节中深入探讨这两个方面,并概述一个合理的任务方法。但首先,一些关键原则支配着调试游戏:
-
错误的难度取决于你对隐藏在其中的代码了解多少。在不了解结构和它应该如何工作的情况下,跳入某个随机的源代码并对其做出任何判断是很困难的。因此,如果你必须调试一些新的代码,先花时间了解它。
关键概念
学习你正在调试的代码——你不能期望在你不理解的代码中找到错误。
-
调试的容易程度也取决于你对执行环境的控制——你可以对运行中的程序进行多少操作并检查其状态。在嵌入式世界中,调试可能更困难,因为工具支持更稀疏。你也在一个可能为你提供很少绝缘的环境下运行;小小的错误可能产生更大的后果。
-
我们调试工具中最有力的武器之一是对任何人的代码的不信任,加上适量的悲观主义。你错误行为的原因可能是绝对任何事情,在诊断过程中,你应该首先排除最不可能的候选者。
关键概念
当你寻找故障时,怀疑一切。首先排除最不可能的原因,而不是假设它们与此无关。不要假设任何事。
捕虫
你如何找到错误?如果有一个简单的三步过程,我们都会学到它,我们的程序现在就会完美无缺。实际上,没有,它们也不是。让我们尝试提炼可用的捕虫智慧。
编译时错误
我们首先看看这些,因为它们相对容易处理。当你的编译器遇到不愉快的事情时,它通常不会只抱怨一次,而是会抓住机会对生活本身发表意见,吐出一连串后续的错误信息。它被告知这样做;在遇到任何错误时,编译器试图自己恢复并继续解析。它很少做得很好,但像你的代码这样的,谁能责怪它呢?
结果是,后来的编译器消息可能相当随机且不相关。你只需要查看报告的第一个错误并解决该问题。当然,你可以向下查看列表;那里可能还有一些其他有用的错误,但通常没有。
案例研究 #1:想象一下
程序
一个具有图形界面的合理小巧的实用程序。
问题
程序经过重新设计,采用了更新的“外观和感觉”——新的图标和布局。旧的界面旨在作为可配置选项保留。在重新开发过程中,一切工作正常,直到发布前,有人尝试使用旧版界面。程序在窗口出现之前崩溃,但你还没有机会完全看到它。
故事
幸运的是,这是一个很好地可重复的问题。程序在调试器中运行,失败点被确定在 UI 库中的某些图像渲染代码深处。
经过调查,似乎失败是由于使用了无效的图形。程序试图在内存位置零处显示一个图标;一个空指针导致了崩溃。我们沿着调用堆栈向上追踪,以查看应该出现哪个图形。有了这些信息,简要查看遗留图形目录就显示,这个特定的图标缺失。
窗口构造函数中的图标加载操作显然失败了,返回一个零指针值表示“没有加载图标”。这个返回值从未被检查——作者假设图形始终存在。修复将包括两个方面:
-
检查所有图标加载例程的返回值,以确保它们能够更优雅地处理任何其他缺失的图形。
-
将缺失的图形放在正确的目录中。
修复时间
几个小时来追踪问题、修复故障并验证修复。
经验教训
-
检查所有函数返回代码,即使你认为它们不会失败。
-
尽快测试所有程序功能,特别是那些不太经常使用的罕见条件。
关键概念
当你的构建失败时,请查看第一个 编译器错误。比后续的消息更信任这一点。
即使是第一个编译器错误也可能很神秘或误导,这取决于编译器的质量(如果你对错误的意义感到困惑,尝试使用另一个编译器)。硬核 C++模板代码可能会从某些编译器那里引发相当有启发的错误——列出大量神秘的模板咒语。
语法错误通常确实在编译器报告的行上,但有时它实际上可能在前面的行上——那里的语法错误导致下一行变得无意义;这就是编译器注意到并抱怨的地方。⁶
链接器错误总体上不太神秘。链接器会告诉你它缺少一个函数或库,所以你最好赶紧去找(或者编写它)。有时链接器可能会抱怨一些神秘的 v-table 相关的 C++问题;这通常是一个缺少析构函数实现或类似问题的症状。
运行时错误
运行时错误需要更多的计划。如果你的程序中存在一个错误,那么很可能代码中某个你认为为真的条件实际上不是。找到错误是一个确认你认为正确的过程,直到你找到那个条件不成立的地方。你必须开发一个关于代码真正如何工作的模型,并将其与你的意图进行比较。有系统地这样做是唯一合理的办法。
关键概念
调试是一种有系统的活动,缓慢地接近故障的位置。不要把它当作一个简单的猜测游戏。
科学方法是科学家用来开发世界准确表示的过程。这听起来就像我们正在尝试做的事情,对吧?科学方法有四个步骤:
-
观察一个现象。
-
形成一个假设来解释它。
-
使用这个假设来预测进一步观察的结果。
-
进行实验以测试这些预测。
虽然我们试图消除异常现象而不是建立其模型,但为了真正修复它,我们需要了解故障。科学方法是良好的调试基础,你将在下面的步骤中看到它的体现。
识别故障
所有这一切都从这里开始,当你注意到程序没有做它应该做的事情时。它可能崩溃,或者它可能显示黄色三角形而不是蓝色正方形,但你知道有问题,你必须修复它。首先要做的事情是将故障报告放入故障数据库(参见第 147 页的"故障跟踪系统")。如果你正在跟踪其他错误或没有时间立即处理故障,这尤其有价值。记录确保故障不会丢失。不要只是心理上记下稍后回来处理问题——你会忘记。
在你急忙寻找你偶然发现的错误之前,确定异常行为的特点。通过回答诸如:它是否对时间敏感?以及它是否依赖于输入、系统负载或程序状态?等问题尽可能完整地描述问题。在你尝试修复错误之前,如果你不理解错误,你只会不断更改代码直到症状消失。你可能只是掩盖了原因,所以同样的故障会在其他地方再次出现。
代码之前是否工作过?通过版本控制系统回溯到最后一个工作版本,并将该工作代码与这个有缺陷的修订版进行比较。
重新复制它
这与描述故障是一致的。制定出必须采取的一系列步骤来可靠地触发问题。如果有多种方式,则记录下来所有这些方式。
关键概念
定位故障的第一步是找出如何可靠地重新复制它。
如果错误似乎无法重现,你将遇到问题;你能做的最好的事情是设置陷阱并看看当它发生时你能找到什么信息。对于这些不可靠的故障,仔细记录你收集到的信息;可能要过一段时间问题才会再次出现。
定位故障
这是关键的一步。你已经找到了线索;现在你需要利用你所学的知识来追踪这个怪物并确定其位置。这比说起来容易做起来难。这是一个排除所有不导致故障或可以证明其正确性的东西的过程,就像福尔摩斯一样。随着你的进展,你会发现你需要收集越来越多的信息——你得到的答案越多,提出的问题就越多。你可能需要制定一些新的测试。你可能需要探索代码的阴暗面。
分析你关于失败所了解的内容。不要急于下结论,列出代码嫌疑名单。看看你是否能发现事件模式,这些模式暗示了原因。如果可能的话,记录下输入和输出,以证明问题。
调查的起点是错误表现的地方——尽管这很少是故障的实际栖息地。记住:仅仅因为一个失败在一个模块中表现出来,并不意味着那个模块就是罪魁祸首。如果程序崩溃了,确定这个位置很容易;调试器会告诉你失败的代码行,那个点的所有变量的值,以及谁调用了这个函数。如果没有崩溃,从一个你知道表现出错误行为的地方开始。从那里回溯,遵循控制流,检查代码在每个点上是否做了你期望它做的事情。
关键概念
从你所知道的地方开始——比如程序崩溃的点。然后从那里回溯到失败的原因。
有几种常见的错误查找策略:
-
最糟糕的事情是随机更改东西,看看失败是否消失。这是一个不成熟的方法。(专业人士至少会尝试让它看起来科学!)
-
一个更好的策略是分而治之。比如说,你把故障锁定在一个由 20 个步骤组成的单一函数中。在第 10 步之后,打印出中间结果,或者设置一个断点并在你的调试器中调查它。如果值是好的,那么故障就在这个指令之后;否则,就在之前的指令中。集中精力在这些指令上,重复操作,直到你找到了故障。
-
另一种技术是干运行方法。而不是依赖直觉来定位错误,你扮演计算机的角色,通过试运行跟踪程序执行,计算所有中间值以得到最终结果。如果你的结果和现实不符,那么你知道代码中存在故障——它没有做你期望它做的事情。虽然这很耗时,但这种方法可以非常有效,因为它突出了你的错误假设。
理解问题
一旦你找到了故障潜伏的地方,你必须理解真正的问题。如果它是一个简单的语法错误,比如使用=而不是==(哎呀!),那么影响并不太严重。对于更复杂的语义问题,在你继续之前,确保你真正知道问题是什么以及它可能以何种方式表现出来——你可能只找到了问题的一部分。
通常故障非常微妙:代码将做它应该做的事情 并且 当你编写它时你认为它应该做的事情!问题是存在一个有缺陷的假设(还记得这些有多邪恶吗?)。一个函数的编写者和调用者可以轻易地假设在特定奇怪的情况下不同的行为是可以接受的。回溯并确切了解问题的原因,以及是否有其他代码片段可能包含相同的错误。
关键概念
一旦你认为你已经找到了一个错误的根源,就要彻底调查以证明你是正确的。不要盲目接受你的第一个假设。
这是与虫害作斗争的一个关键原则。否则,你将加入那些在每次修复工作中引入更多故障而不是修复故障的程序员行列。
创建一个测试
编写一个测试用例来演示失败。如果你足够聪明,你可能在“重现它”步骤中已经做了这件事。如果没有,那么你现在真的需要写一个。利用你的新理解,确保测试是严格的。
修复故障
现在是容易的部分了:你只需要修复这个该死的东西!这实际上应该是容易的部分——你完全理解为什么会出现故障,并且你有一个可复现的方式来练习它。鉴于这些信息,修复通常是小菜一碟。大多数程序员发现修复故障很难,因为他们跳过了前两个步骤。
我们将在下一节更详细地探讨如何修复故障。
证明你已经修复了它
现在你已经知道为什么你需要编写测试用例了。再次运行它,证明世界变得更美好。这个测试用例可以添加到你的回归测试套件中,以确保故障永远不会在以后的某个时刻再次引入。
关键概念
你还没有完成调试,直到你已经证明问题已经被修复,并且永远消失了。
那就结束了!游戏结束——任务完成。做得好。然而……
如果所有其他方法都失败了
有时候你尝试了所有这些方法,但就是不起作用;你只能哭泣和咬牙切齿,因为你的头因为长时间撞击砖墙而疼痛。当事情变得如此糟糕时,我发现向别人解释整个问题总是有帮助的。在描述的某个地方,一切似乎都变得清晰起来,我看到了我一直以来一直缺少的关键信息。试试看。这就是为什么结对编程是一种如此成功的策略的原因之一。
^([6]) C++ 在这里有一个很棒的技巧:前面的行可能位于不同的文件中!如果你忘记在头文件中类声明的末尾添加 ;,实现文件的第一行就没有意义了。编译器会给你一个非常隐晦的错误。
如何修复故障
你会注意到这个部分比前面的部分小得多。有趣的是。通常整个问题在于找到这个该死的故障。一旦你找到了它,修复方法就显而易见了。
但不要让这让你产生错误的安心感。一旦你诊断出错误行为的原因,不要停止思考。在修复过程中,非常重要的一点是不要破坏其他任何东西——在去拔草的时候,意外踩到花坛上的东西是出乎意料的容易。
关键概念
修复错误时务必小心。不要因为你的修改而破坏其他任何东西。
在修改代码时,始终问自己,这个变化的后果是什么? 注意修复是否仅限于单个语句,或者它是否影响了周围的代码。你的变化是否会影响调用此函数的任何代码;它是否微妙地改变了函数的行为?
案例研究 #2:被吊死、被斩首、被四分五裂
程序
控制消费电子设备的嵌入式软件。
问题
随机锁定,发生在大约一周的连续运行之后。这导致了设备的完全死亡;没有任何用户界面响应,没有网络连接,甚至没有中断被处理——处理器完全停滞。这种情况特别令人讨厌,没有简单的方法可以找出原因。
故事
锁定事件发生的频率如此之低,以至于追踪起来非常困难。为了确定原因,我们尝试了多种测试,让每个测试运行一周的时间。首先,我们尝试了不同的使用模式,看看是否可以更快地触发故障,从而确定原因。这些测试没有任何效果。
锁定的性质似乎暗示这是一个棘手的硬件问题。我们尝试在不同的主板上运行不同版本的软件,使用不同的外围组件和不同的 CPU 版本。几周后,我们仍然没有接近解决问题,但我们确实少了一些头发(而且剩下的头发都变灰了)。无论我们使用什么配置,软件仍然可以运行大约一周然后锁定。
接下来,我们尝试从系统中移除不同的代码部分。经过大量的迭代测试,我们将问题追踪到一个单一组件:它在构建中的存在预示着锁定;它的缺失阻止了锁定。最后,终于有了进展!
确定为什么这个软件组件会导致这样的问题并不简单。它建立在第三方库之上,而这个第三方库本身又是基于核心操作系统库构建的。我们发现这个核心操作系统库已经被升级到更近的版本,但第三方库并没有被重新构建。我们一直在链接一个不合适的代码片段。虽然从理论上讲,这不应该有影响——操作系统库的变化据说应该是二进制兼容的——但重新构建第三方库永久地解决了问题。
修复时间
整个过程大约花费了四个月的时间,这是流逝的时间。在这段时间里,涉及了许多人,消耗了大量的测试资源,占用了许多硬件部件,并且导致了比你能想象的还要多的审查会议。就错误而言,这个错误有一个令人讨厌的刺痛,给公司带来了很多痛苦(更不用说费用了)。
教训吸取
每当任何组件发生变化时,都要重新构建整个软件平台,以防止微妙的版本不匹配。
让自己确信你已经真正找到了问题的根本原因,而不是仅仅隐藏了另一个症状。然后你可以自信地认为你已经把修复放在了正确的位置。考虑一下是否在其他任何相关模块中可能犯过类似的错误;如果需要,就去修复它们.^([7])
关键概念
当你修复一个错误时,检查是否有相同的错误潜伏在相关的代码部分。一次性彻底消灭这个错误:现在修复所有故障的实例。
最后,尽量从你的错误中学习。我们必须学习,否则我们将注定要永远重复相同的错误。是简单的编程错误你一直在犯,还是更根本的问题,比如算法的错误应用?
关键概念
随着你修复的每个错误,学习教训。你该如何预防它?你该如何更快地发现它?
^([7]) 这就是为什么复制粘贴编程——复制代码,可能有一些小的修改——是糟糕的。它是危险的;你会无意识地复制错误,然后你将无法在一个地方修复它们。
预防
任何人都会告诉你,“预防胜于治疗。”管理错误种群的最佳方式是不要引入它们。遗憾的是,我认为我们永远无法完全达到这个理想。只要编程涉及问题解决,它就会一直很难——不仅你必须正确解决问题,你首先必须完全理解整个问题。尽管如此,谨慎的防御性编程可以避免许多问题。良好的编程是关于纪律和对细节的关注。彻底的测试将防止错误在软件发布中泄露。
这一节可能会非常庞大,但所有的预防建议都归结为那一个简单的声明:使用你的大脑。话已至此。
蜜蜂喷雾剂,蜗牛驱赶剂,苍蝇纸……
存在着许多有用的调试工具,你不利用它们真是太愚蠢了。其中一些是交互式的,允许你在程序运行时检查代码。其他的是非交互式的,通常作为代码过滤器或解析器运行,在分析后输出有关程序的信息。了解它们的工作原理,可以极大地减少你的调试时间。
调试器
这是众所周知的调试工具;其名称掩盖了其目的。调试器是一个交互式工具,允许您查看运行程序的内部结构,并对其进行探索。您可以跟踪控制流,检查变量的内容,在代码中设置断点以供稍后中断,甚至可以随意运行代码的任意部分。
调试器有多种形状和大小;有些是命令行工具,而另一些则是图形应用程序。您的特定开发平台至少会有一个可用的调试器(尽管无处不在的 gdb 现在似乎已经移植到每个可想象的平台上了)。
调试器依赖于在您的可执行文件中留下的符号(这些是编译器内部信息的元素,通常在最终链接阶段被移除)——它使用这些符号为您提供有关函数和变量名称以及源文件位置的信息。
虽然调试器是丰富且强大的工具,但我相信它们往往被误用或过度使用,实际上可能会抑制良好的调试。程序员很容易陷入追逐程序行为,被观察到的错误变量值、错误的函数调用所分心,而忘记了退后一步思考他们试图解决的问题。对失败进行一些思考可能会比在调试器中寻找更快地确定具体的故障。
关键概念
在遇到无法解释的行为时,谨慎使用调试器。不要常规地伸手去使用它们作为理解您的代码如何工作*的替代方案。
内存访问验证器
这是一个交互式工具,用于检查您的运行程序中的内存泄漏和越界。它可以非常有用,可以揭示大量您从未意识到的内存释放错误。
系统调用跟踪
系统调用跟踪实用程序,如 Linux 的 strace,显示了应用程序发出的所有系统调用。这是一种查看程序如何与其环境交互的好方法,当程序似乎在某些外部活动上停滞不前时,尤其有用。
核心转储
这是 Unix 术语,指的是程序异常退出时操作系统生成的程序快照。这个术语来源于古老的具有铁氧体磁芯内存的机器;今天仍然将转储文件称为核心。它包含程序死亡时的内存副本、CPU 寄存器的状态以及函数调用栈。核心转储可以被加载到分析器(通常是调试器)中,以揭示大量有用的信息。
记录
日志记录功能允许你在程序运行时以编程方式生成有关应用程序的信息。丰富的日志系统允许你为输出分配优先级(例如,调试、警告、致命),然后在运行时过滤出特定的消息级别。程序的日志提供了活动的历史记录,有助于确定触发失败的环境。
即使没有良好的日志记录功能(无论是作为操作系统的一部分还是来自第三方库),你也可以通过在代码中随意添加基本的打印语句来达到相同的效果。然而,这些打印输出可能会干扰正常的程序输出,并且在生产代码发布时都必须仔细删除。
有时甚至低级的打印指令也不可用。有一次,在启动新的硬件时,我唯一可用的诊断输出是一个单色的八段 LED 显示屏和一个连接到备用系统总线的示波器。当你尝试将大量信息塞入几个灯泡时,这真是令人印象深刻!
日志记录有一些缺点:它可能会减慢程序执行速度,增加可执行文件的大小,甚至引入它自己的错误。一些日志系统,其中崩溃会破坏包含日志消息的缓冲区,对于捕获程序崩溃来说是无用的。确保你知道你的日志机制表现如何,并且始终将诊断打印语句发送到无缓冲的输出流。
静态分析器
这是一个非交互式工具,用于检查你的源代码中可能存在的问题。许多编译器在设置为最大警告级别时执行基本的静态分析,但好的分析工具远不止于此。存在一些产品可以检测问题代码和任何未定义行为或非可移植结构的用法,以识别危险的编程实践,提供代码度量,强制执行编码标准,并创建自动测试框架。
使用静态分析工具可以在错误有机会造成影响之前消除许多错误——这是一个方便的安全网。使用与编译器制造商不同的公司的静态分析器是一个实用主义的思想——两家公司不太可能做出了相同的一组假设或错误。
简而言之
我能记得我意识到从那时起我生命中很大一部分时间将花在寻找自己程序中的错误的那一刻。
--Maurice Wilkes
就像死亡和税收一样,无论我们多么努力地避免它们,错误总会发生。当然,你可能能够通过使用各种抗皱霜和巧妙地操纵金钱来减轻前两种错误的影响,但如果你不知道如何面对错误,你的代码就注定要失败。
调试是一种你培养的技能。它不依赖于猜测,而是依赖于有系统的检测和深思熟虑的修复。
| 好程序员…… | 差程序员…… |
|---|
|
-
不要培养错误;要仔细编写代码,以防止一开始就引入它们
-
理解他们的代码做什么,并编写仔细的测试以确保它不会轻易被破坏
-
有条不紊且仔细地寻找错误,而不是没有战斗计划就一头扎进去
-
了解它们的局限性,并在遇到困难时请求他人帮助查找故障
-
在进行“简单”修复时也要小心地更改代码
|
-
不要调试;他们乱打一气,沉没在糟糕代码的海洋中
-
大部分时间都在调试器中,试图弄清楚他们的代码在做什么
-
遇到故障并试图隐藏它——他们积极避免调试
-
对他们代码的质量以及他们修复错误的能力有不切实际的期望
-
通过掩盖症状而不是追踪问题到其真正原因来“修复”错误
|
另请参阅
第一章
如何防止错误在你的代码中扎根。
第八章
在你知道它存在之前,你不能修复一个故障。彻底测试是一种预防机制,可以阻止故障泄漏到你的软件发布中。
第二十章
代码审查有助于定位和根除错误,并可以识别出否则可能未被发现的潜在问题区域。

开动脑筋
在第 500 页的"附录 A"部分可以找到对这些问题的详细讨论。
沉思
-
是由编写代码的原始程序员修复故障最好,还是由发现问题的程序员更适合进行修复?
-
你如何判断何时使用调试器,何时使用你的大脑?
-
在开始尝试查找和修复错误之前,你应该先学习不熟悉的代码。但软件工厂的时间压力通常意味着你无法花太多时间研究和理解你正在修复的程序。最好的前进方式是什么?
-
描述避免内存泄漏错误的良好技术。
-
在什么情况下可以快速尝试查找和修复故障,而不是采用更系统的方法是合理的?
个人感悟
-
你通常使用多少调试技术/工具?你见过哪些可能对你有用的其他工具?
-
在你选择的语言中,常见的错误和陷阱是什么?你如何在自己的代码中防范这些类型的错误?
-
在你的代码中发生的错误大多是粗心的编程错误,还是更微妙的问题?
-
你知道如何在你的平台上使用调试器吗?你有多频繁地使用它?描述如何做以下事情:
-
生成回溯
-
检查变量值
-
检查结构中字段的值
-
运行任意函数
-
交换线程上下文
-
第十章。杰克建造的代码
将源代码转换为可执行代码的机制
你花费多年建造的东西可能在一夜之间被摧毁。尽管如此,还是要继续建造。
--特蕾莎修女
程序员(Geekus maximus)通常在其自然栖息地被发现,弯曲在显示器那神秘的微光中,将深奥的标点符号组合输入到文本编辑器中。偶尔,这只胆怯的生物会离开其巢穴去寻找咖啡或披萨。它迅速返回安全的地方,继续在键盘上执行其仪式。
如果编程仅仅是输入语言结构,那么我们的工作将会容易得多,尽管我们可能会被传说中的无限数量的猴子及其无限数量的文本编辑器所取代。相反,我们必须运行我们的源代码通过编译器(或解释器)以获得可能正如我们意图那样工作的东西。不可避免的是,它并不总是这样。重复这个过程。
将精心打磨的高级语言转换为可分发可执行程序的任务通常被称为构建代码(尽管你会在大多数情况下发现这个术语与制作和编译几乎可以互换使用)。
这种构建行为是我们所做事情的基本部分——我们无法在不执行构建的情况下开发代码。因此,了解涉及的内容以及你的项目构建系统是如何工作的,对于对生成的代码有信心来说非常重要。这里有很多微妙的问题在发挥作用,尤其是在代码库达到合理规模时。有趣的是,几乎所有编程教科书都会略过这类话题;它们展示的是单文件示例程序,没有展示任何真正的构建复杂性。
许多开发者依赖于他们 IDE 的构建系统,但这并不能消除理解其工作原理的负担。一键生成所有代码非常方便,但如果你不知道哪些选项被传递给 C 编译器,或者你的目标文件中留下了哪个级别的仪器,那么你实际上并没有控制权。如果你在命令提示符中输入单个构建指令,也是如此。你必须了解底层发生了什么,才能能够重复执行可靠的构建。
语言障碍
编程语言有多种类型,每种语言都有其从源代码构建可执行程序的独特机械过程。有些构建模型比其他模型更复杂,每种模型都有其优点和缺点。
主要有三种机制:解释型语言、编译型语言和字节编译型语言。这些在图 10-1 中展示。

图 10-1. 编程语言构建和执行方法
我们真的构建软件吗?
构建经常被用作编程的隐喻,将我们所做的事情等同于“传统”的建筑行业。由于两者都是建筑过程,所以有很多显著的相似之处。实际上,我们已经看到了这两个学科之间的一些重叠和协作,正如软件模式运动(参见第 255 页的“设计模式”)从克里斯托弗·亚历山大的建筑工作中汲取了灵感。(亚历山大 79)
理解这个隐喻可以延伸多远以及它实际上有多有用是有价值的。毕竟,没有哪个隐喻是完美的。虽然这是一个哲学性的话题,并且有点离题,但它确实很重要,因为这种比较不可避免地会偏颇我们的开发方法。隐喻在某些地方是有帮助的;在其他地方则不够完美(甚至可能是有害的)。
好的方面
就像房屋的物理建筑过程一样,我们从无到有,通过将一层结构置于另一层之上来构建。在施工开始之前,应该已经完成了需求收集和仔细的设计与架构。虽然你可能不需要太多计划就能建造一个花园小屋,但你如果希望一座没有计划的摩天大楼能站立起来,那就疯了;你需要前期进行严肃的设计和规划。这很巧妙地与我们软件构建相对应。
不好的方面
这个隐喻在其他领域也显得有些单薄。我们比修改房屋的基础更容易修改我们软件构建的基础层。拆除软件建筑比拆除物理建筑要便宜得多。这意味着软件世界提供了比物理世界更多的原型设计和探索的机会。
现实世界的建筑要求遵循良好的工程原则;这一点在法规中得到体现,并由公众责任来执行。许多软件公司即使工程原则打在脸上也未必能认出来。
丑陋的方面
我们整个的开发流程就是类似于一个物理建筑过程,包括系统构思、设计、实施和测试。但我们在本章真正思考的却有所不同——它围绕着编译以及这种建筑任务所涉及的程序。这里的比喻也有点不协调。每次你获取一些源代码的新副本时,你“构建”它,创建一个可执行程序;这就是我们在这里关注的。要清楚这两个“构建”术语的不同用法。
软件构建过程遵循其自身的规则——如果你修改了一个函数,那么你必须执行系统重建。相比之下,你不需要每次粉刷门时都重建房屋的墙壁。
解释性语言
使用解释型语言编写的代码不需要经过特定的构建阶段。编写一些代码后,你只需告诉解释器它的位置;它将实时解析并执行指令。常见的解释型语言有 Perl、Python 和 JavaScript。大多数面向对象的编程语言都是解释型,这主要是因为随着计算机运行解释器速度的提高,这些语言是在最近几年发展起来的。
解释型语言的主要优势是它们的开发速度;没有中间的编译阶段,你可以非常快速地测试每个更改。你还获得了平台独立性——流行的语言解释器可以在许多不同的平台上运行。你的程序将在解释器被移植到的地方工作。
但解释型程序有一些缺点:它们的执行速度比编译型程序慢,因为语言运行时必须读取、解析、解释并执行每个单独的代码语句。这是一项大量工作。现代机器如此之快,这仅是针对最计算密集型应用的问题。有各种解释器技术可以提高代码性能:一些语言在执行前预先编译源文件(减慢启动时间)或采用即时编译(JIT),在函数即将运行时编译每个函数(减慢每个函数的第一次调用)。对于大多数程序来说,这并不是一个显著的开销,JIT 编译的性能与本地编译代码无法区分。
脚本语言通常被解释。这些语言通过非常宽容地对待可疑代码(宽松的语言规则和弱类型)以及避免复杂特性,支持非常快速的开发周期。脚本语言通常用作粘合剂,以更方便的方式调用其他实用程序。Unix shell 脚本、Windows 批处理文件和 Tcl 是脚本语言的例子。
编译型语言
编译型语言使用构建工具链将你的源代码文件转换为在目标平台上本地执行的机器指令。目标执行平台通常与开发平台相同,但嵌入式开发者经常在 PC 上构建,并针对非常不同的机器,使用交叉编译器。大型项目通常在几个阶段编译;每个单独的源文件被编译成一个中间的对象文件,然后这些对象被链接成一个最终的可执行文件。这种构建模型可以用烘焙蛋糕的隐喻来表示,如图图 10-2 所示,其中单个成分(源文件)被混合(编译),最后一起烘焙(链接)。
C 和 C++ 是最受欢迎的编译型语言,尽管大多数结构化语言都是编译的。由于其本质,编译型应用程序将比其解释型对应物运行得更快(至少在没有 JIT 编译的情况下),尽管在实践中,你可能不会注意到这一点——大多数应用程序不是计算密集型的;它们大部分时间都在停滞,等待用户、磁盘或网络输入。
编译型语言的构建过程比解释器更复杂,因此有更多的潜在故障点。应用程序必须为每个你想要运行它的目标平台重新编译.^([1])

图 10-2. 编译糖果屋
字节编译语言
字节编译语言位于解释型语言和编译型语言之间。它们涉及一个编译步骤,但不会生成原生可执行程序。相反,产品是一个包含 字节码 的文件;这是一种伪机器语言,可以被 虚拟机 执行。Java 和 C# 是常见的字节编译语言。
一个常见的误解是执行字节码必然比执行等效的编译二进制文件慢。这并不总是如此。JIT 优化器可以对代码做出智能决策,这可能使其特别快(例如,根据程序正在执行的硬件进行定制)。
作为一种折衷方案,字节编译器继承了先前方法的一些优点和缺点。字节码可以在虚拟机已移植到任何平台上执行,因此你获得了可移植性(尽管某些语言的运行时比其他语言的运行时更广泛地移植)。
^([1]) 目标平台通过其处理器类型和宿主操作系统来区分。其他因素,如可用的外围硬件,可能也很重要。
小题大做
编译型(和字节编译型)的构建模型是最难推理的,因此让我们调查编译软件涉及的内容。令人震惊的是,真正理解这一点的新训练程序员如此之少,所以我们将从基本原则开始。如果你已经知道这些内容,请随意跳过。
为了更好地理解,最好是将每个手动步骤都考虑进去,而不是依赖你的 IDE 为你完成所有重建工作。这个关于简单程序开发的五部分故事将解释:
-
你正在启动一个新的项目,用 C 语言编写。它将解决软件开发世界的所有问题,并将迎来世界和平的新时代。然而,你最初只有包含
main的单个文件。你必须从这里开始。构建和运行这个单文件程序很容易——你只需输入
compiler main.c,^([2]) 然后就会为你生成一个可执行文件,你可以运行和测试它。很简单。 -
程序不断增长。为了帮助组织各个部分,你将其拆分为多个文件,每个功能块一个文件。构建过程仍然很简单。现在你输入
编译器 main.c func1.c func2.c。同样的可执行程序就会生成,让你继续像以前一样进行测试。没问题。 -
很快,你就会意识到代码的一些部分实际上是具有独立关注点的独立组件,几乎就像独立的库。通过将这些代码部分放在它们自己的目录中——将相似的代码部分分组在一起——更容易对这些代码部分进行推理。现在项目开始扩展。构建这种新文件结构的简单方法是手动编译每个单独的源文件,使用一个不构建可执行文件,只生成中间目标文件的编译器调用。之后,
main.c被编译并与所有中间目标文件链接。为此,你可能还需要将编译器指向其他目录的包含文件。现在事情变得稍微复杂一些。每当你在一个新目录中更改一些代码时,你必须在该目录中执行编译命令,然后再次发出最终的“链接所有内容”命令。相当手动。此外,如果你更改了其他目录使用的头文件,所有那些目录也必须重新构建。如果你忘记了,链接器可能会生成一系列神秘的抱怨。
为了消除这个巨大的命令行负担,你可以编写一个shell 脚本(或在 Windows 上的批处理文件),它会遍历每个目录并执行必要的构建命令。隐藏了所有那些杂乱的工作和繁琐的编译器参数后,你可以安心回到代码开发的正事上,不必记住不必要的构建细节。
-
之后,这些子目录变成了真正的独立库;它们也被用于其他项目。你整理代码,使其使用起来更加友好,添加一些良好的用户文档,然后将构建命令更改为生成共享库而不是目标文件。这需要对你的构建脚本进行一些修改,但这是一个相对隐蔽的更改,并不太痛苦。
-
开发就这样持续了一段时间。代码被快速添加。创建了大量的新子目录和子子目录。尽管文件结构看起来相当整洁,但构建时间成为一个问题——每次你启动构建脚本时,它都会重新编译每个源文件,即使那些没有改变的文件。这里的诱惑是自行跟踪所有更改,并再次手动执行子目录构建(也许通过创建单个目录构建脚本作为折中方案)。项目现在如此庞大,很容易遗漏一些依赖项。这可能导致难以解决的构建错误,甚至更微妙的问题(例如,你可能会遇到不会阻止链接工作的缺陷,但会使程序以错误的方式运行)。
现在您的开发正处于边缘。您不能信任用于构建代码的系统。这并不安全。只有当您已经彻底清理并从头开始重建时,您才能真正信任可执行文件。
正是这种场合的工具。经典的解决方案是一个名为 make 的命令行程序。(Feldman 78)它为您处理所有中间对象文件和编译规则,最重要的是,跟踪哪些文件依赖于哪些其他文件。您通过编写 makefiles 来告诉它做什么,这些 makefiles 提供了必要的构建规则。它查看源文件的最后修改时间戳,以检查自上次执行 make 以来发生了什么变化,然后它只重新编译那些文件,以及依赖于它们的任何文件。这是一个比我们上面写的脚本更智能的版本,专门针对编译和重新编译软件的任务。
多年来,出现了许多谦逊的 make 的变体,如今许多都带有相当漂亮的 GUI 界面。GNU Make 是最广泛使用的工具之一(它是免费的,并且非常灵活)。如果你还没有被引入 Make 的崇拜,请参阅第 183 页的"MAKE: A TOURIST'S GUIDE",它解释了其基本操作。
常用的构建系统还有很多。例如,看看 SCons、Ant、Nant 和 Jam。它们各自针对特定的构建环境(例如,Nant 用于构建.NET 项目)或特定的质量(许多旨在简化 make 的语法,而 make 的语法相当复杂!)。
^([2]) 显然,你会用命令来启动你的 C 编译器来替换compiler——这是一个假设的例子。
构建构建
在这个软件构建的泥潭中,我们已经看到了构建过程的一些主要问题。本质上,任何软件构建过程都将以一个或多个源文件作为输入,并在另一端输出一些可执行程序。它甚至可以生成整个发布版本,包括可执行文件、帮助文件、安装程序等等,所有这些都被整齐地打包好,准备烧录到 CD 上。
条款和条件
这些术语构成了主要的软件构建术语:
源代码
源代码物理上包含在你所写的文件中,通常以高级语言的形式出现。这些语言结构可以通过适当的工具转换为功能程序。
编译
源代码可以通过两种方式之一转换为可执行文件。一种是将它编译成可执行程序。另一种是在实时中解释源代码——语言运行时在程序运行时解析并作用于源代码。
构建
这是一个模糊的术语,通常用作编译的同义词。编译是一个单独的构建步骤,而构建描述的是整个构建过程。术语make也以类似的方式使用;更糟糕的是,它也是一个常见的软件构建工具的名称。
目标代码
目标代码存储在目标文件中。它代表了源代码文件的编译版本。目标代码不能直接执行;它依赖于其他代码文件(大多数程序由多个源文件组成)。目标文件必须与其他对象链接以创建一个可执行文件。
库
代码库类似于目标文件——它是一系列编译代码的集合,而不是一个完整的程序。库包含了一组有用的功能,可以集成到任何程序中。库可以是静态的或动态的。前者像目标文件一样链接,而后者在程序运行时由应用程序动态加载。
机器代码
一些编译步骤产生的是机器代码而不是目标文件。这种源代码形式代表了程序的确切 CPU 指令。机器代码通过汇编器转换成实际的 CPU 指令,这也是为什么它也被称为汇编代码。
一些低级操作系统库和嵌入式程序是用汇编语言编写的,但我们通常使用高级语言,并将汇编留给编译器的内部工作。
链接
链接器将一个或多个目标文件(以及可能包含库)组合成一个最终的可执行文件或部分链接的代码库。
可执行文件
编译或链接步骤的结果。这是一个可以在您的计算机上直接运行的独立程序。
就像我从其中毫不脸红地窃取了本章标题的累积故事一样,随着我们的软件发展和成熟,构建过程也随之发展和成熟。也许你的起点不像上面的例子那么基础,但构建脚手架往往从简单开始,并随着构建的代码一起增长。大型项目通常有一个令人困惑的构建过程,它需要(但不一定总是有)足够的文档。我们可以看到,编译单个源文件是构建食物链的最低级别,我们将在这一简单行为之上建立一座额外的塔楼。
构建过程不仅仅是编译源文件。它可能还涉及从模板中准备一些文本注册文件,为 UI 创建国际化字符串,或将图形文件从其源分辨率转换为某些目标格式。实际上,所有这些活动都可以挂载在构建系统上,并在构建的正常过程中运行。这确实假设所有工具都是可脚本化的——它们可以被其他程序(例如,make)运行。
重要的是要考虑你的构建系统是整个源树的一部分,而不是独立的东西。Makefiles 与其他源文件一起存放在版本控制之下,与源代码一起维护,并且与任何其他源文件一样是程序的一部分。它们是必不可少的——没有它们,你无法创建应用程序。
关键概念
将构建系统视为源树的一部分,并一起维护它们。它们紧密相连。
MAKE:旅行者的指南
Make 是编程世界中应用最广泛的构建系统之一。这里简要介绍一下它是什么以及它能做什么。
Make 系统由makefiles驱动,这些文件通常位于构建源代码旁边的目录中。这些 makefiles 包含规则,描述了如何构建应用程序。每条规则描述一个目标(即要构建的程序或中间库),详细说明它所依赖的内容以及如何创建它。文件中的注释以前缀#开头。以下是一个简短的示例(使用假设的compiler程序来构建源代码):
# This first rule says ".o files can be built from
# .c files and here's the command to do it." $< and
# $@ are magic names for the source and destination
# file. Yes, make's syntax can be a little cryptic...
%.o: %.c
compiler -object $@ $<
# This rule says "the program myapp is built from these
# three .o files, and here's how to link them together"
myapp: main.o func1.o func2.o
linker -output $@ main.o func1.o func2.o
这就是一般思路。如果你将它们保存为具有魔法文件名Makefile,然后发出make myapp命令,它将被加载并解析。由于myapp依赖于一些.o文件,因此这些文件将首先使用提供的规则从各自的.c文件构建。然后运行链接器命令来创建应用程序。有许多方法可以使它更整洁,以便更容易管理。例如,makefiles 可以定义变量;myapp规则看起来更简洁,如下所示:
OBJECT_FILES=main.o func1.o func2.o
myapp: $(OBJECT_FILES)
linker -output $@ $(OBJECT_FILES)
对 make 使用细节的更深入描述超出了本书的范围,但这是每个开发者都应该知道的事情。还有许多其他有用的功能。GUI 构建工具本质上是对这种功能性的包装,隐藏了编写 makefiles 的细节。它们通常更容易设置,但在你想要进行一些高级构建配置时可能会成为障碍。
什么是一个好的构建系统?
以下是一些好的构建系统的重要特性。
简单性
构建系统必须对所有程序员都可用,而不仅仅是构建专家。每个开发者都必须能够执行构建,否则他无法完成任何工作。如果构建系统过于复杂,实际上就没什么用了。它必须:
易于学习
也就是说,新开发者应该能够加入团队并快速了解如何构建软件。他必须掌握构建过程才能变得高效。我在一些公司工作过,在那里,弄清楚构建过程并执行它被认为是一种成年礼。这不仅是一种无益的态度,而且是非常危险的——当真正知道如何构建代码的人离开时会发生什么?
随着软件的增长,它变得更大,更难以理解。随着构建系统与软件一起增长,它也变得更大,更难以理解。随着新功能的引入,构建往往会变得更加复杂和晦涩。抵制复杂性。
简单设置
设置构建意味着:
-
使用一个干净的 PC(仅包含主机操作系统的最新副本)
-
安装所有必要的软件(编译器、翻译器、源代码控制、安装程序,以及补丁/服务包)
-
安装所有必要的库(注意正确的版本)
-
创建正确的环境以执行构建(这可能涉及设置目录结构、分配环境变量、获取正确的工具许可证等)
没有明确的设置说明,你怎么能确保你的构建是一个可重复的过程?
不出所料
最好使用常见且广为人知的构建工具。它们是人们期望并知道如何使用的工具,因此学习曲线不那么陡峭。那些做事情没有人真正理解的复杂构建工具令人担忧.^([3])
一致性
每个人使用相同的构建系统是至关重要的。否则,他们不会构建相同的软件。不同的构建机制可能看起来等效——我使用我的 IDE,而他使用 makefiles——但你会增加维护工作量和出错的可能性。细微的差异可能会悄悄出现——例如,编译器选项可能不同,导致不同的可执行文件。
这与维护构建系统与源树并行的要求相吻合。如果构建系统在物理上确实是代码的一部分,那么它就不能被忽视或避免。
关键概念
项目中的每个程序员都必须使用相同的构建环境。否则,你们不会构建相同的软件
这可能看起来非常明显,但很容易出错。即使你确实都在愉快地共享 makefiles,其他差异也可能被忽视——库、工具或构建脚本的版本不匹配都可能导致构建不同的程序。
可重复且可靠
构建必须是确定性和可靠的。在执行构建之前,你应该能够轻松地确定输入文件的集合。在相同的文件集上执行两次单独的构建应该每次都给出完全相同的可执行文件——构建应该是可重复的。
关键概念
一个好的构建系统允许你反复创建物理上完全相同的二进制文件。
然后,你可以在版本控制系统中标记这组源文件为软件的特定版本(或将文件存档到备份存储中),并在未来的任何时候进行许多相同的构建。
这至关重要——一个重要的客户可能在软件的旧版本中发现一个显著的错误,如果你无法回到那个版本并生成完全相同的程序,你可能永远无法重现失败,更不用说找到故障了。
关键概念
你必须能够从三年前提取源代码树并正确地重新构建它。
产生不可重复二进制的构建过程令人担忧。如果构建输出的内容依赖于月相周期,那么世界就变成了一个难以推理的地方。这意味着在源文件中应该将 C 的__DATE__或其他可能改变的信息的使用限制在绝对最小范围内。
构建必须始终完美无缺——它必须是可靠的。如果它每隔一天就崩溃,或者偶尔产生损坏的二进制文件,那么它不仅无用,而且危险。你怎么能确定你在测试一个好的二进制文件?你怎么能确定你的公司正在发布一个可接受的产品?构建系统的问题真的阻碍了开发。
构建应该几乎是无形的;你需要担心的事情只是如何转动把手,你应该确信最终会得到正确的结果。
原子性
理想的构建系统会从未经修改的原始源代码一次性编译,无需人工干预。不应该有特殊的步骤需要你执行构建。你不应该在构建过程中启动另一个应用程序并推动一个文件。你甚至不需要运行多个命令来执行构建。这确保了没有信息被锁在你的脑海中,只等着丢失。所有的构建魔法都记录在一个可靠的地方——构建脚本本身。构建总是可重复的。它是安全的。
关键概念
良好的构建过程被描述为单一步骤。你只需按一个按钮或发出一个命令即可。
如果你无法达到这个理想(而且这并不完全不合理),那么构建越少手动越好。所有手动步骤都需要完整的文档。将程序拆分成这些单独的部分是可以接受的(事实上,这是建议的):
-
获取原始源代码。
-
构建它。
-
从这个版本创建发布分发。
看看构建代码的概念是如何与获取它分开的——相同的构建指令可以根据你开始的源代码版本创建软件的任何版本。打包程序也是一个单独的步骤;对于开发工作,你并不总是想浪费时间创建完整的安装包。
一个战争故事
可重复构建是必不可少的;你必须能够重新生成你软件的任何已发布版本。否则你会遇到麻烦。我曾经为一家公司工作,他们正面临着这个确切的问题。
他们在一个客户的网站上对代码进行了实时更改,但没有在版本控制的主副本中复制这个更改。客户不再运行“官方”的软件版本。后来,当客户发现一个关键错误时,程序员无法重现它。但当然,没有人能弄清楚为什么,因为现场调整已经被遗忘了。
他们为什么要这样做?因为快速而粗糙的修改比正确地(即,修复主代码库中的错误、测试、发布官方软件版本、将其发送给客户,然后在安装前获得适当的批准和签字)要容易得多。当你的客户的业务依赖于你的软件,并且整个生产线都在等待你修复错误时,进行快速修复的压力是巨大的。
应对错误
在开发结束时,当完成代码尘埃落定后,将没有构建错误。但在开发过程中,你会在各个地方破坏东西。构建系统必须应对这种情况,并应该提供帮助来处理它。
-
你的构建系统在出现错误后不应继续运行。它应该停止,并让你毫无疑问地知道什么出了问题以及在哪里可以修复。如果构建过程继续,几乎肯定会因为第一个跳过的错误而导致其他问题。这些问题将非常难以理解。为了你自己的精神健康,不要打破这条规则!
-
构建系统应在构建步骤失败时删除任何不完整的对象。否则,下次你运行构建时,它将假设该文件实际上完好无损,并从它那里继续。这将在以后造成很多痛苦;那些神奇地隐藏自己的错误很有趣。
-
构建过程不应嘈杂。这并不是由构建过程本身决定的,而是由你所编写的源代码决定的.^([4]) 如果你的代码生成了编译器警告,那么其中肯定有一些你应该检查的地方。通过编写更好的代码来说服编译器保持安静。大量的愚蠢警告可能会掩盖你应该阅读的更隐蔽的信息。
为了最大限度地放心,请启用所有编译器警告进行构建——关闭它们并不能解决问题;它只是隐藏了问题。
遵循这条建议的唯一真正方法是从一开始就考虑:在你项目的开始阶段就考虑构建过程。当你已经编写了大量代码时,尝试添加表示所有警告启用的标志,会导致立即出现大量警告。最可能的反应是迅速关闭标志,假装它从未发生过。为了轻松的生活,什么都愿意做。你真的必须从你打算开始的地方开始。
^([3]) 我对任何比 GNU Make 更聪明的工具都持有内置的不信任,但这可能更多地反映了我自己,而不是其他聪明的 make 工具。感谢 GNU Make,它非常聪明!
^([4]) 实际上,这可能是——你可以禁用编译器警告来消除噪音。这是错误的解决问题的方式。
构建机制
除了这些质量担忧之外,还有构建系统的实用性。为了具体讨论这个问题,我们将讨论 make,一个特定的构建系统,以及 makefiles——不要过于担心;除了语法差异外,其他构建系统遵循类似的约定(甚至包括那些漂亮的图形界面)。
目标选择
Makefiles 定义 规则,描述如何构建 目标。(记住:其他构建系统以非常相似的方式工作,即使术语略有不同。)系统足够智能,可以推断所有中间目标并在构建过程中构建它们。一个 Makefile 可以包含多个目标。这允许你使用一个构建系统生成多个不同的输出,例如:
-
不同的程序(常见于两个程序有一些共同的代码组件,因此它们位于构建源树中)
-
为构建应用程序选择不同的目标平台(例如,Windows/Apple/Linux 版本,或桌面/PDA 发布)
-
产品变体(完整的 发布构建 或禁用保存/打印的 演示版本)
-
开发构建(启用调试支持,开启日志记录,并使断言成为致命错误)
-
不同的 构建级别(仅构建内部库,构建应用程序,构建整个发行版)
你甚至可能需要这些目标的某些组合,例如一个“演示 PDA”构建.^([5]) 你可以设计你的源树,使得每个这些目标都可以从同一个地方构建。你不必只输入 make,你可以输入 make desktop 或 make pda,然后就会得到一个合适的可执行文件。(make 后面的名称是它应该尝试构建的规则。)
与为每个目标维护单独的源树相比,这样做有很大的好处。在大多数代码都相同的情况下维护多个源树将是一项艰巨且容易出错的任务。你可能会忘记将你的修改之一应用到代码的所有副本上.^([6])
那么,这些目标规则有何不同?实际差异可以归结为许多事情:
-
正在构建不同的文件(例如,
save_release.c或save_demo.c) -
传递给编译器的不同宏定义(例如,编译器预定义一个
DEMO_VERSION宏,以选择save.c中适当的#ifdefed 代码) -
使用不同的编译器选项(例如,启用调试支持)
-
为构建选择不同的工具集或环境(例如,使用针对目标平台的正确编译器)
虽然你可以为所有各种细微差异设置任意数量的目标,但这可能会使你的构建系统变得复杂且难以控制。一些选择可以移动到构建配置选项。一些配置实际上可以在代码安装时完成,甚至可以在运行时完成。如果这减少了需要测试的不同构建的数量,那么这是更好的选择。
MAKE 之后的生活
我们在这里调查的许多问题都非常特定于 C 风格的开发生命周期,其中编译器从源文件生成目标代码和库,并将它们链接到最终的可执行文件。有些语言遵循不同的模型。Java 极大地简化了构建过程;javac 编译器接管了 make 的角色,自动执行依赖性检查。它使你更加受限,强制执行特定的构建树结构,但这样做也使你的生活更加轻松。
简单的 Java 程序不需要复杂的构建系统;一个javac命令就可以安全地重建整个世界。然而,一个相当大的 Java 项目通常会使用 make。我们已经看到,构建不仅仅是编译源代码。你需要一个机制来准备支持文件,运行自动化测试,以及创建最终的发行版。Make 是一个很好的框架,可以挂载这些功能,所以它并不是完全多余的。
家庭管理
对于你定义的每个目标规则,都应该有一个相应的清理规则来撤销所有的构建操作——移除程序可执行文件、中间库、目标文件以及构建过程中创建的任何其他文件。源代码树应该恢复到其原始的原始状态——验证这一点相对容易。([2])
这意味着一个物理修改源文件的构建系统是令人讨厌的——你怎么能轻松地回滚这些更改?你应该使用原始文件作为模板,并将修改发送到不同的输出文件。
清理规则是一种良好的家庭管理惯例。它们允许你在认为构建小鬼正在逼近时,轻松地将所有内容清除并从头开始重建。
关键概念
对于每个构建规则,都应该有一个相应的清理规则来撤销操作。
依赖关系
构建系统如何知道哪些文件依赖于哪些其他文件?在没有 ESP 的情况下,这是一个困难的任务,因此我们将寻求那些知道的人的帮助。
你在 makefile 规则中提供依赖信息:make 首选格式的配方。Make 可以构建并跟踪依赖树,检查每个文件的最后修改时间戳,并在任何修改后确定哪些部分需要重新构建。
对于可执行构建规则来说,这已经足够简单了——你只需要指定哪些目标文件和库构成了它。然而,你并不想费力地为每个单独的源文件指定依赖信息;毫无疑问,有许多被#include的文件,它们自己又#include了许多其他文件。这是一个相当长的列表。一开始很容易打错字,而且很可能变得过时;你可能会添加一个新的#include,却忘记相应地修改 makefile。
谁知道所有这些依赖信息?编译器知道——它是构建系统中唯一一个实际追踪所有源文件依赖的组件。方便的是,所有好的编译器都有一个选项,可以让它们输出依赖信息。技巧是编写一个 make 规则,收集这些依赖信息,将其放置在适当格式的文件中,然后将其包含在依赖树中。
自动构建
如果你的构建过程是原子的,只需发出一个命令,你就可以轻松地设置整个源树的夜间构建.^([8]) 定期的夜间构建将应用全天的代码,并对其执行完整的构建过程。这是一种非常有帮助的做法,具有许多好处:
-
每天早上都会有一份最新的技术状态。开发者们常常整天沉浸在自己的小世界里,忘记与同事的代码提交同步。这项技术提供了一种无痛苦的集成测试,确保一切都能正确地编织在一起。
-
它可以在早期就识别出构建问题,而无需你做额外的工作。当你早上坐在办公桌前,手里拿着咖啡,你可以看到源树是否处于可构建状态。你将立即知道从哪里开始修复,而不是等待自己的构建完成。
-
你可以将自动回归和压力测试添加到夜间构建中。这是一种在任何人尝试使用代码之前对代码进行合理性测试的好方法。在白天,你可能没有时间在每次构建时运行完整的测试套件——这确保了它永远不会被忽视。这是一个强大的验证机制。
-
夜间构建可以用作项目进度的衡量标准。发布夜间测试结果,随着越来越多的测试通过,开发者会获得成就感。
-
你可以从夜间构建中制作实际的产品发布。你会信任这个构建没有受到命令输入错误、配置错误或其他人为错误的影响。
-
它证明了你真的知道如何构建软件,并且构建过程确实是原子的。如果没有运行自动构建,你怎么知道你的构建过程不依赖于其他活动,比如开发者首先清理旧的构建树?
关键概念
建立软件的自动构建。使用它来确保代码库处于一致状态。
自动构建对于大型系统(构建所有内容可能需要几个小时)或多人协作的系统(每个开发者可能在任何时候都没有最新的系统源代码副本)特别有用。
对于夜间构建,一个好的做法是捕获构建日志(构建过程的输出)并使其公开可访问。也许甚至在构建失败时将结果发送电子邮件,以突出显示问题。了解每次构建发生的事情很重要,尤其是在出现问题的时候。
夜间构建成为项目开发的核心心跳。如果构建成功,代码就会健康、快乐地发展。许多项目上强制执行的一个伟大规则是:不要在源树中破坏任何东西——在夜间构建期间破坏代码的行为会受到极其痛苦和令人不快的惩罚(最好是涉及公开羞辱)。第二个规则是:如果构建失败,那是每个人的问题。如果夜间构建失败,所有开发者都必须放下手中的工具,直到它再次工作。
你可以将这个自动构建过程推向极致,并使用在源代码库更改时执行构建的工具。这被称为持续集成,并且是检查你的代码在任何时间点都是一致和可构建的强大方式。
构建配置
一个好的构建系统允许你根据每个构建配置某些方面。这可以通过你的 IDE 中的选项实现,但 makefile 通常通过定义变量来实现。变量可以从多个地方获取:
-
从调用环境中继承
-
在 make 命令行上设置
-
在 makefile 中显式定义
配置变量通常按以下方式使用:
-
定义了一个
PROJECT_ROOT变量,指向构建树的根。这允许构建系统知道在哪里查找其他文件——例如,为头文件建立路径。你真的不希望在开发机器上硬编码构建树的位置。如果你这样做了,你就无法移动它,而且你将无法同时管理两个构建树。 -
其他变量可能指定每个外部库的位置(这样你可以在测试目的下指向不同的版本)。
-
它们可能指定要生成的构建类型(例如,开发或发布)。
-
调用每个构建工具(编译器、链接器等)的命令可以放入一个变量中。这使得测试不同的命令行参数或使用不同供应商的工具变得容易。
你可以在 makefile 中放入默认值。这有两个目的:它记录了所有可用的选项,意味着你不必总是为每个配置选项提供值。
递归 Make
源代码自然地嵌套到目录中。如果一个大项目中的所有文件都放入一个目录中,事情会迅速变得难以管理。由于源树嵌套,构建系统也必须嵌套。远非使生活更加复杂,适应这种嵌套可以使构建系统更加灵活。
在一个目录中的 makefile 可以通过执行另一个 make 命令来调用子目录中的 makefile,就像它会调用编译器一样。这是一种常见的称为 递归 make 的技术;递归到每个子目录的构建系统会构建那里的组件,然后返回构建此目录中的组件。这样,您可以从项目根目录中输入 make 来构建整个代码库,或者在一个子组件目录中进行部分构建。无论您想构建什么,都会被构建。
递归 make 有助于隔离和管理构建组件,但也会引入一些自身的问题。它是慢的(因为它会启动许多子进程来遍历子目录),并且由于每个子 make 只能看到整个构建树的一部分,它可能会得到错误的依赖信息。如果您看到递归 make,请谨慎对待——更愿意使用非递归的构建系统。(有关更多信息,请参阅本章 "Mull It Over" 问题 7,第 506 页的答案。)
^([5)) 在这种情况下,机制发生了变化:您一次只能构建一个目标,因此“演示性”将变成构建配置而不是目标。后面的部分将讨论配置。
^([6]) 注意这种危险的方法与在版本控制系统中维护项目的多个 分支 是不同的。版本控制系统提供了一种机制来 合并 分支间的更改,并轻松比较分支之间的差异。
^([7]) 只需进行构建,进行清理,然后检查树与初始状态之间的差异。
^([8]) 可以使用 Unix 中的 cron 工具或 Windows 中的计划任务功能设置时间延迟命令。
请释放我
有些构建尤其重要,需要更仔细的准备。这些是 发布构建,这些构建是为了特殊目的而进行的,而不是在代码开发过程中。发布可能是一系列令人兴奋的事件之一:测试版,第一个官方产品发布,或维护发布。它也可能是一个内部开发里程碑或测试部门的临时发布;这些构建不会离开公司,但与外部发布一样受到高度重视,几乎可以看作是官方发布的消防演习。
如果构建系统被精心设计,则发布构建不需要任何额外的准备。然而,这些重要的构建必须经过深思熟虑地处理,因此我们需要确保没有构建问题会损害最终的可执行文件。发布构建的关键问题是:
-
发布构建应始终从原始源树开始,而不是从某人的半构建开发树开始。从头开始。我们需要知道正在构建的源文件的确切状态。不要相信乔的电脑上的文件处于“足够好”的状态。
-
在构建本身之前,一个特定的步骤会确定要包含在这个发布中的哪些源代码和特定文件版本。然后以某种方式标记它们,通常是通过在源控制系统中标记或标签它们。现在可以在任何后来的时间点检索发布文件集。
知识(源)树
所有代码都存在于一个源树中;这是一个包含目录和源文件的文件结构。这个树的结构会影响代码的可操作性。一团乱糟糟的文件远比一个整洁的层次结构难以理解。我们可以利用源文件结构来提高开发效率。这个树结构与构建系统紧密相连,因为构建系统实际上是源树的一部分(因此术语构建树与源树可以互换使用)。对其中一个的修改需要干预另一个。
我们将代码划分为独立的模块、库和应用。一个好的源树反映了这种结构。代码组成应该整洁地映射到文件中,使用目录作为逻辑分组机制。这有助于管理多程序员开发——每个人可能都会在自己的自包含目录中工作,与其他人的工作保持合理的距离。
库
将每个库放在一个独立的自包含目录中。使用树结构来区分库接口(公共头文件)和私有的实现细节。将公共 API 放在编译器查找路径上的目录中是一个好主意,并将任何私有头文件放在一边。
应用程序
结构化更容易;没有公共文件,只有一些链接到库的源文件集合。即便如此,每个应用程序都应该封装在自己的目录中,以明确其边界。如果应用程序足够大,有明显的组成部分,它们应该被分离到子目录中,甚至可以是库,并单独构建。使构建树反映程序结构。
第三方代码
源树应该清楚地标记出你自己的代码与第三方工作的区别。项目越来越多地依赖于他人的代码;常见的库是从外部引入的(来自商业供应商、免费软件项目,甚至是公司内的其他部分)。这些外部文件应该保持独立。
其他事项
程序文档可以存放在源树中。将其放在与它相关的代码旁边的目录中。同样适用于图形和其他任何支持文件。
-
每个发布构建都有一个特定的名称,你可以通过它来识别,有时是一个酷炫的代号,有时只是一个构建号。这应该与代码标记的源控制标签相匹配。如果我们同意在调查故障时谈论“第五版”,那么我们就在一起工作。如果你正在使用第五版,但我发现第六版中有一个故障,我们如何知道我们会看到相同的问题?
关键概念
发布构建始终从原始源代码开始。确保这些原始源代码在将来可以从源代码控制或备份存档中检索到。
-
在代码构建完成后,可能会有一些额外的打包阶段,例如准备光盘、添加文档、集成许可信息,或者任何其他内容。这一步也应该自动化。
-
每个版本都应该存档并存储以供将来参考。显然,你应该存储最终构建的可执行文件的副本,无论它以何种形式发送给用户(确切的发送 Zip 文件、自解压 EXE,或任何其他形式)。如果可能的话,你也应该捕获构建树的最终状态,但通常这会非常大且不切实际。
-
至少,应该保留构建日志,即发出的确切命令序列和生成的响应。这些日志允许你回顾旧构建,查看哪些编译器错误被忽略,或者在构建过程中确切发生了什么。有时这可以提供线索,了解在产品多年前的版本中报告的故障。
-
每个版本都有一个发布说明,描述了发生了什么变化。这可能是或可能不是面向客户的文档,具体取决于你正在构建的内容。这些说明也应该存档。通常,发布说明描述了自上次发布以来的变化,并包含官方文档打印后的更新、已知问题、升级说明等。它是发布流程的重要组成部分,不应被忽视。
-
在执行发布构建时,你必须选择正确的编译器开关——它们可能与开发构建中使用的不同。例如,调试支持会被关闭。你还需要选择适当的代码优化级别。对于开发构建,优化可能被禁用,因为优化器通常需要特别长的时间来执行。在非常大的构建树中,这可能会变得难以忍受。然而,将优化器加速到九倍速度可能会暴露编译器错误,这些错误会破坏你的代码;你必须仔细选择(并测试)一个级别。
如果你为开发和发布构建使用不同的编译器选项集,要小心。你必须在截止日期临近之前定期测试发布构建。目标是尽量减少发布构建和开发构建之间的差异。
关键概念
确保你测试的是应用程序的发布配置,而不仅仅是开发构建。细微的差异可能会对代码的行为产生不利影响。
由于创建发布构建是一个相对复杂的任务,而且非常重要,因此责任通常委托给指定的团队成员(可能是其中一位编码者,也可能是 QA 部门的人)。这个人负责为项目生成所有发布构建,以确保每个构建都具有相同的高质量。发布构建既关乎程序,也关乎构建系统。
万能的构建大师?
许多组织雇佣特定的人来担任构建工程师角色,通常被称为构建大师。这个人的工作是维护构建系统。这个角色可能还涉及规划和管理发布计划,或者它可能完全是技术性的。构建大师对构建系统了如指掌。他或她可能设置了它,根据需要添加新目标,维护夜间构建脚本,等等。构建大师还拥有构建系统文档,并可能管理源代码控制系统。
构建大师执行发布构建,因此通常与跟踪组件稳定性密切相关。他或她负责确保发布过程的可靠性和安全性。
构建大师并不总是指一个特定的全职职位;有时程序员会兼职承担这项任务。
简而言之
拆卸比构建更容易
--拉丁谚语
在表面上,如果你有合适的工具,构建软件很容易。但你必须知道如何正确使用这些工具。构建系统的质量至关重要;如果没有安全、可靠的构建过程,你无法现实地开发出坚实的代码。为生产环境生成可信赖的发布构建是一个更加复杂的任务——它需要一个彻底的方法和明确定义的程序。即使你不必每天更改构建系统,了解你启动构建时发生的事情也很重要。
执行良好的构建并非易事;我们的工作不会受到传说中的无穷无尽的猴子的影响。他们太忙于争论他们无穷无尽的文本编辑器中哪一个更好,无论如何。
| 优秀的程序员 . . . | 次等的程序员 . . . |
|---|
|
-
了解他们的构建系统是如何工作的,如何使用它,以及如何扩展它
-
设计简单、原子化的构建系统,并与其源代码一起维护
-
尽可能自动化许多构建活动
-
使用夜间构建来捕捉集成问题
|
-
忽略构建系统机制,然后被愚蠢的构建问题困扰
-
不关心他们的构建系统有多么不安全和不可靠
-
期待新来者以一种几乎对抗的方式掌握他们复杂的构建流程
-
不遵循定义的发布流程就草率地创建发布构建
|
参考内容
第九章
描述如何处理构建错误。
第十八章
构建树存储在源代码控制系统中,两者紧密相连。

开始思考
关于这些问题的详细讨论可以在第 502 页的"附录 A"部分找到。
仔细思考
-
为什么拥有良好的集成开发环境的人应该担心使用命令行 make 工具,当他们只需按一个按钮就可以构建他们的项目时?
-
为什么将源代码的提取视为与构建分开的步骤很重要?
-
构建步骤的中间文件(例如,目标文件)应该放在哪里?
-
如果你将自动测试套件添加到构建系统中,它应该在软件构建后自动运行,还是你必须发出一个单独的命令来调用测试?
-
夜间构建应该是调试还是发布版本?
-
编写一个 make 规则来自动从你的编译器生成依赖信息。展示如何在 makefile 中使用这些信息。
-
递归 make 是创建跨越多个目录的模块化构建系统的流行方法。然而,它存在根本性的缺陷。描述其问题并建议替代方案。
个人化
-
你知道如何使用你的构建系统执行不同类型的编译吗?你如何从相同的源代码、相同的 makefile 构建调试或发布版本的应用程序?
-
你当前项目的构建过程有多好?它是否与本章中提到的特性相匹配?你该如何改进它?以下操作有多容易:
-
向库中添加新文件?
-
添加新的代码目录?
-
移动或重命名代码文件?
-
添加不同的构建配置(比如,演示构建)?
-
在不进行清理的情况下,在一个源代码树的副本中构建两个配置?
-
-
你是否曾经从头开始创建一个构建系统?是什么驱使你选择了特定的设计?
-
每个人有时都会在构建系统中遇到缺陷。当编写构建脚本时,你引入错误的可能性与编写真实代码时一样大。
你遇到过哪些构建错误,你该如何修复,甚至预防它们?
第十一章。速度的需求
优化程序和编写高效代码
生活不仅仅是提高速度。
--甘地
我们生活在一个快餐文化中。不仅我们的晚餐应该昨天就到,我们的汽车应该快速,我们的娱乐应该即时。我们的代码也应该像闪电一样运行。我想要我的结果。而且我想要它现在。
具有讽刺意味的是,编写快速程序需要很长时间。
优化是软件开发的幽灵,正如著名计算机科学家 W.A. Wulf 所观察到的:“在效率的名义下犯下的计算错误(不一定实现了效率)比任何其他单一原因都要多——包括盲目的愚蠢。”(Wulf 72)
优化是一个老生常谈的话题,每个人都提出了自己的看法,同样的建议一次又一次地被提出。但尽管如此,许多代码仍然没有合理地开发。优化看起来是个好主意,但程序员经常出错:他们被效率的诱惑所分散,以性能的名义编写糟糕的代码,他们在不必要的时候进行优化,或者应用了错误的优化方式。
在本章中,我们将讨论这个问题。我们将回顾熟悉的内容,但也要注意沿途的一些新观点。不用担心——如果主题是优化,它不应该花费太多时间……
优化是什么?
单纯的“优化”一词纯粹意味着使某物变得更好,改进它。在我们的世界中,它通常被理解为“使代码运行更快”,即测量程序的性能与时间的关系。但这只是其中的一部分。不同的程序有不同的要求;对某个程序“更好”的,对另一个程序可能就不是“更好”。软件优化实际上可能意味着以下任何一种:
-
加快程序执行速度
-
减少可执行文件大小
-
提高代码质量
-
提高输出精度
-
最小化启动时间
-
提高数据吞吐量(不一定等同于执行速度)
-
减少存储开销(即数据库大小)
传统优化智慧可以总结为 M.A. Jackson 著名的优化法则:
-
不要这样做。
-
(仅限专家) 还不要这样做。
即,你应该不惜一切代价避免优化。一开始忽略它,只有在代码运行不够快时,在开发结束时才考虑它。这是一个过于简化的观点——在某种程度上是准确的,但可能具有误导性和有害性。性能实际上是一个从开发初期就需要认真考虑的有效因素,甚至在写下一行代码之前。
代码性能由多个因素决定,包括:
-
执行平台
-
部署或安装配置
-
软件架构决策
-
低级模块设计
-
旧式工件(如与系统旧部分的互操作性需求)
-
每行源代码的质量
其中一些是整个软件系统的基本要素,一旦程序编写完成,效率问题将不易纠正。注意单独的代码行对性能的影响有多小;还有更多其他因素会影响性能。我们必须在开发过程的每一步管理性能问题,并处理出现的任何问题。从某种意义上说,优化(虽然不是一个具体的计划活动)是贯穿整个开发阶段的一个持续关注点。
关键概念
从一开始就考虑你程序的性能——不要忽视它,希望开发结束时能快速修复。
但不要以此为借口编写基于你对快或慢的直觉的痛苦代码。无论程序员经验如何丰富,他们对瓶颈位置的直觉很少是正确的。在接下来的几节中,我们将看到解决这个代码编写困境的实际解决方案。
但首先,黄金法则。在考虑进行代码优化之前,你必须牢记以下建议:
关键概念
正确的代码比快的代码更重要。快速得到错误答案是没有意义的。
你应该花更多的时间和精力证明你的代码是正确的,而不是让它运行得快。任何后续的优化都不能破坏这种正确性。
一个战争故事
我曾经发现一个我编写的模块运行得不可思议地慢。我对其进行了性能分析,并追踪问题到一行代码。它被频繁调用,并向缓冲区追加一个单个元素。
检查后发现,缓冲区(我接手但没有编写)每次满了都会自己扩展一个单个元素!换句话说:每次单独的追加都会分配、复制和释放整个缓冲区。哎呀。不用说,我并没有期待这种行为。
这有助于展示我们如何得到次优程序:通过增长。很少有人故意尝试编写一个蹒跚的程序。当我们把软件组件粘合到一个更大的系统中时,我们很容易对代码的性能特征做出假设,最终得到一个令人不快的惊喜。
什么使代码次优?
为了改进我们的代码,我们必须知道哪些东西会减慢它的速度、膨胀它或降低其性能。稍后,这将帮助我们确定一些代码优化技术。在这个阶段,理解我们正在对抗的是什么是有帮助的。
复杂性
不必要的复杂性是杀手。要完成的工作越多,代码运行得就越慢。减少工作量或将其分解为一系列更简单、更快的任务可以大大提高性能。
间接性
这被吹捧为解决所有已知编程问题的方案,总结为臭名昭著的程序员格言:每个问题都可以通过额外一层间接性来解决。但间接性也被指责导致了大量慢速代码。这种批评通常由老派的程序设计员提出,针对现代面向对象设计。
重复
重复通常可以避免,并且不可避免地会破坏代码性能。重复通常可以避免,并且不可避免地会破坏代码性能。它以许多形式出现——例如,未能缓存昂贵计算或远程过程调用的结果。每次重新计算,你都会浪费宝贵的效率。不必要的重复代码段会无谓地扩展可执行文件的大小。
糟糕的设计
这是不可避免的:糟糕的设计将导致糟糕的代码。例如,将相关的单元放置得非常远(例如跨越模块边界)将使它们的交互变得缓慢。糟糕的设计可能导致最基本、最微妙和最难以解决的性能问题。
I/O
程序与外部世界的通信——它的输入和输出——是一个非常常见的瓶颈。一个执行被阻塞等待输入或输出(来自用户、磁盘或网络连接)的程序肯定会表现不佳。
这个列表远非详尽无遗,但它为我们提供了在研究如何编写最优代码时需要考虑的一些思路。
为什么不优化?
从历史上看,优化是一个关键技能,因为早期的计算机运行速度非常慢。让程序在合理的时间内完成需要大量的技能和对手动机器指令的精细调整。这种技能在当今并不那么重要;个人电脑革命已经改变了软件开发的面貌。我们通常拥有过剩的计算能力,这与过去的时代正好相反。看起来优化可能已经不再那么重要了。
嗯,并不完全是。软件工厂仍然会给我们带来需要高性能代码的情况,而且如果你不小心,你可能在最后一刻需要进行疯狂的性能优化。但尽可能地避免优化代码是更好的选择。优化有很多缺点。
为了获得更快的速度,总是需要付出代价。优化代码就是用一种期望的品质去交换另一种品质的行为。代码的某些方面将会受到影响。如果做得好,被正确识别的更期望的品质将会得到增强。这些权衡是避免优化代码的最主要原因:
可读性下降
优化后的代码通常不如其较慢的版本清晰易读。由于其本身的性质,优化版本并不是逻辑的直接实现,也不是那么直接。你为了性能牺牲了可读性和整洁的代码设计。大多数“优化”的代码都很丑陋且难以跟踪。
复杂性增加
更聪明的实现——可能利用特殊的后门(从而增加模块耦合)或利用平台特定的知识——将增加复杂性。复杂性是优秀代码的敌人。
难以维护/扩展
由于复杂性的增加和可读性的缺乏,代码将更难维护。如果一个算法没有清晰地展示,代码更容易隐藏错误。优化是添加微妙新错误的一定方法——这些错误将很难找到,因为代码更加复杂且难以跟踪。优化导致危险的代码。
它还会限制你代码的可扩展性。优化通常来自于做出更多的假设,限制通用性和未来的增长。
引入冲突
通常,优化会非常依赖于特定平台。它可能在某个系统上使某些操作更快,但以牺牲另一个平台为代价。为一种处理器类型选择最佳数据类型可能会导致其他处理器上的执行速度变慢。
更多努力
优化是一项需要完成的任务。我们已经有足够多的事情要做,谢谢。如果代码已经足够工作,那么我们应该将注意力集中在更紧迫的问题上。
优化代码需要很长时间,而且很难找到真正的根源。如果你优化了错误的东西,你就浪费了大量的宝贵精力。
由于这些原因,优化应该是在你关注列表中的最后一项。在优化代码的需求与修复错误、添加新功能或发布产品的要求之间取得平衡。通常,优化并不值得或是不经济的。如果你一开始就注意编写高效的代码,那么你就不太可能需要优化。
替代方案
通常,代码优化是在实际上并不需要的时候进行的。我们可以采用一些替代方法,而无需改变现有的高质量代码。在你过于专注于优化之前,考虑这些解决方案:
-
你能忍受这种性能水平吗——这难道真的那么灾难性吗?
-
在更快的机器上运行程序。这听起来显然荒谬,但如果你有足够的控制权来控制执行平台,指定一台更快的计算机可能比花时间修改代码更经济。考虑到平均项目周期,你保证在完成时,处理器将大大加快。如果它们没有快很多,那么它们将在相同的物理空间内嵌入双倍的 CPU 核心数量。
并非所有问题都能通过更快的 CPU 来解决,尤其是瓶颈不在于执行速度——例如,一个慢速的存储系统。有时,更快的 CPU 反而会导致性能大幅下降;更快的执行可能会加剧线程锁定问题。
-
寻找硬件解决方案:添加一个专门的浮点单元以加快计算速度;添加更大的处理器缓存、更多内存、更好的网络连接或更宽频带的磁盘控制器。
-
重新配置目标平台,以减少其上的 CPU 负载。禁用后台任务或任何不必要的硬件设备。避免消耗大量内存的进程。
-
以异步方式运行慢速代码,在后台线程中。如果你不知道自己在做什么,在最后一刻添加线程是通往灾难的道路,但仔细的线程设计可以相当好地适应慢速操作。
-
专注于影响用户对速度感知的用户界面元素。确保 GUI 按钮立即改变,即使它们的代码需要超过一秒钟才能执行。为耗时任务实现进度条;在长时间操作中挂起的程序看起来像是崩溃了。操作进度的视觉反馈传达了对性能质量的更好印象。
-
设计系统以实现无人值守操作,这样没有人会注意到执行速度。创建一个具有整洁用户界面的批处理程序,允许你排队工作。
-
尝试使用具有更激进优化器的较新编译器,或者针对最具体的处理器变体(启用所有额外指令和扩展)来利用所有性能特性。
关键概念
寻找优化代码的替代方案——你能否以其他方式提高程序的性能?
为什么需要优化?
看过代码优化的危险后,你现在是否应该放弃任何优化代码的愚蠢想法?嗯,不:你应该尽可能避免优化,但确实有许多情况下优化是重要的。而且与普遍看法相反,某些领域保证需要优化。
-
游戏编程始终需要经过精心打磨的代码。尽管 PC 性能有了巨大的进步,但市场对更逼真的图形和更令人印象深刻的人工智能算法的需求更大。这只能通过将执行环境拉伸到极限来实现。这是一个极具挑战性的工作领域;随着每块新硬件的发布,游戏程序员仍然必须榨取每一滴性能。
-
数字信号处理(DSP)编程完全是关于高性能。数字信号处理器是专门优化以对大量数据进行快速数字滤波的专用设备。如果速度不重要,你就不会使用它们。DSP 编程通常不太依赖于优化编译器,因为你希望始终对处理器正在执行的操作有高度的控制。DSP 程序员擅长驱动这些设备以实现最大性能。
-
资源受限的环境,如深度嵌入式平台,可能难以利用现有硬件实现合理的性能。你必须优化代码以实现可接受的服务质量,或者努力将其适应设备紧凑的内存。
-
实时系统依赖于及时执行,能够在明确指定的量子内完成操作。算法必须经过精心打磨并证明能够在固定的时间限制内执行。
-
数值编程——在金融领域或科学研究——需要高性能。这些大型系统在配备专用数值支持的大型计算机上运行,提供向量运算和并行计算。
也许优化对于通用编程来说并不是一个严肃的考虑因素,但有很多情况下优化确实是一项关键技能。性能很少在需求文档中指定,但如果你的程序运行速度不令人满意,客户会抱怨。如果没有替代方案,代码表现不佳,那么你就必须进行优化。
优化和不优化的理由比不优化的理由要短。除非你有特定的优化需求,否则你应该避免这样做。但如果你确实需要优化,确保你知道如何做得很好。
关键概念
了解何时需要优化代码,但首先优先考虑编写高效、高质量的代码*。
核心内容
那么如何进行优化?与其学习一系列特定的代码优化技巧,不如更重要的是理解正确的优化方法。不要慌张;我们稍后会看到一些编程技巧,但它们必须在这个更广泛的优化过程中进行阅读。
加速程序的速度的六个步骤是:
-
确定它太慢了,并证明你需要进行优化。
-
识别最慢的代码。针对这一点。
-
测试优化目标的性能。
-
优化代码。
-
测试优化后的代码仍然可以工作(非常重要)。
-
测试速度提升,并决定下一步做什么。
这听起来像是一项大量工作,但如果没有这样做,你实际上会浪费时间和精力,最终得到的是运行速度没有提高的残缺代码。如果你不是试图提高执行速度,相应地调整这个过程;例如,通过识别哪些数据结构消耗了所有内存来处理内存消耗问题,并针对这些数据结构进行优化。
重要的是在明确的目标下开始优化——你进行的优化越多,代码的可读性就越低。了解你需要的性能水平,并在足够快时停止。继续进行,不断尝试挤出一点额外的性能是很诱人的。
要正确地进行优化,你必须非常小心地防止外部因素改变你的代码的工作方式。当世界在你脚下变化时,你无法进行真实的比较。这里有两种基本技术可以帮助你:
关键概念
将代码优化与其他工作分开,这样一项任务的成果就不会影响另一项。
. . . 和 . . .
关键概念
优化你的程序的发布版本,而不是开发版本。
开发版本可能由于包含调试跟踪信息、对象文件符号等而与发布版本运行非常不同。
现在,我们将更详细地查看这些优化步骤中的每一个。
证明你需要优化
首先要确保你确实需要优化。如果代码的性能是可以接受的,那么就没有必要去修改它。Knuth 说(他自己引用了 C.A.R. Hoare 的话):“我们应该忘记那些小的效率,比如说 97%的时间:过早的优化是所有邪恶的根源。”有很多令人信服的理由不要优化,所以最快的、最安全的优化技术是证明你不需要这样做。
你根据程序需求或可用性研究做出这个决定。有了这些信息,你可以确定优化是否比添加新功能或修复错误有优先级。
识别最慢的代码
这是大多数程序员容易出错的地方。如果你打算花时间优化,你需要针对那些会产生影响的区域。调查表明,平均程序超过 80%的时间都花在了不到 20%的代码上。(Boehm 87)这被称为80/20 法则。^([1])这是一个相对较小的目标,很容易错过,这意味着你可能会浪费精力优化很少运行的代码。
你可能会注意到你的程序中有一部分相对容易优化,但如果这部分很少被执行,那么优化就没有意义了——在这种情况下,清晰的代码比快速的代码更好。
你如何确定应该集中注意力的地方?最有效的技术是使用分析器。这个工具会记录程序中控制流的耗时。它显示了执行时间的 80%都花在了哪里,这样你就知道应该在哪里集中精力。
分析器不会告诉你代码中哪些部分是最慢的;这是一个常见的误解。它实际上告诉你 CPU 大部分时间都花在了哪里。这有一个细微的差别.^([2])你必须解释这些结果,并运用你的大脑。程序可能大部分执行时间都花在几个完全有效的函数上,这些函数根本无法改进。你并不总是可以优化;有时物理定律会获胜。
周围有很多基准测试程序——许多优秀的商业程序和许多免费工具。投资一个好的分析器是值得的:优化很容易消耗你的时间;这也是一种昂贵的商品。如果你没有可用的分析器,还有一些其他的计时技术你可以尝试:
-
在你的代码中添加手动计时测试。确保你使用准确的时钟源,并且读取时钟的时间不会过多地影响程序性能。
-
计算每个函数被调用的次数(一些调试库提供了对此类活动的支持)。
-
利用编译器提供的钩子,在每次函数进入或退出时插入你自己的会计代码。许多编译器都提供了这样做的方法;一些分析器就是使用这种机制实现的。
-
采样程序计数器;在调试器中定期中断程序以查看控制流。这在多线程程序中更困难,是一种非常缓慢且手动的方法。如果你控制执行环境,你可以编写脚本来自动化这种测试——实际上编写你自己的分析器形式。
-
通过使单个函数运行变慢来测试其对整个程序执行时间的影响。如果你怀疑某个特定函数导致了减速,尝试连续替换其调用两次,并测量它对执行时间的影响.^([3]) 如果程序运行时间延长了 10%,那么该函数大约消耗了 10%的执行时间。将此用作一个非常基本的计时测试。
在分析时,确保你使用真实的数据输入,模拟现实世界的事件。你的代码执行方式可能会因你提供的输入类型或驱动方式而大幅改变,所以请确保你提供真正的代表性输入集。如果可能,从实际系统中捕获一组真实输入数据。
尝试分析几个不同的数据集,看看这会有什么不同。选择一个非常基本的集合,一个高使用率集合,以及几个通用使用集合。这将防止你针对特定输入数据集的特定怪癖进行优化。
关键概念
仔细选择分析测试数据以代表现实世界程序的使用。否则,你可能会优化那些通常不会运行的程序部分。
虽然分析器(或等效工具)是选择优化目标的好起点,但你很容易错过相当基本的问题。分析器只显示当前设计中代码的执行情况——并鼓励你仅在代码级别进行改进。也要看看更大的设计问题。性能不佳可能不是由单个函数引起的,而是一个更普遍的设计缺陷。如果是这样,那么你需要更努力地解决问题。这表明正确获取初始代码设计并了解已建立的性能要求是多么重要。
关键概念
不要仅仅依赖分析器来查找程序效率低下的原因;你可能会错过重要的问题。
完成这一步后,你已经找到了代码中性能改进将带来最大效益的区域。现在,是时候攻击它们了。
代码测试
我们在优化过程中识别了三个测试阶段。对于每个要优化的代码片段,我们在优化前测试其性能,确认优化后代码仍然正确工作,并在优化后测试其性能。
程序员经常忘记第二个检查:优化后的代码在所有可能的情况下仍然正确工作。检查正常操作模式很容易,但测试每一个罕见的案例并不符合我们的天性。这可能是晚上出现奇怪错误的原因,所以对此要非常严格。
你必须在修改前后测量代码的性能,以确保你确实做出了真正的改变——并确保这是一个向好的改变;有时所谓的“优化”可能是不知不觉的劣化。你可以使用你的分析器或手动插入计时工具来执行这些计时测试。
关键概念
永远不要在没有进行某种前后测量的情况下尝试优化代码。
在运行你的计时测试时,以下是一些非常重要的事情需要考虑:
-
使用完全相同的输入数据集运行前后测试,以确保你测试的是完全相同的东西。否则,你的测试是没有意义的;你并没有在比较苹果和苹果。自动化的测试套件是最好的(参见第 144 页的"Look! No Hands!")——使用我们在分析步骤中使用的同种实时代表性数据。
-
在相同的环境条件下运行所有测试,这样 CPU 负载或空闲内存的数量就不会影响你的测量结果。
-
确保你的测试不依赖于用户输入。人类可能会使计时结果波动很大。自动化测试流程的每一个可能方面。
代码优化
我们将在稍后探讨一些具体的优化技术。速度提升可能从对代码小部分简单的重构到更严重的设计层面改动。关键是优化而不至于完全破坏代码。
确定优化已识别代码的不同方式有多少,并选择最佳方案。一次只进行一个更改;这样风险更低,你也会更清楚地知道哪个更改提高了性能。有时最意想不到的事情会产生最显著的优化效果。
优化后
不要忘记对优化后的代码进行基准测试,以证明你已经成功进行了修改。如果优化不成功,请移除它。撤销你的更改。这时源代码控制系统非常有用,它可以帮助你恢复到之前的代码版本。
同时移除那些稍微成功的优化。优先选择清晰的代码而不是适度的优化(除非你绝对需要改进,而且没有其他途径可以探索)。
^([1]) 有些人甚至声称这应该是90/10 规则。
^([2]) 所有代码都以固定的速率运行,这取决于 CPU 时钟的速度、操作系统正在处理的其它进程数量以及线程的优先级。
^([3]) 这并不一定会使函数运行速度减半。文件系统缓冲区或 CPU 内存缓存可以增强重复代码段的表现。把这当作一个非常粗略的指南——更多的是定性而非定量。
优化技术
我们已经避免这个问题很长时间了;现在是时候看看真正令人毛骨悚然的细节了。在遵循上述优化程序之后,你已经证明你的程序表现不佳,并找到了最糟糕的代码罪魁祸首。现在你需要把它整理好。你能做什么?
有许多优化可供选择。最合适的优化取决于问题的确切原因、你试图实现的目标(例如,提高执行速度或减少代码大小),以及所需的改进程度。
这些优化可以分为两大类:设计变更和代码变更。在设计层面上的变更通常比代码级别的调整对性能有更深远的影响。一个低效的设计可能比几行糟糕的源代码更能扼杀效率,因此,虽然设计修复更困难,但回报更大。
最常见的目标是提高执行速度。基于速度的优化策略包括:
-
加快缓慢的事情
-
减少执行缓慢的事情的频率
-
将缓慢的事情推迟到真正需要它们的时候
另外一些常见的优化目标是减少内存消耗(主要通过改变数据表示、调整内存消耗模式或减少一次性访问的数据量),或者减少可执行文件的大小(通过删除功能或利用共性)。正如我们将看到的,这些目标往往相互冲突:大多数速度提升都是以内存消耗为代价的,反之亦然。
设计变更
这些是宏观优化,是在大范围内进行的修复,可以改善你软件的内部设计。糟糕的设计很难修复。项目离发布截止日期越近,你进行设计变更的可能性就越小;风险太大.^([4]) 我们最终通过采用小型、代码级别的修复来填补裂缝。
当我们有足够的勇气时,我们可以执行以下类型的设计优化:
-
添加缓存或缓冲层以增强缓慢的数据访问或防止长时间的重计算。预先计算你知道将需要的值,并将它们存储以供即时访问。
-
创建资源池以减少分配对象的开销。例如,预先分配内存,或者保持一组文件打开而不是反复打开和关闭它们。这种技术通常用于加快内存分配;较老的操作系统内存分配例程是为简单的非线程使用设计的。它们的锁会导致多线程应用程序停滞,从而产生糟糕的性能。
-
如果可以的话,为了速度牺牲准确性。降低浮点精度是一个明显的例子。许多设备没有浮点单元 (FPU) 硬件,而是使用较慢的 FPU 模拟软件。你可以切换到定点算术库来绕过缓慢的模拟器,但这会牺牲数值分辨率。在 C++ 中,通过利用其抽象数据类型功能,这尤其容易实现。
准确性并不仅仅取决于你选择的数据类型;这种策略可以深入到你对算法的使用或输出质量。也许你可以让用户做出这个决定——允许他们选择慢但准确或快但近似的操作模式。
-
将数据存储格式或其磁盘表示形式更改为更适合高速操作的形式。例如,通过使用二进制格式来加速文本文件解析。传输或存储压缩文件以减少网络带宽。
-
利用并行化和使用线程来防止一个动作在另一个动作之后序列化。随着处理器速度的提升放缓,CPU 制造商越来越多地引入多核、多流水线处理器。为了有效地使用这些处理器,你的代码必须以线程模型为核心进行设计。优化战斗的前线正在迅速向这个方向发展。
-
高效地使用线程:避免或移除过多的锁定。这会抑制并发,产生开销,并经常导致死锁。使用静态检查来证明哪些锁是必要的,哪些不是。
-
避免过度使用异常。它们可能会阻碍编译器的优化^([5]),并且当频繁使用时,会妨碍及时操作。
-
如果可以节省代码空间,就放弃某些语言功能。一些 C++ 编译器允许你禁用 RTTI 和异常,从而减少可执行文件的大小。
-
移除功能:最快的代码是根本不运行的代码。如果一个函数做了太多事情,其中一些是不必要的,那么它就会变慢。删除多余的东西。将其移到程序的其他地方。推迟所有工作,直到真正需要时再进行。
-
为了速度牺牲设计质量。例如,减少间接引用并增加耦合。你可以通过破坏封装来实现这一点:通过公共接口泄露类的私有实现。打破模块障碍会对设计造成不可修复的损害。如果可能,首先尝试一个不那么破坏性的优化机制。
复杂度表示法
算法复杂度是衡量算法扩展性的指标——它相对于输入大小的长短。它是一个定性的数学模型,允许你快速比较不同实现方法的表现特性。它不测量确切的执行时间(这高度依赖于 CPU 速度、操作系统配置等)。
复杂度由算法必须执行的工作量决定:它执行的基本操作的数量。基本操作类似于算术运算、赋值、测试或数据读写。算法复杂度不计操作的确切数量,只计这个值与问题规模的关系。我们通常对算法的最坏情况性能感兴趣,即永远需要完成的最多的工作量。一个好的比较是查看最佳情况和平均时间复杂度。
算法复杂度使用由德国数论学家埃德蒙·兰道发明的 大 O 符号表示。对于输入大小为 n 的问题,它可能具有以下复杂度:
`O(1):一阶
这是一个常数时间算法。无论输入集有多大,完成任务的所需时间总是相同的。这是可能的最优性能特征。
`O(n):n 阶
线性时间算法的复杂度与输入大小成正比。随着链表大小的增长,搜索链表将涉及访问更多的节点;操作的数量与列表的大小直接相关。
`O(n²):平方阶
这就是性能真正开始变差的地方:复杂度增长速度超过了输入增长速度。当给一个小数据集时,一个二次时间算法可能看起来还不错,但大数据集需要花费很长时间。冒泡排序算法的复杂度是 O(n²)。
当然,复杂度可以是任何阶数;快速排序算法的平均复杂度为 O(n log n)。这比 O(n) 要差,但远比 O(n²) 好得多。对于慢速冒泡排序算法的一个简单优化途径是将其替换为快速排序算法,尤其是考虑到有许多免费的快速排序实现。
这些大 O 表达式不包括常数或低阶项。你很少会看到关于 O(2n+6) 的复杂度讨论。当 n 足够大时,这些常数和低阶项变得微不足道。
主要的设计级优化涉及对算法或数据结构的改进。大多数速度下降或内存消耗归结于一个或两个选择不当,后续的更改将纠正这一点。
算法
算法对执行速度有深远的影响。一个在小范围本地测试中表现可接受的功能,当面对现实世界的数据时可能无法扩展。如果分析显示你的代码大部分时间都在运行某个特定的例程,你必须让它运行得更快。一种方法是在代码级别,从每条指令中提取小的改进。更好的方法是替换整个算法为更有效的版本。
考虑这个现实例子:一个特定的算法运行循环 1,000 次。每次迭代需要 5 毫秒(ms)来执行。因此,操作大约需要 5 秒钟完成。通过调整循环内的代码,你可以从每次迭代中节省 1 毫秒——这节省了 1 秒钟。不错。但相反,你可以插入一个不同的算法,其中每次迭代需要 7 毫秒,尽管它只迭代 100 次。这节省了近 4.5 秒——显著更好。
因此,更倾向于查看改变基本算法的优化,而不是调整特定代码行。计算机科学领域有许多算法可供选择,除非你的代码特别糟糕,否则通过选择更好的算法,你总能获得最显著的性能提升。
关键概念
更倾向于用更快的变体替换慢速算法,而不是调整算法的实现。
数据结构
数据结构与你的算法选择密切相关;某些算法需要特定的数据结构,反之亦然。如果你的程序消耗了过多的内存,改变数据存储格式可能会改善情况,尽管这通常是以执行速度为代价的。如果你需要快速搜索包含 1,000 个项目的列表,不要将它们存储在具有O( n)搜索时间的线性数组中;使用(更大的)二叉树,其性能为O(log n)。
选择不同的数据结构很少需要你自己实现新的表示形式。大多数语言都提供了对所有常见数据结构的库支持。
代码更改
因此,我们现在紧张不安地转向真正令人厌恶的东西:微观级别、小规模、短视的代码调整优化。有无数种方法可以为了性能而折磨源代码。你必须进行实验,以查看每种情况下什么最有效:一些更改会有效;其他更改可能效果很小,甚至产生负面影响。一些可能阻止编译器的优化器执行其任务,产生惊人的更差结果。
第一个任务很简单:开启编译器优化或提高优化级别。由于优化器可能需要非常长的时间运行,这通常会导致大型项目的构建时间增加一个数量级。6 尝试配置优化器,并测试这会产生什么影响。许多编译器允许你偏向于额外的速度或减少代码大小进行优化。
有一些非常底层的优化,你应该知道但通常应该避免。这些是编译器能够为你执行的改变。如果你已经开启了优化器,它已经在这些区域进行了检查——启用优化并充分利用其帮助。你很少需要手动应用这些优化,这是好事:它们会破坏代码的可读性,因为它们扭曲了其基本逻辑。只有在你能够证明这些优化确实是必需的,你的优化器还没有执行它们,并且没有更好的替代方案时,才考虑使用这些优化之一。
循环展开
对于体非常短的循环,循环框架可能比循环操作本身更昂贵。通过将其展开来移除这种开销——将你的 10 次迭代循环转换为 10 个连续的单独语句。
循环展开可以部分进行;这对于大型循环更有意义。你可以每迭代插入四个操作,并且每次增加循环计数器四个。但是,如果循环不总是迭代整个展开数,这种策略就会变得很糟糕。
代码内联
对于小型操作,调用函数的开销可能很大。将代码拆分为函数可以带来显著的好处:更清晰的代码,通过重用实现一致性,以及隔离变化区域的能力。然而,可以通过合并调用者和被调用者来移除这些好处以增加性能。
有多种方法可以实现这一点。在有语言支持的情况下,你可以在源代码中请求它(在 C/C++中使用inline关键字);这种方法保留了代码的大部分可读性。否则,你必须自己合并代码,要么是通过重复复制函数,要么是使用预处理器为你完成工作。
内联递归函数调用很难——你如何知道何时停止内联?尝试找到替代算法来替换递归。
内联通常为在代码级别进行进一步的优化(之前在函数边界之外不可能进行)打开了道路。
常量折叠
涉及常量值的计算可以在编译时进行,以减少运行时的工作量。简单的表达式return 6+4;可以简化为return 10;。仔细排序大量计算中的项可能会将两个常量放在一起,使它们能够被简化为一个更简单的子表达式。
程序员编写像return 6+4;这样明显的东西是不常见的。然而,这些类型的表达式在宏展开后很常见。
移至编译时
在编译时你可以做的不仅仅是常量折叠。许多条件测试可以静态地证明并从代码中移除。某些类型的测试可以完全避免;例如,通过使用无符号数据类型来移除对负数的测试。
强度降低
这是一种用等效但执行速度更快的操作替换一个操作的行为。这在具有较差算术支持的 CPU 上尤为重要。例如,将整数乘法和除法替换为常数移位或加法;如果处理器上更快,x/4 可以转换为 x>>2。
子表达式
公共子表达式消除 避免重新计算值未改变的表达式。在如下代码中:
int first = (a * b) + 10;
int second = (a * b) / c;
表达式 (a * b) 被评估了两次。一次就足够了。你可以提取出公共子表达式,并用以下内容替换:
`int temp = a * b;`
int first = `temp` + 10;
int second = `temp` / c;
删除死代码
不要编写不必要的代码;修剪任何不是程序严格必要的部分。静态分析将显示从未使用过的函数或永远不会执行的代码部分。删除它们。
虽然这些代码优化特别令人反感,但以下优化在社会上稍微更容易接受。它们侧重于提高程序执行速度。
-
如果你发现你反复调用一个慢速函数,那么不要频繁调用它。缓存其结果并重用此值。这可能会导致代码不够清晰,但程序将运行得更快。
-
在另一种语言中重新实现函数。例如,使用 Java 原生接口 (JNI) 功能将关键的 Java 函数重写为 C。传统的编译器在执行速度上仍然优于 JIT 代码解释器。
不要天真地认为一种语言比另一种语言快——许多程序员对使用 JNI 产生的差异之小感到惊讶。普遍认为,面向对象的编程语言比过程式语言慢得多。这是谎言。糟糕的面向对象代码 可能 会很慢,但糟糕的过程式代码也可能很慢。如果你用 C 编写面向对象风格的代码,它可能比好的 C++ 慢;C++ 编译器将生成比你的尝试更好的方法调度代码。
-
重新排序代码以改善性能。
推迟工作直到绝对必要。不要在你即将使用它之前打开文件。如果你可能不需要它,不要计算值;等待它被需要。如果代码可以不使用它而工作,那么不要调用函数。
将检查提升到函数的更高位置以避免不必要的操作。如果可以将导致早期返回的测试放在函数的顶部或中间,则优先将其放在顶部。尽早进行检查以避免延迟。
将不变的运算移出循环。这个问题的最微妙来源是循环条件。如果你写
for (int n = 0; n < tree.appleCount(); ++n),但appleCount()在每次调用时手动计算 1,000 个项目,那么你将有一个非常慢的循环。将计数操作移到循环之前:`int appleCount` = `tree.appleCount();` for (int n = 0; n < `appleCount;` ++n) { ... do something ... }然而,不要忘记首先进行性能分析,以证明循环确实是一个问题。这是一个很好的例子,说明了优化是如何局限于特定执行环境的:在 C#中,新版本可能会更慢,因为未优化的代码是 JIT 编译器理解并可以优化的模式。
-
使用 查找表 进行复杂计算,以空间换取时间。例如,而不是编写一组单独计算其值的三角函数,预先计算返回值并将它们存储在数组中。将输入值映射到这个数组中最接近的索引。
-
利用 短路求值。确保将可能失败的测试放在前面以节省时间。如果您编写了一个条件表达式
if (condition_one && condition_two),请确保condition_one在统计上比condition_two更有可能失败(除非,当然,condition_one作为condition_two有效性的保护者)。 -
不要重新发明轮子——重用已经过性能优化的标准例程。库的编写者已经仔细地优化了他们的代码。但请注意,库可能已经针对不同于您的目标进行了优化;可能一个嵌入式产品被针对内存消耗进行了分析,而不是针对速度。
以大小为导向,代码级别的优化包括:
-
生成在运行前解包其代码的压缩可执行文件。这不一定影响运行程序的大小,但减少了所需的存储空间.^([7]) 如果您的程序存储在有限的闪存中,这可能很重要。
-
将常用代码因式分解到共享函数中,以避免重复。
-
将很少使用的函数移到一边。将它们放入动态加载的库或单独的程序中。
当然,终极的硬核优化技术是将代码的一部分重写为汇编语言——这是您对 CPU 拥有完全控制的环境,可以做到您想要的任何事情(包括自毁)。这始终是最后的手段,并且几乎肯定是不必要的。如今,编译器生成的代码已经足够好,编写、调试和维护“优化”的机器代码部分所花费的时间远远超过了所获得的优势。
^([4]) 可惜,通常只有在项目截止日期临近时,人们才会注意到性能不够好。
^([5]) 与函数一样,try/catch 块充当优化器的障碍。无法穿过障碍进行优化,因此可能会丢失一些潜在的速度提升。
^([6]) 它必须对解析后的代码进行复杂的检查,以确定可能的加速集并选择最合适的那些。
^([7]) 这可能有一个令人愉快的副作用,即减少程序启动时间:压缩的可执行文件将从磁盘加载得更快。
编写高效代码
如果最佳方案是不进行优化,我们如何避免任何改进代码性能的需求?答案是为性能而设计,从一开始就计划提供足够的服务质量,而不是试图在最后一刻削减它。
有些人认为这是一条危险的道路。确实,对于不小心的人来说,存在潜在的风险。如果你在编写代码的同时尝试优化,那么你将低于所需的级别编写代码;你最终会得到充满低级性能增强和后门接口的糟糕、混乱的代码。
我们如何调和这些看似对立的观点?这并不难,因为它们实际上并不矛盾。有两种互补的策略:
-
编写高效的代码。
-
以后再优化代码。
如果你现在就注重编写清晰、良好、高效的代码,你就不需要以后进行大量优化。有些人认为一开始你不知道是否需要进行优化,所以你应该尽可能简单地将一切写出来,只有在分析证明存在瓶颈时才进行优化。
这种方法有明显的缺陷。如果你知道你需要一个具有良好搜索性能的数据结构(因为你的程序必须进行快速搜索),那么选择二叉树而不是数组。^([8]) 如果你没有意识到任何这样的要求,那么选择最合适的东西,它仍然可能不是最简单的——原始 C 数组是一个难以管理的数据结构。
在设计每个模块时,不要盲目追求性能——只有在必要时才付出努力。了解规定的性能要求,并证明你的选择在每个阶段如何满足这些要求。当你知道需要什么级别的性能时,设计适当的效率就更容易了。这也有助于你编写明确的测试,以证明你确实实现了这些性能目标。
一些简单的设计选择可以增加效率并有助于后续优化,包括:
-
最小化对可能在远程机器上实现或需要访问网络或慢速数据存储系统的函数的依赖
-
理解目标部署以及程序预期如何运行,以便你可以设计出在这些情况下表现良好的程序
-
编写模块化代码,以便在不重写其他部分的情况下轻松加快某一部分的运行速度
PESSIMIZATIONS
没有仔细测量,你很容易写出并不优化的优化。一个在一种情况下可能很好的优化,在另一种情况下可能会变成性能灾难。这里有一个案例研究。案例 A:写时复制字符串优化。
这是在 1990 年左右应用于 C++ 标准库实现中的一种常见优化。执行大量字符串操作的程序在复制长字符串时,在执行速度和内存消耗方面都会产生巨大的开销。复制大型字符串意味着复制和移动大量数据。许多字符串复制是自动生成的临时对象,在创建后不久就被丢弃——它们实际上从未被修改过。昂贵的复制操作是不必要的成本。
写时复制(COW)优化将 string 数据类型转换成一种 智能指针;实际的字符串数据被保存在一个(隐藏的)共享表示中。现在的 string 复制操作只需要执行一个低成本的智能指针复制(将一个新的智能指针附加到共享表示),而不是复制整个字符串内容。只有当你对一个共享字符串进行修改时,内部表示才会被复制,智能指针才会重新映射。这种优化避免了大量不必要的复制操作。
COW 在单线程程序中表现良好;它被证明可以大大提高性能。然而,当多线程程序使用 COW 字符串时,问题变得明显。(实际上,如果 COW 字符串类是用多线程支持构建的,这个问题也会在单线程程序中显现)。实现需要非常保守的线程锁定来围绕复制操作——这些锁定成为了一个 主要 的瓶颈。突然间,一个快速运行的程序速度慢了下来。COW 优化证明是一个严重的负优化。
通过回归经典的 string 实现并编写更仔细的代码来减少自动字符串复制,实现了更好的多线程性能。幸运的是,C++ 库供应商现在提供了更智能的 string 类版本,它们既线程安全又快速。
^([8]) 但是,像往常一样,事情并不一定那么简单。数组通常提供更好的缓存一致性(因为二叉树节点可以轻易地散布在内存中)。一个保持排序的数组(在插入时平均分配时间)值得考虑。测量,测量,再测量。
简而言之
技术进步仅仅为我们提供了更有效的手段来走回头路。
--奥尔德斯·赫胥黎
高性能代码并不像有些人认为的那么重要。虽然有时你确实需要卷起袖子修改代码,但优化是一个你应该积极避免的任务。为了做到这一点,确保在开始工作之前你了解软件的性能要求。在设计的每个级别上,确保你提供这种服务质量。然后优化将不再必要。
当你进行优化时,要非常有条理和谨慎。有一个明确的目标,并证明每一步都让你更接近目标。以可靠的数据为指导,而不是你的直觉。在编写代码时,确保你的设计是高效的,但不要在质量上妥协。只有在证明它是问题时,才担心代码级别的性能。
| 精通编程的程序员 . . . | 不擅长编程的程序员 . . . |
|---|
|
-
除非证明绝对必要,否则避免优化
-
有条理地尝试优化方法,采取深思熟虑和谨慎的方法
-
在求助于代码级别的优化之前,先寻找替代方案并调查设计改进
-
倾向于选择不会破坏代码质量的优化方法
|
-
在代码证明不足之前就开始优化
-
直接深入,攻击他们认为的瓶颈代码,而不进行测量或调查
-
从不考虑更广泛的视角:他们的优化在其他代码区域和使用模式中的全部影响
-
考虑速度比代码质量更重要
|
参见
第一章
移除“不必要的”代码的优化通常与任何额外的防御性代码冲突。
第四章
优化代码的需求通常与自文档化代码相矛盾。
第十三章
效率必须从项目开始时就设计到代码库中。
第十九章
在开始构建之前,必须仔细指定性能要求,以便你知道需要多少优化。

思考
这些问题的详细讨论可以在第 510 页的“附录 A”部分找到。
思考
-
优化是一个权衡的过程——为了另一个期望的质量牺牲代码的一个质量。描述导致性能提升的权衡类型。
-
查看 202 页上“为什么不优化?”中列出的每个优化替代方案。描述是否做出了任何权衡。
-
解释这些术语及其确切关系:
-
性能
-
效率
-
优化
-
-
在一个慢速程序中,可能存在的瓶颈有哪些?
-
你如何避免需要优化的需求?哪些方法可以防止你编写低效的代码?
-
多线程的存在如何影响优化?
-
为什么我们不编写高效的代码?是什么阻止我们首先使用高性能算法?
-
List数据类型是通过数组实现的。以下每个List方法的最坏情况算法复杂度是什么?-
构造函数
-
append—将新项目放置在列表的末尾 -
insert—在给定位置将新项目滑入两个现有列表项之间 -
isEmpty—如果列表不包含任何项则返回true -
contains—如果列表包含指定的项目则返回true -
get—返回给定索引的项目
-
个人化
-
在你当前的项目中,代码性能有多重要(老实说)?这种性能要求的动机是什么?
-
在你上一次的优化尝试中:
-
你是否使用了分析器?
-
如果是,你测量了多少改进?
-
如果不是,你怎么知道你是否做出了任何改进?
-
你测试了优化后的代码是否仍然正常工作吗?
-
如果是,你测试得有多彻底?
-
如果不是,为什么?你怎么能确保代码在所有情况下仍然正常工作?
-
-
如果你还没有尝试优化你目前正在工作的代码,猜测一下哪些部分是最慢的,哪些部分消耗了最多的内存。现在运行它通过一个分析器——你的猜测有多准确?
-
你的程序的性能要求有多明确?你是否有具体的计划来测试你是否满足这些标准?
第十二章。不安全复杂
编写安全的程序
安全性大部分是一种迷信。在自然界中并不存在。…生活要么是一场勇敢的冒险,要么什么都不是。
--海伦·凯勒
不久以前,计算机访问是一种稀缺的商品。世界上只有少数几台机器,由少数组织拥有,并由一小队经过高度训练的人员访问。在那些日子里,计算机安全意味着穿上正确的实验室外套和通行证,才能通过门卫。
快进到今天。我们口袋里的计算能力比那些操作员梦寐以求的还要多。计算机很丰富,更重要的是,它们高度互联。
计算机系统携带的数据量正以惊人的速度增长。我们编写程序来存储、操作、解释和传输这些数据。我们的软件必须防止信息走失:落入恶意攻击者的手中,经过意外观察者的眼睛,或者甚至消失在虚空中。这是关键的;机密公司信息的泄露可能导致财务灾难。你不想敏感的个人信息公开(比如你的银行账户或信用卡详情),供任何人使用。大多数软件系统都需要一定级别的安全。1
谁的责任是构建安全的软件?坏消息是:这是我们的头疼事。如果我们不仔细考虑我们作品的安保,我们不可避免地会写出不安全的、漏洞百出的程序,并收获这些回报。
软件安全是一件非常重要的事情,但我们通常很糟糕。几乎每天你都会听到一个流行产品的新安全漏洞,或者看到病毒破坏系统完整性的结果。
这是一个巨大的话题,远远超出了我们在这里的范围。这是一个高度专业化的领域,需要大量的培训和经验。然而,即使是基础知识,现代软件工程教学也没有得到充分的解决。本章的目的是突出安全问题,探讨问题,并学习一些基本技术来保护我们的代码。
风险
宁可因为过于焦虑的担忧而受到鄙视,也不要因为过于自信的安全措施而毁灭。
--埃德蒙·伯克
为什么有人会费心攻击你的系统?通常是因为你拥有他们想要的东西。这可能是:
-
你的处理能力
-
你发送数据的能力(例如,垃圾邮件)
-
你私藏的信息
-
你的能力——可能是指你安装的特定软件
-
你连接到更多有趣的远程系统
人们可能会仅仅为了乐趣而攻击你,或者因为他们不喜欢你,想要通过破坏你的计算机资源来造成伤害。虽然恶意的人确实在寻找容易、不安全的猎物,但一个程序意外地向错误的目标泄露信息也可能导致安全漏洞。幸运的用户可能会利用这个漏洞对你造成伤害。
关键概念
了解你拥有的重要资产。你是否拥有特别敏感的信息或特定能力,攻击者可能想要?保护它们。
要了解你可能遭受的攻击类型,区分保护整个计算机系统(由多台计算机、一个网络和许多协作应用程序组成)与编写一个单一的、安全的程序是很重要的。这两个都是计算机安全的重要方面;由于两者都是必要的,它们相互交织。后者是前者的一部分。只需一个不安全的程序就可以使整个计算机系统(或网络)变得不安全。
这些是运行中的计算机系统的常见安全风险和妥协:
-
一个获得笔记本电脑或 PDA 的小偷可以读取任何未加密的敏感数据。被盗设备可能被配置为自动拨入私有网络,从而为直接穿过你公司所有防御的简单途径。这是一个严重的安全威胁,你很难在代码中防范!我们可以做的是编写不立即对计算机小偷开放的系统。
-
有缺陷的输入例程可以被利用,导致许多类型的妥协——甚至使攻击者能够访问整个机器(我们将在第 229 页的"缓冲区溢出"中看到这一点)。
通过未加密的公共网络接口的入侵尤其令人担忧。虽然 GUI 界面中的漏洞只能被实际使用该 UI 的人利用,但在公共网络上运行的不安全系统可能导致全世界都在试图打破你的大门。
-
权限提升发生在具有有限访问权限的用户通过欺骗系统获得更高的安全级别时。攻击者可能是一个合法用户或刚刚入侵系统的人。他们的最终目标是达到root或管理员权限,在那里攻击者对机器拥有完全控制权。
-
如果通信未加密且穿越不安全的中介(例如,互联网),那么任何途中的计算机都可以窃取并读取数据,就像电话窃听一样。这种攻击方式的一种变体被称为中间人攻击:攻击者的机器假装是另一通信方,坐在双方发送者之间,窃听他们的数据。
-
任何系统都有一小部分受信任的用户。恶意授权用户可以通过复制和共享他们不应该拥有的数据或输入错误数据来破坏计算机系统的质量。
防御这一点很困难。你必须相信每个用户都有足够的责任感来处理他们被指定的系统访问级别。如果用户不可信,你不能编写一个程序来解决这个问题。这表明,安全性与管理政策和编写代码一样重要。
-
粗心的用户(或粗心的管理员)可能会使系统不必要地开放和脆弱。例如:
-
人们忘记注销;如果没有会话超时,任何人都可以稍后拿起你的程序并开始使用它。
-
许多攻击者使用基于字典的密码破解工具,不断尝试登录,直到成功为止。用户选择易于记忆但同时也容易被猜到的密码。任何允许弱密码和容易被猜到的密码的系统都是脆弱的。更安全的系统会在几次登录失败后暂停用户的账户。
-
社会工程学——从人们、办公室物品甚至外出的垃圾中获取重要信息的技术——通常比渗透到你的计算机系统要容易得多(并且通常更快)。人们比计算机更容易被骗,攻击者知道这一点。
-
过时的软件安装允许许多妥协。许多供应商发布安全警告(或公告)和软件补丁。管理员可能很容易落后于前沿,使系统容易受到攻击。
-
-
设置宽松的权限将允许用户访问系统敏感部分——例如,让普通观众阅读每个人的薪资详情。解决办法可能只是简单地设置数据库文件的正确访问权限。
-
病毒攻击(自我复制的恶意程序,通常通过电子邮件附件传播)、特洛伊木马(看似良性的软件中的隐藏恶意负载)和间谍软件(一种特洛伊木马,它监视你的行为、你访问的网页等)会感染机器并可能造成各种混乱。例如,它们可以通过键盘记录器捕获甚至最复杂的密码。
-
存储数据“明文”(未加密)——即使在内存中——是危险的。内存并不像许多程序员认为的那样安全;病毒或特洛伊木马可以扫描计算机内存,并为攻击者提取大量有趣的片段以供利用。
随着进入系统的路径数量的增加,风险也会增加,包括更多的输入方法(网络访问、命令行或 GUI 界面)、更多的单个输入(不同的窗口、提示、Web 表单或 XML 馈送)以及更多的用户(有人发现密码的机会更大)。随着输出的增加,更多的机会会导致显示代码中的错误,泄露错误信息。
关键概念
计算机系统越复杂,越有可能包含安全漏洞。因此,编写尽可能简单的软件!
^([1]) 正如我们将看到的,这无论他们是否处理敏感数据都是正确的。如果一个非关键组件有一个公开的接口,那么它对整个系统构成安全风险。
对手
很可能难以相信有人会花时间和精力来破解你的应用程序。但这些人确实存在。他们有才能、有动力,并且非常、非常耐心。在编写安全软件的战斗中,了解你是在与谁作战非常重要。确切了解他们在做什么,他们是如何做的,他们使用的工具以及他们的目标。只有这样,你才能制定出应对策略。
有知的保障
这些重要术语帮助我们推理安全问题:
错误
安全漏洞是应用程序中一个意外的错误。它是一个程序错误(参见第 130 页的“术语和条件”)。并非所有漏洞都是安全问题。
漏洞
当一个漏洞使程序可能不安全时,就存在一个漏洞。
利用
这是一个自动化的工具(或手动方法),它利用程序漏洞强制执行非故意且不安全的操作。并非所有漏洞都被发现和利用(这被称为运气)。
谁
你的攻击者可能是一个普通的骗子、一个有才华的破解者、一个脚本小子(破解者运行自动化破解脚本的贬义词——他们自己几乎不需要技能就能利用已知的漏洞),一个欺骗公司的 dishonest employee,或者一个因不公平解雇而寻求报复的 disgruntled ex-employee。
破解者消息灵通。存在一个破解者亚文化,其中知识得以传承,易于使用的破解工具得以分发。不了解这一点并不使你无罪和纯洁,只是天真且容易受到最简单攻击的威胁。
位置
多亏了无处不在的网络,攻击者可能无处不在,在任何大陆,使用任何类型的计算机。当在互联网上工作时,攻击者很难定位;许多人擅长掩盖自己的踪迹。他们经常破解简单的机器作为更大胆攻击的掩护。
何时
他们可以随时攻击,白天或夜晚。跨越大陆,一个人的白天是另一个人的夜晚。你需要全天候运行安全程序,而不仅仅是工作时间内。
为什么
在如此多的潜在攻击者中,攻击的动机是多样的。它可能是恶意的(一个政治活动家想要破坏你的公司,或者一个小偷想要访问你的银行账户),或者可能是为了乐趣(一个大学生恶作剧者想在你的网站上发布一个滑稽的横幅)。它可能是好奇的(一个黑客只是想看看你的网络基础设施是什么样子,或者练习他的破解技能),或者机会主义的(一个用户偶然发现了他不应该看到的数据,并设法利用它来获得自己的利益)。
在一个网络化的世界中,你通常在敌人攻击之后才会知道他们的真实身份。你可能甚至无法知道他们是谁;你的取证技能可能无法从一堆燃烧的数字碎片中回溯。但就像任何好的童子军一样:做好准备。不要忽视漏洞,并假设没有人对攻击你的系统感兴趣——外面有人 确实 对此感兴趣。
KEY CONCEPT
不要忽视漏洞,假装自己是不可战胜的。肯定有人,某个地方,想要利用你的代码。
CRACKER VS. HACKER
这两个术语经常被混淆并错误地使用。它们的正确定义是:
Cracker
某人故意利用计算机系统中的漏洞以获得未经授权的访问。
黑客
经常被错误地用来表示 cracker,一个 hacker 实际上是指那些在代码上工作——破解代码的人。这是一个 1970 年代的术语,被一种特定的编程极客群体自豪地使用。一个黑客是计算机专家或爱好者。
你还可能看到这两个黑客术语被使用:
白帽
白帽黑客会考虑他们工作的后果,蔑视 crackers 和不道德的电脑用户的行为。他们认为自己的工作是为了社会的利益。
黑帽
这是一个来自黑暗面的程序员,喜欢滥用计算机系统。黑帽是那些积极寻求不诚实地使用系统的 crackers。他们不关心他人的财产或隐私。
借口,借口
攻击者是如何频繁地破解代码的?他们拥有我们没有的武器,或者(由于缺乏教育)对它们一无所知。工具、知识、技能:这些都在他们的优势之中。然而,他们有一个关键的优势,这决定了所有差异——时间。在软件工厂的热潮中,程序员们被迫尽可能多地交付代码(可能还有一点点多),并且按时完成,否则。这段代码必须满足所有要求(对于功能、可用性、可靠性等),这让我们几乎没有时间去关注其他“外围”问题,比如安全性。攻击者不承担这种负担;他们有足够的时间去了解你系统的复杂性,并且他们已经学会了从许多不同的角度进行攻击。
游戏规则明显有利于他们。作为软件开发者,我们必须捍卫系统的所有可能点;攻击者可以选择最薄弱的点并集中攻击。我们只能防御已知的漏洞;攻击者可以花时间寻找任何数量的未知漏洞。我们必须时刻警惕攻击;攻击者可以随心所欲地发动攻击。我们必须编写出与世界其他部分良好协作的优质、干净的软件;攻击者可以随心所欲地玩弄伎俩。
软件安全给辛苦工作的程序员带来了许多额外但重要的问题和挑战。这告诉我们什么?简单地说,我们必须做得更好。我们必须更加了解情况,装备更好,更加了解我们的敌人,并且更加意识到我们编写代码的方式。我们必须从一开始就设计安全性,并将其纳入我们的开发流程和计划中。
感觉到脆弱
在这个混乱中,程序员的职责是编写安全的代码,因此让我们调查我们软件中的弱点,以确定我们必须集中精力在哪里。这些都是特定的代码漏洞,攻击者可以从中攻破。
不安全的设计和架构
这是最基本的缺陷,因此也是最难修复的。在架构层面没有考虑安全性会导致在各个地方犯下安全错误:在公共网络上发送未加密的数据,存储在易于访问的媒体上,以及运行具有已知安全漏洞的软件服务。
安全性应该在开发一开始就出现在雷达上。每个系统组件都必须考虑是否存在安全漏洞;计算机系统的安全性仅与其最不安全的部分相当,而这部分可能甚至不是你正在编写的代码。例如,Java 程序的安全性不会超过执行它的 JVM。
缓冲区溢出
大多数应用程序都是面向公众的,监听开放的网络安全端口或处理来自网页浏览器或 GUI 界面的输入。这些输入例程是安全失败的热点。
C 代码程序通常使用标准库函数sscanf来解析输入。尽管它是 C 标准库的一部分,并且经常出现在 C 代码中,但sscanf却毫不掩饰地提供了编写不安全代码的微妙方式.^([2])
你可能会看到这样的代码:
void parse_user_input(const char *input)
{
`/* first parse the input string */`
int my_number;
char my_string[100];
sscanf(input, "%d %s", &my_number, my_string);
`... now use it ...`
}
你能看出这个明显的问题吗?一个格式不正确的input字符串——任何超过 100 个字符的字符串——将会溢出my_string缓冲区,并将任意数据涂抹到无效的内存地址上。
结果取决于被丢弃的是哪种内存。有时程序会继续运行而不受影响;你非常、非常幸运。[3] 有时程序会继续运行,但其行为会微妙地改变——这可能是难以察觉的,调试起来也很混乱。有时程序会因此崩溃,可能还会将其他关键系统组件拖垮。但最糟糕的情况是,溢出的数据被写入 CPU 执行路径中的某个地方。这实际上并不难做到,允许攻击者在你的机器上执行任意代码,可能获得对它的完全访问权限。
当缓冲区位于堆栈上时,溢出最容易利用,如上面的例子所示。在这里,可以通过覆盖函数调用的堆栈存储的返回地址来指导 CPU 的行为。然而,缓冲区溢出漏洞也可以滥用基于堆的缓冲区。
嵌入式查询字符串
这种攻击类型可以用来使程序崩溃、执行任意代码或窃取未经授权的数据。像缓冲区溢出一样,它依赖于解析输入的失败,但与突破缓冲区边界不同,这些攻击利用程序随后对未过滤输入的处理。
格式字符串攻击是 C 程序中此类问题的经典例子。一个常见的罪魁祸首是printf函数(及其变体),使用方式如下:
void parse_user_input(const char *input)
{
printf(input);
}
恶意用户可以提供一个包含printf格式标记(如%s和%x)的输入字符串,并迫使程序从堆栈或甚至从内存中的某个位置打印数据,具体取决于printf调用的确切形式。攻击者还可以使用类似的手段(利用%n格式标记)将任意数据写入内存位置。
解决这个问题的方法并不难找。通过写入printf("%s", input)可以确保input不会被解释为格式字符串。
有许多其他情况下,嵌入的查询可以恶意利用程序。SQL 语句可以秘密地输入到数据库应用程序中,迫使它们为攻击者执行任意的数据库查找。
松散的基于 Web 的应用程序所表现出的另一种变体被称为跨站脚本攻击,因为攻击在系统中的工作方式:从攻击者的输入,通过 Web 应用程序,最终在受害者的浏览器上显现。攻击者在基于 Web 的消息系统上的虚假评论将被所有查看该页面的浏览器渲染。如果消息包含隐藏的 JavaScript 代码,浏览器将在用户未意识到的情况下执行它。
竞态条件
可以利用依赖于事件微妙顺序的系统,来引发意外的行为或使代码崩溃。这通常体现在具有复杂线程模型或由许多协作进程组成的系统中。
一个线程化的程序可能在其两个工作线程之间共享其内存池。如果没有足够的保护,一个线程可能会读取写入线程尚未打算释放的信息——可能是特权交易的一部分或不同用户的信息。
尽管这个问题并不局限于线程化应用程序,但请考虑以下 Unix C 代码片段。它打算将一些输出写入文件,然后更改该文件的权限。
fd = open("filename"); `/* create a new file */ /* point A (see later) */`
write(fd, some_data, data_size); `/* write some data */`
close(fd); `/* close the file */`
chmod("filename", 0777); `/* give it special privileges */`
这里有一个攻击者可以利用的竞争条件。通过在点 A 处删除文件并替换为其自己的文件链接,攻击者获得了一个具有特殊特权的文件。这可以用来进一步利用系统。
整数溢出
粗心使用数学结构可能导致程序以不寻常的方式放弃控制。当变量类型太小无法表示算术运算的结果时,会发生整数溢出。无符号 8 位数据类型 (uint8_t) 使得以下 C 计算出错:
uint8_t a = 254 + 2;
a 的内容将是 0,而不是你预期的 256;8 位只能计数到 255。攻击者可以提供非常大的数值输入来引发溢出并生成意外的程序结果。不难看出这会导致重大问题;以下 C 代码包含一个即将发生的堆溢出,这是由于整数溢出:
void parse_user_input(const char* input)
{
uint8_t length = strlen(input) + 11; `/* a uint8_t might overflow */`
char *copy = malloc(length); `/* so this might be too small */`
if (copy)
{
sprintf(copy, "Input is: %s", input);
`/* oh dear, we might have overrun the buffer */`
}
}
虽然 uint8_t 很不可能成为字符串长度变量的候选者,但相同的问题也会在更大的数据类型中表现出来。在正常操作中,这种情况不太可能发生,但同样可被利用。
这种问题也发生在减法操作(称为整数 下溢)、混合有符号和无符号赋值、类型转换错误以及乘法或除法中。
^([2]) 这个例子是用 C 编写的,在 C 代码中很常见,但请记住,这个问题远非 C 语言特有的问题。
^([3]) 或者,换一种说法,你非常不幸。你在测试时没有发现这个缺陷;它将进入生产代码,只等待黑客利用它。
保护费
你越寻求安全,你拥有的就越少。
--布赖恩·特雷西
我们已经看到软件构建就像建造一所房子(参见第 177 页的 "我们真的在构建软件吗?" 和第十四章)。我们必须学会像保护一所房子一样保护我们的程序,锁上所有的门窗,雇佣哨兵,并添加安全机制(如防盗报警器、电子通行证、身份卡等)。但你必须始终保持警惕:无论有多少复杂的锁,门都可能被留下一丝缝隙,防盗报警器也可能被忽略。
我们的软件安全策略适用于不同的层次:
系统 安装
精确的操作系统配置、网络基础设施以及所有运行应用程序的版本号都具有重要的安全影响。
软件系统的设计
我们需要解决设计问题,比如用户是否可以无限期地保持登录状态,每个子系统如何通信,以及使用了哪些协议。
程序的实现
它必须是完美的。有缺陷的代码会导致安全漏洞。
系统的使用程序
如果它被错误地常规使用,任何软件系统都可能被破坏。我们应该通过良好的设计尽可能防止这种情况,但用户必须被教导不要造成问题。有多少人在他们的终端旁边写下他们的用户名和密码?
创建一个安全系统永远都不容易。它总是需要安全与功能的妥协。一个系统越安全,它就越不实用。最安全的系统没有输入和输出;没有人可以攻击的地方。然而,它不会做很多事情。最简单的系统没有身份验证,允许每个人完全访问一切;它只是极其不安全。我们需要找到一个平衡点。这取决于应用程序的性质、其敏感性和感知到的攻击威胁。为了编写适当安全的代码,我们必须非常清楚这些安全要求。
正如您会采取措施来保护一座建筑一样,以下技术将保护您的软件免受恶意攻击者的侵害。
系统安装技术
无论你的应用程序有多好,如果目标系统不安全,你的程序就会受到威胁。即使是最安全的程序也必须在它的操作环境中运行:在特定的操作系统下,在特定的硬件上,在网络上,以及与一组特定的用户。攻击者同样可能破坏这些中的一个,就像你的实际代码一样。
-
不要在您的计算机系统上运行任何不可信的、可能不安全的程序。
这引发了一个问题:是什么让你信任一个软件?你可以审计开源软件来证明它是正确的(如果你有这个倾向)。你可以选择其他人使用的相同软件,认为人多力量大。然而,如果在该软件中发现漏洞,你和其他许多人必须更新。或者,你可以根据供应商的声誉来选择,希望这是一个值得信赖的指标。
关键概念
只在你信任的计算机系统上运行可信软件。制定一个明确的政策来决定你信任谁。
-
采用安全技术,如防火墙、垃圾邮件和病毒过滤器。不要让黑客通过后门进入。
-
通过记录每次操作、记录谁做了什么以及何时做,为恶意授权用户做好准备。定期备份所有数据存储,以免虚假修改丢失您所有的良好工作。
-
最小化进入系统的途径,给每个用户分配最小权限集,并在可能的情况下减少用户池。
-
正确设置系统。某些操作系统默认为非常宽松的安全设置,几乎是在邀请黑客直接进入。如果你正在设置这样的系统,那么学习如何完全保护它是至关重要的。
-
安装一个诱饵系统:一个比你的真实系统更容易被攻击者找到的诱饵机器。如果它看起来足够可信,那么他们会浪费精力试图入侵它,而你的关键机器将继续不受影响。希望你能及时发现诱饵系统的妥协并在此之前击退攻击者。
软件设计技术
这是确保你的安全故事清晰的基本地方。你可以在开发周期结束时试图将安全强加于代码,但你会失败。它必须是你的系统架构和设计的基本部分。
关键概念
安全性是每个软件架构的基本方面。在早期开发工作中忽略它是错误的。
最简单的软件设计具有最少的攻击点,因此也最容易受到保护。更复杂的设计自然会导致组成部分之间的更多交互,因此为黑客提供了更多的攻击点。如果你是那 99.9%的程序员之一,你不能在一个未公开的沙漠中的地下掩体中运行你的程序,那么你需要考虑如何使你的设计尽可能简单。
在设计代码时,考虑如何积极防止任何人滥用它。以下是一些成功的策略:
-
限制你设计中的输入数量,并将所有通信通过系统的一个部分进行路由。这样,攻击者就不能在你的代码中四处游走——只能通过一个(受保护的)瓶颈。他们的影响力被限制在一个隐蔽的角落,你可以在那里集中你的安全努力。[4]
-
尽可能以最严格的权限级别运行每个程序。除非绝对必要,否则不要以系统超级用户身份运行程序,并且在这种情况下要更加小心。这对于运行 setuid 的 Unix 程序尤为重要——这些程序可以被任何用户运行,但在启动时被赋予特殊的系统权限。
-
避免任何你实际上不需要的功能。这不仅会节省你的开发时间,还会减少程序中出现错误的机会——可供它们栖息的软件更少。你的代码越简单,它就越不可能不安全。
-
不要依赖不安全的库。不安全的库是指你不知道其安全性如何的任何库。例如,大多数 GUI 库都不是为安全性设计的,因此不要在以超级用户身份运行的程序中使用它们。
关键概念
在设计程序时,只依赖已知且安全的第三方组件。
-
将你的代码调整到管理安全问题的执行环境中。.NET 运行时提供了一个 代码访问安全 基础设施,允许你断言,例如,调用代码已被受信任的第三方签名。这并不能消除所有潜在问题(公司的私钥可能会丢失),你必须学会正确使用它,但它确实有助于管理安全问题。
-
避免存储敏感数据。如果必须存储,请加密它,以便好奇的眼睛难以轻易阅读。当你处理机密信息时,要非常小心地将它们放在哪里;锁定包含敏感信息的内存页面,这样你的操作系统的虚拟内存管理器就不能将其交换到硬盘上,使其可供攻击者读取。
-
谨慎地从用户那里获取机密信息。不要显示密码。
最不令人印象深刻的策略被称为 通过隐蔽性来提高安全性,但实际上这是最普遍的。它只是将所有软件设计和实现隐藏在墙后,这样就没有人能看到代码是如何工作的,也无法找出如何滥用它。隐蔽性意味着你不宣传你的关键计算机系统,希望没有攻击者能找到它们。
这是一个有缺陷的计划。你的系统总有一天会被发现,总有一天会被攻击。
这并不总是有意识的决策,而且当你忘记在系统设计中考虑安全性时,这种技术非常方便——也就是说,直到有人真的破坏了你的系统,那时就完全是另一回事了。
关键概念
预期你的软件会受到攻击,并且要以此为出发点来设计每个部分。
代码实现技术
有了坚不可摧的系统设计,你的软件就是坚不可摧的,对吧?遗憾的是,并非如此。我们已经看到安全漏洞如何利用代码中的缺陷来造成他们特有的混乱。
我们的代码是前线,是攻击者最常尝试进入的地方,也是我们战斗的地方。没有良好的系统设计,即使是最好的代码也容易受到攻击;但在深思熟虑的架构基础上,我们必须用安全的代码构建坚固的防御墙。正确的代码不一定是安全的代码。
-
防御性编程是实现稳健代码的主要技术。其核心原则——假设一切都不对——正是安全编程的精髓。偏执是一种美德,你永远不能假设用户会像你期望或意图的那样使用你的程序。
简单的防御规则,如“检查每一个输入”(包括用户输入、启动命令和环境变量),以及“验证每一个计算”,将消除你代码中的无数安全漏洞。
-
进行 安全审计。这是安全专家对源代码的仔细审查。正常的测试不会发现许多安全漏洞;它们通常是由普通测试人员不会想到的奇特的使用组合引起的(例如,非常长的输入序列会引发缓冲区溢出)。
-
仔细地生成子进程。如果攻击者可以重定向子任务,那么他就可以控制任意设施。除非没有其他解决方案,否则不要使用 C 的
system函数。 -
无情地进行测试和调试。尽可能严格地消灭虫子。不要编写可能导致系统崩溃的代码;它的使用可能会立即使运行中的系统崩溃。
-
将所有操作包装在原子事务中,这样攻击者就不能利用竞争条件来获得优势。你可以在第 231 页的 "竞争条件" 示例中通过使用
fchmod在打开的文件句柄上,而不是按名称chmod文件来修复chmod示例:如果攻击者替换了文件,你也知道正在更改哪个文件,即使攻击者替换了文件。
程序技术
这在很大程度上是一个培训和教育工作的问题,尽管选择不是完全无能的用户会有所帮助(如果你有这个奢侈)。
必须教会用户安全的工作习惯:不要告诉任何人他们的密码,不要在关键 PC 上安装随机软件,并且只按规定的使用系统。然而,即使是最勤奋的人也会犯错误。我们设计以最大限度地减少这些错误的风险,并希望后果永远不会太严重。
^([4]) 当然,事情永远不会那么简单。缓冲区溢出可能发生在你的代码的任何地方,你必须始终保持警惕。然而,大多数安全漏洞存在于程序输入的地点或附近。
简而言之
安全是一种死亡。
--田纳西·威廉姆斯
编程就是战争。
安全是现代软件开发中的一个真正问题;你不能把头埋在沙子里,逃避它。鸵鸟编写糟糕的代码。我们可以通过更好的设计、更好的系统架构和更高的对问题的认识来预防大多数安全漏洞。一个安全系统的益处是令人信服的,因为风险是如此严重。
| 好程序员…… | 坏程序员…… |
|---|
|
-
了解他们工作的每个项目的安全需求
-
本能地编写避免常见安全漏洞的代码
-
将安全设计到每个系统中;不要等到最后才修补
-
制定安全测试策略
|
-
将安全视为一个不重要的关注点
-
自认为是安全专家(非常少的人是安全专家)
-
只有在发现漏洞或更糟糕的是,当他们的代码被破坏时,才考虑他们程序中的安全漏洞
-
在编写代码时关注安全,而在设计和架构层面忽略它
|
参见
第一章
防御性编程是编写安全代码的重要技术。
第八章
我们必须严格测试我们的软件以发现安全问题。
第十三章
安全性同样对于代码每个部分的构建至关重要。
第十四章
安全性是计算机系统基本架构关注点之一。它必须从一开始就设计进去。

思考
以下问题的详细讨论可以在第 515 页的"附录 A"部分找到。
思考
-
什么是“安全”程序?
-
在安全程序中必须验证哪些输入?需要什么样的验证?
-
你如何防御来自受信任用户池的攻击?
-
可利用的缓冲区溢出可能发生在哪里?哪些函数特别容易发生缓冲区溢出?
-
你可以完全避免缓冲区溢出吗?
-
你如何保护应用程序使用的内存?
-
C 和 C++是否天生比其他语言更不安全?
-
C 语言的经验是否导致 C++成为更好、更安全设计的语言?
-
你如何知道你的程序已被入侵?
个人化
-
你当前项目的安全需求是什么?这些需求是如何建立的?谁了解它们?它们在哪里被记录?
-
你发布的应用程序中最严重的安全漏洞是什么?
-
对你的应用程序发布了多少安全公告?
-
你是否运行过安全审计?它揭示了哪些类型的缺陷?
-
最有可能攻击你当前系统的人是什么样的?这受什么影响?
-
你的公司
-
用户类型
-
产品类型
-
产品的普及度
-
竞争
-
你运行的平台
-
系统的连通性和公开可见性
-
第三部分。代码的形状
与美酒不同,你的代码不太可能随着时间的推移而变得更好。如果它开始像狗产下的一小堆东西,那么它无疑会变成大象产下的一大堆东西。
这不是秘密,然而软件工厂不断地生产出庞大的作品,然后承受后果。他们的产品既不适应,也不可扩展,也不够灵活,以满足未来的需求,也不容易开发:他们无法按时按预算交付。作为程序员,这伤害了我们的自尊——但它伤害了管理者的钱包,更严重。
答案?一个解决方案是根本不尝试代码开发,但这几乎不切实际。另一个解决方案是以整个系统的结构为视角来开发代码。优秀的代码不是偶然出现的;它是精心制作的结果,强调前期规划和设计。但这也源于灵活的开发方法,足够敏捷以应对途中不可避免的困难和变化。
本节探讨了这一过程。我们将查看:
第十三章
代码微观设计:针对单个代码模块的低级构建技巧。
第十四章
大规模系统设计——任何软件开发的第一步构建阶段。
第十五章
查看软件随着时间的推移如何增长和扩展,以及一些将新工作整合到旧代码库中的实用建议。
这些不是可选的额外内容或美好的愿望。它们是我们工艺的必要阶段,因此对于高质量软件的生产至关重要。忽视这些内容将带来危险。
第十三章。宏伟设计
如何制作优秀的软件设计
骆驼是委员会设计的马。
--艾略特·艾萨克尼斯爵士
有些代码只会让你叹息。
我曾经不得不为嵌入式产品编写一个设备驱动程序。该驱动程序与操作系统的接口相当复杂。我使用的硬件接口也很复杂。为了保持理智,我将代码分为两个部分。第一部分是一个内部库,它访问硬件,执行一些数据缓冲,并提供一个简单的 API 来访问这些缓冲数据。然后我编写了第二个,独立的层,它根据这个内部库实现了挑剔的操作系统的驱动程序接口。设备驱动程序的结构看起来像图 13-1。
后来,硬件制造商给我发送了同一设备驱动程序的样本实现。这段代码的作者显然没有仔细思考。代码混乱不堪,将复杂的操作系统接口与硬件逻辑以完全无法理解的方式紧密交织在一起。其结构近似如图图 13-2 所示。

图 13-1. 皮特的理智软件设计

图 13-2. 如何不设计软件
现在,我并不是在吹嘘自己(至少不是过分吹嘘)。这个例子的目的是明确的。第一个设计更好。它更容易理解,因为它非常直接,它更容易实现,因此也更容易维护。
C.A.R. Hoare 写道:“构建软件设计有两种方法:一种方法是将它做得如此简单,以至于显然没有缺陷,另一种方法是将它做得如此复杂,以至于没有明显的缺陷。第一种方法要困难得多。”(霍尔 81 页)
成熟程序员的标志之一是其代码的设计质量。在本章中,我们将探讨构成良好设计的内容,并研究如何制作高质量的软件设计。
编程即设计
人们普遍认为,“设计”是在编写代码之前完成的一个阶段。它的产品是某种形式的设计规范,这对于一个通用的代码猴子来说足够了。
事实却大相径庭。编程——编写代码的行为——是一种设计活动。
即使是最详细的规范也有漏洞,否则它就会是代码——你无法在设计文档中描述每一个微小的细节。编程行为验证了最初的设计决策,并执行剩余的设计工作。它揭示了漏洞、不一致性和错误,并允许你找到绕过它们的方法。“有些程序员在编程时认为自己没有在做设计,但无论何时你编写代码,你总是在做设计,无论是明确还是隐含。”(琼斯 96 页)
关键概念
编程是一种设计活动。它是一种创造性和艺术性的行为,而不是机械的代码生成。
一个好的开发过程会认识到这一点,并在适当的时候不会回避编写代码。极限编程的实践者主张设计就是代码。没有独立的设计活动;没有设计团队。是程序员通过不断优化和扩展代码来不断优化和扩展设计。这在他们的测试驱动设计方法中得到体现:在编写任何代码之前,编写代码测试作为设计验证工具。这是一个明智的想法。
这是否意味着在开始编写代码之前不需要思考?当然不是!在文本编辑器的深处不是规划你要写什么的地方。这就像在没有决定路线的情况下试图从柏林开车去罗马。在你确定哪个方向是北方之前,你可能会到达莫斯科。按照定义,设计是你首先做的事情。
关键概念
在输入之前先思考;建立一个连贯的设计。否则,你最终会得到混乱的代码。
我们要设计什么?
程序员设计代码结构,这是显而易见的。但在开发过程的各个阶段,这意味着不同的事情。在每一个阶段,设计都是一个将任务分解为其组成部分的过程,并弄清楚每个部分如何工作。
这些软件设计的级别包括:
系统架构
在这里,我们将系统作为一个整体来审视,识别主要子系统,并确定它们如何进行通信。架构设计对系统的整体性能和特性影响最大,对特定代码行的影响最小。这是最重要的设计活动,将在下一章中介绍。在这一章中,我们关注代码的内部设计,这涉及到后续的设计级别。
模块/组件
架构子系统通常太大,无法直接在代码中实现,因此下一步是将每个子系统分解成可理解的模块。在模块级别进行设计时,很容易变得模糊不清。在某种程度上,“模块”实际上并不存在。“模块”可能根据设计方法的不同而具有不同的含义;它可能是一团逻辑代码,可能是一些物理单元,如 Java 包、C++/C#命名空间或可重用库。它可能是一个类层次结构,甚至可能是一个独立的可执行文件。
这个设计阶段通常会生成已发布的接口。这些接口在以后很难更改,因为它们在代码模块和编写它们的程序员团队之间形成了严格的契约。
类和数据类型
接下来,我们将模块分解成小块。界面设计通常较为非正式,并且更容易在模块之后进行更改。这种在键盘上进行的微观设计趋势应该被抵制,否则你可能会写下头脑中第一个出现的代码,而不是最适合该问题的代码。
函数
这可能是食物链中的最低设计级别,但它的作用并不小。程序是由例程构建的:如果例程设计得不好,那么整个系统都会受到影响。在确切地确定了需要哪些函数之后,我们设计它们内部的工作方式,控制流的路由方式,以及使用的算法。1] 这通常是一种心理练习,而不是一种记录的程序,但勤奋的设计是必不可少的。
^([1]) 关键算法通常会跨越多个函数;它们将在模块设计阶段确定。
所有这些喧嚣都是关于什么的?
你不会找到任何人为糟糕的设计辩护,但尽管如此,仍然有很多设计糟糕的代码。在一线工作几年后,任何开发者都有证明它的疤痕。(久经沙场的老兵已经在点头,并在心理上排练他们的战争故事。)但为什么会出现这种情况?
满足的设计可能是缺乏经验的程序员的产物,但更常见的是由软件工厂的商业压力造成的,挤压出任何可能用于良好设计的时间。没有人听那些可怜的抗议的编码者。在现实世界中编程必然受到按时交付软件——任何软件——的驱使。讽刺的是,在几乎所有情况下,缺乏良好的设计最终比正确地做它所花费的时间要多。正如他们所说,“永远没有时间做对,但总有时间做两次。”
设计正确真的很非常重要。你的代码设计是其构建的基础。如果它错了,那么代码将是不稳定的、不安全的,并且不适合用途——危险的。糟糕的设计基础会导致软件等同于比萨斜塔。虽然它在承受实际使用的压力下设法站立起来是新颖的,但它永远不会像它应该的那样好,而且随着时间的推移,这不可避免地会显现出来。
一个良好的设计使代码:
-
更容易编写(有一个明确的攻击计划,并且很清楚它将如何组合在一起)
-
更容易理解
-
更容易修复(你可以确定问题的位置)
-
更不可能隐藏错误(程序错误不会被神秘的设计问题所隐藏)
-
更能抵御变化(设计将鼓励扩展并适应修改)
良好的软件设计
对于任何编程问题,都会有许多潜在的代码设计。你的任务是找到一个。最好的一个。或者至少是一个足够好的一个。这不是一项容易的任务……
-
你怎么知道你的设计会起作用?在完成一个无懈可击的攻击计划后,你自信地开始实施。后来,一个意外的问题会露出它的丑陋面目。回到画板。
-
你怎么知道你的设计何时完成?除非你真正实现了它并发现它可行,否则你无法知道。许多问题无法预先猜测;你必须走出第一步,实现设计,看看它是否完整。只有通过尝试解决方案,你才开始真正理解原始问题。有了这些新知识,你然后可以尝试再次正确地解决它。
-
你怎么知道这是针对该问题的最佳设计解决方案?除非你尝试了每一种可能性,否则你无法得知。这并不实际。那么,你怎么知道它足够好?如果性能是一个要求,你只有在系统真正运行时才会真正知道。
最佳的设计方法解决这些问题。它们是:
迭代
通过进行少量设计、实施它、评估其影响,并将这些反馈到下一轮设计中,可以避免太多令人不快的惊喜。这种增量构建方法非常强大。
谨慎
不要试图一次性设计太多。如果某件事失败了,可能是因为任何数量的设计决策。限制失败的空间,你会发现更容易进步。小而确定的设计步骤比大而笨拙的步骤更有可能成功。
现实
指令性的设计流程并不总是每次都有效。结果取决于建立的要求的质量、团队的经验以及流程应用的严谨性。实用方法结合了所有方法的优点,并承认它依赖于程序员的直觉——经验在很大程度上塑造了良好的设计。
信息
您必须完全理解所有需求和激励原则,以便清楚地了解您正在解决的问题,以及正确解决方案的重要品质。如果您不这样做,您可能会解决错误的问题。您需要这些信息来做出早期设计决策,其中一些很难逆转。
您的设计方法不可避免地会受到正在使用的整体开发方法的影响(有关这些方法的描述,请参阅第 420 页的“编程风格”)。良好的设计过程是创造良好设计的一步,但并非保证。这最终还是取决于你所做出的设计决策的质量。不同的权衡会导致不同的设计。例如,针对速度的设计将与针对可扩展性的设计不同。最终,没有正确或错误的设计。最好的情况是,有好的设计和坏的设计。
良好的设计具有许多吸引人的特征,其对立面是坏设计的明确指标。我们将在下面讨论这些。
简单性
这是良好设计代码最重要的特征。简单的设计易于理解,没有不必要的瑕疵,易于实现。它是连贯和一致的。
简单的代码尽可能小,但不要更小。这需要一些努力,正如数学家布莱士·帕斯卡所欣赏的:“我为信件的长度感到抱歉,但我没有时间写一封简短的信。”仔细计算出所需的代码最少是多少,然后只写那么多。记住,您总是可以稍后添加更多代码以实现额外功能,但您很少能移除已经紧密相连的东西。
做出权衡
软件设计是一个做决策的过程——将系统分解为其组成部分,但也是平衡不同方向拉扯的对抗力量。需要做出权衡,这些权衡塑造了最终的设计。
这些是此类走钢丝和拔河游戏的常见例子:
可扩展性与简单性
可扩展性的设计提供了足够的接口点,以便未来的代码可以插入,并确保脚手架足够通用,以支持任何后续需求。简单性避免了额外间接层次和不需要的普遍性。
效率与安全
性能的提升通常是通过牺牲设计的纯粹性来实现的——为某些重要操作设置特殊的后门,或者添加大量的耦合以防止过多的间接访问。高度优化的系统通常在面对变化时不太清晰且更脆弱。
虽然并非所有高效的设计都是不好的;许多好的设计自然表现良好,正是因为它们的简单性。
功能与开发工作量
在项目启动时,有一千个期望的功能和合理的想法,即它们应该在何时交付(如果不在明天,那就更早)。没有无限数量的猴子和他们无限的电脑,你永远无法全部完成。
更多的功能需要更多的时间来实现。
这些特性中哪一个最重要取决于项目需求。这就是为什么一开始就弄清楚它们如此重要的原因。
懒惰可以带来回报。设计你的设计,以便尽可能推迟工作,并只专注于当前的问题。
关键概念
少即是多。追求简单但功能强大的代码。
简单的设计不一定容易创建。它需要时间。对于除了最基本程序之外的所有程序,必须筛选大量信息才能达到最终解决方案。设计良好的代码看起来很明显,但可能需要大量的思考(以及大量的重构)才能使其如此简单。
关键概念
使事物简单是一项复杂的工作。如果一个代码结构看起来很明显,不要假设它设计起来很容易。
有很多方法可以使设计变得不必要地复杂,包括错误的组件分解、无思考地增加线程、不恰当的算法选择、复杂的命名方案以及过度或不适当的模块依赖。
优雅
优雅体现了设计的审美方面,通常与简单性相辅相成。这意味着你的代码不是巴洛克式的、令人困惑的聪明或过于复杂。设计良好的代码在其结构中具有美感。这些是可取的特性:
-
控制优雅地环绕系统。一个操作不会通过每个模块,在 16 种不同的表示形式之间转换其参数格式,最终忽略它。
-
每个部分都补充了其他部分,增加了独特且有价值的东西。
-
设计没有充斥着特殊情况。
-
它将类似的事物关联起来。
-
没有令人讨厌的惊喜潜伏在角落里。
-
存在一个小的变化局部性:在一个地方的单个简单改变不会导致代码在许多其他地方的修改。
好的设计与平衡和美学有很大关系。我不会走得那么远,以至于说编程是艺术,尽管有些人可能会为这一点提出有说服力的论据。优雅和简单是支撑本列表中大多数剩余特性的基础。
模块化
当我们解决设计问题时,我们会自然地将它分解成称为模块或组件的部分。我们将它们分解成子系统、库、包、类等等。每个部分比原始问题更简单,但组合起来,它们形成了一个完整的解决方案。这种分解的质量至关重要。
模块化的关键特性是内聚和耦合。我们追求具有以下特性的模块:
强内聚
内聚是衡量相关功能如何聚集在一起以及模块内部的各个部分如何作为一个整体工作的指标。内聚是使模块粘合在一起的胶水。
弱内聚的模块是分解不良的迹象。每个模块都必须有一个明确定义的角色,并且不能是无关功能的杂乱组合(就像可怜的utils命名空间那样——人们为什么要写这些?)。
低耦合
耦合是衡量模块之间相互依赖程度的指标——即与它们连接的线路数量。在最简单的设计中,模块之间的耦合很小,因此它们相互依赖性较低。显然,模块不能完全解耦,否则它们根本无法一起工作!
模块以多种方式相互连接——有些是直接的,有些是间接的。一个模块可以调用其他模块的函数或被其他模块调用。它可能使用另一个模块的数据类型或共享一些数据(可能是变量或文件)。良好的软件设计将通信线路限制在绝对必要的范围内。这些通信线路是决定代码设计的一部分。
一旦识别出来,每个模块都可以独立工作并单独测试。这是模块化的一个优点;它允许你将任务分配给程序员。不过要小心;康威定律警告说软件结构可能会遵循团队结构:“如果你有四个团队一起构建一个编译器,它将变成一个四遍编译器”(参见第 320 页的“组织和代码结构”)。确保分解是合理的,并且基于问题,而不是团队组织。
关键概念
设计内部内聚且耦合度最小的模块。分解必须代表问题空间的有效划分。
良好的接口
模块帮助我们分离关注点并划分问题。每个模块定义了一个接口,这是它隐藏内部实现的公共外观。这个可用的操作集通常被称为应用程序编程接口(API)。它是访问模块功能的主要途径,其质量决定了该模块的质量,至少从外部看是这样。
关键概念
划出人们不需要跨越的界限:识别清晰的 API 和接口。
要创建一个良好的接口,请遵循以下步骤。
-
识别客户端及其想要做什么。
-
识别供应商及其能够做什么。
只有在正确识别了双方并理解了他们的个别需求后,才能通过接口成功地将用户和实现者分开。一旦你对此有了明确的认识,你就有机会创建一个既能满足用户需求又实际可实现的接口。
恶劣的设计将操作放在错误的位置,使得跟踪应用程序逻辑成为噩梦,并且难以扩展设计。它导致模块耦合增加和内聚度降低。
-
推断所需的接口类型。
它是一个函数、一个类、一个网络协议,还是其他什么?这通常由提供功能的人决定,但也可以将接口封装起来,以不同的方式呈现。例如,将 CORBA 对象封装在库中,可以将其功能发布到一组协作计算机的网络中。
-
确定操作的性质。
真正需要提供的功能——这是否比这个客户端的具体需求更通用?在每一个函数中,通常都有一个更有用的操作等待被提取出来。
有几个关键原则有助于我们推理接口的性质和质量。如图图 13-3 所示,这些原则是:
分区
接口形成了一个接触点,但也是客户端和实现者之间的分隔线。他们只能以定义的方式沟通,而不能以任何其他临时方式。
优秀的代码清晰地定义了角色和责任。了解系统中的主要演员是谁以及他们应该做什么,确保接口清晰有效。
一个很好的例子是我的房子:它的主要接口是前门。门将居住者与访客分开,并确定他们相遇的地方。还有许多其他接口用于其他操作:窗户、电话、烟囱等等。
抽象
抽象允许观察者集中精力做出重要决策,有选择地忽略某些细节。它将现实背后的复杂性以更简单的形式组织起来,帮助我们应对复杂性。在面向对象设计中,这是一个特别重要的概念。在设计接口时,你通过仔细选择对用户重要以及对他们有用地隐藏的内容来创建一个抽象。
给你一碗水果,你可以高兴地说,“吃掉上面的那个东西,”然后“吃掉下一个,”而不必担心这具体意味着什么;葡萄柚需要剥皮,而大黄需要煮熟并裹上糖。这些细节都隐藏在抽象的“吃”背后;你只关心水果已经被吃掉了,而不是如何吃掉它.^([2])

图 13-3. 房屋提供的界面
抽象可以形成层次结构。你可以从不同的抽象层次来看我的房子,取决于你是一个建筑师、一个粒子物理学家,还是一个银行经理。它可以被视为:
-
房间集合
-
墙、地板和天花板的布局
-
砖木结构的建筑
-
分子集合,甚至原子集合
-
需要支付的抵押贷款
压缩
这是接口用更简单的东西来表示大型操作的能力。压缩通常是良好抽象的结果,但糟糕的抽象可能导致更冗长的代码。
可替换性
如果一个接口的实现满足相同的契约,你可以用另一个实现来替换它。如果你在程序中定义了一个排序接口,那么任何算法都可以放在它后面:它可以是快速排序、堆排序,或者(天哪)冒泡排序。你可以在任何时刻更改它,只要通过接口可见的行为保持不变。
在类继承层次结构中,任何对象都可以被其超类型所替代。
如果你希望我打开前门,你会按门铃。过去这是一个连接到铃铛机构的有线开关,但我刚投资了一个新式的无线门铃。这对你没有任何影响,实际上你甚至不会知道我已经更换了它;你按一个按钮,我就出现了。
面向对象
计算机科学的大部分内容都是围绕定义接口和组织围绕它们的复杂性来构建的。那句著名的格言是,“任何问题都可以通过添加一个额外的间接层来解决”——也就是说,通过另一个接口隐藏新的复杂性。
有许多类型的接口。它们都向客户端展示一些公共的面孔,并在这一面纱背后隐藏了血腥的实现细节。
你将创建的常见接口形式是:
-
库
-
类
-
函数
-
数据结构(尤其是具有额外行为的外来结构,如信号量)
-
操作系统接口
-
协议(例如网络通信)
可扩展性
设计良好的代码允许在必要时在适当的位置插入额外的功能。危险在于这可能导致过度设计的代码,试图应对任何潜在的未来修改。
可扩展性可以通过软件脚手架来实现:动态加载的插件、精心选择的具有顶部抽象接口的类层次结构、提供有用的回调函数,甚至是一个基本合理且可塑的代码结构。
关键概念
设计以扩展性为目标,但不要过于泛化——你最终会写出一个操作系统,而不是一个程序。
良好的设计师会仔细思考他的软件将如何扩展。随意在代码中添加扩展钩子实际上可能会降低质量。你应该平衡现在需要的功能、肯定需要添加的功能以及可能需要的功能,以确定设计应该有多大的可扩展性。
避免重复
设计良好的代码不包含重复;它永远不需要重复自己。重复是优雅和简单设计的敌人。不必要的冗余代码会导致程序脆弱:给定两个只有细微差别相似的代码片段,你可能会在其中一个中找到并修复一个错误,然后忘记在另一个中修复相同的错误。这显然会损害代码的安全性。
大多数重复是通过剪切和粘贴编程——在编辑器中复制代码——产生的。它也可能通过程序员通过重新发明轮子(不理解整个系统)而更加微妙地产生。
-
如果你看到不同的代码部分执行着惊人的相似操作,请将其概括为一个具有适当参数的函数。现在有一个地方可以修复任何错误。这有助于通过描述性的函数名使代码的意图更清晰。
-
类似度极高的类表明某些功能可能需要提升到超类中,或者可能缺少一个接口来描述共同的行为。
关键概念
一次做好。避免重复。
可移植性
良好的设计并不一定是可移植的;这取决于代码的需求。可以采取很多措施来防止平台依赖,但为了不必要的可移植性而牺牲代码质量是糟糕的设计。良好的设计应该是适当地可移植的,并在出现问题时管理可移植性关注点。
这个故事很熟悉:你的代码从未打算在任何其他环境中运行,因此它没有被设计成能够应对。后来的开发意外地需要一个新的运行时平台;适应旧程序比编写一个新程序要简单。代码不利于可移植性,而且没有足够的时间重构或重新设计以支持跨平台。结果呢?一个混乱的代码团,其设计已被不可修复地扭曲,充满了#ifdef NEW_PLATFORM结构。这不是由工程师编写的;它是由哲学家管道工编写的。
在设计早期就仔细选择你的操作系统依赖或硬件依赖代码的结构。这将在未来带来回报,并且不需要影响性能或清晰度(有时甚至可能提高清晰度)。尽早考虑这一点很重要;重新工作旧假设是昂贵的。
常见的方法是创建一个平台抽象层(这可能是几个操作系统接口函数的简单覆盖)。你可以在每个平台上以不同的方式实现这个层。
关键概念
在设计代码时,管理其可移植性,而不是将其作为事后考虑的修补。
惯用语
一个好的设计自然采用最佳实践,与设计方法(见第 420 页的“编程风格”(Programming Styles))和实现语言的惯用语相匹配。这允许其他程序员立即理解代码的结构。
针对实现语言(可能是固定的,也可能是设计领域的一部分),你必须理解如何很好地使用它。例如,C++有像资源获取即初始化(RAII)和操作符重载这样的惯用语,这对你设计代码的方式有很大影响。学习它们。理解它们。使用它们。
文档齐全
最后,但同样重要的是,一个好的设计应该有文档。不要让读者自己去推断结构。这在设计的高层次尤其重要。文档应该很小,因为设计本身很简单。
在光谱的一端,架构设计以规范的形式进行文档化。在另一端,函数使用自文档化的代码。在中间,你可能使用文献编程来编写 API 文档。
^([2]) 这种在单个逻辑抽象背后隐藏多个物理行为的能力被称为多态性,在“多态性”一节中描述,见第 423 页。
如何设计代码
总是考虑事物在其更大的上下文中进行设计——一把椅子在一个房间里,一个房间在一所房子里,一所房子在一个环境中,一个环境在一个城市规划中。
--Eliel Saarinen
你如何学会设计得很好?优秀的设计师是天生的还是后天培养的?设计可以教授或习得吗?一些程序员天生具有优秀设计的天赋;这与他们的大脑工作方式相契合。他们自然欣赏美学,并能理解足够的问题以做出平衡的判断。然而,你可以学会更有效地设计。
当我出生时,我并不擅长陶艺。(我从未遇到过任何人擅长。)我现在仍然很糟糕,但我曾经上过一些课程。我理解了技巧并能制作(几乎可辨认的)陶器。如果我稍微练习一下,可能会更好,但我永远不会成为大师级工匠。
同样,没有人天生就会设计代码:我们学习。我们被教导设计方法和良好的工程实践。这些旨在使设计成为一个可重复的过程,但它们不能替代工艺。创造性思维过程和创新设计的构建要难得多;总会有人更好地掌握这一点。
良好的软件设计是审美的;要创造这种数字艺术需要技能、经验和实践。本章无法尝试提供一个按数字描述如何设计软件的步骤。真遗憾:如果我能把好的设计装瓶,我将成为百万富翁。要成为一名优秀的设计师,你必须了解构成良好设计的东西,并学会避免不良设计的特征。然后是练习。很长时间。
除了个人能力之外,还有设计方法和工具可以为程序员带来很多帮助。我们将通过调查它们如何(或不能)帮助我们来结束讨论。
设计方法和过程
软件设计方法有很多。有些强调符号,有些强调过程。系统化的方法比“凭直觉”设计要好;你使用哪种方法通常由公司的实践和文化决定。我总是小心翼翼地避免陷入特定过程的泥潭——满足其微小的细节往往扼杀创造力。
现代设计方法分为两大类,它们基于的基本设计哲学:
结构化设计
这主要是关于功能分解,将系统的功能分解成一系列较小的操作。例程是主要的结构化工具;设计由例程的层次结构组成。结构化设计以分而治之的方法为特征,将问题分解成越来越小的过程,直到每个部分不能再分解。
有两条主要攻击线:自顶向下和自底向上。
-
毫不奇怪,自顶向下的方法从整个问题开始,将其分解成更小的活动。然后,这些活动被设计成自包含的单元,直到不再需要进一步分解。
-
相比之下,自下而上的设计从功能的最小单元开始,即你知道系统必须执行的最简单的事情。然后它将这些功能拼接在一起,直到得到一个完整的解决方案。
在实践中,这些方法通常是协同使用的,设计过程在它们交汇的地方结束,通常是在中间某个位置。
面向对象的设计
与结构化设计侧重于表示系统必须执行的操作相比,面向对象的设计侧重于系统中的数据。它将软件建模为一系列相互作用的单个单元,称为对象。
面向对象的设计确定了问题域中的主要对象,并确定了它们的特征。这些对象的行为被建立,包括它们提供的操作以及它们各自关联的其他对象。这些对象被编织到设计中,包括任何实现域所需的对象。当所有对象的行为和交互都被确定时,设计就完成了。
面向对象编程被誉为软件设计世界的救世主,一种新的范式,将引领世界和平,以至于人们常常因为不进行 OO 设计而感到尴尬。但它在很大程度上实现了炒作,使软件设计能够管理远比更大的问题的复杂性。
请参阅第 420 页的“编程风格”,以获取对设计方法和过程的更详细描述。
设计模式
模式在过去的几年中已经成为面向对象编程社区的流行词汇。由被称为“四人帮”的作者(Gamma 等人 94)所著的《设计模式:可复用软件元素》(Design Patterns: Elements of Reusable Software)一书(因此它通常被称为GoF书籍)普及开来,设计模式是克里斯托弗·亚历山大(Christopher Alexander)建筑作品的软件版本。(亚历山大 99)
模式建立了一套经过验证的设计解决方案的词汇表,每个模式描述了一个可识别的协作对象结构。这些不是聪明的创新设计,而是在真实代码中发现的重复出现的模式,并且已被证明是有效的。模式语言汇集了一系列设计模式,展示了它们如何相互关联和补充。语言中的每个模式都遵循一个共同的形式,描述了上下文、问题和解决方案。这些信息允许你适当地在你的设计中应用模式。
模式在软件系统的几个层面上出现。架构模式对系统的组织有深远的影响。设计模式是软件组件的中级协作。语言级别的模式是特定的代码技术,更常见地被称为语言的习语。
设计模式的名称已经进入日常用语,这是它们有用性的证明。你将听到程序员愉快地谈论适配器、观察者、工厂和单例。
设计模式远不止这个简短的描述所能做到的。它们是一个真正有用的概念,值得花些时间来了解它们。阅读 GoF 书籍及其相关材料。
设计工具
我们的设计最终以代码的形式表达,但经常在更抽象的层面上工作可能会有所帮助。工具帮助我们推理设计,帮助我们产生更有效的设计,并帮助我们向其他程序员传达这些设计——记录我们打算生产和我们已经已经创建的内容。
在某种意义上,方法论是工具,但还有广泛的其他设计辅助工具与之相辅相成。
符号
精美的图片胜过千言万语。许多图形符号存在,帮助我们以图形方式表达我们的设计。大多数只是短暂地流行,然后默默地退出了聚光灯,被一种更加吸引人的绘制框线和线条的方式所取代。统一建模语言(UML)目前是最流行且最规范的符号。它提供了一种标准的方式来建模和记录软件开发过程中产生的几乎所有工件。事实上,它已经变得如此全面,以至于你可以用它来可视化远不止软件;它已经被用来建模硬件、业务流程,甚至组织结构。
符号提供了一种媒介,帮助你表达、思考和讨论你的软件设计。它们有两个目的:
-
它们允许你快速绘制“信封背面”的设计,并在白板上分享想法。
-
它们允许你正式记录设计。
为了在后者中保持你的理智,图表创建必须通过一个专门的绘图工具自动化。否则,图表将难以更新,并且会随着代码的开发而与现实脱节。把时间花在做一些有用的事情上,而不是画框线和线条。
我更喜欢不被过于正式的符号使用所困扰,高兴地将其用作传达设计基本要素的方法。知道足够多的东西来能够进行沟通对我来说就足够了;我不想过于关注每种图片中的每个菱形和虚线线意味着什么。
设计模式
一种强大的设计工具,提供了一系列经过验证的设计技术词汇,并展示了如何在实践中应用它们。“设计模式”在第 255 页更深入地讨论了设计模式。
流程图
一种特定的图形符号,用于可视化算法。它们适用于提供高级概述,但不如代码精确,并且成为需要与代码更改保持同步的另一件事。因此,最好谨慎使用。
模拟代码
模拟代码帮助你起草函数实现。它是软件设计中最奇特的一项发明——介于自然语言和编程语言之间,一种类似皮钦英语的东西。它的优点是摆脱了任何特定语言的语法和语义。你可以专注于需要完成的事情,而不是语言机制,并且可以包含任意数量的描述性文字以提高清晰度。
与其缺点相比,这些并不是令人难以置信的好处。模拟代码需要翻译成实现语言。你本来就可以用那种语言开始编写,从而节省一些精力。如果模拟代码被用作设计文档,那么你必须将其与代码保持同步。
程序设计语言(PDL) 是一种更加荒谬的发明——一种形式化的伪代码。我想当时对某个人来说是有意义的。我很想看看他们的伪代码编译器。
代码设计
这是一种有用的非正式的代码设计方法。在初始设计阶段,你将所有 API 和底层接口以代码的形式捕捉,但并不实现它们——你只是编写返回合理值的占位符,并在每个占位符内部添加注释,描述应该做什么。当你达到足够成熟的设计时,系统已经编写了大量的代码。
这可能是一把双刃剑,因为它可能导致设计不够流畅。你改变设计的次数越多,你需要修改的代码就越冗长。
CASE 工具
计算机辅助软件工程(CASE) 工具协助设计过程中的所有或部分工作,自动化繁琐的任务并管理工作流程。大多数工具能够从你的漂亮图片中生成代码(质量可变)。一些甚至在你修改代码时更新图片;这被称为 往返工程(或 往返)。许多 CASE 工具支持协作工作,允许程序员团队为单一的大型设计做出贡献。
值得一提的一种 CASE 工具是 快速应用开发(RAD) 工具:快速构建应用程序的环境。它们通常在其特定领域(通常是简单 UI 聚焦的应用程序)中表现良好,但不是好的通用软件设计模型。
关键概念
对设计工具和方法论采取实用主义的态度——当它们真正有用时使用它们——但不要成为它们的奴隶。
简而言之
在复杂的背后,出现了简单的元素。
-- 温斯顿·丘吉尔
好的代码是精心设计的。它具有某种美学吸引力,让人感觉 很好。在开始编写代码之前,你必须计划设计,否则你最终会得到一个令人不快的混乱。考虑像清洁结构、可能的未来扩展、正确的接口、适当的抽象和可移植性要求等因素。追求简单和优雅。
设计涉及强烈的工艺元素。最好的设计来自经验丰富和技艺精湛的手。最终,一个好的设计师是好的设计的关键。平庸的程序员不会产生优秀的设计。
| 精通编程的人 . . . | 不擅长编程的人 . . . |
|---|
|
-
希望他们接触的任何事物都处于良好的状态
-
将编程视为一个创造性的过程,并将艺术元素融入他们的工作中
-
在开始工作之前,考虑代码的结构
-
在开始任何额外工作之前,感觉有必要整理和重构混乱的代码
-
不断学习其他软件的设计,积累成功和失败的知识
|
-
继续将越来越多的代码编织成一个紧密的球,直到他们认为已经足够,然后对结果抱怨
-
在处理密集的代码时,不会注意到糟糕的设计或感到任何厌恶
-
你是否愿意快速黑客攻击并逃离,留下别人来清理混乱
-
不欣赏或尊重他们正在工作的代码的内部设计;他们以一种不友好的方式践踏它
|
参见
第八章
描述了如何为测试设计代码—使证明你的代码正常工作更容易。
第十四章
软件设计的最高级别被称为软件架构。它提供了自己的特定问题,并在本章中处理。
第十九章
软件设计通常被记录在规范文档中。
第二十二章
设计融入了整个软件开发过程。
第二十三章
你正在构建的系统类型不可避免地会影响软件的内部设计。

开动脑筋
这些问题的详细讨论可以在第 519 页的"附录 A"部分找到。
深思熟虑
-
项目大小如何影响你的软件设计和创建它的工作?
-
一个文档齐全的糟糕设计是否比一个未文档化的优秀设计要好?
-
你如何衡量一段代码的设计质量?你如何量化其简洁性、优雅性、模块化等等?
-
设计是团队活动吗?团队合作技能在创造良好设计方面有多重要?
-
不同的方法论是否更适合不同的项目?
-
你可以通过哪些方式确定一个设计是否高度内聚或松散耦合?
-
如果你以前解决过类似的设计问题,这对你判断这个问题的难度有多大的指示作用?
-
设计中是否有实验的余地?
个人感悟
-
回顾并思考你是如何学习设计代码的。你如何将你获得的知识传达给一个完全的初学者?
-
你在使用特定的设计方法论方面有什么经验?这些是好的还是坏的经验?结果代码是什么样的?什么可能做得更好?
-
你认为坚持你正在使用的方法论很重要吗?
-
你见过的最佳设计代码是什么?最差的设计代码是什么?
-
编程语言本质上是你实现设计的工具,而不是争论的宗教。真正了解语言惯用法的意义有多大?
-
你认为编程是工程学科、手艺还是艺术?
第十四章。软件架构
软件设计的基础
架构是浪费空间的艺术。
--菲利普·约翰逊
去一个城市。站在它的中间。四处看看。除非你选了一个不寻常的地方,否则你将被大量不同年龄和建筑风格的建筑所包围。有些与周围环境和谐相处。有些看起来格格不入。有些看起来美观,比例协调。有些则非常丑陋。有些可能会在未来 100 年里仍然存在。许多则不会。
设计这些大楼的建筑师在动笔之前考虑了很多因素。在设计过程中,他们小心翼翼、有条不紊地工作,以确保建筑可以制造,并平衡所有竞争力量:用户需求、建造方法、可维护性、美学等等。
软件不是由砖石和灰泥构成的,但同样需要仔细思考以确保系统满足类似的集合要求。我们在建造建筑方面比编写软件的历史要长得多,这很明显。我们仍在学习什么构成了好的软件架构。
在这次对软件架构世界的短暂探索中,我们将研究一些常见的架构模式,并探讨软件架构的真正含义,它真正不是什么,以及它的用途。
地下运动
我加入了一个项目,该项目产生了大量未文档化的软件,这些软件没有计划或目的,没有建筑师来指导建造过程。自然地,它变成了一颗难看的疖子。当我们需要真正理解它是如何运作的时候,系统架构图就被绘制出来了。有如此多的不同组件(许多很大程度上是冗余的),不恰当的连接,以及不同的通信方式,使得图表成为了一团混乱的紧密交织的线条,许多线条呈现出多种解释色彩——几乎就像一只蜘蛛掉进了几罐不同的油漆里,然后在办公室里织出了迷幻的网。
然后,我意识到。我们几乎绘制了一张伦敦地铁的地图。我们的系统与它如此相似,令人毛骨悚然——对于局外人来说几乎无法理解,有众多路线可以达到相同的目的,而计划仍然是对现实的粗略简化。这是一个会让旅行推销员感到烦恼的系统。
缺乏架构视野在软件上留下了明显的印记。它难以使用和理解,功能片段散布在完全随机的模块中。它已经到了这种地步,你唯一能做的有用的事情就是把它扔掉。
在软件开发中,就像在建筑建造中一样,架构至关重要。
什么是软件架构?
这只是另一个稍微拉伸了建筑隐喻的术语吗(参见第 177 页的"我们真的在构建软件吗?")?可能吧,但它确实是一个真正有用的概念。软件架构有时也被称为高级设计;无论使用什么术语,其含义都是相同的。架构是对该概念更富有表现力的描述。
软件蓝图
当建筑师为建筑物准备蓝图时,软件架构师为软件系统准备蓝图。然而,尽管建筑物的蓝图是一个严格详细的计划,包含了所有重要特征,但我们的软件架构是一个顶层定义,是对系统的概述,特别避免过多细节。它是宏观的,而不是微观的。
在这个高级视图中,所有实现细节都被隐藏了;我们只看到软件的基本内部结构和其基本行为特征。架构视图执行以下操作:
-
确定关键软件模块(或组件、或库;在这个阶段,你可以随意称呼它们——块)
-
确定哪些组件相互通信
-
帮助识别和确定系统中所有重要接口的性质,明确各种子系统的正确角色和责任。
这些信息使我们能够对整个系统进行推理,而无需了解每个单独的部分将如何工作。架构提供了一个框架,后续的开发可以适应其中。它展示了工作如何在不同团队之间分配,并允许你权衡不同的实现策略。
不仅架构描绘了系统是如何组成的,它还展示了系统应该如何随着时间的推移而扩展。在大型团队中,当有一个清晰的、统一的软件应该如何适应、每个模块应该包含什么以及模块如何连接的愿景时,程序将更加优雅地发展。
关键概念
架构是影响软件系统设计和未来增长的最大因素。因此,在开发的早期阶段将其做正确是至关重要的。
作为一项前期活动,架构是我们第一次将问题域(我们正在解决的现实世界问题)映射到解决方案域的机会。两个域之间并不总是存在简单的对象和活动的一对一映射,因此架构展示了如何从另一个角度思考。
软件架构需要解决的问题将因项目而异。目标平台在这个阶段并不重要;可能可以在使用不同语言和技术的大量不同机器上实现架构。然而:
-
对于某些项目,可能需要指定特定的硬件组件,这很可能是为了嵌入式设计。
-
对于分布式系统,机器和处理器数量以及它们之间的工作分配可能是一个架构问题。应考虑最小和平均的系统配置。
-
如果特定的算法或数据结构对整体设计至关重要,架构也可能描述它们(尽管这种情况很少发生)。
总是存在权衡。在架构级别确定的信息越多,在后续的设计或实施阶段就越少有操作空间。
视角
在物理架构中,我们使用许多不同的图纸或同一座建筑的视图:一个用于物理结构,一个用于布线,一个用于管道,等等。同样,我们在架构过程中开发不同的软件视图。常见的四种视图如下:
概念视图
有时被称为逻辑视图,它展示了系统的主要部分及其相互连接。
实现视图
这种观点是从实际实施模块的角度来看的,这些模块可能需要与整洁的概念模型有所不同。
过程视图
设计用于展示任务、流程和通信方面的动态结构,当涉及高度并发时,此视图最为适用。
部署视图
使用此视图来展示在分布式系统中任务分配给物理节点的情况。例如,你可以在数据库服务器和一系列网络接口网关之间分割功能。
你不会一开始就拥有所有这些。特定的视图随着开发工作的进展而出现。初始架构阶段的主要结果是概念视图,这是我们在这里关注的重点。
值此机会
软件架构具有广泛的影响——远远超出代码的初始结构,深入到软件工厂的核心。架构将是一个持久的遗产,在技术和实践领域都是如此。架构影响代码如何增长以及团队如何协作来扩展它;软件设计影响工作流程。采用三层架构,你最终会拥有三个团队分别处理不同的部分。可能还会有三套管理团队,以及三条管理汇报线。某个人的早期设计决策将影响你坐在哪个办公桌前。
由于架构决定了软件的灵活性和代码库如何适应未来的需求,它最终会影响你公司的商业成功。糟糕的架构不仅仅是麻烦——它可能会让你失去生计。这是严肃的事情。
作为程序员,这直接影响我们——它将影响我们的工作有多有趣。没有人愿意辛勤劳作来添加一个微不足道的特性,而这个特性如果有一个正确的设计,只需两秒钟就能完成。在构思阶段,要检查架构是否支持你认为应该支持的内容,而不仅仅是建筑师所相信的内容。
何时何地进行?
架构被记录在一个高级文档中,可能被称为架构规范这样的富有创意的名字。这个规范解释了系统的结构,并展示了它是如何满足需求的,包括达到任何性能要求的策略以及如何实现可接受的容错性。
关键概念
在已知的地方捕获系统架构;一个对所有相关人员——程序员、维护者、安装人员、经理(甚至可能是客户)——都易于访问的文档。
架构是初始的系统设计。因此,它是需求确定后的第一步开发步骤。提前生成规范很重要,因为它提供了第一次审查和验证将对项目产生最大影响的决策的机会。它将暴露出弱点和潜在问题。在早期就纠正一个糟糕的决定将节省大量时间、精力和金钱。一旦在系统之上构建了大量代码,改变系统的根基就会变得非常昂贵。
架构工作是一种设计形式,但它与模块设计阶段是分开的,并且与低级代码设计不同,尽管它确实有一些重叠。详细设计的工作可能会将变化反馈到系统架构中。这是自然且健康的。
谁的职责?
我们已经看到,软件架构影响着项目中的每个人——而不仅仅是程序员。相比之下,架构是由一个远小得多的群体决定的。这是一个多么重大的责任。
架构设计师被称为软件架构师。这是一个宏伟的头衔,就像工程师一样,有些争议。真正的建筑师必须学习、取得资格并达到专业卓越的水平,才能被称为建筑师。在软件世界中没有这样的要求。
软件架构师是项目发起者之一,他们在开发周期的初期就开始工作。随着开发活动的增加,程序员将加入努力,实施这个既定的架构。
然而,在需要较少专业架构经验的小项目中,程序员自己将设计架构。不需要请来大炮。准备好为架构设计做出贡献。
它有什么用途?
架构是初始的系统设计。但它的用途甚至更广泛。我们使用系统架构来做:
验证
架构是我们验证将要构建的内容的第一机会。有了它,我们可以在心理上检查系统是否满足所有要求。我们可以检查它是否真的可行构建。我们可以确保设计在内部是一致的,并且没有特殊情况或无用的黑客攻击。高级设计中的瑕疵只会导致更低级别上更危险的黑客攻击。
架构有助于确保没有工作重复、浪费的努力或冗余。我们用它来检查策略中是否存在空白,是否包含了所有必要的部分。我们确保在将各个部分组合在一起时不会出现不匹配。
沟通
我们使用架构规范来将设计传达给所有感兴趣的各方。这些人可能是系统设计师、实施者、维护者、测试人员、客户或经理。它是理解系统的首要途径,也是一份重要的文档,应该始终在发生变化时保持最新。
关键概念
架构规范是传达您系统形状的必要工具。确保您将其与软件保持同步。
架构传达了您系统的愿景,将问题域映射到解决方案域。它应该清晰地标识出未来扩展如何融入其中,有助于保持系统的概念完整性。(布鲁克斯 95)它隐含地提供了一套约定,并包含了一定的风格元素。例如,如果整个设计使用 CORBA 基础设施,那么显然不应该引入一个具有自定义套接字通信的新组件。
该架构提供了一条自然过渡到下一级设计的路径,同时不会过于具体化。
区分
我们使用架构来帮助我们做出决策。例如,它确定了构建与购买决策,确定是否需要数据库,并明确了错误处理策略。它将标识问题区域,项目中的特定风险区域,并帮助我们计划以最小化这种风险。正如建筑师的主要目标是确保他的建筑在建成时能够经受住所有预期条件(以及一些不寻常的条件)——我们的软件结构也应该如此。一点风或额外的负载不应该使东西倒塌。
我们需要这种系统级的视角来做出适当的权衡,确保设计满足其所需属性。这些重要问题应该在开发初期考虑,而不是在开发末期才被添加。
关键概念
在架构的背景下做出所有软件设计决策。始终检查您是否与系统愿景和策略保持一致。不要创建一个不与任何其他事物相协调的小瑕疵。
组件和连接
建筑学主要关注组件和连接。它决定了每种组件的数量和类型。
组件
架构捕捉了关于每个组件的信息,无论在架构的上下文中“组件”意味着什么。它可能是一个对象、一个过程、一个库、一个数据库或第三方产品。系统的每个组件都被识别为一个清晰且逻辑的单位。每个组件执行一项任务,并且做得很好。除非有特定的厨房水槽模块,否则没有任何组件包含厨房水槽。
虽然架构不会过多关注组件实现问题,但它将描述所有公开的设施,也许还有重要的外部可见接口。它定义了组件的可见性:它能看到什么,不能看到什么,以及什么能看到它,什么不能。不同的架构风格意味着不同的可见性规则,我们稍后会看到。
架构师与市场营销人员
如果架构不能满足初始部署或任何未来发展的产品需求,那么它是不充分的;设计质量不仅仅是技术卓越的问题。技术问题必须与产品管理和市场营销考虑因素一起解决。
开发没有人想要的产品的意义不大;这显然是巨大的时间浪费。但是,如果你从技术考虑中省略了市场营销要求,你可能会错过重要的商业机会。市场营销部门确定核心商业目标,包括销售策略(你是收取一次性费用还是采用许可/计费模式?),产品在市场中的定位(它是一个高端、功能丰富、高成本的产品,还是一个便宜的大众化产品?),以及贯穿整个系统的独特品牌的重要性。
在某些情况下,明显的良好架构可能是一个独特的卖点,并可能提供强大的竞争优势。其他市场可能不太关注内部系统结构,但能够预见并处理未来客户需求的架构对于建立和维持强大的市场地位仍然是至关重要的。
技术架构师必须与市场营销决策者紧密合作,了解新软件如何融入公司的整体战略以及客户对真正卓越解决方案的需求。软件架构将解决诸如可用性、可靠性、可升级性和可扩展性等市场营销问题。这些因素都对软件设计有实际影响。仅支持不同的收费方式就可能对项目的盈利能力产生巨大影响——包含丰富的日志支持将为按交易计费铺平道路,这可能导致产品收入增加。然而,这也可能要求在架构规划中包含额外的安全和欺诈预防措施。
市场需求输入到技术架构中。技术考虑也会反馈到市场策略中。当技术和战略愿景相遇,创造出与众不同的产品时,真正的伟大架构就诞生了。
连接
架构确定了所有组件间的连接,并描述了连接属性。连接可能是一个简单的函数调用或通过管道的数据流。它可能是一个事件处理器或通过某些操作系统或网络机制传递的消息。连接可以是同步的(阻塞调用者直到实现完成请求)或异步的(立即将控制权返回给调用者,并安排在稍后日期回复)。这是重要的,因为它影响系统周围的控制流。
一些通信是间接的(因此相当微妙)。例如,组件可以共享某些资源并通过它们进行交流——就像在共享的白板上发布消息。共享通信渠道的例子包括:一个从属组件、共享内存区域,或者像文件内容这样基本的东西。
什么是好的建筑?
好的架构的关键是简单性。几个精心选择的模块和合理的通信路径是目标。它还需要是可理解的,这通常意味着视觉表示。我们都知道一张图胜过千言万语。
关键概念
好的系统架构是简单的。它可以被描述在一句话中,并总结在一个优雅的图表中。
在一个设计良好的系统中,组件既不应太少也不应太多。这个标准随着问题的大小而变化。对于一个小程序,架构可能适合(甚至可以在信封背面完成),只有几个模块和一些简单的互连。一个大型系统自然需要更多的努力和更多的信封。
过多的细粒度组件会导致架构变得令人困惑且难以操作。这暗示着建筑师过于深入细节。过少的组件意味着每个模块要做的工作太多;这使得结构不清晰,难以维护和扩展。正确的平衡在两者之间。
建筑并不规定每个模块的内部运作——这是模块设计的作用。目标是每个模块对系统的其他部分了解得非常少。我们在这个设计层面上追求低耦合和高内聚(参见第 247 页的"模块化"),就像在其他所有层面上一样。
关键概念
架构确定了系统的关键组件以及它们如何交互。它并不定义它们如何工作。
架构规范列出了做出的设计决策,并清楚地说明了为什么这种方法被优先考虑而不是任何其他替代策略。它不需要详细说明这些其他方法,但应该证明所选架构是合理的,并证明对其进行了认真思考。它必须正确地确定了系统的首要目标:例如,可扩展性与性能是不同的游戏,将导致不同的架构设计决策。
一个好的架构留有操作空间;它允许你改变主意。它可能指定我们用抽象接口包装第三方组件,这样我们就可以用另一个版本替换掉一个版本。它可能建议在部署期间选择不同实现的技术。随着项目的发展,正确的实现选择变得清晰——它们一开始并不总是显而易见的。一个成功的架构是灵活的,在初始的不确定性期间提供了一种敏捷设计的机制。架构是平衡相互竞争力量的第一个支点;它将展示我们如何权衡一个质量以换取另一个质量。
关键概念
好的架构留有操作空间、扩展和修改的空间。但它并不是毫无希望的通用。
架构必须清晰且无歧义。现有的、众所周知的架构风格或众所周知的框架是最好的(下一节将详细介绍这些内容)。架构必须易于理解和操作。
好的设计和好的架构都有一种美学吸引力,让人感觉正确。
架构风格
形式永远追随功能。
--路易斯·亨利·苏利文
正如一座宏伟的哥特式大教堂和一座古老的维多利亚式小教堂,或者一座令人敬畏的摩天大楼和 20 世纪 70 年代的公共洗手间采用了不同的建筑风格一样,有许多公认的软件架构风格可以作为系统的基础。一个风格可能出于各种原因被选择,无论是好是坏——可能是基于坚实的技术基础,可能是基于建筑师以往的经验,甚至可能是基于当前流行的风格。每种架构都有不同的特点:
-
它对数据表示、算法和所需功能变化的适应性
-
它的模块分离和连接方法
-
它的可理解性
-
它对性能要求的适应
-
对组件可重用性的考虑
在实践中,我们可能会在一个系统中看到多种架构风格的混合。一些数据处理可能通过管道和过滤器过程进行,而系统其余部分则采用基于组件的架构。
关键概念
识别关键架构风格并欣赏它们的优缺点。这将帮助你同情地处理现有软件并执行适当的系统设计。
以下几节将描述一些常见的架构风格。然后将其与意大利面进行比较。
没有架构
系统总是有一个架构,但就像我的伦敦地铁项目一样,它可能没有计划的架构。不久,这种状况就会成为你开发团队的负担。结果产生的软件将会一团糟。
如果你想要构建好的软件,定义一个架构是至关重要的。不规划架构是注定要使开发在开始之前就失败的。

分层架构
这可能是概念视图中最常用的架构风格。它将系统描述为一系列层级的层次结构,采用积木式方法。这是一个非常简单的模型,易于理解;即使是非技术人员也能迅速掌握它所传达的信息。

每个组件在堆栈中由一个单独的块表示。堆栈中的位置表示什么在哪里,组件如何相互关联,以及哪些组件可以看到哪些其他组件。块可以放在同一层的旁边,甚至可以高到跨越两层。
这方面的一个著名例子是 OSI 七层参考模型,用于网络通信系统。(ISO 84)一个更有趣的例子是图 14-1 中展示的 Goodliffe 七层三明治参考模型。

图 14-1. The Goodliffe 七层三明治参考模型
在堆栈的最低层,我们找到硬件接口,如果系统确实与物理设备交互。否则,这一层被保留用于最基本的服务,可能是操作系统或像 CORBA 这样的中间件技术。最高层很可能会被用户交互的华丽界面占据。随着你进一步向上移动堆栈,你将越来越远离硬件,中间的层为你提供了愉快的隔离,就像房子的屋顶不必担心地球核心的岩浆一样。
在任何时刻,你都可以清除所有底层,并插入该层以下的新实现——系统将像以前一样运行。这是一个关键点:这意味着你可以在支持你的 C++环境的任何计算平台上运行相同的 C++代码。你可以更换硬件平台,而不必触及你的应用程序代码——依赖于操作系统层(例如)来吞没技术差异。方便。
高层直接使用下一层的公共接口。它们是否可以使用低层的公共接口取决于你对分层定义的理解。有时图表会被调整以表示这一点,就像特丽尔蛋糕中的雪莉酒砖。同一层上的组件是否可以互连也不是严格定义的。您当然不能使用来自更高层的东西;如果您违反这一规定,您就不再拥有分层架构,而只是一个以堆叠形式绘制的无意义图表。
如您所见,大多数层图都是非正式的。方框的相对大小和位置提供了关于组件重要性的线索,这通常足以作为一个概述。组件连接是隐含的,通信方式无关紧要。(然而,这可能是系统效率的关键架构问题——您不会通过 RS232 串行端口发送数 GB 的数据。)
管道和过滤器架构
这种架构模拟了数据通过系统的逻辑流程。它通过一系列顺序模块实现,每个模块读取一些数据,处理它,然后再将其输出。链的起点是一个数据生成器(可能是用户界面或可能是某种硬件采集逻辑)。终点是一个数据接收器(可能是计算机显示器或日志文件)。这是数字形式的古老通过葡萄藤电话游戏。数据沿着管道流动,在途中遇到各种过滤器。转换通常是增量式的;每个过滤器执行一个简单的过程,并且通常具有非常小的内部状态。

管道和过滤器架构要求在每个过滤器之间有一个定义良好的数据结构;它隐含了重复编码输出数据以通过管道传输,并在每个后续过滤器中再次解析的额外开销。因此,数据流通常非常简单——只是一个纯文本格式。
这种架构通过只需将新的过滤器插入到管道中就可以轻松添加功能。其主要缺点是错误处理。当问题在接收器处显现时,很难确定错误在管道中的起源。将错误代码传递到输出阶段是繁琐的;它们需要额外的编码,并且在多个单独的模块中统一处理是困难的。过滤器可能使用单独的错误通道(例如,stderr),但错误消息很容易混淆。
客户端/服务器架构
一种典型的基于网络的架构,客户端/服务器模型将功能分为两个关键部分:客户端和服务器。它与较老的主机式网络设计在各个部分之间的工作分配上有所不同;主机“客户端”是一个哑终端——除了捕获和传输按键以及一些输出显示外,别无其他。

接口的打击
一个关键的软件构建原则是模块化,即从可替换的组件设计系统。这几乎是一种“乐高积木”式的构建方法。如果做得正确,你应该能够取出一个蓝色方块积木,并用一个稍微花哨一点的红色积木替换它。如果积木大小和形状相同,并且具有相同的连接器,它们将能够放入相同的孔中并完成相同的工作。
我们如何在软件中实现这一点?我们定义接口;这些是我们的连接点和组件障碍。它们定义了每个组件的大小和形状(至少从外部看是这样)并确定你提供类似替换所需做的事情。关键类型的接口包括:
APIs
应用程序编程接口(APIs)被指定为物理链接的应用程序中函数的集合。要替换实现特定 API 的组件,你只需重新实现所有函数并重新链接代码。
类层次结构
你可以设计一个抽象的“接口”类(在 Java 和 C#中,你实际上会定义一个interface)。然后提供任何数量的具体实现,这些实现从它派生并实现该接口。
组件技术
如 COM 和 CORBA 等技术允许你的程序在运行时确定正确的实现组件。通常,接口在抽象的接口定义语言(IDL)中定义。这种方法的优点是组件可以用任何语言编写。它需要中间件或操作系统支持。
数据格式
这些格式可以在关注数据移动而不是控制流的设计中形成连接点。你可以用与相同数据类型交互的类似物替换数据链中的任何组件。
如你所见,架构——实际上,大多数软件设计——是关于构建适当的接口。每种接口技术都映射到特定的架构风格。选择一种与架构相辅相成的接口机制。
客户端/服务器架构的客户端更加丰富、更加智能,通常能够以交互式、图形化的方式呈现数据。以下是对这两个元素角色的更详细分析:
服务器
服务器向客户端提供某些定义良好的服务。它通常是一台功能强大的计算机,专门用于提供特定功能或管理资源(共享文件、打印机、数据库或池化处理能力)。
服务器向客户端提供某些定义良好的服务。它通常是一台功能强大的计算机,专门用于提供特定功能或管理资源(共享文件、打印机、数据库或池化处理能力)。
客户端
客户端消费服务器的服务。它发送请求并处理返回的结果。一些客户端是专用终端,只履行一个角色;其他客户端提供许多功能(例如,“客户端”应用程序可能运行在标准的桌面 PC 上,该 PC 也可以浏览网页和查看电子邮件)。
可以有使用一个服务器但类型不同的许多不同类型的客户端,它们执行相同的一组请求,但方式不同。一个客户端可能是基于 Web 的,一个可能有一个 GUI 界面,而另一个可能提供命令行访问。
这种客户端/服务器方法有时被称为两层架构,原因很明显。它非常常见,在软件开发世界的各个方面都可以看到。客户端和服务器之间的通信方式多种多样——最简单的是使用标准网络协议,但你也可能看到使用远程过程调用(RPC)、远程 SQL 数据库查询,甚至专有的应用程序特定协议。
在两个组件之间分配工作的方法有很多种。主要的应用逻辑(也称为业务逻辑)可能运行在客户端或服务器上,这取决于客户端被期望有多智能和专业化。随着越来越多的应用逻辑被推送到客户端,设计变得不那么灵活——单独的客户端需要重新实现类似的功能,从而抵消了中央服务器的优势。客户端通常关注提供对已发布服务器功能的合理的人机界面。
我们有时会看到这种两层设计的扩展,它引入了另一层(中间层)。这个组件被明确设计用来包含业务逻辑,将其从客户端应用程序(现在是肯定只是一个界面)和后端数据存储中分离出来。这是一个三层架构。
客户端/服务器方法与对等网络架构不同,在对等网络架构中,没有网络节点比其他节点有更多的能力或重要性。对等网络架构部署起来更困难,但更能容忍故障。当服务器不可用(由于某些软件故障或常规维护)时,客户端/服务器设计就会受损:没有客户端能够操作,直到服务器恢复运行。因此,客户端/服务器安装通常需要一个指定的管理员来确保所有系统运行顺畅。
组件化架构
这种架构将控制权去中心化,并将其分割成多个独立的协作组件,而不是一个单一的整体结构。这是一个面向对象的方法,但并不一定需要用面向对象的语言实现。每个组件的公共接口通常在接口定义语言(IDL)中定义,并且与任何实现都分开,尽管一些组件技术(如.NET 内置的组件支持)可以从实现代码本身确定这一点。

基于组件的设计随着从预制组件快速组装应用程序的诱惑而来,据说可以实现即插即用的解决方案。关于这已经取得了多大的成功,仍然存在争议。并非所有组件都设计用于重用(这是一项艰苦的工作),而且并不总是容易找到一个能够完成你想要它完成的任务的组件。对于 UIs 来说,这是最容易的,因为存在流行的框架和成熟的市场。
基于组件的架构的核心是一个通信基础设施,或称中间件,它允许组件被插入、广播其存在,并宣传它们提供的服务。组件是通过中间件机制查找这些信息来使用的,而不是通过硬编码两个组件之间的直接连接。常见的中间件平台包括 CORBA、JavaBeans 和 COM;每个都有其不同的优势和劣势。
一个组件^([1])本质上是一个实现单元。它遵循一个(可能更多)特定的已发布 IDL 接口。这个接口是组件客户端与之交互的方式。没有后门。客户端关注的是处理该接口的一个实例,而不是组件的实现方式。
每个组件都是一段独立的代码。在其接口背后,它实现了一些逻辑(可能是业务逻辑或用户界面活动)并包含了一些数据,这些数据可能是局部的,也可能是公开的(例如文件存储或数据库组件)。组件不需要了解彼此太多。如果它们紧密耦合,那么架构就是一个被混淆的单一系统。
基于组件的架构可以在网络环境中部署,组件分布在不同的机器上,但它们也可以作为单一机器的安装存在。这可能会取决于所使用的中间件类型。
框架
在为特定项目开发新架构之前,使用现有的应用程序框架并将其添加到该框架结构中可能更为合适。框架是一组可扩展的代码库(通常是一组协作类),为特定问题域提供了一个可重用的设计解决方案。框架中的大部分工作已经为你完成,剩余的部分遵循填空法。不同的框架遵循不同的架构模型;使用框架,你承诺遵循其特定的风格。

框架与传统库在交互方式上有所不同。当使用库时,你会在自己的控制线程中明确调用库组件。框架则相反;它负责结构和控制流程。它根据需要调用你提供的代码。
与现成的框架并列的是架构 设计模式。虽然它们本身不是一种架构风格,但模式是小型架构模板。它们是为几个协作组件的微观架构,提炼出重复的结构通信。架构模式描述了在架构设计层面的常见组件结构,解释了它们如何满足特定上下文的要求。模式是一套设计最佳实践,在无处不在的 GoF 书中(Gamma 等,94)和许多后续出版物中描述(参见第 255 页上的"DESIGN PATTERNS")。
^([1]) 我们已经讨论过组件作为模块、短暂的实现单元。但这是对这个词的新定义,非常具体地针对基于组件的架构世界。遗憾的是,这些术语被多重含义所淹没。
在一瞥
罗马建筑师维特鲁威对构成良好建筑设计的要素做出了永恒的声明:强度(firmitas)、实用性(utilitas)和美观(venustas)。(维特鲁威)这对我们的软件架构同样适用。如果没有一个定义良好、沟通良好的架构,软件项目将缺乏一致的内结构。它将变得脆弱、不稳定、丑陋。最终,它将达到破裂点。
所有关于意大利面的谈话让我饿了。我要去制作一个七层的参考甜点。 . . .
| 精通编程的程序员 . . . | 不擅长编程的程序员 . . . |
|---|
|
-
理解他们的软件架构,并在其中编写新代码
-
可以将适当的架构应用于每个设计场景
-
创建简单、美丽、优雅的架构——他们欣赏软件设计的审美
-
在一个持续更新的活文档中捕捉系统架构
-
将结构问题反馈给系统架构师,试图改进设计
|
-
不考虑任何整体架构愿景而编写代码——导致不和谐的瑕疵和未集成的组件
-
在投入代码编写之前未能进行任何高级设计,忽略任何架构替代方案
-
将架构信息锁在人们的头脑中或在一个危险过时的规范中无法访问
-
忍受不完善的架构,添加更多设计糟糕的代码而不是修复根本问题——他们懒得打开更大的罐头
|

另请参阅
第十二章
安全问题必须由系统架构来解决。
第十三章
代码 设计 是代码构建的下一级。
第十五章
架构是软件生命的起点,但绝不是唯一引导其发展的因素。
第二十二章
架构设计在软件开发过程中的位置。
开始思考
关于这些问题的详细讨论可以在第 522 页的"附录 A"部分找到。
仔细思考
-
定义架构结束和软件设计开始的地方。
-
坏的架构以何种方式影响系统?有没有部分不会受到架构缺陷的影响?
-
一旦架构缺陷显现,修复起来有多容易?
-
架构在多大程度上影响以下事物?
-
系统配置
-
记录
-
错误处理
-
安全性
-
-
成为一名软件架构师需要哪些经验或资格?
-
销售策略应该影响架构吗?如果是,如何影响?如果不是,为什么?
-
你会如何为可扩展性进行架构设计?你将如何为性能进行架构设计?这些设计目标如何影响系统,以及它们是如何相互补充的?
个人化
-
你习惯的架构风格范围有多广?你最有经验的是哪种——它如何影响你编写的软件?
-
你在架构成功或失败的个人经验有哪些?是什么因素使它们成为成功的解决方案或障碍?
-
让你的每个开发者为当前项目的系统架构画一张图——单独(不与任何人交谈)且不参考任何系统文档或代码。比较这些图。看看哪些地方让你印象深刻——除了相对的艺术价值之外!
-
你当前项目是否有普遍可用的架构描述?它有多新?你使用的是哪种类型的视图?如果你需要向新来者或潜在客户解释系统,你真正需要记录下什么?
-
你的系统架构与市场上竞争对手的架构相比如何?你的架构是如何定义的,以确定项目的成功?
第十五章。软件进化还是软件革命?
代码是如何成长的?
我不能说如果我们改变,事情是否会变得更好;我能说的是,如果它们要变得更好,它们必须改变。
--G.C. 李希滕贝格
如果软件能像植物一样生长。你只需将一个想法的种子放入肥沃的编程土壤中,加一点水,然后等待。你会小心地照料它:施肥,保持良好的光照,并覆盖它以防止鸟儿啄食。随着时间的推移,代码幼苗会发芽,当程序植物足够大时,你就可以将它释放到世界上了。为了增加额外的功能,你会继续浇水并添加一些肥料,它就会继续发展。树干会变得更加强壮,以支撑新的分支,程序将保持完美的平衡。如果它向你不喜欢的方向发展,稍加修剪就能让它变得笔直。
不幸的是,现实世界并不像这样运作。根本不是这样。
软件确实是一个有生命的实体。它不是有感知或有生命的,但它有自己的生命形式:它是被构思出来的,稳步发展,最终达到成熟。然后它被送到广阔的世界中去谋生,并希望获得尊重和钦佩。它可能还会继续发展,也许会发展到中年期,失去年轻时的外观。随着时间的推移,它会变得疲惫和衰老,最终被退休,被安置在数字农场中,在那里它可以优雅地死去。
本章将探讨我们如何培养软件,特别是在初始开发阶段之后。程序需要深思熟虑的照料,很少得到它们真正应得的关心和注意。我们怎样才能防止缓慢蔓延的代码癌症,导致过早死亡?
为了回答这个问题,我们将从后往前看。我们将研究糟糕代码增长的症状,探讨我们如何增长我们的代码,并确定一些策略来开发更健康的软件。
更多关于软件构建的隐喻
我们已经探讨了“建造”的隐喻,并讨论了它告诉我们关于软件构建过程的内容(参见第 177 页的“我们真的在建造软件吗?”)。在本章中,我将介绍一些更多的隐喻。它们为我们提供了对编程方法的不同的见解:
软件的成长
这与我们是怎样扩展现有软件的相关的,通常是通过添加新功能。修复错误不是增长:它是照料代码中的病态部分。
当我们向代码中添加内容时,代码确实会增长,但编程并不完全等同于植物生长——我们对代码增长的控制和影响力远大于对幼苗的控制。代码的增长更像是牡蛎形成珍珠的过程:缓慢地,通过逐渐积累小的额外部分。
软件的进化
另一个常见的构建隐喻是软件的“进化”。我们从一个单细胞代码生物体开始,逐渐看到它发展成为一个更大、更复杂的生物。这是一个渐进的过程;软件通过多个进化阶段发展。然而,与生物进化有几个关键的区别:
-
我们是那些故意进行更改的人;软件不是自行发展的。
-
我们不使用自然选择来选择最佳的设计。我们既没有时间也没有意愿去开发同一程序的许多不同变体。
我们确实有机会迭代地提高我们代码的质量,某种程度上模仿进化发展。我们可以使用从前一个版本中获得的经验来适应代码的自然栖息地,确保其长期生存。
软件退化
当你年轻的时候,你在成长。当你成熟的时候,你在腐烂。
--Ray Kroc
好的代码也会遇到坏事。无论你开始得多好,无论你的意图多么高尚,无论你的设计多么纯粹,第一版实现多么干净,时间都会扭曲和扭曲你的杰作。永远不要低估代码在其生命周期中获得瑕疵的能力。
有一种误解认为软件只在其生命周期的初期阶段发展。软件开发中的维护阶段总是最长的。1 它是大部分总体努力所在的地方——即使这种努力并没有像最初的设计和开发工作那样被压缩成一个紧凑的球。B.W. Boehm,一位受人尊敬的计算机科学教授,观察到 40%到 80%的总开发时间都花在了维护上。(Boehm 76)
软件在发布后永远不会停滞不前。无论进行了多少测试,总会有些奇怪的错误需要修复。客户要求新功能。需求在开发团队脚下发生变化。在开发过程中做出的假设在现实世界中证明是错误的,需要调整。结果是:在项目被认为完成后,还会编写更多的代码。
在初始开发阶段,你可以在可用的时间限制内牢牢掌握你的代码,随心所欲地玩弄它。一旦它发布后,你的限制就更多了。这些限制可能是实际的:
-
变更必须最小化,以减少它们对经过仔细测试的代码库的影响。
-
已发布的 API 已经被客户使用,因此它们更难修改。
-
用户界面对用户来说是熟悉的,不能随意更改。
限制也可能是心理上的,基于开发者(可能错误的)先入之见:
-
代码一直是这样工作的,所以我们不能像那样更改它。
-
在这个阶段修改架构太难了。
-
现在花时间或费用来正确进行这种修改不值得;产品不会存在很长时间。
限制甚至可能是一种简单的缺乏理解——维护程序员可能不理解原始作者的代码思维模型;这导致不适当的修改。
在维护现有产品和发展下一个版本之间有一条很细的界限。它在哪里是一个无足轻重的问题。但无论你做什么,原始代码库都会被修改——有时是原始作者,通常是其他人。这就是腐烂开始的地方。这是一个“做也受罪,不做也受罪”的场景;无论你做什么,代码都会腐烂。
如果你再也不碰代码,如果你不通过修复和修改来保持其更新,程序将会退化。在最坏的情况下,当操作系统发生变化或其假设变得过时时,程序将停止工作。Y2K 错误就是这一点的光辉例子。2 或者,随着竞争解决方案开发出更多功能和获得更多人气,程序可能会腐烂。未触及的代码会慢慢腐烂。
如果你随着代码的增长进行扩展和修复,代码仍然可能会腐烂。在修复一个故障时,程序员往往会作为副作用引入更多的错误。布鲁斯发现,高达 40%的修复引入了新的错误。(布鲁斯 95) 由一位不知名的吟游诗人创作的《程序员饮酒歌》(以“墙上有 99 瓶啤酒”的曲调演唱),简洁地总结了这一点:
代码中有 99 个小错误,代码中有 99 个小错误,
修复一个错误,再次编译,代码中有 101 个小错误。
(重复直到 BUGS == 0)
即使是没有任何错误的修改也可能导致代码混乱。草率且不彻底的修复层层叠加,一钉接一钉地钉进原始设计的棺材,使得未来的维护变得更加困难。植物类比在这里很有用:如果顶部生长出更多的重枝,而没有任何东西加固树干,整个代码库的稳定性就会降低。最终,不可避免地,它会摇摇欲坠。健康的植物不会这样生长;我们为什么期望我们的代码会这样呢?
关键概念
注意代码在修改过程中如何容易退化。不要满足于让系统处于更糟糕状态的变化。
所有这些听起来过于悲观了吗?如果你小心谨慎,代码不会腐烂?也许吧,但今天的软件工厂并没有采取足够的谨慎。这是一个文化问题。修复必须快速且便宜。程序有习惯于比预期更长时间地存在。许多快速修复的代码活过了它们的预期寿命。
^([1]) 即,初始交付后所做的、不被视为重大新版本的工作。
^([2]) 许多旧程序从未被期望在 2000 年运行,因此程序员认为用两位数字编码年份是安全的——76而不是1976。当数字翻转到00时,所有他们的日期计算都出了问题。
警示信号
打开你的代码雷达,并持续寻找腐烂的代码。警惕这些明显的迹象:任何导致缺乏清晰度或使系统更复杂的变更都会导致腐烂。不必要的复杂性以多种形式出现。
这里有一些,闪烁的红色灯光和警报声:
-
代码中充斥着许多大型类和复杂的函数。
-
函数名称晦涩或误导。函数有与名称不符的意外副作用。
-
没有结构:不清楚在哪里查找特定的功能。
-
存在重复:许多独立的代码片段执行相同的功能。
-
存在高耦合:复杂的模块互连和依赖关系意味着一处小的变化在整个代码中产生涟漪,甚至影响到看似无关的模块。(参见第 247 页的“模块化”)。
-
当数据在系统中流动时,它被反复转换为不同的表示形式(例如,显示数据在
std::string、char*、Unicode、UTF-8 之间传输,然后再转换回来)。 -
API 变得模糊;曾经整洁的接口现在范围过于广泛,新功能被不加思考地添加。
-
代码版本之间 API 变化迅速。
-
私有实现的部分泄露到公共 API 中,以实现其他快速修补。
-
代码中充斥着权宜之计:针对症状的修复,而不是针对原因。它们隐藏了真正的问题。系统的边缘充满了这些权宜之计,让错误潜伏在核心。
-
存在着参数列表巨大的函数。许多函数没有使用这些参数,只是将它们传递给下级函数调用。
-
你会发现代码太可怕,甚至不敢考虑改进。你不知道你会改进它,还是微妙地破坏它,或者让它变得更糟。
-
新功能添加时没有相应的支持文档;现有文档已过时。
-
代码编译时噪声很大,产生了许多警告。
-
你会发现注释说,不要碰这个...。
这些腐烂的形式在代码中特别明显,可以通过快速检查或使用某些工具来识别。然而,还存在一类更微妙、不可见的退化,它们通常在比语法垃圾更高的层面上表现出来。那些篡改原始代码架构或微妙地规避程序约定的修改很难被发现,直到你深入沉浸在系统中。
关键概念
学会检测恶臭代码。了解警示信号,并极其小心地处理烂代码。
我们为什么把代码搞得这么乱?答案很简单:复杂性。一个程序是一个包含大量信息的大型集合,这些信息在多个层面上组织:架构、组件设计、接口、每个代码片段的实现,等等。在开始项目工作之前,你需要理解这些内容。在紧迫的截止日期下,没有足够的时间去弄清楚几行代码的实际工作方式,更不用说它们如何融入整体画面。我们还没有学会管理这种巨大的复杂性。
代码是如何增长的?
没有代码开发会遵循经典的模型:锁定所有需求,完全设计,完全编码,集成,测试,发布。意外的修改发生在现有的代码库上。以某种方式嫁接了新的部分。这是一个朝着不断变化的终点线的增量开发周期。
代码增长是通过以下机制之一发生的,按令人厌恶的程度大致排序:
运气
这是制作代码最令人恐惧的方式,而且过于常见。通过运气增长的代码从未有任何设计。它是未经思考就修改的。其结构完全取决于偶然,它竟然能工作真是个奇迹。
即使你的代码最初确实是精心设计的,维护修改也可能采取这种随遇而安的方法。击中并希望修复可能只是掩盖了立即的问题,并使后续的真正修复更加困难。
积累
我们需要添加一个新功能。正确地做这会涉及撕毁几个关键模块之间的接口并修改大量代码。没有时间做所有这些,即使我们做了,这可能会仍然过于复杂。我们只是再嫁接一大块代码。它将悬挂在现有的一个模块上——好吧,也许几个模块上——并使用它自己的特殊后门接口与它们通信。我们将很快就有东西可以工作了。
好吧,这是一个怪物般的混乱。哦,性能将会很糟糕。模块将不再有明确的角色和责任。将不再有整洁的设计,未来的维护将是一场噩梦。但我们将快速推出这个版本,而且我们现在没有时间正确地做这件事。
也许我们稍后会回来把它做得更好……
重写
当你意识到你正在工作的代码真的很糟糕——难以理解、脆弱且不可扩展时,它需要重写。根据以往的经验,重写通常比在原始混乱中修补更快、更安全。然而,重写很少进行。这需要勇气和远见。
随着你一次性攻击更多的代码,重写会变得更加危险。重写整个产品与重写一个麻烦的功能或类是不同的前景。良好的模块化和关注点分离意味着你不需要重写整个系统,只需重写你正在工作的模块,保持其原始接口。如果接口很糟糕,或者你需要重写因为系统实际上不够模块化,那么就需要做更多的工作。
翻倍
软件已经达到了一个重要的十字路口。现有的代码库前景并不乐观——它真的需要重写。最终管理层接受了这个事实,并制定了一个计划。开发者被分成两个团队。一些继续在现有的代码库上修改,试图让它再坚持一段时间。其余的程序员则从头开始重新构建整个应用程序。
一个任务是光鲜亮丽的:设计一个新颖的设计,具有有趣的实现挑战,以及在一个新鲜、无冗余的代码库上工作的机会。另一个任务是枯燥的:修补一艘沉船的漏洞,直到新邮轮准备好(届时所有旧工作都将被废弃)。你更愿意加入哪个团队?
毫不奇怪,这导致了怨恨和挫败感的积累,以及团队之间的竞争。许多被分配到旧应用的开发者要求更换项目或离开公司去寻找更广阔的天地。在旧代码库上的工作质量低下,因为这是一个低质量的项目。
重构
重写的一个正式化版本。如果你的代码大部分还可以,但其中一些部分需要一些工作,你可以重构这些不愉快的部分。重构是一个对代码体进行小幅度修改的过程,以改善其内部结构而不改变其外部行为。它改善了设计,以便你将来更容易与之工作。这并不是关于性能改进,而是设计增强。与完全重写相比,重构是对现有代码的一系列温和的按摩。
这是一个特定类型的代码修改的华丽名称。马丁·福勒对其进行了正式化,记录了许多小而易于理解的代码改进。(福勒 99)
增长设计
你通常会对你未来的代码扩展有一个想法;也许一些功能被推迟到下一个版本。你可以仔细设计系统,使其易于添加这些未来的功能。大多数时候,这不会使设计工作变得更困难。
即使你不知道未来添加的功能集,仔细的设计也能为增长留出空间。一个可扩展的系统为新功能提供插入点。不过,要小心这不要变成一种追逐风的活动,当你不知道系统如何扩展时,试图猜测未来。可扩展性是以复杂性为代价的。如果你正确地猜测了这种复杂性所在的位置,你就赢了;如果你猜错了,那么你会创建一个不必要的复杂系统。这是过度设计的危险,而且当设计由委员会进行时,这种情况尤其可能。
有一种思想流派,以极限编程为例,它坚持在任何特定情况下可能工作的绝对最简单的设计。这可能与增长设计思维(取决于初始简单设计的可塑性)相矛盾。确切地说,你应该采用多少增长设计可能是一个困难但重要的平衡点。
混沌理论
代码显然受到设计的影响,但构建它的组织及其历史也起着重要作用。多年前,我加入了一个特别糟糕的用户界面代码项目。它通常能工作,但难以理解,是一团错综复杂的逻辑,没有明显的架构和错综复杂的执行路径。而且它之所以如此,是有原因的:历史。
代码最初是为一个单一客户的简单一次性电视用户界面而创建的,规格最小化。成功构建后,它很好地完成了其任务。遗憾的是,故事并没有就此结束。
它随后被卖给第二个客户,客户希望它看起来不同。于是给它添加了一个第二层皮肤(外观)。然后它又被卖到另一个不同国家的客户手中。为了国际化,又添加了另一层皮肤。接着它又被卖给第三个客户,客户希望有一些新的用户界面功能——这些功能被强行加入。这个故事就这样继续下去。持续了很长时间。如今,用户界面已经与其前身截然不同,而且现在它也难以维护:每一次的添加都是一个快速的修补,因为整个系统始终都是一个临时系统。
如果最初的设计就包含了所有这些功能,那么代码仍然会保持简洁和逻辑性。然而,这会是一个前期工作量巨大的项目,公司可能永远不会开始这个项目。可怜那些在这些现实世界条件下工作的程序员们。
^([3]) 传道书 2:11
相信不可能
也许我们之所以看到这么多糟糕的代码和如此多的脏乱修补,是因为错误地认为正确完成工作需要更长的时间。当你考虑到调试所花费的时间和后期修改的便利性时,这证明是一个错误的假设。你可能能够通过快速修补来迅速关闭一个故障报告,但这并不是一个好的解决方案。真正的工匠会对他们所做的代码负责。
在企业界,管理层常常期望快速修复。向管理者展示一个五吨重的混凝土块被堆放在一个摇摇欲坠的旗杆顶部,它不会持续很长时间,这是相对容易的。让他站在那个东西下面则更难。当我们谈论软件时,要让这个信息传达出去则更加困难。管理者们根本不理解。对他们来说,程序员就像是练习黑暗神秘艺术的魔术师,拥有无限的力量。告诉他们该做什么,提供一个截止日期,事情就会发生,无论需要多少个通宵的编码。
天赋和奉献精神有时使我们能够满足这些期望。这实际上使事情变得更糟,因为管理层现在期望这种策略总是有效。更糟糕的是,当它不起作用时,他们假设这是我们的过错。遗憾的是,总有那么一个时刻,匆忙编写的软件再也无法扩展,它只想倒下,安静地在一个角落里找到它的最终归宿。管理层是不会高兴的.^([4])
如果公司的文化是在小步骤中逐步开发软件(参见第 245 页的“迭代”(Iterative)和第 432 页的“迭代和增量开发”(Iterative and Incremental Development)),代码增长就会更容易。这样,演变就内置到设计策略中,并且预期需要重写代码以适应变化。另一种选择,当你必须在 20 秒内用一把小镐头攻击一个庞大的代码建筑时,这是不合理的——但并不罕见。
^([4]) 当然这是一个粗略的概括,但并不太不准确。许多管理者曾经是程序员,理解这些紧张关系。一个好的管理者会倾听程序员的反对意见。一个好的程序员会让他或她的老板倾听。太经常了,这两种情况都没有发生,软件就受到了影响。
我们能做些什么?
上帝赐予我平静接受我不能改变的事情,勇气去改变我能改变的事情,以及智慧去知道两者的区别。
--雷因霍尔德·尼布尔
现在我们已经识别了一些代码库演变的问题,我们如何管理混乱?我们可以采用哪些策略来避免这种情况?
第一步和最重要的一步是认识到问题。太多的程序员在不考虑代码质量的情况下随意编写代码。只要他们能在最短的时间内平息用户的尖叫,他们就不在乎他们留下的代码状态。下次有人可以处理它。
关键概念
有意识地编写代码。好的程序员更关心几年后他们的代码会是什么样子,而不是编写它需要多少努力。
编写新代码
在我们考虑如何与遗留(现有)代码一起工作之前,这里有一些创建全新代码的策略,这将极大地帮助以后的维护:
-
考虑模块之间的相互连接,尽可能减少耦合。避免有一个中心模块,其他所有模块都依赖于它;那里的任何变化都会影响系统中的其他模块。
-
模块化和信息隐藏(参见第 247 页的“模块化”(Modularity))是现代软件工程的基石。将任何可能的变化隔离到系统的一个小部分,使你的系统在变化下更加粘稠,因此更加稳定。
-
扩展性和灵活性需要被设计进去——但,正如我们所看到的,不能以复杂性的代价。现代基于组件/对象的范式承诺更大的重用和可扩展性。它们在代码模块之间提供了清晰的接口点。然而,如果接口不支持后续扩展,那么代码就不能增长。在创建系统接口时,要非常仔细地考虑这些问题。
-
编写整洁、清晰、易于理解和工作的代码,并辅以良好的文档和定义明确、命名清晰的 API。考虑使用文献编程工具来记录接口。
-
KISS。也就是说,保持简单,傻瓜。不要过度复杂化;不要过度工程化。只有在你知道确实存在性能问题时,才优化算法,而不仅仅是因为你认为你知道一种让代码运行更快的好方法。简单性几乎总是比性能更可取,而且它确实使得后续维护更容易。
关键概念
编写新的代码时,要考虑到其可修改性。使其可读、可扩展和简单。
现有代码的维护
维护良好的代码需要与维护糟糕的代码不同的战斗计划。在前者中,你必须仔细保持设计的完整性,并确保你不引入任何不合适的东西。在后者中,你必须努力不让混乱变得更糟,并在可能的情况下,在通过的过程中改进事物。如果你不能重写有问题的代码,一点重构就能走很长的路。
在你接触任何代码之前,应该考虑以下组织问题:
-
优先处理任何需要的更改。权衡每个任务的重要性和复杂性,并决定哪个应该先做。哪些早期更改将对后续工作产生影响?
-
只改变必要的部分。如果它没有坏,就不要修复它。不要随意“改进”代码,因为你认为它们需要它——只做真正需要的更改。重构你需要与之工作的坏代码。给其他部分留出很大的空间。
-
监控一次进行的修改数量。自己进行多个并行修改要么非常聪明,要么愚蠢;很可能是后者。一次只做一件事。仔细地。
如果有几个人同时在工作,要注意你周围的变化。过多的独立修改可能会导致奇怪的冲突。一个开发者的有序更改可以最清晰地显示代码被拉伸的地方和最需要关注的地方。几个同时进行的修改可能会使代码变得薄弱,而没有人理解或注意到。
关键概念
谨慎管理更改。确保你知道还有谁试图修改你正在工作的代码附近的代码。
-
正如初始代码在其开发期间应进行审查一样,后续的更改也应进行审查。组织正式的审查,并尽量包括代码的原始作者和审阅者。很容易在小的代码扩展中引入微妙的新的错误;审查将捕捉到许多这些错误。
关键概念
审查敏感的更改,尤其是在发布前夕。即使是微小的更改也可能破坏其他代码。
一旦到达代码层面,我们如何处理现有的源代码?以下是一些建议:
-
要进行良好的修改,你必须了解你正在工作的代码。在修改文件或代码模块之前,理解:
-
它在整个系统中的位置
-
它有哪些相互依赖性(即哪些组件可能会受到你的更改的影响)
-
在创建代码时所做的假设(希望已在代码规范中记录)
-
已经进行的修改历史
检查代码的质量。这出奇地容易做到,并且迅速让你对代码的可维护性有一个直观的感受。你可能发现使用可视化代码并生成质量指标的工具有帮助;这将突出隐藏的陷阱可能潜伏的地方。整理所有相关的文档。
-
-
采用正确的态度——避免“再简单修改一下”的心态。不要轻视代码,认为它将被丢弃或重写。它不会。
时刻关注第 282 页上列出的警告信号 "警告信号"。如果你的修改使代码库接近这些状态之一,重构代码以减轻问题。对这些问题负责。
准备做一些重新设计的工作。不要害怕在必要时拆解代码并进行重大手术。有时修改可能会立即付出高昂的代价(就你的时间和精力而言),但投资将带来回报:未来的代码工作将容易得多。对于遗留代码,这可能被认为是不经济的。遗憾的是,正是遗留代码创造了现金,并且不太可能被淘汰。如果你知道你将在未来大量工作于某个代码部分,确保代码结构将支持未来的扩展。
关键概念
不要无目的地摆弄代码。退后一步,看看你在做什么。
-
尽量不要通过新添加的代码引入额外的依赖。耦合的增加会使代码更加复杂,下次更改更困难。
-
在维护任何代码时,保留你正在工作的源文件的编程风格,即使它不是你最喜欢的风格或公司风格。包含多种格式的文件会让人困惑,难以工作。如果你不觉得过于随意,可以在进行过程中应用展示整理。但请注意,这样做的话,源代码的版本差异将会更难处理。维护你正在工作的代码周围的注释(参见第 86 页的“维护和无聊的注释”(Maintenance and the Inane Comment))。
-
使用代码的测试套件来检查你是否破坏了任何东西。彻底的回归测试是唯一真正能够让你对所做的更改有信心的方法。
确保你有一个充分的测试套件,并且定期运行它。
关键概念
仔细测试你做出的任何修改,无论多么简单。愚蠢的错误很容易被忽视。
-
如果你正在修复一个故障,你是否真的理解了原因?编写一个测试框架来触发它;这证明了你的理解,并将证明你已经修复了问题。将其添加到回归测试套件中。
一旦你成功修复了问题,在代码库中寻找类似的问题。这个被忽视的步骤可以产生很大的影响:许多问题成群结队地出现,一次性击败它们比慢慢逐个解决它们要容易得多。
-
如果你做出了一个糟糕的更改,请迅速撤销。不要在代码中留下不必要的废弃代码。
作为一名代码工匠,你应该始终避免快速修补的压力。努力做出谨慎、经过深思熟虑的更改。不幸的是,我们并不在象牙塔中工作,有时在战斗前线需要妥协;以神学正确的方式完成任务并不总是商业上可行的。
这解释了为什么如此多的代码是脆弱的、不可靠的和危险的。但它也解释了为什么有代码存在。如果没有推动软件发布的商业动力,程序员将永远调整他们的软件以使其完美,编写和重写。公司会在他们完成之前就崩溃了。
然而,不要在没有计划在以后日期修复它们的情况下引入实用(但令人不快的)修改。在开发计划中放置一个整理任务。
简而言之
万事万物都在变化中是美好的。
--亚里士多德
我不确定我是否同意亚里士多德的观点。变化可能真的会让人头疼。我们应该谨慎管理代码更改。然后,一个好的程序将演变成更伟大的东西,而不是退化成一个不稳定的大杂烩。
维护软件并正确扩展它非常重要,同时保留代码设计并进行同情的修改。不要期望维护工作会容易。你可能需要投入大量时间来重写、重新设计或重构。
参见
第十七章
我们作为一个团队构建和维护软件。团队动态不可避免地会影响代码的最终形态。
第十八章
你的代码的发展历史记录在版本控制系统中。
第二十二章
软件开发生命周期:我们遵循的创建和增长软件的程序。
| 好程序员…… | 坏程序员…… |
|---|
|
-
编写具有清晰结构和逻辑布局的可维护软件
-
识别并准备好处理坏代码
-
在开始工作之前,尽可能多地理解代码和作者的原始思维模型
-
重视他们正在工作的代码质量;他们拒绝笨拙地修补代码
|
-
在不考虑维护程序员需求的情况下创建复杂的代码
-
避免维护旧代码,宁愿忽略问题而不是修复它们
-
宁愿快速修补也不考虑一个好的解决方案
-
在代码中充斥着快速和肮脏的修复;他们使用他们能找到的每一个捷径
-
将注意力集中在错误的地方,修补实际上不需要修复的代码
|

努力思考
以下问题的详细讨论可以在第 527 页的"附录 A"部分找到。
思考
-
软件增长的最好隐喻是什么?
-
通过我在引言中提到的多彩的生命周期隐喻来看一个程序的发展,以下哪些现实世界的事件对应于一个程序:
-
概念
-
出生
-
成长
-
成长
-
将其发送到广阔的世界
-
中年
-
感到疲倦
-
退休
-
死亡
-
-
软件的生命有极限吗——在必须从头开始之前,你可以开发和工作多少时间?
-
代码库的大小是否与项目的成熟度相对应?
-
在维护代码时,向后兼容性有多重要?
-
如果你修改它或让它闲置,代码是否更容易腐烂?
个人化
-
你写的代码大多是全新的还是现有源代码的修改?
-
如果是全新的代码,你是创建全新的系统还是现有系统的扩展?
-
这是否影响了你的写作方式?在哪些方面?
-
-
你有与现有代码库一起工作的经验吗?如果有,:
-
它是如何塑造你的当前技能集的?你学到了哪些教训?
-
它主要是好代码还是坏代码?你不得不与之比较的是什么?
-
-
你是否曾经做出过降低代码质量的更改?为什么?
-
你的当前项目经过了多少次修订?
-
修订之间在功能上变化了多少?代码是如何变化的?
-
它是靠运气、设计还是两者之间的某种东西成长起来的?现在这是如何体现的?
-
-
你的团队是如何保护代码,使其不能被一个以上的程序员同时更改的?
第四部分。程序员群?
一排排的隔间,排列在漫长而单调的行列中。灵魂农场。不切实际的日程安排、糟糕的管理和灾难性的软件的职场苦役。没有自然光线和糟糕的咖啡。
欢迎来到软件工厂。
一些程序员是自由职业者,从一个办公室跳到另一个办公室。一些人在家里编写开源代码以娱乐自己。但大多数人被束缚在令人沮丧的软件工厂中,为着一个他们仍然充满热情的事业服刑。
我们是一群有趣的人:天生反社会,更喜欢与编译器和网络浏览器的陪伴。然而,为了创作软件杰作,我们被迫合作,这与我们的自然本能相悖。正如我们将看到的,你软件的质量取决于你程序员的素质以及他们的协作。如果没有在现实世界中应对的策略,你将陷入困境。
本节探讨了文化和动态如何影响你的代码形状。我们将看到:
第十六章
强大程序员的必要技能和个人品质。
第十七章
如何作为一个有效和高效的软件团队工作。
第十八章
管理多个程序员之间共享的源代码:如何避免灾难和痛苦。
那么,程序员的集体名词是什么?它当然不是蜂群:我们远没有那么快,也远没有那么有组织。它也不是狮群:我们既没有狮子那么凶猛,也不太可能创造出值得夸耀的东西。答案(至少对于 C 家族程序员来说)是明确的:程序员的集体名词是一排。
第十六章。代码猴子
培养正确的编程态度和方法
我们只是高级的猴子,在非常普通的一颗恒星的一个小行星上。但我们能理解宇宙。这使得我们非常特别。
--斯蒂芬·霍金
快速问答:需要多少程序员来换一个灯泡?它是:
-
无。灯泡并没有坏;这是一个节能特性。
-
只有一个,但可能需要一整夜的时间,以及大量的披萨和咖啡。
-
二十个。一个来修复最初的问题,十九个来调试产生的混乱.^([1])
正确的答案是什么?这取决于谁来做这项工作。不同的程序员以不同的方式工作,并且会有他们自己独特的解决问题的方法。总有不止一种方法可以做到,^([2]) 不同程序员的态度将导致他们做出非常不同的决定。
在这本书的整个过程中,我们一直在识别优秀程序员的正确态度。这一章专门关注这一点:我们将研究程序员的正确和错误态度,并确定成功编程的关键因素。这包括我们如何处理编码任务,以及我们如何与其他程序员相处。我们将得出一些关于什么使最佳程序员令人惊讶的结论。
猴子生意
软件工厂住着一群奇怪的怪人和社会不适应者,即代码猴子。任何严肃的软件系统都是由这些人建造的,他们拥有不同的技能水平和态度,共同朝着共同的目标努力。
我们如何合作以及我们编写的代码类型,将不可避免地受到我们对工作的态度以及技术能力的影响。如果每个人都勤奋、务实、努力工作,我们的软件会好得多——按时交付、预算合理、没有错误。但我们并不完美,不幸的是,这在我们的代码中有所体现。
为了制定应对策略,我将带领大家进行一次导游之旅,参观一个程序员刻板印象的画廊。这些都是基于我在软件工厂遇到的人的类型。当然,这是一个必要的通用列表;你们会知道有些程序员不属于这里列出的类别,甚至同时符合几个描述。
尽管如此,这种无耻的分类突出了重要的事实,并展示了我们如何改进。我们将看到:
-
什么激励着不同类型的代码猴子
-
如何与他们每个人合作
-
每个代码猴子如何提升自己
-
我们可以从他们每个人身上学到什么
阅读每个代码猴子的描述时,请问自己:
-
你是这种类型的程序员吗?这个描述与你的编程风格有多接近?你可以从中学到什么来改进你的编码方法?
-
你认识多少这样的人?他们是你的亲密同事吗?你如何更好地与他们合作?
渴望的程序员

我们将从这位先生开始,因为他^([3])可能体现了本书大多数阅读者的特质。渴望的程序员快速而短暂;他思考的是代码。一个冲动、天生的程序员,一旦头脑中形成想法,就会立即编写代码。他不会退后思考。因此,尽管渴望的程序员确实拥有非常好的技术技能,但他所编写的代码从未展现出他的真正潜力。
渴望的程序员经常尝试使用新的功能或习语,因为它们很时尚。他尝试新技巧的愿望意味着他在不合适的情况下也会应用技术。
优点
渴望的程序员在代码数量上很有效率。他们编写了大量的代码。他们喜欢学习新事物,对编程非常热情——甚至可以说是充满激情。渴望的程序员热爱他的工作,并且真心想要编写好的代码。
弱点
由于他无拘无束的热情,热心的程序员急躁,在匆忙进入代码编辑器之前没有思考。他确实写了很多代码,但由于他写得很快,所以存在缺陷——热心的程序员花费大量时间进行调试。一点前瞻性思维可以防止许多愚蠢的错误和许多时间来纠正粗心大意的小错误。
不幸的是,热心的程序员是一个糟糕的调试员。就像他匆忙进入编码一样,他会直接跳入调试。他不是有条理的,所以他会花费大量时间在死胡同里追查错误。
他对时间的估计能力很差。当一切顺利时,他会对情况做出合理的估计,但事情从来不会按照计划进行;他总是比预期花费更长的时间。
如果你是其中一员,应该怎么做
不要失去那份热情——这是程序员最好的特性之一。因为你的快乐在于看到程序运行并退后欣赏代码的美丽,找出实际的方法来做这件事。将单元测试作为代码开发的一个组成部分是一个很好的主意。但它主要归结为这条简单的建议:停下来思考。不要急躁。制定出对你有帮助的个人纪律,即使是像在便利贴上写上THINK并把它贴在显示器上这样基本的事情!
如何与他们合作
当他们工作得好的时候,这些人是一些最好的编程伙伴。关键是把他们的能量引导到有生产力的代码上,而不是无意义的挥霍。他们在结对编程中合作得很好。
询问一个热心的程序员他每天在做什么以及他的计划。对他的设计表示兴趣——这将鼓励他真正思考!如果你依赖一个热心的程序员的代码,请要求早期预发布版本,并要求查看他的单元测试。
热心的程序员从适当的管理中受益,这有助于他的纪律。确保他的时间被仔细地安排在项目计划中(你不需要亲自规划他的时间)。
代码猴子

如果你需要无限数量的猴子,这些人将是你的首选。(不过我不建议这样做;你将需要挑选猴子很长时间!)
代码猴子编写的是稳固但缺乏灵感的代码。给定一个任务,他会忠诚地一步步完成,准备接受下一个任务。由于他们的低级工作,这些人也被称为苦力程序员。
代码猴子们性格内向。害怕争取好工作,他们在一些不引人注目的项目中边缘化。他们作为维护程序员,在先驱们编写令人兴奋的替代品时,保持老旧代码库的运行。
一个初级代码猴子在时间和指导的帮助下可以学习和进步,但他被分配了低风险的任务。一个年长的代码猴子可能已经停滞不前,并作为代码猴子退休。他会很乐意这样做。
优点
给他们一项任务,他们会完成它——相当不错,相当准时。代码猴子是可靠的,在关键时刻通常能够付出额外的努力。
与渴望的程序员不同,代码猴子是时间估算高手。他们是条理清晰且细致的。
弱点
尽管代码猴子小心谨慎且条理清晰,但他们不跳出思维定式。他们缺乏设计灵感和直觉。代码猴子会无条件的遵循现有的代码设计规范,而不是解决任何潜在的问题。由于他们不负责设计,他们不会对出现的问题承担责任,通常也不会主动去调查和修复这些问题。
教会代码猴子新东西很难;他们根本不感兴趣。
如果你是其中一员,应该怎么做
你想探索新的领域并扩大你的责任吗?如果是这样,开始通过个人项目练习来加强你的技能。找一些书来学习新技术。
推动承担更多责任,并主动提出参与设计工作。在你的当前工作中采取主动——尽早识别可能的失败点,并制定避免它们的计划。
如何与他们合作
不要轻视代码猴子,即使你拥有更强的技术技能或更大的责任。鼓励他——赞美他的代码,并教他提高工作效率的技术。
考虑周到地编写代码,使维护程序员(即维护代码猴子)的工作尽可能容易。
大师

这就是传说中的神秘天才:一个程序巫师。大师通常很安静、谦逊,甚至有点古怪.^([4]) 他编写出色的代码,但与凡人沟通不佳。
大师被单独留下来处理基础工作:框架、架构、内核等。他受到同事应有的尊重(有时甚至有些恐惧)。
全知全能,大师无所不知,无所不见。在任何技术讨论中,他都会出现,并发表他的专业意见。
优点
大师是经验丰富的魔术师。他们知道所有的现代技术,并了解哪些旧技巧更好。(大师最初发明了所有酷炫的技术。)他们拥有丰富的经验,并编写成熟且易于维护的代码。
一个好大师是一位出色的导师——从他那里可以学到很多东西。
弱点
很少有大师擅长沟通。他们并不总是口吃,但他们的想法飞快,水平远超常人,所以很难跟上他们。与大师的对话会让你感到愚蠢、困惑,或者两者兼而有之。
大师的沟通技巧越差,他作为导师的表现就越糟糕。大师们很难理解为什么别人不知道得和他们一样多,或者思考得不够快。
如果你是其中一员,应该怎么做
尝试从你的云端下来,生活在现实世界中。不要期望每个人都像你一样快,或者用和你一样的方式思考。解释某事简单明了需要很多技巧。练习这一点。
如何与他们合作
如果你遇到了一位大师,就要向他学习。吸收你能吸收的一切——不仅仅是技术方面的。成为大师需要一定的气质和个性——知识但不要傲慢。注意这一点。
半大师

半大师认为自己是天才。他不是。他谈得头头是道,但都是废话。
这可能是最危险类型的代码猴子;半大师很难被发现,直到破坏已经造成。管理者认为他是天才,因为他听起来很有说服力,自信满满。
半大师通常比大师更聒噪。他更自吹自擂,更自以为是。他自封为权威。(另一方面,大师是由他们的同行认可的专家。)
优点
假设半大师没有任何优点,但他的巨大优势是他对自己的信念。信任自己的能力,确信你写的代码质量高。然而……
弱点
半大师的巨大弱点是他对自己的信念。他高估了自己的能力,他的决定将危及你项目的成功。他是一个严重的负担。
即使半大师已经转移到新的领域,他也会继续困扰你。你将留下他糟糕的设计和过于聪明的代码的后果。
如果你是这种人应该怎么做
现在,诚实地评估你的技能。不要过分夸大自己。野心是好事;假装成你不是的人是不对的。
你可能并非有意为之,所以对于你能做什么和不能做什么要客观。更多地关注你软件的质量,而不是你看起来多么重要或聪明。
如何与他们合作
要非常、非常小心。
一旦你识别出半大师,你就赢得了半场战斗。他大部分能造成的破坏都会在你还没有完全了解他的时候发生。仔细观察半大师:你必须过滤掉他说的垃圾话,与他有缺陷的设计搏斗,检查他糟糕的代码。
傲慢的天才

这个人是大师种类的一个微妙但重要的变化。他是一个杀手级的程序员,他速度快、效率高,写的代码质量好。他不是完全的大师,但他很受欢迎。但是,因为他太清楚自己的技能,所以他自负、傲慢、贬低他人。
天才极其好斗,因为他通常是对的,总是必须推广自己的正确观点,而不仅仅是别人的错误观点。他已经习惯了这一点。最令人烦恼的是,大多数时候,他确实是对的,所以你注定会输给他。如果你是对的,他会继续争论,直到争论转移到他确实是对的某个问题上。
优势
天才拥有相当的技术技能。他可以提供强大的技术领导力,并在每个人都同意他时催化团队。
劣势
天才不喜欢被证明是错的,他们认为他们必须始终是对的。他们觉得自己有义务充当权威;天才对一切了如指掌。他们永远不会说我不知道,完全绕过了谦卑。
如果你是天才,你应该怎么做
并非每个人都达到神一般的地位,但有很多值得尊敬的优秀程序员。认识到这一点。练习谦卑,尊重他人的观点。
寻找那些可能拥有更丰富观点的人,并向他们学习。永远不要假装或掩饰你的不成熟——诚实地面对你所知和所不知。
如何与他们合作
确实要尊重天才,并尊重他周围的其他程序员。避免与他发生无建设性的争吵。但坚守立场——坚持你的合理观点和看法。不要被他吓倒。与技术天才讨论技术问题可以使你成为一名更好的程序员;只是首先学会控制你的情绪。如果你知道自己是对的,就争取盟友来帮助与他争论。
注意并避免自己变得傲慢或好斗。
个人特质
这种程序员态度的分类并不特别科学。心理学家已经设计了更正式的性格分类;权威地称呼你为怪人。他们并不专注于软件开发世界,但确实为程序员的行为提供了有价值的见解。
迈尔斯-布里格斯类型指标可能是最受欢迎的工具。(布里格斯 80)它将你的性格分解为四个维度:外向(E)或内向(I);感觉(S)或直觉(N);思考(T)或情感(F);以及判断(J)或感知(P)。这种分类产生了一个四字母的描述符;ISTJ 对于程序员来说很常见。
贝尔宾的团队角色是一种态度分类,定义为以特定方式行为、贡献和与他人互动的倾向。(贝尔宾 81)这是一种描述你的自然社交行为和建立关系能力的方法,以确定它如何帮助或阻碍团队进步。它显示了你的性格类型如何影响你的团队合作技能。贝尔宾确定了九种具体的行为角色:三个以行动为导向,三个以人为导向,以及三个以头脑为导向。理解这些使我们能够从具有互补技能的人中构建有效的团队;如果每个程序员都是协调者,那么什么都不会完成。
这两种人格分类学都没有与我程序员分类的一对一映射。它们也明显缺乏灵长类动物。
牛仔

牛仔是一个糟糕的程序员,他积极地避免艰苦的工作。他会找到尽可能多的捷径。有些人会错误地将这个人归类为黑客。他并不是在传统意义上的黑客。黑客是极客们用来自豪地描述一个英勇的程序员的一个术语.^([5])
牛仔直接跳入代码,并做最少的努力来解决眼前的问题。他不会在乎这并不是一个非常好的解决方案,如果它破坏了代码结构,或者不会满足未来的需求。
牛仔急于完成每个任务并继续下一个。如果他读过一点关于流程的内容,他会称之为敏捷编程。这实际上只是懒惰。
优点
牛仔的代码是有效的,但并不特别优雅。牛仔喜欢学习新事物,但很少有时间去做(因为这太像艰苦的工作了)。
缺点
你会花费很长时间清理牛仔留下的烂摊子。他的后果是一个不愉快的地方。牛仔的代码总是需要后续的修复、重做和重构。他们使用的技巧有限,并且没有真正的工程技能。
如果你是其中之一,应该怎么做
在正确的意义上学习黑客代码。为你的工作感到自豪,并花更多的时间在上面。承认你的不足,并努力改进。
如何与他们合作
永远不要进入牛仔的房子;如果他的代码可以作为一个依据,那将会是一个 DIY 灾难!理解他们并不是恶意的人,只是有点懒惰。组织他们的代码审查。让他进行结对编程。(牛仔可能和渴望的程序员合作得很好;如果你想看到羽毛飞舞,就让他和规划者配对。)
规划者

规划者思考得太多,以至于在开始编写任何代码之前,项目就已经被搁置了。
的确,你必须提前规划并建立一致的设计,但这个人会围绕自己形成一个无法穿透的茧,拒绝与外界接触,直到他完成。与此同时,周围的一切都在变化。
学识渊博,规划者学习和阅读了很多。一个常见的亚种是流程小丑;他了解所有的“正确开发流程”,但在截止日期或完成任务方面很弱。(流程小丑最终会成为中层管理者,然后他们就会被解雇。)
优点
他们确实设计。他们确实思考。他们不会编写无脑的代码。
缺点
当规划师开始工作,存在一个很大的风险是过度设计。他倾向于创建非常复杂的系统。规划师是分析瘫痪的关键原因——开发变得更加关注方法和建模,而不是原型设计和创造解决方案。规划师喜欢生成无尽的文档,每隔一小时就召开会议。
他花了很多时间思考,但做事情的时间不够。他知道很多,但并不是所有都从理论跃迁到实践。
如果你是其中一员,该怎么办
在一开始就创建细致的设计是很重要的,但考虑逐步开发和原型设计作为验证设计的方法。有时,直到你真正开始实施,你才能承诺一个设计。只有那时,你才会欣赏到所有的问题。
努力在规划和行动之间建立更好的平衡。安慰自己,花太多时间设计总比写出糟糕的代码要好——后者更难修复。
如何与他们合作
提前就规划师工作的所有里程碑和截止日期达成一致。加入一个设计完成的里程碑;规划师会很高兴它被认可为重要的任务,并会鼓励他按时完成设计工作。这通常足以使规划师采取行动。
避免与规划师开会。你将花费一个小时争论如何决定议程。
老手

这位老兄是老派的资深程序员。坐下来,听他回忆那些美好的旧时光,当时他使用穿孔卡片,机器没有足够的内存来存储整数加法的结果。
老手要么很高兴自己还在做他最喜欢的事情,要么因为无数次错过晋升而感到痛苦。他见过了所有的事情,知道所有答案,而且不会学习新技巧(他会告诉你没有什么新东西可以学;我们只是重新包装了旧想法)。他不愿意学习新语言:“我不需要 C++。我可以用汇编语言过得很好,非常感谢。”
老手不乐意忍受傻瓜。他有点古怪,容易烦躁。
优势
他编程多年,因此积累了相当的经验和智慧。老手对编码有一个成熟的方法。他已经学会了哪些品质使程序变得好或坏,以及如何避免常见的陷阱。
劣势
老手不愿意学习新技术。对那些承诺很多但实际交付很少的时尚想法感到厌倦,他有点迟钝,可能对改变有抵触。
多年的公司无能让他几乎没有耐心。他经历了无数紧迫的最后期限和不合理的经理。
如果你是其中一员,该怎么办
不要对年轻、更有热情的程序员过于评判。你曾经和他们一样,你的代码也不差,对吧?
如何与他们合作
你们这些年轻的程序员不知道你们有多幸运。不要与老程序员纠缠,否则你会知道他是如何在软件工厂中生存这么长时间的。明智地选择与他战斗,向他表示尊重,但把他当作一个同伴,而不是一个神。
理解老程序员的动机。找出他是出于热爱编程,还是因为处于职业死胡同。
狂热者

狂热者是被洗脑的皈依者,一个盲目认为 BigCo 生产的每样东西都是优秀的门徒。青少年有摇滚明星崇拜;程序员有自己的偶像。在热情洋溢中,狂热者自告奋勇成为无偿的技术传教士。他会试图将 BigCo 的产品融入到他接受的每一个任务中。
狂热者只追随 BigCo,排斥所有其他方法,并且很少了解替代方案。任何在当前 BigCo 产品线中不是优秀的,都肯定会在下一个版本中得到修复,我们必须立即升级到这个版本。⁶
优点
他对 BigCo 的产品了如指掌,并会基于它们产生真正优秀的设计。他在该技术上是富有成效的,但并不一定是最高效的——其他不熟悉的方法可能更有效。
弱点
作为狂热者,他既不客观也不实际。可能存在更好的非 BigCo 设计,他会错过。更糟糕的是,狂热者对 BigCo 的持续抱怨。
如果你是狂热者该怎么办
没有人期望你离开你钟爱的 BigCo。了解其技术和知道如何部署它们是有价值的。但不要成为技术教条主义者。拥抱不同的方法和新的思维方式。不要带着优越感看待它们或预先判断它们。
如何与他们合作
不要与狂热者进行哲学争论。不要试图解释你偏好的技术的优点——他不会听的。小心:与这个人的一次对话可能会让你也变成狂热者。他具有传染性。
狂热者通常是无害的(并且从远处观看很有趣),除非你的项目处于关键设计阶段。在这个阶段,提供对问题领域的清晰、无偏见的观点,并坚持对所有实现方法进行彻底评估。记住:他可能是对的。
如果你遇到愚蠢的争论,就用你准备充分、准确、详细的信息来反驳,关于你方法的优势和他的方法的弱点。
单一文化程序员

这是典型的极客,一个生活在技术中、呼吸着技术的男人。这是他的一生;他可能梦寐以求。
单一文化程序员有一个显著的一心多用的大脑。他把工作带回家,回来时已经设计并编写了整个系统,修复了所有主要错误,并制定了如何实施项目其余部分计划。在你吃早餐之前,他已经全部完成了。
优点
单一文化程序员专注且坚定。他会确保项目能够成功,或者他会拼尽全力。他愿意投入大量的额外努力,在截止日期临近时,他非常有用。
弱点
他期望别人像他一样痴迷和专注,并可能不赞成那些不是这样的人。他最大的危险是忽视事情,因为他总是离问题太近。
如果你是其中一员怎么办
开始集邮——或者任何东西——以帮助你放松。只工作不玩耍,聪明孩子也变傻。但你可能根本不在乎。
如何与他们合作
这些人很好合作。他们的热情具有传染性,当他们加入时,项目会迅速推进。但不要让他们接管。如果有机会,单一文化程序员也会做你所有的任务!虽然听起来不错,但你将不得不维护外来的代码。这不值得麻烦。
不要担心他们缺乏个人生活,也不要感到有压力要花所有的时间在项目上——有时最好的设计工具就是放松的夜晚。
懒散者

懒散者是工作懒惰的懒虫。他很难被发现,因为他学会了让自己看起来工作负担很重。他的“设计”是打单人纸牌,他的“研究”是浏览网上的跑车,他的“实施”是忙于自己的事情。懒散者积极避免所有任务。(哦,我太忙了,根本没时间做那件事。)
更微妙的懒散者只会做他想做的事情或他认为应该做的事情,而不是他应该做的事情。尽管他一直在工作,但他永远不会完成他的工作。
懒散者知道如何享受乐趣。他聚会过多,通常可以在他的桌子下找到他睡觉。他的饮食主要由咖啡组成,除了午餐时间,那时你会在酒吧找到他。
这个人可能会变得疲惫不堪;太多的失败项目已经杀死了他工作的欲望。
优点
至少他知道如何享受乐趣。
弱点
懒散者是一个明显的缺点。很难证明他在偷懒——一些难题确实需要一段时间才能解决。程序员可能并不是在偷懒;他可能只是没有足够的技能快速解决问题。
如果你是其中一员怎么办
修炼你的道德,开始付出一些努力。或者学会忍受罪恶感。
如何与他们合作
最好不要抱怨懒散者——你也有自己的缺点。他会在适当的时候得到应有的惩罚。
采取措施证明你正在有效地工作,并且延误是懒散者的责任。可能有助于保持你工作的系统日记。一套明确的截止日期通常足以让懒散者开始工作。即使是在绝望中,也不要开始写他的东西。他只会期望你下次这样做。
避免自己过度劳累;工作时尽量保持乐趣。也许你应该有时和他一起去酒吧。
不情愿的团队领导者

这是组织经典案例;一个在没有任何进一步技术晋升途径的情况下被晋升为团队领导者的开发者。
你可以明显看出他在这角色中感到不舒服。他没有正确的技能集,而且他努力保持跟进。他是一个程序员,他想要编程。这个人不是天生的组织者或人群管理者,而且他是个糟糕的沟通者。
大多数程序员都是糟糕的领导者。真正优秀的软件团队领导者很少;这需要一种既技术又组织的特定技能集。
不情愿的团队领导者通常非常温和且犹豫不决——否则他怎么会同意接受这份工作?他夹在开发团队和管理层之间,承担着进度延误和软件质量差的责备。他的表情变得越来越焦虑,直到最终精疲力竭。
优势
不情愿的团队领导者对程序员的困境有真正的同情——他曾经在那里,现在希望回到那里。他通常非常愿意承担责任,以防止程序员被管理层欺负。正如他不好于分配工作一样,他也不擅长分配责任。
劣势
当一个团队领导者试图编写代码时,结果将是糟糕的。他永远没有足够的时间去编写、设计或仔细测试。他天真地计划在团队领导职责的同时,用一整天的时间进行编码。他无法完成所有工作,因此不情愿的团队领导者不得不在办公室待得越来越久,试图跟上进度。他无法很好地组织,无法向经理解释事情,也无法正确管理团队成员。
如果你是其中一员,应该怎么做
快速接受培训。
如果你在这个角色中不开心,争取职业变动。这并不是承认失败;做自己不喜欢且不擅长的事情而精疲力竭是毫无意义的。并不是每个人都有管理技能和热情。转到你既有技能又有热情的领域。
如果你喜欢艰巨的任务,试着整理你公司的晋升路径。让公司认识到,管理职位不应是高级开发人员之后的下一步。很少有程序员能成为合格的管理者;他们的思维方式不正确。
如何与他们合作
要有同情心,尽你所能去帮助团队领导。按时提交报告,并尽量按计划完成工作。如果你可能错过截止日期,尽早通知团队领导,以便他可以做出相应的计划。
你

为了礼貌起见,我们不再多谈这个奇怪的家伙。遗憾的是,有些人已经无法帮助了……
^([1]) 这不是玩笑:我有一个朋友,她一生中只换过两次灯泡。第一次,玻璃碎片洒满了地毯。第二次,电工不得不之后安装一个新的灯座。
^([2]) Perl 程序员的座右铭。
^([3]) 我将描述所有代码猴子为男性,没有其他原因,只是为了行文的清晰性。
^([4]) 好吧,至少比“正常”程序员更奇怪。古怪可能是更礼貌的说法。
^([5]) 它也被无知的人滥用,错误地用来指代黑客——未经许可就闯入计算机系统的人。参见第 228 页的"CRACKER VS. HACKER"。
^([6]) 狂热者不仅崇拜软件供应商。狂热者可能是一个开源倡导者,或者渴望过时的软件包。
理想程序员
从这个混乱的局面来看,很明显我们是一个奇怪的群体。我们应该努力成为哪种代码猴子?哪种代码猴子鸡尾酒能创造出理想程序员?
不幸的是,在现实世界中,没有完美的程序员——这个怪物是一个都市传说。因此,这是一个学术问题,但找到答案将给我们一个目标去追求。
传说中的理想程序员是以下部分:
政治家
他必须要有外交手腕,能够处理这些奇怪的代码猴子的恶习以及软件工厂中居住的许多其他生物——经理、测试员、支持人员、客户、用户等等。
关系型
他与他人的合作良好。他对自己的代码没有领地意识,如果任务是为了公共利益,他不怕动手。他沟通能力强——他既能倾听也能交谈。
艺术型
他能够设计优雅的解决方案,并欣赏高质量实现的美学方面。
技术天才
他编写的是稳固、工业级的代码。他拥有广泛的技术技能,并懂得何时以及如何应用它们。
再次阅读这个列表,很明显我们应该成为什么样的人。如果你还没有意识到,我会明确指出:理想的程序员是一个

好吧,那是一个值得追求的目标。
那又如何?
只有最聪明和最愚蠢的人永远不会改变。
-- 孔子
当你盯着这些代码猴子的笼子看,并嘲笑他们时,这很有趣,但对此你该怎么办?如果你什么都不做,那么这仅仅是一种娱乐;你将离开,继续做你一直以来的愚蠢事情。
要作为程序员改进,你必须改变。改变是困难的——它与我们本性相悖。俗话说,豹子不会改变它的斑点。如果他改变了,他就不再是豹子了。也许这就是关键。我们中更多的人应该成为野牛或犀牛。
抽空思考以下问题。你可能会发现使用本章末尾的行动清单来记录你的答案很有用。
-
你最像哪种代码猴子?如果你诚实,你身上可能都有一点。找出最能描述你的那一个或两个。
-
你的特别优势和劣势是什么?
-
再次审视你的代码猴子描述,看看你可以做出哪些实际改变。哪些具体技巧能帮助你克服不良态度?你如何利用你的优点?
关键概念
了解你是什么样的程序员。确定如何利用你的优势并弥补你的劣势。
最愚蠢的人
为了帮助我们思考所需的变化类型,我们能从每个代码猴子那里学到什么教训?我们都有各自的个性缺陷,但这个总结显示了一些良好的态度和几个需要改进的常见领域。要成为一名优秀的程序员,你必须学会

那就是:
团队玩家
学习与他人有效合作。尽量理解每位同事的独特特点,并学习如何更好地应对他们。
诚实且谦逊
对你的能力保持现实:了解你的优势和劣势。不要假装你更有能力。采取一种寻求帮助他人并有效与他们合作的态度。
持续改进
无论你知道多少,你有多少经验,你的代码有多好,总有更多要学习的东西,新的技能要掌握,不良态度要克服。孔子说:“真正的知识是知道自己的无知。”承认你不是完美的。一个优秀的程序员处于不断改进的状态。
体贴
训练自己始终思考你在做什么。当你没有注意时,愚蠢的错误就会悄悄进来。始终使用你的大脑。在你写每一行代码之前,考虑你在做什么。然后回读你所写的,即使它只是一个简单的更改。
敏锐
尽量保持渴望的编码者的热情。如果你热爱学习新技能,那么继续阅读并继续练习。如果你在定期休息时工作得最好,那么计划你的假期!如果你喜欢面对新挑战,那么将自己置于最能激发你的位置。
如果你变得沉闷无聊,你的态度会变差,你的代码质量也会受到影响。
简而言之
达尔文人,尽管行为良好,充其量只是一个剃了毛的猴子!
--吉尔伯特和萨利文
程序员是一种社会性生物(考虑到他们缺乏社交技巧,这很奇怪)。他们出于必要性而社交;没有紧密合作的程序员团队,你无法创建优秀的大型软件系统(无论是部门、公司还是开源文化)。
每个程序员都有自己的缺点和独特之处。他们潜在的态度影响着他们的编程能力,塑造了他们对代码和与队友关系的处理方式。
如果你想要成为一名杰出的程序员,你需要培养正确的积极态度。记住:目标是成为一个笨拙的实践者。
| 好程序员…… | 坏程序员…… |
|---|
|
-
是PRAT:政治家、关系型、艺术型和技术型
-
是PRAT:政治家、关系型、艺术型和技术型
|
-
不对编写好代码感兴趣
-
不擅长团队合作
-
尽量表现得比他们实际更好
-
停滞不前——他们不寻求自我改进
|
参见
第十七章
深入讨论团队动态。

行动计划表
看一下下面的行动计划表。花点时间填写它,并找出如何将你所学应用到实践中。

激发思考
在第 532 页的"附录 A"部分可以找到对这些问题的详细讨论。
深思熟虑
-
改变一个灯泡需要多少程序员?
-
是热情但技能不足(不是无能)更好,还是非常有才华但缺乏动力更好?
-
谁会编写更好的代码?
-
哪个程序员更好?(不是同一件事。)
是你的技术能力还是你的态度对塑造你编写的代码影响更大?
-
-
我们编写的程序有多种不同类型,通过代码“遗产”来区分。以下类型代码的编写有何不同?
-
一个“玩具”程序
-
一个全新的系统
-
扩展现有系统
-
对旧代码库的维护工作
-
-
如果编程是一种艺术,那么考虑和计划与直觉和直觉之间的正确平衡是什么?你是通过直觉还是通过计划来编程?
个人化思考
-
如果你还没有做,请仔细填写上一页的行动计划表。确保你找出如何改进,并开始付诸行动!
-
这里有一个有趣的游戏,你可以为你的开发团队设置,以帮助每个程序员找出他们自然的编码方法。
团队
如果你们很多人,可以分成三到五人的小团队。
任务
你是一支程序员团队,负责开发以下新产品。在可用的时间内,设计系统。解释你将如何将其拆分为组件,并在团队成员之间安排工作。
你现在不必编写代码(尽管如果你能展示一个工作原型可能会得到一些加分!),不要陷入完美主义(没有时间);只需开始设计一些能工作的东西。
系统
由于 NASA 的大规模裁员,你成了整个团队,负责编写下一个火星探测器的控制软件。它必须能够:
-
驾车四处转转
-
拍照
-
测量大气条件
-
与地球控制通信
-
非常可靠
时间限制
这里是最好玩的部分。你只有五分钟时间。当然,这完全不合理,但它是我们项目时间表的绝佳隐喻。(只是看看时间的流逝……)
之后
看看人们是如何协作的。哪些团队最成功?哪些失败了?为什么?不同的人是如何处理任务的?这个任务的结果并不像人们尝试去完成它那样重要。
回答这类问题将清楚地显示出每个团队成员最像哪种代码猴子。
第十七章。我们站在一起
团队合作与个体程序员
成功公式中最重要的单一因素是知道如何与人相处。
——西奥多·罗斯福
周六晚上,你正准备吃爆米花、喝饮料,准备看电影。也许你已经说服了一些毫无察觉的非计算机极客和你一起看。你没有告诉他们那是《黑客帝国》,对吧?
你正在观看的制作是众多致力于创造最终电影的团队付出的巨大努力的成果。虽然你可能看不到,但投入了成千上万的人时(人日,以及“神话”人月)。
当你看一些电影时,你可能会想知道他们是否真的应该费这个功夫。
将那庞大的协调努力与我们的软件开发方式进行比较。如果你试图自己制作一部电影,结果会很糟糕。没有人能独自制作一部电影——或者至少,不能制作一部好电影。要将完整的、编辑过的电影送到电视上需要更多的努力:市场营销、制造、分销、零售等等。也许你可以自己制作一个完整的“专业”软件包,但这需要非常长的时间。在商业世界中,谁会给你这样一个风险合同?
在大多数职业中,优质的产品是良好团队合作的成果。软件开发也不例外。事实上,团队合作对于项目的生存至关重要。一个效率低下的团队会迅速扼杀任何软件开发活动,让进步依赖于少数几个在巨大困难中工作的敬业人士。成为一名优秀的软件工程师不仅仅是成为一名优秀的程序员。你可能能在不到五行代码的情况下计算出 PI 的荒谬精度。做得好。但还有许多其他技能是必需的,其中之一就是团队合作。
关键概念
团队合作是 高质量软件开发者的一项基本技能。
在本章中,我们将探讨团队合作在我们程序员中的应用。我们将研究构成良好团队合作的因素以及我们如何提高团队效率。
我们的团队——整体图景
多年来,许多不同类型的团队共同合作,生产出软件产品。它们从高度正式的团队(办公室里的西装人士),具有严格的结构和定义的过程,到开源运动的新前沿工作,任何人都可以贡献,并且根据其价值进行变更。
这两种工作方法都取得了巨大的成功,也都遭遇了巨大的失败。IBM OS/360 和 Linux 内核分别是各自阵营的显著成功案例。阿丽亚娜 5 号是一次传奇性的失败——这个欧洲发射器在首次发射时爆炸,因为两个软件团队误解了正式定义的接口。Mozilla 是一个有趣的开放源代码失败案例——当网景开源其代码时,他们期望快速的开发和改进。与其它开源项目相比,Mozilla 几年的开发令人失望。
软件开发者通常参与不同层次的团队,每个团队都有不同的动态,需要不同水平的贡献。考虑以下场景:
-
你正在创建一个独特的软件组件,它是更大项目的一部分。你可以独立开发它,或者作为程序员团队的一部分:团队一。
-
该组件将融入更广泛的产品中。所有参与该产品的人员(包括任何硬件设计师、软件开发者、测试员以及其它非工程角色,如管理和营销)组成了团队二。
-
你也是一家可能同时开发许多不同产品的公司的成员——团队三。
在现实中,任何合理的大型软件开发公司都有更多层次的团队合作。这些层次在图 17-1 中展示,以及不同动态的例子。当不同团队互动时,团队间的动态引入了最复杂的团队合作考虑因素:政治和管理错误困扰着组织内部的协作。尽管一个公司实际上是一个大团队,但部门之间和群体之间有“他们和我们”的心态是很常见的。这不是有效产品开发的好环境。

图 17-1. 团队合作的层次
作为程序员,我们直接参与较小的团队活动层面:在我们的日常开发团队中。我们对这个层面有最大的控制和影响力。这是我们负责的层面,我们有权力做出设计和实现决策,并报告团队进度。程序员对高级团队的影响较小,但我们受到团队合作“大范围”的影响,就像我们受到团队合作“小范围”的影响一样,即使它不是那么明显。
开发团队的大小决定了共享软件开发工作的动态和性质,以及团队在组织食物链中的位置。一个单独的工程师负责所有的软件架构、设计和实现工作。在非常小的组织中,他们可能还需要负责收集需求并制定和执行详尽的测试计划。
一旦更多的开发者加入到这个组合中,编程任务的本质就会发生变化。它不再仅仅是关于编码技能;它需要社交互动、协调和沟通技巧。这就是你的团队合作技能会影响你构建的软件——无论是好是坏的地方。
关键概念
你开发团队内部和外部的互动都会影响你产生的代码。注意它们是如何影响你的工作的。
团队组织
软件开发团队的架构不可避免地受到管理方法和成员之间责任划分的影响。这两个因素自然会决定你工作的代码量和单元的大小。这表明我们产生的代码是由我们团队的架构所塑造的。
管理方法
一个项目可能以平等的方式管理,没有任何编码者比其他编码者更重要,或者在一个超级程序员/经理的领导下。编程团队可以被视为软件生产线的一部分:从上游团队接收设计,他们按照规格生产代码。1] 开明的软件工程师被赋予更多的自主权和责任。
任务可以在长期计划中提前分配数月(这些计划可能会迅速过时和不准确),或者通过在开发者完成上一个工作包时分配每个工作包来实现即时分配。程序员可能单独在其系统的各个部分工作,或者通过结对编程来分担责任和知识。
责任分工
责任轴决定了如何将开发工作分配给程序员:
-
在垂直团队组织中,你雇佣了一支通才团队,他们在许多角色中都有技能。他们各自被分配了一部分工作,并从头到尾实施它——从架构和设计,到实施和集成,再到开发测试和文档。
这种方法的主要优势是开发者能够获得更广泛的技能,并在整个软件系统中变得更加经验丰富。每个特性都有一个关键开发者,因此在设计和实现上具有凝聚力。然而,通才昂贵且难以找到。他们不是所有领域的专家,因此解决某些问题需要更长的时间。由于由不同的开发者实现,因此不同特性之间的凝聚力可能较低。客户必须与更多的人合作,因为没有特定的联络点——每个开发者都需要提供输入以界定需求并验证设计。
要使这种团队合作有效,你必须定义共同的标准和指南。你必须有良好的沟通以防止人们重复造轮子。必须尽早达成共识,确定一个共同的架构,否则将会出现混乱和随意性的系统。
-
相比之下,水平组织团队由一支专家团队组成,每个开发任务在他们之间分配,并在适当的时候使用他们的各自专长。由于每个工作方面(需求收集、设计、编码等)都是由专家完成的,因此应该具有更高的质量。
这与垂直安排有许多相反的特征:我们在不同的工作包之间建立凝聚力,但存在风险,即由于更多的人参与,每个工作包的凝聚力可能较差。团队外部的互动(与客户或其他公司部门)由少数专家完成。这对团队本身和外部联系来说更容易管理。
一切都在对偶形式中进行
结对编程是一种协作软件开发方法,在敏捷开发圈子中特别流行。它声称可以使程序员更有效率:更快地产生代码,错误更少。
两位开发者一起编码——在同一时间,在同一终端上。当一个人(驾驶员)输入代码时,另一个人(领航员)思考正在执行的内容,并充当第二双眼睛,在错误有机会出现之前将其消除。这对组合会定期交换角色。领航员能看到驾驶员可能忽略的后果,从而消除专注于正在输入的代码时的常见危险。两个人会想到解决任何问题的多种方法,因此更有可能找到最佳的代码设计。正因为这种不同寻常的紧密合作,结对编程最适合具有积极态度的才华横溢的程序员。
研究表明,一旦经过培训,两位程序员在特定任务上的生产力将超过两倍。根据《经济学人》杂志发布的数据,“犹他大学盐湖城的劳里·威廉姆斯表明,结对程序员比两个独立的个人程序员慢 15%,但产生的错误少 15%。由于测试和调试通常比初始编程成本高得多,这是一个令人印象深刻的结果。”(经济学人 01)
结对编程有许多优点。它促进了知识转移和辅导,增加了你的专注力(你不太可能做白日梦、打长途电话或走神),增加了纪律性,并减少了中断(与一个盯着空旷空间发呆的单个程序员相比,你不太可能打断两个紧密合作的程序员)。它作为一个早期的实时检查机制——即时代码审查——并带来更好的代码的好处。它是一个社会过程;有了合适的人,它可以提高士气(尽管当两个人相互摩擦时可能会造成灾难)。程序员们更深入地了解彼此,并更好地理解如何一起工作。结对编程促进了集体代码所有权,传播良好的编码文化和价值观,并强调开发过程。
你必须小心确保专家们协调得很好,并且他们能够看到每个工作包的终点,否则他们的工作将目光短浅。由于许多人在每个开发过程中参与,团队管理起来更困难;有更多的流程。这种安排需要良好的沟通、定义的过程和开发者之间的顺利交接。
没有一种“正确”的组织形式。哪一种最适合取决于团队成员、团队规模和工作的性质。实用安排可能位于中间。
组织和代码结构
团队的组织不可避免地会影响其产生的代码。这在软件传说中被称为康威定律。简单地说,它说:“如果你有四个小组在编写编译器,你将得到一个四遍编译器。”你的代码不可避免地会采取你互动团队的架构和动态。主要软件组件位于团队聚集的地方,它们的沟通遵循团队互动。当小组紧密合作时,组件通信简单且定义明确。当团队分离时,代码的交互笨拙。
我们自然地旨在在每个团队的工作之间创建明确的接口,以促进我们与该团队的互动。即使在某些情况下,深入到另一个组件的内部部分可能是一个有效且更好的方法,我们也会这样做。这样,团队可以培养任意划分;尽管我们的意图良好,但设计决策是由团队组成所强加的。
当然,封装和抽象本身并没有错;但它们必须出于正确的理由而设计。实际上,你应该让正在构建的代码来定义团队成员和组织。
关键概念
围绕你正在构建的代码组织你的团队,而不是围绕团队组织你的代码。
^([1]) 在这里,管理层期望的是可替代的商品级程序员,即“粗工程序员”。参见第 298 页的“代码猴子”。
团队协作工具
有一些基础工具帮助我们组织一个运作良好的软件团队。它们促进了协作,并有助于将联合开发从混乱提升到一个运转良好的机器。单独来看,它们不会使你成为一个指挥官级程序员团队,但它们是每个精英团队所依赖的军火库——有效软件开发者互动的先决条件。
源代码控制
开发团队围绕着源代码,这就是它所在的地方。源代码控制有助于协调谁在做什么以及何时做,提供了最终的最新代码快照,并允许你管理变更、撤销错误,并确保没有人错过源代码更新。在 100 人以上的团队和单人项目中,你都需要它。
故障数据库
我们已经探讨了它是如何帮助开发的(参见第 147 页的“故障跟踪系统”),但请注意它是如何促进团队间互动的:故障跟踪系统充当测试和开发之间的枢纽。它有助于组织测试和修复工作,优先处理故障,将问题分配给个人,并跟踪软件中的待修复问题。它确定了哪些故障是当前开发者的责任,哪些是测试者的。
群件
一个团队需要有效的沟通基础设施,尤其是在地理上分散的情况下。一个集中的日历、通讯录和会议预订系统提供了一个数字化的行政骨干。
你还需要一个机制来共享和协作文档。考虑使用维基(基于网络的社区文档工具)和内部新闻组(具有永久存储功能的电子邮件讨论板)来促进团队互动。
一种方法
建立一个定义明确且普遍理解的开发方法非常重要,否则工作将变得混乱,并且是临时性的。一个开发者会发布代码,而另一个会拒绝放手,直到代码经过彻底测试和调试。一个开发者会停止所有编码,直到生产出一个复杂详细的规范,而另一个会直接进入代码的原型设计。圣战是由比这更小的事情引起的。
一种方法定义了开发过程的细节,谁负责什么工作,以及工作是如何传递的。有了这个,每个开发者都知道期望什么,以及如何作为团队的一部分工作。你必须根据团队的大小、你正在生产的代码类型、人才、经验和人们的动态选择一个适当的方法。这在本章的第二十二章中有所描述。
项目计划
要以可预测、及时的方式产生任何工作,你需要一些组织。这是由项目计划提供的,详细说明了在开发过程中谁在做什么。为了有用,计划必须基于合理的估计,并随着任何所需的变化而保持更新。
程序员在估计方面臭名昭著地差,而经理在规划方面也臭名昭著地差。我们不应该被迫按照不切实际的项目计划工作。这是一个真正困难的问题,我们在第二十一章中进行了剖析。
团队疾病
从失败中恢复往往比从成功中建设更容易。
--迈克尔·艾斯纳
即使有优秀的程序员和出色的组织,你仍然可能有一个功能失调的团队。团队未能产生结果的原因有很多,就像我们给不同种类的程序员贴上标签一样,我们也可以识别出注定要失败的开发团队类别——以便了解我们能从他们身上学到什么。
这里有一些经典的团队灾难。在每种情况下,我们将看到:
-
特定的毁灭之路
-
警告信号(这样你就可以识别出你正在走向这个方向)
-
如何扭转陷入那条特定困境的团队
-
如何在那个团队环境中成为一名成功的程序员(有时甚至尽管团队)^([2])
希望你不会在以下列表中认出你当前的团队。
巴别塔

就像圣经中的建造者一样,一个巴别塔式的团队遭受了巨大的沟通崩溃。一旦程序员无法沟通,开发工作就注定要失败——如果有什么能工作,那更可能是运气而不是设计。
在沟通无效的情况下,人们会做出错误的假设。工作片段会遗漏,潜在的错误案例会被忽视,错误会被遗忘,程序员会重复工作,接口会被误用,问题没有得到解决,微小的偏差,如果不被注意,会逐渐演变成巨大的项目延误,因为没有人监控进度。
原始的巴别塔建造者因多种口语语言而分裂.^([3]) 然而,跨国项目很少受到巴别塔综合症的影响——因为需要跨越语言障碍,人们会更加努力地沟通。
不仅不同的口语语言可以分隔开发者。不同的背景、方法、编程语言,甚至不同的个性都会导致团队成员之间相互误解。一个小小的困惑种子,如果得不到控制,最终会生根发芽;怨恨和挫败感会逐渐积累。在最糟糕的情况下,巴别塔团队最终会完全停止交流,每个程序员都坐在自己的角落里,做着自己的事情。
这种问题可能存在于直接软件团队内部,也可能存在于相互作用的团队之间。当开发者未能与测试人员交谈或管理层与开发脱节时,就会发生跨团队巴别塔综合症。
警告信号
当一个开发者懒得询问另一个开发者关于某事,觉得不值得费力时,你就知道你的团队正走向巴别塔。这种感觉会随着缺乏详细规范和模糊的代码契约而悄悄蔓延。你会看到太少或太多的电子邮件飞来飞去。太多的电子邮件意味着每个人都大声喊叫,没有人倾听:没有人有时间跟上不断的信息轰炸。
在通往巴别塔的道路上,没有团队会议,没有人确切知道项目中的情况。如果你随机挑选一个人,他们无法告诉你开发是否按计划进行。
扭转局势
与人们交谈。继续吧——打开闸门!很快他们都会这么做。
一旦问题恶化,巴别塔态度就很难纠正,因为士气已经被拖到了前所未有的低点,冷漠盛行,没有人相信改变是可能的。最有效的策略是努力提升团队士气,让开发者更加紧密地团结在一起。做一些社交活动来打破团队的沉闷:考虑团队建设练习,甚至简单的集体外出饮酒。有一天买披萨作为午餐,并与团队分享。
然后制定一些策略来强迫人们相互交流。创建小型焦点小组来规划新功能。让两个人负责一项设计工作。引入结对编程。
成功策略
面对这类问题,要写出优秀的代码,你必须非常自律。在开始一个工作包之前,确保它被严格定义。如果需要,自己编写规范,并将其发送给所有相关人员以获得他们的认可(提供评论的时间限制,说明没有反馈即视为同意)。然后,当你成功时,因为你会满足约定的规范,所以会变得清晰。
完全锁定所有外部代码接口,以便不会对您所依赖的内容或人们可以期待您代码的内容产生混淆。
独裁制

这是原始的独角戏,一个由意志坚定、个性强烈的领导者带领的团队,他通常是技术高超的程序员。其他程序员需要成为唯唯诺诺的人,即使他们不愿意,也要无条件地遵循独裁者的命令。
在某些团队中,这种方法运作得很好——有一个明智的仁慈领导者,以及尊重他的团队。当独裁者的个性不支持他的职位,或者当他在技术上标准不高时(参见第 300 页的“半神”),问题就会出现。如果他的自我意识阻碍了团队,团队就会陷入麻烦:他们会怨恨他,并且陷入挫败的停滞。
当这种团队有意为之时,它是一个等级制度,有明确的权威线。这种结构被 Frederick Brooks 比作外科团队。(Brooks 95)外科团队将最合格的、技术最精湛的个人,即首席外科医生,放在最上面:作为代码编写者,不是管理者。他执行大部分开发工作,如果发生不好的事情(如果病人死亡),他将承担最终责任。他有一支精心挑选的团队作为后盾。这包括一位执行较小、风险较低任务的初级外科医生,支持首席外科医生,并学习这一行。团队还涉及软件上的麻醉师、护士,以及可能更多初级外科医生学习技能(例如,缝合病人)。
这种团队存在两种危险。第一种是当外部压力迫使独裁者更多地扮演管理者的角色时;他的技术专长几乎保证了缺乏管理技能。他的焦点将远离软件,项目将崩溃。第二种危险是一个自封的独裁者,团队并不认可他。由于团队既没有结构也没有准备支持他的领导,工作流程将停滞。
警示信号
这种团队结构往往会缓慢而微妙地发展,一个潜在的独裁者会逐渐调整他的工作焦点,并假定他的权威水平。当你经常发现自己说:
-
没有咨询我就不能做这件事……。
-
哦……如果我们这样做,他们会抱怨的。
-
但是……说我们必须先做……。
转折点
如果你有一个不称职的独裁者作为团队领导者,那么你必须处理这种情况。否则,团队会在这个权威的暴君手下僵化。要么与他一起解决这些问题(坦白说,这不太可能成功——改变很难,尤其是对于那些自视甚高的人),要么通过向经理提出这个问题来将他从宝座上拉下来。
在推翻国王之后,你需要团队重组或一个新的国王。顶尖的外科医生很难找到,所以可能最好是重组团队。
成功策略
在一个(功能性的或功能失调的)独裁统治中,确定你的权力和责任水平。与真正有影响力的那个人讨论这个问题——你的经理或团队领导者。
然而,一旦你确立了你的正当开发角色,你(和其他程序员)仍然必须倾听并与独裁者合作,即使你不喜欢他的当前位置。否则,你们将无法很好地合作,也不会写出互补的代码。设计上必须有共识,否则软件将无法工作。
不要对独裁者无礼或粗鲁;这只会降低团队士气,让你更加愤怒。
发展民主

有句古老的谚语说,人人生而平等,在这里这一点得到了体现。这是一个由同等地位的人组成的团队——具有相似技能水平和互补个性的程序员们,他们以非等级的方式组织自己。这在企业界是一种不寻常的生物,企业界期望有人必须是老板。自我组织的团队的想法似乎是一种异端。然而,它已经被证明是一种可以很好地工作的团队模式。一些民主团队通过定期从他们中间选举出领导者,基于在这个项目阶段谁的需求技能最高,来管理团队。通常没有明确的领导者,所有决定都是通过共识做出的。开源开发通常遵循这种模式。
我们往往忘记了这句谚语的另一半:人人生而平等,但通过实践会逐渐分离。要使这种团队文化发挥作用,需要一套特殊的个人。以这种值得赞扬的原则为基础的团队的危险在于,随着团队的增长或当某个成员离开(那个将团队凝聚在一起做出决策的人)时,事情开始变得混乱。团队可能会失去焦点,无法达成一致,无法及时产生结果。在最坏的情况下,团队会永远争论一个单一的问题,沉思自己的肚脐,实际上从未取得任何成就。
在无休止的会议和循环讨论中,团队面临分析瘫痪的危险:专注于过程,而不是项目的交付。就像真正的民主一样,真正的团队业务可能会在政治斗争的海洋中迷失。
如果你有一个无效的团队领导,他无法做出决定,你可能会意外地陷入发展民主。这种笨拙的领导者会慢慢地自己退出,而自己没有意识到。沮丧的团队最终会共同接管他的角色——迫使做出决定并选择发展方向。
即使是有意建立的,民主在危机中也是一个特别困难的团队结构。如果个性摩擦阻止了选举出适合该情况的正确领导者,那么必须引入外部领导者来引导项目。
警示信号
你可以从一英里外闻到病态的民主的味道:决策速度像石头一样下降。如果有软件团队领导,那么每个人都会绕过他,而不是被他的犹豫不决所阻碍。他现在只是一个名义上的领导者;没有人认可他的权威或他实现任何事物的能力。
没有领导,没有人被分配负责每个任务;不清楚谁应该确保任务的完成,因此什么也不做。可能过去几周都没有完成规范,也没有明显的进展。
在猖獗的发展民主中,最小的决定都会迫使团队进入委员会模式,并需要几天时间才能得出结论。或者做出决定:比如说,先说“是”,直到我们决定做其他事情。“让你的‘是’就是‘是’,你的‘不’就是‘不’”,^([5]) 否则你将花费大量时间拆毁旧代码并重新编写,每当有人改变主意时。
你可能还会注意到,初级程序员感到被排斥,因为他们永远不会被选为领导者。
转折点
民主旨在消除一个特定的瓶颈:所有决定都必须由老板做出,而老板并不总是做出这些决定的最合适人选(尤其是当老板不是技术人员时)。在一个功能失调的民主中,没有决策过程,任何级别都没有做出决定。为了回到健康的民主,确保领导可以在团队中自由移动,并且更换领导是容易的。除非你有足够的潜在领导者,否则不要试图运行民主。
就像任何其他滑落的项目一样,确保问题对每个人都是可见的,无论是开发者还是经理。确保清楚谁应该对这个问题的责任——特别是如果这不是你的责任!
你可以通过展示一些坚定的意志来尝试纠正犹豫不决的民主;不要满足于让事情不断滑落。你可能会被命名为麻烦制造者,但最终你也会被命名为一个取得成果的人。但是,要小心成为半独裁者的风险作为反作用。
成功策略
为了你自己的精神健康,避免犹豫不决的人——那些无法决定最简单事情的人。
确保你被分配到项目的一个明确部分,并且有明确和现实的截止日期。这是对抗不确定领导波动的主要锚点。
卫星站

一个卫星团队——与主要开发团队分开——呈现了一个充满潜在痛苦和陷阱的世界。当团队的一部分物理上分离,就像被切断的肢体一样,作为一个整体工作变得困难。
卫星可能是一个完整的周边部门,或者是你直接软件团队中分离到不同地点的一部分。远程办公(在家工作)是一个特殊情况,卫星中只有一个人。
高层管理人员在总部以外的办公室并不罕见,但由于他们对日常编程活动的影响很小,这并不成问题。然而,如果开发团队中有一部分离得很远,那么你需要采取措施确保项目成功。你必须有意识地这样做——分割的团队不会偶然地一起工作。
编程需要紧密的团队合作,因为我们的代码片段必须紧密交互。任何威胁我们人际互动的东西也会威胁到我们的代码。卫星团队带来了这些威胁:
-
物理上分割的开发团队失去了在咖啡机旁自然发生的非正式、自发的对话。轻松动态合作的机遇消失了。随着它一起消失的是共享洞察力和对代码的团队理解。
-
开发过程中缺乏凝聚力。每个地点的本地实践和开发文化都会有所不同(即使只有细微的差别)。不一致的方法使得交接工作变得更加复杂。
-
由于你对卫星团队中的人不太了解,不可避免地存在信任和熟悉度的缺乏。出现了一种“他们和我们”的态度。
-
有句古老的谚语说,“眼不见,心不念。”当你不经常看到卫星程序员时,你会忘记他们,你不知道他们的进度,你也不会考虑你的工作是否对他们产生影响(技术上或程序上)。
-
卫星团队使得简单的对话变得困难。你需要更加关注其他程序员的日程安排;当他们开会或度假时。
-
跨国项目引入了时区问题。团队之间的沟通窗口较小,而黑暗期较长。
警告信号
地理上分割的团队很明显,但也要警惕同一办公室内的分离团队。将开发者分到不同的房间或甚至走廊的另一边,人为地造成了阻碍协作的隔阂。
要注意部门之间的分离。这同样可能造成损害。例如,测试团队通常与开发者分开,有时在另一间办公室或建筑物的不同部分。这真是个遗憾;它阻碍了团队之间的基本互动,结果是质量保证过程并不流畅。
扭转局势
卫星站团队并不一定注定失败;它只需要仔细的监控和管理。问题并非不可克服,但肯定是不方便的——如果可能的话,避免它们。
一个基本的生存策略是在项目早期让所有团队成员面对面交流。这有助于建立融洽、信任和理解。定期的会议甚至更好。当团队聚集时提供食物和饮料;这会让人们感到放松,并营造一个更社交的氛围。
将卫星安排得使其工作需要与母船的最小协作和协调。这将最小化任何通信问题的影响。
通过在早期定义不同站点工作之间的接口来避免代码交互问题。但要注意不要围绕团队设计你的代码;你可能不会创建最合适的设计。编程是一个做出实用选择的过程,所以要选择得当。
在卫星中,群件成为使沟通有效的必备工具。同时考虑在站点之间使用即时消息通信。记住:不要害怕电话!
成功策略
如果你必须与远程人员合作,确保你非常了解他们——无论是个人还是职业上。这会有很大的不同。你会知道他们如何反应,以及他们何时真诚或讽刺。努力对卫星程序员友好——当他们只在不方便的时候给你打电话时,很容易被误认为是一个脾气暴躁的白痴。
确保你确切地知道谁不在现场。了解每个人的名字,并找出他们做什么以及如何联系他们。努力提高你的沟通技巧。当你需要时,不要害怕联系某人:想想如果他们坐在你旁边,你会不会和他们交谈。
大峡谷

这个团队由技能水平和经验处于光谱两端成员组成。存在明显的技能差距;高级开发人员和初级开发者之间的鸿沟尚未弥合,因此形成了两个截然不同的派系。在几乎每一个大峡谷团队中,这既是社会现象也是技术现象:初级程序员之间互相社交,而高级程序员之间也互相社交。当高级开发者坐在一个据点,而初级开发者在一个单独的贫民窟时,这并没有得到帮助。
大峡谷文化的形成往往具有历史性:一个项目开始时只有少数几位有经验的开发者,他们必须迅速建立架构并推出概念验证代码。他们自然地坐在一起,学会作为一个快速、团结的单元工作。随着项目的进展,需要更多的程序员,新成员被引入。由于现有的办公布局,他们被安排在边缘位置,并分配较小的编程任务,以便学习系统的结构。
如果不仔细检查,资深开发者会采取优越的态度,并俯视新程序员。他们分配一些小而繁琐的工作,并继续进行有趣的宏大设计工作。资深开发者认为,教一个新手了解大局需要过长的时间,这在某种程度上是正确的。这样,新程序员就永远没有机会获得责任和进行更有趣的编程。他们感到沮丧和幻灭。
新程序员想要学习这一行业,拥有青春的热情,并对编程充满激情。资深程序员可能有一个非常不同的(可能更世故的?)世界观,有管理或更高级开发角色的抱负。这些不同的个人动机将派系推向不同的方向。
警示信号
观察你所在的团队如何成长。仔细观察成员的构成和他们在工作分配中的情况。监控你团队的社交动态;不健康的团队会形成小圈子。
转折点
大峡谷问题在于团队没有融合;存在极端的派系。解决办法很简单:采用能够使他们融合的策略。例如:
-
改变座位安排,使派系相互交错。这可能消耗宝贵的开发时间,但一天的时间移动办公桌可能会赢得数周的生产力。
-
介绍团队会议以传播信息。
-
开始结对编程,混合资深和新程序员。让新手来驾驶,而资深程序员导航。这对资深程序员是一种纪律,对新手则是教育性的。
-
开始一个导师计划来培训新程序员。虽然这会强调技能差距,但它也会迫使派系更接近。
-
看看所有开发者的职位名称——它们是否培养了一种危险且不必要的等级制度?
成功策略
将每个人都视为平等,视为同伴。
-
如果你是一名资深程序员,要认识到新程序员需要学习。你曾经也是一个新手,并不理解世界是如何运作的。不要独占所有有趣的编程任务。愿意让别人承担责任。
-
如果你是一名新程序员,请要求更具挑战性的任务。寻求学习。尽可能好地完成当前任务;这将证明你已准备好承担更大的责任。
流沙

只需一个人,一个坏苹果,一个不受控制的成员,就能让一个团队陷入困境。你需要一群优秀的程序员来组成一个优秀的团队,但只需要一个糟糕的程序员就能毁掉一个团队。一个陷入泥潭的团队无意中触犯了一个不规矩的成员。这可能是微妙的:可能没有人注意到问题开始的地方,而肇事者无意造成任何伤害。
你可能因为多种原因陷入泥潭:
-
一个技术上 不称职的程序员(可能是我们在第 302 页看到的牛仔程序员)加入了团队。这个人不容易立即被发现,在他编写糟糕的代码时,没有人会注意到。定时炸弹已经埋下,项目将停滞不前,直到他的混乱被清除并替换。
-
一个 士气低落 的成员就像是一片小小的乌云,使整个团队士气低落,吸走了所有的热情和快乐。在几周内,没有人愿意编写任何代码,他们都在考虑跳下最近的桥梁。
-
一个 管理不善 的管理者正在执行与优秀管理者完全相反的行动,不断改变决策,改变优先级,调整时间表,并向客户承诺不可能实现的事情。团队成员不知道自己处于什么位置,因为脚下的地面总是在移动。
-
一个 时间扭曲 程序员正在扭曲相对论定律,使得时间在他周围变慢。任何向他靠近的事物都需要一个异常漫长的时间来处理。他的决策停滞不前,编码工作无法完成,会议总是迟到,因为他无法准时开始。总有一个很好的理由——也许他正在做其他非常重要的工作——但他积累了大量任务,却从未着手处理。最终,其他程序员会感到厌倦,绕过他。
在一个泥潭团队中,一个成员的弱点可以迅速摧毁整个团队的生产力。当肇事者处于食物链的高层时,这尤其危险。他的责任越大,后果就越严重。
警告信号
寻找那个与团队格格不入的人。他是每个人都抱怨的人^([6]),或者那个总是独自工作的程序员(因为每个人都避开他)。
扭转
最激烈但可能也是最容易的解决办法是消除泥潭的原因。但首先你必须识别他,有时这相当困难。不公平解雇的呼声让管理者感到害怕,他们不太愿意因为少数人无法与他相处而解雇某人。要使这个计划发生,需要一些非常严重的无能。
因此,你必须找到一种方法来最小化他可能造成的混乱,或者找出更好地将他融入团队的方法。
成功策略
最重要的是:不要成为泥潭!^([7])
假设你不是,尽量尽可能多地保护自己免受沙坑团队成员的影响。为了你的血压,限制与他互动。不要过多依赖他的代码,并尽可能避免他的输入。不要被他的不良习惯所吸引,也不要对他过度反应——采取完全相反的行动,使事情变得更糟。
工作中的糟糕管理者
开发者是一个很好的团队。他们喜欢他们的工作。他们工作非常努力。遗憾的是,他们(最多)只有平庸的管理。
有一天早上,经理(看起来他那天特别倒霉)召开会议,抱怨开发者们不理解“现实世界”,他们偷懒,而且他们从未达到他设定的(不可能的)截止日期(因为他已经卖出了不存在的产品)。他注意到人们有时不在核心工作时间工作,从现在起每个人都必须这样做。否则。
这进行得非常顺利。
那天下午,程序员们一点工作都没做。什么都没做。他们决定严格按照核心工作时间工作:不再有无偿加班。我估计这位经理一挥手就至少减少了 50%的生产力和士气。
麦哲伦

就像一群可爱、毛茸茸的动物,有着从最近的悬崖上跳下去的疯狂冲动,这个团队非常愿意——甚至渴望——满足他们所接受的简报。即使简报是虚假的。
这个团队由非常信任、非常忠诚的成员组成。他们在技术上很胜任,但看不到他们具体指令之外的东西。他们的热情和渴望值得赞扬,但没有一个有远见的成员——一个会问“为什么”,会看到规格之外真正需求的人——这个团队始终处于危险之中,可能会交付所要求的东西,而不是真正需要的东西。
麦哲伦团队特别容易受到初创公司需求的影响。这种疾病始于管理者说:“快速写这段代码;我们稍后会正确地重做。”后来永远不会到来;相反,麦哲伦们听到:“公司需要更多的代码,快速,所以也赶紧快速加上去。”不久,团队文化变成了有人播放音乐时就跳舞。工作逐渐变得越来越困难,任务越来越艰巨,代码库质量不断下降。
最终,这个团队发现自己身处 60 英尺悬崖的底部,一片狼藉。游戏结束。
警告信号
如果你对你目前正在工作的规格不满意,你可能在一个麦哲伦团队中。如果你对当前项目没有信心,你只是一个代码雇佣兵。当你发现自己听着空洞的承诺,被不合理的工作所承诺,而且没有人争论或指出计划的缺陷时,你肯定是在麦哲伦国家。我们希望你在那里过得愉快。
转型
回顾你团队目前正在做的事情。不要停止工作,但看看从客户需求到最终交付的全过程。你正在工作的代码能提供最终所需的东西吗?它是一个短视的修补,不能经受住多年在你的代码库或多年使用中的考验吗?
成功策略
对你被分配的工作提出质疑。理解其动机。坚持良好的编程原则,并且永远不要相信除非你能看到它被安排在你相信的计划上,否则你将被允许稍后修复代码。
^([2]) 我并不声称这些策略能解决团队的一般问题;它们是故意采取的短视方法,以最小化问题的风险来完成任务。
^([3]) 创世记 11:1-9
^([4]) 通常这个人是一个技术专家,如贝尔宾团队角色定义的那样。
^([5]) 马太福音 5:37,除非你是巴别塔的建造者,在这种情况下,你的“是”可能是 Oui,你的“不”可能是 Nein!
^([6]) 他们会在他背后抱怨,这是将团队拖入泥潭的一部分。没有人直面问题。没有人喜欢搅动局面。面对他的努力将比任何人都愿意投入的努力更多。
^([7]) 路加福音 6:42
优秀团队合作的个人技能和特点
当没有人关心谁得到赞誉时,所能完成的事情真是令人惊讶。
--罗伯特·耶茨
当然,并非每个团队都是注定要失败的。现在,让我们看看如何理清这个混乱,如何正确行事。在本章的其余部分,我们将探讨提高你的软件开发团队的技术,并希望避免这些陷阱。虽然工具和技术确实有助于提高生产力,但最大的收益与人与人之间及其工作的人类方面有关。
每个软件团队都由个人组成。为了开始提高你团队的表现,你可以从身边开始——通过解决你对团队和联合开发工作的态度。我们并非都是管理者,所以这实际上是我们能有所影响的真正主要领域。
要成为一名高质量的程序员,你必须是一名高质量的团队成员。在我们可以考虑他的编程语言熟练度或设计能力之前,一个有效的团队成员必须发展一系列非技术技能、特点和态度。
沟通
没有沟通,团队合作就死了。各个部分不能作为一个整体移动,没有沟通。目标和愿景不能共享,没有沟通。项目确实因为缺乏良好的沟通而失败。
团队内部沟通以多种方式进行:个人工程师之间的对话,电话,会议,书面规范,电子邮件往来,报告和即时消息。有时我们甚至用图片进行沟通!每种媒介都有特定的使用动态,最适合特定类型的讨论。
最有效的沟通应该涉及(或至少对相关方可见)所有相关方。它应该足够详细,但不应消耗太多时间或精力。它应该在合适的媒介中进行——例如,设计决策应该记录在书面规范中,而不是口头达成并口头传播。
我们已经看到代码本身也是一种沟通形式。程序员必须能够良好地沟通。这需要良好的输入和输出——即能够:
-
编写明确的规范,清晰地描述想法,并保持简洁。
-
正确阅读和理解规范,仔细倾听,并理解你所被告知的内容。
除了团队内部沟通外,我们还必须考虑团队之间的沟通。在大多数公司中常见的糟糕沟通的典型例子存在于市场营销部门和工程师之间。如果市场营销部门不询问工程师什么可行,那么它就会销售公司无法制造的产品。这个问题是循环的:一旦发生并且人们受到了伤害,两个团队就很少再互相交谈(因为怨恨)。然后它就会一次又一次地发生。
关键概念
清晰的沟通渠道对于高效团队至关重要。它们必须建立并培养。优秀的程序员能够良好地沟通。
谦逊
这是我们的职业中经常缺乏的一种基本特征。
谦逊的程序员希望为团队做出贡献。他们不会偷懒,让其他人做所有的工作。他们不相信自己是唯一有才能做出有价值贡献的人。
你不能把所有的好工作都留给自己;一个人做所有的事情是不可能的。你必须愿意让其他团队成员做出贡献——即使是你想要做的事情。
你应该倾听并重视他人的意见。你的观点不是唯一的,解决方案也不是唯一的。你并不一定知道解决每个问题的唯一或最佳方式。倾听他人,尊重他们,重视他们的工作,并向他们学习。
处理冲突
我们必须现实:有些人无法避免互相挑拨。在这种情况下,我们必须在态度上成熟和负责任,并学会避免(或学会解决)冲突情况。冲突和敌意会严重降低团队的表现。
然而,利用和引导的冲突可以是团队合作的重大成功因素。相互刺激和挑衅的队友能产生最佳的设计。分歧可以作为精炼过程,确保想法的有效性。知道你的工作将受到批判性的审视,这让你保持专注。
保持这种冲突的建设性——在严格的专业水平上,而不是个人层面上。
沟通中断
在我们高度互联的世界中,有众多沟通方式,我们必须学会有效地使用它们来支持和促进团队互动。关键在于理解它们的特定动态、礼仪和个体优点。
电话
最好用于需要紧急响应的沟通,电话会打断你正在做的事情。因此,对于非紧急事项来说,被打扰是不方便的:请使用另一种方法。随着移动电话的使用,我们比以前更加互联;这既是祝福也是诅咒。
由于只有音频,你无法看到对方的脸部或他们的微妙肢体语言提示。在电话中误解某人并得出错误结论很容易。
太多的技术人员害怕使用电话。不要害怕:对于紧急沟通来说,它是无价的。
电子邮件
一种异步的、非带外通信媒介。你可以指定紧急程度,但电子邮件永远不会立即到达;它不是实时对话。它是一种丰富的媒介,允许你快速发送附件并在方便的时候回复。它通常用于向多个收件人发送备忘录式的广播。
你的电子邮件历史提供了一个相对持久的通信记录。
电子邮件是一种极其强大的沟通机制。
你必须学会将电子邮件作为一种工具来使用,而不是成为它的奴隶。不要每收到一封新邮件就打开;你的编码工作会被频繁打断,你的生产力会受到影响。指定阅读邮件的时间,并坚持下去。
即时消息
一种快速、对话式的媒介,比电子邮件需要更多的关注,但比电话更容易被忽视或置于一旁。它是一个有趣且有用的中间地带。
书面报告
书面报告比电子邮件沟通更少对话性,但更永久。书面报告和规范是正式文件(见第十九章)。
他们需要更长的时间来准备,因此更难误解。书面报告通常会被审查和同意,因此它们更具约束力。
会议
尽管有所有这些现代技术魔法,但面对面交流仍然是快速有效地解决问题的好方法。程序员们往往试图避免人际互动(我们天生就不是社交物种!),但会议在我们的团队合作中占有宝贵的位置。我们将在第 340 页的"面对你的命运"中更详细地探讨这一点。
学习与适应性
你必须不断地学习新的技术技能,但你也必须学会团队合作。这不是天生的礼物。一个新的团队必须学习如何一起工作,了解每个成员的反应,每个成员的优缺点,以及如何利用个人技能为团队带来好处(有关更多信息,请参阅第 341 页的"团队成长")。
爱默生写道:“我遇到的每一个人都在某种程度上比我优越。”看看你能从你的同伴那里得到什么。从他们所知道的东西中学习,了解他们的性格,了解他们的反应。学会与他们沟通。在所有层面上寻求他们的批评,从正式的代码审查到他们在谈话中提供的随意意见。
适应性与学习紧密相连。如果团队有一个开发者目前无法满足的需求,而且不可能引入外部资源,那么就需要找到解决方案。适应性的程序员能够快速学习新技能来填补空缺并为团队服务。
了解你的局限性
如果你承诺做你知道你做不到的工作,或者你发现你无法完成的工作,那么你应该尽快让你的经理知道这一点。否则,你将无法交付你的项目部分,整个团队将因此受到影响。
许多人认为承认无能是弱点的标志。不是的。承认你的局限性比成为团队中的失败点更好。一个好的经理会提供额外的资源来帮助你完成工作,在这个过程中,你将学会你之前缺乏的新技能。
团队合作原则
这些是关键团队信条,一旦融入你团队的 DNA 中,就会改变你编写软件的方式。它们将焦点从个人转移到软件及其协作开发。记住:为了让这些原则在你团队中有效,你必须有意识地朝着它们改变;不要只是同意它们是好的想法,然后继续像以前一样编码。
集体代码所有权
许多程序员对自己的工作非常具有领地性。这是自然的:编程是一个非常个人化和创造性的行为。当我们精心制作出一个优雅的模块时,我们不想让任何人践踏它,破坏这个杰作。那将是亵渎。
但有效的团队合作要求我们在进入软件工厂之前放下自我。不要抱怨“弗雷德篡改了我的代码。”这是一个团队努力的结果:代码不属于你,它属于团队。没有这种态度,每个程序员都会建立自己的帝国,而不是一个成功的软件系统。
关键概念
没有任何程序员拥有代码库的任何部分。团队中的每个人都能够访问整个代码库,并且可以适当地对其进行修改。
在这种文化下,团队保护自己免受小程序员国王的危险,每个程序员都统治着自己的代码岛屿。如果没有人被允许看到某个人的代码,当那个人离开项目时会发生什么?失去一个本地专家将严重损害团队。
对于你生产的代码感到一种家长式的责任感,保护它,并希望培育它,这并不错。但这一点必须与健康的团队焦点相结合。与其说是所有权,不如说是代码的监护权。监护人不拥有他们的责任,他们是受托代表所有者维护它们。监护人对代码的维护、除草和照顾边界负有主要责任。通常监护人是所有更改的执行者,尽管受信任的团队成员也可以进行更改,这些更改最终将由监护人验证。这是一种对代码的建设性方法,并且将对团队大有裨益。
尊重他人的代码
即使在没有代码所有权的开明开发文化中,你也必须仍然尊重他人的代码。不要随意对其进行篡改。这一点在他们在工作时尤其正确。你不能在另一个程序员的脚下改变某些东西;这会造成无法言喻的混乱。
尊重他人的代码意味着你应该尊重目前存在的呈现风格和设计选择。不要随意进行不适当的修改。尊重错误处理的方法。适当地注释你的更改。
避免制作那些你在代码审查中会感到尴尬的快速修复。这些修复在你需要快速让代码工作并且对其他地方进行微小调整时悄悄出现。如果你忘记整理这些调整,那么你就已经降低了别人的代码质量。即使是临时修改也必须表现出尊重。
代码规范
为了协作开发出合理的代码,你的团队必须有一套代码规范。这些规范规定了程序员必须遵循的代码标准,确保系统中的每一部分都达到一定的最低质量。
在代码布局上避免引起争论是很重要的(尽管所有代码遵循一种风格会更好)。然而,对于代码文档的标准和机制、语言使用和常用习语、接口创建的行为以及架构设计,必须达成共识。
即使没有这样的指南,仍然有一些团队:就像不成文的传统一样。这种隐含知识的问题是新团队成员的代码在他或她融入代码文化之前不会与现有代码库匹配。
定义成功
为了让他们觉得自己在取得成就,并且他们在一起工作得很好,团队成员需要一套明确的目标和目标。这必须不仅仅是项目计划中的里程碑,尽管里程碑可以是好的激励因素:定义许多小里程碑作为短期目标,并在达到它们时庆祝。
你必须定义成功的标准,这样团队就知道它是什么样子以及如何实现它。对你当前的项目来说,成功意味着什么?是按时交付工作,达到一定的质量,^([8])让客户满意,带来特定的收入,还是具有特定的错误数量?优先考虑这些因素,并让程序员知道他们开发工作的主要动机。这将改变他们的行为和做事方式。
定义责任
所有有效的团队都有一个明确的结构和清晰的责任。这并不意味着你的团队必须是一个毫无希望的等级制度,有着严格的等级秩序和多层管理。团队结构必须清晰且易于识别。它应该是清晰的:
-
谁对重要决策有最终决定权?谁负责预算,谁负责招聘/解雇决策,谁负责优先排序任务,谁批准设计,签署代码发布,管理进度,等等?这些不一定是团队内部的职位,但这些都是团队必须了解的职位。
-
如果项目是一个无法弥补的灾难,责任在哪里,谁的脑袋会滚?
-
成员的责任和问责制是什么?他们被分配了哪些个人权限,对他们有什么期望,他们要对谁负责?
避免疲劳
没有团队应该设定不可能实现的目标。对你即将着手的项目进行理智检查——知道失败不可避免是最缺乏激励的事情。
观察工作是如何在程序员之间分配的。避免将所有困难的工作或高风险的工作都分配给少数人。这是一个常见的错误,尤其是在团队培养程序员国王时。如果他们因为加班工作或担心错误的影响而筋疲力尽,他们将危及项目并打击团队的士气。
当团队表现良好且努力工作时,要向团队表示祝贺。公开地做这件事。不断向团队成员提供赞扬和鼓励。一些支持和热情是多么令人耳目一新。
打乱人们的职责;不要强迫某人反复做同一类任务,直到他们感到无聊并放弃。给每个人一个学习和培养新技能的机会。“改变就像休息一样好。”即使没有机会减缓发展步伐,一点多样性也可以防止程序员疲劳。
^([8]) 你将如何衡量这一点?
团队生命周期
聚在一起是开始,保持在一起是进步,一起工作是成功。
--亨利·福特
重要的是要从软件团队整个生命周期的角度看待它们。团队不是从地洞中突然出现的,它们也不会永远存在。
关键概念
成功的团队是有意培养和管理的;它们不是偶然发生的。
团队生活有四个不同的阶段:创建、成长、工作和关闭。在每个阶段,活动的重点都不同。有时你可能需要以不同的顺序迭代这些阶段几次,但每个团队都会经历每个阶段。主开发项目团队内的子团队将经历一个类似的过程;这是一个递归模型。我们将在下一节中详细探讨每个阶段。
团队创建
有一个新的项目即将到来。它需要一个开发团队。各就各位。准备。出发。由当权者任命一位领导者,他的责任是团结团队。成员可能来自其他团队或专门为这个项目雇佣。无论人们来自哪里,他们都必须作为一个有效的团队融合在一起——项目的成功(以及领导者的工作)取决于这一点!
所以一切从这里开始。组建确立了核心团队成员。在这个早期阶段,团队还没有开始认真工作,也没有真正团结起来。在团队形成过程中有许多重要的考虑因素:
-
您必须确定团队在组织食物链中的位置。它将与其他哪些团队进行交互?与他们建立沟通渠道,以便明确工作如何在部门之间流动以及联系人是谁。
仔细思考这一点,并尽量减少团队边界之间的沟通,使工作尽可能简单。在这个阶段,你可以设计你的团队,使其有最大的成功机会,通过消除不必要的官僚开销。
-
为了有效,团队需要具备能力、有才华的成员,他们有潜力成为一个高性能的单一单位。他们必须在需要之前涵盖所有关键的经验和专业知识;否则,在寻找另一个人时,开发将停滞不前。根据需要制定团队成长计划,并确定何时开始寻找更多的人。
-
选择并传达一个合适的团队合作模式;否则,团队将采用临时结构并采取混乱的工作实践。安排团队结构以消除管理开销和内部沟通路径,尽可能保持灵活。
接受命运的安排
被困在软件工厂的程序员很快就会对会议产生厌恶,仅仅是因为他们被迫参加无数次会议,所有这些会议都很糟糕。会议消耗了大量的宝贵时间,这些时间本可以用来编程以防止项目灾难。同样的几个观点被无休止地辩论,直到会议结束,然后每个人都忘记了说过的话,并在下次会议上重复一遍。
要有效管理软件团队,我们必须学会有效召开会议。这并不难;只需一点计划和纪律。以下是一个七点指南,以最大限度地提高会议效率:战斗规则。责任落在召集会议的人身上:
-
会议很重要且不可避免。当需要时不要回避召开会议。然而,当在走廊里进行非正式聊天就能更快解决问题且成本更低时,不要召开会议。
-
提前充分通知会议——几天,而不是几小时。邀请合适的人:不要人太少(因为决策者缺席,无法完成工作),也不要人太多(因为每个人都努力让别人听到自己的声音,无法完成工作)。
-
在合理的时间召开会议。不是在早上荒谬地早,那时只有一半的与会者是清醒的,也不是在一天结束时太晚,那时每个人都感到疲倦、沮丧,渴望回家。
-
设定严格的时间限制,并提前宣布。坚持到底。这样,与会者就知道他们一天中剩余的时间可以用来做其他工作。如果你超时,将业务推迟到另一次会议。
-
确保每个人都清楚会议的内容以及为什么被邀请参加。在会议通知中分发议程。确保需要提前准备的人知道他们预期的输入。
-
确保每个人都清楚会议在哪里举行。确保地点有适当的设施:白板、电脑,甚至足够的椅子(这听起来很傻,但经常被忽视)。
-
在会议开始前定义角色。你必须至少有:
主席
这个人负责主持会议,确保讨论围绕主题和议程进行。
他或她确保会议按时结束,并得出合适的解决方案(可能是指安排另一次会议)。
秘书
这个人负责记录会议纪要,之后整理并分发给适当的听众(这可能是比会议参与者更多的群体)。
决策者
这些人有权对每个问题做出最终决定。如果没有明确的权威,讨论就会无休止地循环,无法得出结论。
理解会议的目的。大多数会议要么是信息性的(为了传播信息;与会者主要是被动的听众),要么是冲突解决(为了解决紧迫的问题)。要举办有效的会议,每个人都必须理解这一点并采取适当的行动。个人议程可能会迅速将信息性会议引向随机方向;主席必须注意到这一点,并防止人们为了自己的目的而劫持会议。
当组建团队时的初步目标是创建一个不仅仅是群体的东西。你不需要另一群人或者一个小型的社交俱乐部;你需要一个团结、有动力且目标一致的团队。
除非你真的知道团队存在的目的,否则不要将团队聚集在一起。如果人们被要求开始工作,但实际上没有给他们任何任务,他们被留下等待进一步的指示,那么团队的长期精神将会是保守;将永远存在未被挖掘的潜力。如果团队不能从一开始就开展工作,那么不要将他们聚集在一起。
团队增长
在创建后,一旦团队配备了核心人员,项目开始获得动力。团队必须增长以适应增加的工作量。这有几个方面:团队必须在人数上增长,但也必须在经验和视野上增长。它必须内部增长和外部增长。
内部团队增长
随着他们一起工作,团队成员在个人和专业层面上相互了解。团队逐渐形成工作模式,并建立了一种编码文化。最初,这必须微妙地引导,以确保文化健康,并为团队结构和目标服务。这被汤姆·德马克称为凝固;个人成员凝固成一个团结的团队的时刻。(德马克 99)
这个阶段将个人和团队目标对齐,并确定个人角色和关系。团队此时的感觉为整个项目定下了基调,因此要警惕怀疑或恶意。
如果还没有提供,随着工作的进行,团队基础设施将被建立。如源代码控制和群件等工具被部署。项目规范被编写,目标得到巩固,工作范围被确定。
外部团队增长
外部增长意味着成员数量的增加。这是团队增长的可见形式。在其顶峰时期,团队包含以下所有角色。这些角色不一定是个人职位名称;这取决于团队的大小。在小团队中,个人成员可能承担一个以上的角色,无论是全职还是兼职。大型项目可能每个角色都有整个部门。
分析师
编程团队和客户之间的联络。分析师(也称为问题域专家)对现实世界问题有足够的了解,足以编写开发者可以实施的规范。
架构师
一个高级设计权威,根据分析师的要求设计系统结构。
数据库管理员
设计并部署项目的数据库基础设施。
设计师
在架构师之下工作,设计系统的组件。这通常是程序员工作的一部分。
程序员
自然是整个团队中最重要的那个人!
项目经理
对项目承担总体责任,做出关键决策。经理平衡相互竞争的项目力量(例如,预算、截止日期、需求、功能集和软件质量)。
项目管理员
支持经理,处理项目团队日常运营。
软件质量保证工程师
制定 QA 计划并确保生成的代码达到适当的标准。
用户教育者
编写产品手册,确保营销信息准确无误,制定培训计划,等等。
产品交付专家
也被称为发布工程师,负责规划如何打包、制造、分发和安装最终产品。
运维/支持工程师
一旦产品落入最终用户手中,就在现场支持该产品。
一个成功的项目必须确保所有这些活动都得到覆盖。在需求变得迫切之前,需要根据每个角色的需求提前引入人员。任命成员需要管理洞察力,包括候选人的性格类型、其技术技能以及工作要求。一旦团队建立,新成员必须适应工作实践,并补充现有团队成员。
团队合作
这是团队在所有成员到位时充分发挥功能时的性能点。齿轮转动,软件构建过程不断向前推进。
团队的大部分时间都花在这个阶段,确定项目的目标。为此,将单一的大任务分解成一系列较小的任务。团队成员被分配自己的工作包,并保持同步(可能通过项目会议或通过密切沟通)。他们的工作在完成时进行整合。慢慢地,软件开始成形。
尽管按照预定的开发流程工作,但团队必须适应出现的任何变化:处理意外问题、团队变化或可怕的“需求变动综合症”。随着工作的进展,每个成员都必须识别和管理未解决的问题和风险。
团队必须进入一个开发节奏——找到适当的工作速度,并在每个步骤中达到目标。然而,你必须防止这种节奏变成一个固定的模式。不要害怕打破工作实践——如果需要的话——以确保团队不会变得自满或懒惰,或者对抗可能危及进度的不称职团队成员。
团队关闭
最终,即使是延迟最久的项目也会结束。这个结束可能是让客户满意的软件;它可能是一个注定失败的产品和过早放弃的开发。无论如何,项目结束了,团队被从项目中移除。
从开发一开始,就必须有一个清晰的终点。没有团队可以永远继续或无限期地计划工作。完成的诱惑实际上激励了人们,许多程序员直到面对硬性截止日期之前不会投入太多努力。
因此,每个团队都必须在项目完成后计划解散、解散或转型为不同类型的团队(可能是维护或支持团队)。此计划必须涵盖正常和异常完成条件。
团队解散不会突然发生。项目不会没有警告就停止;它们会逐渐减少。我们通常会在人员成为多余时逐渐将他们从项目中移除。没有团队需要人们无所事事,消耗资源。随着每个成员离开团队,确保捕捉到他们所有的重要知识和工作成果。信息在分裂的团队之间泄露是很常见的。
关键概念
当团队成员离开团队时,不要丢失信息。进行交接,并从团队成员那里捕捉所有重要知识。包括所有*代码文档、测试工具和维修说明。
当一个团队完成一个项目后会发生什么?你可以采取以下步骤之一:
-
将团队转移到支持模式,维护产品。
-
开始一些新的开发工作(可能是相同软件的新版本)。
-
如果项目失败,进行事后分析。
-
将团队拆分以分别处理不同的项目(或者如果他们的合同到期,就让他们解散)。
团队是否被回收或解散是一个困难的选择,而且通常选择得很糟糕。一个团队在一个项目上成功并不意味着他们在下一个项目上也会成功。一个新的项目可能需要不同的技能组合或不同的开发方法。然而,保持一个优秀的团队在一起是明智的。具有合格成员和有效工作文化的团队是罕见的。不要无谓地抛弃它们。
当有选择时,应根据下一个项目的特点来做出选择。有时这个选择是为你做出的:在小型开发组织中,项目团队就是整个开发团队。简单地混合和匹配程序员是不可能的,你被迫在下一个项目中使用相同的人。
人力优势!
这里有一些简单的指南,用于管理和维护一支软件开发团队。没有程序员,就没有程序,因此我们需要一些技术来释放人们的潜力并帮助他们共同工作。即使你现在不在领导岗位上,你也可以将这些指南作为一个简单的标准来评判你的团队是如何运作的,以及人们是如何被对待的。它们将我们已看到的许多智慧浓缩成实用的、易于消化的片段。
使用更少但更好的人。
大型团队需要更多的沟通线路和管理,提供更多的潜在故障点,更难共享愿景。
根据能力分配任务,也要根据动机。
避免彼得原理。9 优秀的程序员不应该被提升到他们不适合或不感兴趣的经理职位。
投资于人。
如果你能在他们身上建立一些东西,你就能从他们那里得到更多。技术发展迅速;不要让他们技能过时。否则,他们会去其他地方获得更好的经验。
不要培养专家。
当一个程序员成为某个领域的唯一专家时,这是危险的。那个人成为了一个单点故障。10 有些人积极尝试成为程序员之王,而有些人则被迫成为,不允许从事其他工作。当你的专家需要新的挑战时,他会离开。你现在将如何维护软件呢?
选择互补的人才。
团队成员不可能都是世界级的专家。同样,他们也不可能都是缺乏经验的程序员。你需要一个健康的技能组合。你还需要一个健康的人际关系组合,拥有能够很好地融合和合作的人格特质。
消除失败。
不合适的人应该被移除。这并不容易,但一个腐烂的部分可以迅速破坏整个团队——拖延的后果可能是灾难性的(参见第 330 页的"Quicksand")。不要等待看事情会如何发展,或者只是希望他们会改善。处理这个问题。
团队成员是决定一个开发团队成败的关键。一个成功的组织会选择合适的人,并让每个人充分发挥其潜力。
9 来自劳伦斯·J·彼得博士的一个理论:成功的人被提升到他们最高水平的竞争力,一旦他们能够胜任那份工作,就会被提升一步——到他们不再胜任的水平。
10 项目经理们会开玩笑说一个项目的卡车号码:如果没有这个项目,有多少人可能会被卡车撞到。
简而言之
重要的是要认识到,这需要一支团队,团队应该得到成功和失败的认可。成功有很多人,失败则无人。
--菲利普·卡尔多
程序员真正关心的是编写好的代码,这一切都重要吗?是的:我们软件团队的健康和结构直接影响我们代码的健康和结构。它们是密不可分的。软件是由人编写的。正如软件组件必须相互配合、良好沟通并形成一个整体结构一样,构建它的程序员也必须如此。
良好的团队合作不仅仅来自一个定义良好的流程或固定的结构。良好的团队合作源于优秀的个人。俗话说,“整体大于部分之和”。当然,这只有在所有部分都正常工作时才成立。如果任何单个部分失败,整体就会受到影响。我们的个人态度会影响我们团队的质量,从而影响我们产生的代码。我们必须解决这些态度问题,以编写出好的代码。了解你的自然态度和反应将有助于提高你的编程技能。
一名专业的程序员必须能够团队合作。除了技术技能外,你必须能够创建一个能够融入更大拼图的作品。这意味着能够与他人沟通和合作。这意味着理解你的角色并适当执行,尽你所能工作。这意味着与其他团队成员合作,以团队为中心,而不是以自我为中心。
后续章节将进一步发展这些协作主题:我们将涵盖源代码控制、开发方法和估算与规划技术。
| 优秀的程序员... | 次要的程序员... |
|---|
|
-
对他们编写的代码没有领地意识
-
如果有助于推进软件系统,将执行任何类型的发展任务
-
在为团队做出贡献的同时学习和成长;他们有个人目标,但不会牺牲团队
-
是优秀的沟通者;他们总是倾听其他团队成员
-
谦逊,为团队服务,尊重和重视其他成员
|
-
努力建立代码帝国,使自己变得不可或缺
-
想做自己想做的事情,寻找最吸引人的任务
-
以牺牲团队效率为代价追求个人议程
-
总是想要坚持自己的个人观点
-
认为团队是为了服务他们,他们是团队中最好的成员——上帝赐予编程社区的礼物
|
参考内容
第十六章
优秀程序员的个人技能和特点。
第十八章
软件团队在代码上进行协作,没有源代码控制系统这几乎是无法实现的。
第二十二章
开发方法:团队如何互动和共同开发代码。

行动计划表
看看下面的行动计划表。花些时间填写它,并找出如何将你所学应用到实践中。

开始思考
这些问题的详细讨论可以在第 533 页的"附录 A"部分找到。
沉思
-
为什么要在团队中编写软件?与独自编写系统相比,真正的优势是什么?
-
描述良好和不良团队合作的明显迹象。良好团队合作的先决条件是什么,不良团队合作的特点是什么?
-
将软件团队合作与建筑隐喻进行比较(参见第 177 页的"我们真的在构建软件吗?")。这揭示了我们对团队合作的哪些见解?
-
外部或内部因素会对软件开发团队的效率造成最大的破坏?
-
团队规模如何影响团队动态?
-
你如何使团队免受不熟练成员造成的问题的影响?
个性化
-
你现在在哪种类型的团队中工作?它与第 322 至 332 页上的哪些刻板印象最相似?
-
这是不是有意为之的?
-
这是一个健康的团队吗?
-
需要更改吗?
你遇到了哪些因素阻碍了良好的团队合作?
如果你还没有这样做,请仔细填写上一页的行动表。确保你制定出改进团队的方法,并开始做出改变。
-
-
你是一个好的团队成员吗?你如何与队友更好地合作,并构建更好的软件?
-
在你当前团队中,软件工程师的职责具体是什么?
第十八章。实践安全的源代码
源代码控制与自我控制
高尚的人,在安全的环境中,不会忘记危险可能来临。在安全的状态下,他不会忘记毁灭的可能性。在一切有序时,他不会忘记混乱可能来临。因此,他本人不会受到威胁,他的国家及其所有部落都会得到保护。
--孔子
没有哪个大师珠宝匠会制作一条精美的钻石项链,然后把它放在一个未上锁的工作室里,任由路过的窃贼偷走。当汽车制造商将一款新车型推向市场时,他们不会立刻忘记如何支持和服务旧车型。这两者都会是专业(和财务)上的自杀,对有价值的工作持无所谓的态度。
我们编写的代码同样珍贵:我们投入了时间和精力,它不仅具有财务价值,而且情感上也非常重要。我们必须像保护其他珍贵物品一样保护源代码,并采取工作实践来确保我们不会破坏、危及或丢失它。
关键概念
代码是有价值的。请尊重并妥善对待它。
在本章中,我们将扮演监护人、保镖和看守的角色,制定出保护代码的基本技术,以确保我们的代码得到良好的保护。我们是在保护它免受谁(或什么)的侵害?以不同程度的戏剧性,我们正在与以下内容作斗争:
-
我们自己和我们的愚蠢错误
-
我们的队友和他们的愚蠢错误
-
协同开发过程中的固有问题
-
机械故障(爆炸的计算机和消失的硬盘)
-
想要利用软件的盗贼
你的理智、幸福,甚至你的生计都取决于本章的内容。那些在后面打瞌睡的人应该注意了!
我们的责任
作为负责任的软件工匠,我们必须对我们的工作负责。我们不仅要编写高质量的代码,还要确保我们的工作是:
安全和可靠
在三个月的开发后不会意外丢失,并且不能作为机密信息泄露到公司外部。
可访问性
适当的人可以轻松修改它。它对适当的人可见,而对其他人不可见。
可重复性
一旦发布,源代码就不会丢失或被丢弃。它仍然可以用来构建 10 年后仍然相同的应用程序镜像,当时工具版本已经改变,原始语言也不再受支持。
可维护性
这不仅包括使用良好的编程习惯,还要确保代码可以被整个编程团队修改。是否可以同时让多个程序员工作而不会导致灾难?在开发新产品版本的同时,是否可以修复和更新旧产品?
我们通过采用安全的发展实践来实现这些目标。在本章中,我们并不考虑我们正在运行的执行文件的安全性;^([1]) 我们关注的是我们的开发技术。这些问题可能看起来与编写代码的行为相去甚远,但我们不应低估它们的重要性。一门手艺不仅包括创造过程,还包括最终产品。
^([1]) 这在第十二章中有详细说明。
版本控制
为了团队成员能够协作进行代码开发,他们必须能够同时工作在代码库上。这并不像最初看起来那么简单——你必须确保并发代码修改不会相互干扰,并且在路上不会丢失任何工作。有一些低技术含量的代码协作方式:
-
最基本的是共享一台计算机并轮流编辑代码。两个程序员在没有争斗的情况下无法坐在同一把椅子上,因此不会有代码编辑冲突。然而,由于只有一个人可以同时编码,你将遭受巨大的生产力损失。
你可以在机器前放两把椅子,进行结对编程以获得潜在的生产力提升(参见第 319 页的"IT'S ALL GOING PAIR SHAPED")。但当三个、四个或更多的程序员都试图同时工作在相同的代码上时,这就不起作用了。
-
或者,你也可以将代码共享到网络文件服务器上。这样其他开发者可以看到源文件,甚至可以同时编辑它们。但这远非理想。代码虽然可以共享,但不安全,因为你无法阻止两个人同时编辑同一个文件。当他们都点击保存按钮时,这将会引起各种混乱——并且丢失工作。如果有人在构建过程中中途编辑了中央头文件,会发生什么?答案是:一个不一致的可执行文件,它可能会崩溃或以完全不可预测的方式运行。
因此,当编程团队从原始的数字汤中演变出来时,他们发明了源代码控制工具,作为源代码的中心存储库,提供访问权限,并管理源代码的并发修改。但即使你一个人工作,源代码控制也很重要;正如我们将看到的,中央代码库是一个极其有用的设施。
关键概念
源代码控制是软件开发的一个基本*工具。对于团队安全协作来说至关重要。
源代码控制允许一个人或多人以受控的方式在同一个代码库上工作,避免所有这些问题。它允许每个开发者创建(或检出)他或她自己的个人源代码库副本,并在隔离状态下工作。这个副本被称为沙盒,因为局部的代码更改无法逃逸去污染他人的工作。沙盒可以通过要求工具与存储库重新同步来更新其他用户的更改——当需要时。当完成时,更改将被提交(或检入)到主存储库,供其他开发者查看。
为了实现这一点,源代码控制系统将遵循两种访问模型之一:
严格锁定
一些系统通过文件预留机制物理上阻止用户同时编辑同一个文件,最初,沙盒中的所有文件都是只读的;你不能编辑它们。你必须告诉系统你想要编辑foo.c;它变为可写,直到你提交更改或未修改地释放文件,其他人无法编辑该文件。([5])
乐观锁定
更复杂的系统允许用户并发地编辑相同的文件。没有预留步骤,沙盒文件始终是可写的。更改在检入时合并。合并通常自动发生。偶尔,会发生冲突,开发者必须手动合并(这通常不是一个困难的任务)。这被称为乐观锁定(尽管实际上根本没有任何锁定)。
人们对于哪种操作模式最好持有热情的观点,并誓言坚持一种方法或另一种方法。对于广泛分布的开发者群体,通过互联网工作,并发修改效果最好。当人们更难以管理时,较低的过程障碍更可取;锁定文件进行修改可能会变得令人沮丧。
一个战争故事
糟糕的源代码管理可以与源代码控制结合产生痛苦的开发头痛。看似合理、简单的规则可能会意外地阻碍软件开发。
一家知名公司的一个大型项目有一个严格的锁定政策——所有代码检出都是独占的,防止开发者在同一时间修改相同的文件。不幸的是,编码政策规定所有 枚举 必须放在同一个源文件中。这个文件越变越大。
最终的结果不难预测:文件变成了检查出的瓶颈。开发者们不断地围绕着等待这个文件变得可用。
版本控制
源代码控制系统不仅保存每个文件的最新版本。仓库记录了每次提交时的更改。有了这些重要的版本信息,您可以获取文件在其整个开发历史中的任何版本。因此,我们也谈论 版本(或 修订 或 更改)控制系统。这是一件非常强大的武器:任何更改都可以完全撤销——您拥有一个代码时间机器!仓库的文件版本控制意味着您可以:
-
在历史的任何时刻撤销您所做的任何更改
-
在您工作时跟踪源代码的更改
-
查看谁更改了每个文件以及他们何时更改(甚至进行复杂的搜索以查看单个开发者对特定产品所做的全部工作——当开发跨越多年时很有用)
-
检出一个特定日期的仓库副本
控制狂
应该将哪些类型的文件放入源代码控制系统中?为了有效地管理和版本化您的软件,您必须将您的 整个 源树收集在一个仓库中。这包括:
-
所有源代码
-
构建脚手架
-
单元测试代码和任何测试框架
-
创建打包分发所需的其他任何资产(图形、数据文件、配置文件等)
最终目标是完全从仓库中执行整个构建。从仅包含构建工具链和源代码控制工具开始,您应该能够通过几个简单的步骤生成一个完整的产品(即检出仓库并输入 make)——无需提供更多文件或手动修改任何内容。如果您必须向源树添加其他内容,那么您的软件不是处于变更控制之下。
但为什么只到这里呢?我们可以扩展这个列表以使其更加详尽:
-
考虑将整个开发环境置于变更控制之下。将每个构建工具的更新检查入库,并保持这些文件与您软件的每个发布版本同步。
-
对所有文档进行版本控制:规范、发布说明、手册等。
所有良好的源代码控制系统都允许您创建命名的标签(或标记)并将它们应用到一组文件的特定版本。这允许您标记重要的仓库状态:您可以通过这个标签识别构成特定代码发布的所有文件,并在以后轻松检索它们。这在您正在开发产品版本 3 时,但一个重要的客户在第一版中发现了一个关键错误,您需要尽快获取代码时非常有用。
每次提交时,您都可以附加元数据:至少,对您所做更改的文本描述。通过使用这些消息,您可以通过查看文件的修订记录来获取开发概况。更复杂的工具允许您向文件修订添加任意元数据,包括故障报告、支持文档、测试数据等。
良好的源代码控制工具不仅对文件进行版本控制,也对目录进行版本控制。这使得您能够跟踪文件结构的修改,包括文件的创建、删除、移动和重命名。一些源代码控制工具按文件逐个记录更改;当您一次性提交多个文件时,每个文件都会单独进行版本控制。其他工具实现更改集:它们将每批文件更改记录为一个原子修改。这有助于您可视化一项工作如何同时影响多个文件。
访问控制
源代码仓库可以存储在您的计算机上或远程机器上,通过网络连接访问。通过适当的安全措施,它可以通过互联网由全球的开发者访问,从而减轻不同时区的开发者协调工作的负担。
源代码控制工具还控制哪些用户可以访问代码库的哪些部分。通过这种方式,您可以实施可见性规则和修改权限。项目构建大师通常负责管理源代码控制工具,分配这些访问权限,并确保仓库保持整洁。拥有指定的源代码管理员非常重要。如果所有开发人员都被赋予对仓库的管理员权限,这将鼓励他们随意摆弄它并做出粗心的管理更改。即使有最好的意图,事情也会出错。
与仓库一起工作
在受版本控制的仓库中开发代码有两种方式:
-
在少量多次的提交方法中,每次进行小更改时都会提交每个文件。因此,仓库包含每个文件的许多修订版本。这样做使得跟踪开发过程中所做的更改变得容易,并有助于您可视化文件在其生命周期中进行的所有修改。然而,您会看到文件修订的激增,这可能会造成混淆。
-
另一种方法(可能被称为大而少)是只提交重要更改:为产品的每个版本提交一个修订,或者在你成功将一个完整功能添加到代码模块时提交。这使得获取特定的先前版本代码变得更容易,但跟踪它所包含的所有个别更改则变得非常困难。
倾向于小而频繁的方法。存储库标签允许你标记每个主要里程碑,因此它不缺少其对应者的任何功能。
在提交代码修改时,你必须自律。你的工作会立即被每个其他开发者看到,所以首先彻底测试你的代码:不要提交任何会破坏构建或使自动化单元测试失败的代码。如果你的错误导致整个团队停滞不前,你不会受欢迎。许多团队对这种反社会的提交实施惩罚,以鼓励人们仔细工作。这并不是什么严重的事情——可能是通过电子邮件公开嘲笑或购买下一轮饮料。
关键概念
尊重存储库。永远不要提交会阻碍其他开发者的损坏代码。
将分支留给树
最强大的源代码控制功能之一是分支:一种在文件或一组文件上创建多个并行开发流机制。分支有许多应用,包括:
-
同时向代码库添加多个功能
-
为开发者提供一个个人工作空间,以便在不会破坏主代码库的情况下提交可能损坏的正在进行中的工作
-
在开发新版本的同时维护旧软件版本
假设你销售一个图像处理应用程序,并且需要添加一些新的绘图工具。你还想开始第二个开发工作,将代码移植到新的操作系统。这两个任务必须分别开始,但最终它们将合并在一起。这是一个常见的开发场景。对于每个任务,你都在存储库中创建一个分支,并将代码修订提交到分支上,而不是提交到主代码开发线。这使两个任务保持独立。一位开发者专注于绘图工具,另一位专注于移植工作。他们的工作不会相互干扰。
正如其名所示,分支在仓库中创建了一个并行文件修订的树状结构。总会有至少一条代码开发线称为主干(出于明显的原因)。图 18-1 展示了在实际中为特定文件创建分支的情况。它是在版本 1时创建的,最初在主干上(中心列)进行开发。在版本 2时,我们创建了第一个功能分支(用于新的绘图工具)并在其上执行了多次提交。这些都没有影响主干上的代码。主干上的工作继续并行进行,在版本 3时,为了适应移植任务,创建了第二个分支。

图 18-1. 在版本控制下分支一个项目
在分支上进行的作业可以被合并到任何其他分支或回滚到主分支。这意味着,例如,你可以在一个分支上尝试一些探索性的错误修复工作,当证明稳定后,你可以将其合并回主代码。如果它是一个开发死胡同,那么你可以放弃这个分支——对主分支没有任何影响。非常有用。在我们的例子中,第一个分支在版本 2.9时合并到主分支的版本 4。这导致主分支的版本 5。后来,第二个分支也被合并到主分支。
即使你不在代码库中同时开发功能,分支也可以被有效地应用于单线程开发。这种方案使主干版本保持稳定:始终是一个完整、经过测试的产品——可能是代码的最新发布版本。每个功能都在自己的功能(或发布)分支上开发,产品本身从这个分支发布。当完成时,我们将其合并回主干,并从那里创建一个新的分支以供下一个功能使用。这保持了主线代码不受可能损坏的正在进行的工作的影响,并将所有相关工作整理在开发分支上,而不是散布在主线旁边的大量其他功能开发工作中。
源代码控制简史
可用的源代码控制系统有很多,既有开源也有专有许可证。通常,源代码系统的选择是由公司惯例强制的。(“我们一直使用……,我们知道它是如何工作的。”)遗憾的是,这并不一定意味着它是正确或最好的工具。许多公司运行着遗留系统;将大量代码从一种源代码控制系统迁移到另一种系统的投资和复杂性是难以承受的。
所有版本控制系统的鼻祖是SCCS (源代码控制系统),它于 1972 年在贝尔实验室开发。随后被RCS (修订控制系统)所取代。在开源世界中,目前最常用的源代码控制工具是CVS (并发版本系统),尽管它已经开始显露出其年代感。CVS 最初建立在 RCS 之上,并引入了一个协作环境,使得多个开发者可以同时在一个文件上工作。而 RCS 实现了文件预留模型(在第 351 页的“严格锁定”中描述),CVS 则是并发的。CVS 的现代继任者是Subversion,它改进了 CVS 的大部分不足之处。
虽然它们在功能上存在细微的差异,但大多数源代码控制工具都具备命令行和 GUI 界面。它们都可以嵌入到流行的 IDE 中。如果你正在寻找一个源代码控制工具来开始使用在私人项目中,可以看看 Subversion 和可用的 GUI 前端之一。
^([2]) 因此,长时间锁定文件被认为是不良的做法——这可能会阻止其他程序员继续他们的工作。这是这种访问模型的一个固有局限性。
配置管理
软件配置管理是一个与源代码控制紧密相关的主题,尽管它经常被误认为是源代码控制。实际上,它是一个超越存储源代码的世界。
术语和定义
源代码控制是我们保护代码的主要武器。它是每个软件工匠都无法离开的必需工具。我们已经看到了用来描述它的各种名称。它们可以互换使用,但每个名称都揭示了其操作的一个特定方面:
源代码控制
也称为源代码管理,这是一种管理我们编写的代码文件的机制。它维护文件及其目录结构;它还协调对代码的并发访问和修改。
版本控制
也被称为修订控制或变更控制,这是一种记录你对文件所做的更改的源代码控制系统。它允许你检查、检索和比较文件在其整个开发历史中的任何版本。
版本控制通常适用于基于文本的文件格式——它们可以很容易地扫描差异——但你也可以对其他类型的文件进行版本控制:文档、图形文件等等。这本书的源文件存储在一个修订控制系统中,这样我就可以跟踪开发历史。
配置管理
在版本控制的基础上提供一种可靠的环境,以确保软件开发得到精心管理,并强制执行流程。
一些常用的源代码控制缩写是:SCMS(源代码管理系统)、VCS(版本控制系统)和 RCS(修订控制系统)。
我们已经看到源代码控制的目标是:
-
将源代码集中存储
-
提供你已对文件所做的历史记录
-
允许开发者在不干扰彼此工作的同时协作
-
允许开发者并行处理单独的任务,稍后合并他们的工作
配置管理建立在这样的基础上,以管理项目生命周期中的整个软件开发。它包括源代码控制,并为其使用添加了一个开发流程。软件配置管理正式定义为“在离散的时间点识别系统配置的学科,目的是系统地控制此配置的变化,并保持整个系统生命周期中此配置的完整性和可追溯性。”(Bersoff 等人 80)它控制项目的工件(你放入源代码控制中的东西)和其开发过程。
一些源代码控制工具提供配置管理功能,并且可以与项目工作流程工具集成;例如,管理故障报告和变更请求,跟踪它们的进度,并将它们与代码库中的物理更改联系起来。
配置管理涉及:
-
定义系统中的所有单个软件组件以及构建它们所需的工件(这在单个代码库可以配置为生成多个产品变体或可以针对多个平台时特别有用)。
-
管理产品的发布版本,以及每个发布版本包含的组件版本。
-
跟踪和报告代码及其组件的状态。它现在处于beta状态,还是现在是一个发布候选?(见第 140 页的"ALPHA, BETA, GAMMA ...")
-
管理正式的代码变更请求,跟踪哪些已被优先考虑并获得开发批准;将变更请求与必要的设计工作、调查、代码修改、测试和审查工作联系起来。
-
确定哪些文档与特定产品变体相关,以及需要什么样的编译环境。
-
验证软件组件的完整性和正确性。
你目前是如何管理代码库的配置的?
备份
这是一种古老而朴素的常识。备份是你的保险政策,可以防止文件意外删除、计算机系统故障,以及如果备份在异地,办公室失火时数据丢失。它们还不能治愈普通感冒,但可能有些有进取心的备份公司正在研究这个问题。
每个人都知道他们应该定期备份他们的工作。但我们都是人;仅仅因为执行一项任务既合理又明智,并不意味着我们一定会做——有更多紧迫(而且更有趣)的事情要做。事后诸葛亮是无用的:当你坐在你计算机的燃烧残骸中,硬件已经无法修复,所有数据都丢失在数字炼狱中时,你会诅咒你决定玩单人纸牌游戏而不是备份你的代码的那一天。几天的工作必须重写,虽然你会记住大部分内容,但第二次总是感觉更难、更乏味(当然,也更有损灵魂)。
想想看:你的所有源代码都有备份吗?当我发现有多少工作是在没有备份的计算机系统和工作站上完成的,我感到很害怕。风险水平是荒谬的。
关键概念
备份你的工作。不要等到灾难发生才考虑恢复策略。
你必须建立一个可靠的备份程序。不要依赖于手动备份计划,比如手动执行文件复制操作。总有一天你会忘记启动那个关键的备份,或者备份间隔太长,或者手动复制了错误的东西。记住墨菲定律(在第 5 页):如果可能出错,它就会出错。这对你所做的任何事情都加倍适用!相反,确保所有重要文件都放在一个正在备份的文件系统上。当使用未备份的工作站时,我会将我的代码保存在一个已备份的网络挂载文件服务器上,而不是不安全的本地磁盘上.^([3])
为了有用,备份必须:
-
定期完成
-
检查和审计
-
容易恢复
-
自动(既可以通过自动启动,也可以在没有干预的情况下运行)
关键的是,所有源代码仓库都必须保存在一个已备份的服务器上。否则,你只是把东西放在一个安全的地方,但没有关上门。事实上,“少量多次”的提交减少了个人计算机备份的依赖——你大部分的工作都提交到了一个已备份的仓库中。你工作站上的文件丢失对整个项目来说不会是关键。
核心问题是:除非在人为或机械故障的情况下可以恢复,否则你的工作是不安全的。即使它“只是”个人使用的代码,也要通过备份来保护它。在备份软件、额外存储和一点管理时间上的小投资是极其有价值的。失败的成本和麻烦远远超过了这笔微不足道的开销。
^([3]) 当然,这有一个权衡。这种简单的方法使得文件访问变慢,因为引入了网络延迟和文件服务器延迟。但我会忍受这种(通常)的小不便。
发布源代码
源代码有时需要摆脱你的严格控制,去探索广阔的世界。也许你销售的是一个库:你的发货产品就是源代码本身。也许你已经承包了与可执行文件一起发货的代码。即使你不想发布你的源代码,它也可能有一天被卖给新的所有者,或者你可能需要与外部人士合作开发新功能。在这些情况下,我们也必须采取合理的措施来确保代码的安全性和可访问性。
这种恐怖的规模取决于你代码的性质。专有源代码——专门为公司在产品内部使用而编写的——是受到严格保护的知识产权,通常认为公开发布它是商业自杀,因为你的竞争对手可以找到并利用它。与之相反的是开源或免费代码,专门编写以发布:可以自由查看和修改。软件发布的选项和性质在每个案例中都有所不同:
-
如果你正在发布一些封闭的专有代码,那么在让第三方看到它之前,你需要获得一份签署的保密协议(NDA)。这是一项标准的合同协议,旨在确保他们不会滥用、共享或以违反协议的方式使用代码。它是法律上具有约束力的,其主要目的是在技术团队忙于创建令人兴奋的软件的同时,让公司的律师远离。
如果你发布的人将利用代码以获取商业利益,你必须还强制执行一份许可协议,以确保你也能从中获利。这实际上涉及到市场营销或销售团队,普通的程序员无需担心这一方面的公司争斗。
-
开源开发者必须选择一个合适的许可证来规定用户可以对代码做什么,以及他们是否必须共享任何衍生作品。有关软件许可的更多信息,请参阅侧边栏。
在这两种情况下,你必须确保源文件是可展示的。代码必须完全是你的作品,或者你必须拥有所有非自己作品的分发权。这就是为什么很多旧的商业代码不能开源的原因:如果一家公司没有对其源代码的所有权,那么他们就不能在付出高昂的修改成本后自由发布。
为了站在稳固的法律基础上,确保每个源文件都包含一个版权声明,将其归因于正确的所有者(作者或公司)以及一个简短的关于其发布许可的描述。然后,如果有人找到这段代码,它显然是机密材料。有关文件头部注释的更多信息,请参阅第 83 页的“文件头部注释”。
警惕意外的源代码发布:防止您的可执行文件被轻易逆向工程。有时可以从分发的二进制文件中重建源代码。这在字节码编译语言(如 Java 和 C#)中是一个特别的问题。考虑混淆字节码;有一些工具可以为您完成这项工作。
无论我在哪里放置我的源代码
最后,考虑您放置源代码的地方。绝密公司工作不应留在未锁车的笔记本电脑上。同样,源代码也不应留在公开可访问的网络中。
确保您的登录密码保密。外部人员(或恶意同事)不应能够使用不适当的访问权限破坏工作。
许可证
软件许可证定义了用户对其拥有的权利。这适用于既包括二进制分发的程序,也包括创建它的源代码。大多数专有许可证撤回了复制、修改、出借、出租和在多台机器上使用等权利。另一方面,开源许可证努力保护您随意复制和分发软件的权利。
软件作者根据特定的目标和理念选择他们的许可证。确实,作者可以选择在多个许可证下发布软件,覆盖不同的使用模式,并允许不同的价格和支持模式。虽然有许多类型的源代码许可证,但只有少数被广泛使用。它们在以下方面有所不同:
允许的使用
许可的代码是否可以用于商业目的,或者它只能用于免费软件?真正的问题并不是赚钱,而是专有封闭产品是否可以在未经许可的情况下纳入您的作品。一些开源许可证要求用户发布使用他们的软件构建的任何代码。典型的商业许可证允许您做您想做的事情,只要您支付费用。
修改条款
如果您更改了代码,您必须发布这些更改吗?或者您可以在没有任何进一步义务的情况下分发派生作品?一些开源许可证被描述为“病毒性”的,因为您所做的任何更改都必须在相同的开源许可证下发布,同样,您使用它分发的任何代码也必须如此。
商业许可证是由公司律师起草的,以适应他们的不良目的(即,保护公司的商业投资)。然而,有许多常见的免费或开源许可证。"开源"这个术语是由开源促进会(OSI)提出的,这是一个认证软件许可证的组织。源代码的可用性不足以将产品描述为开源。它必须提供某些权利:允许免费修改和重新分发代码或任何修改,但必须将这些权利给予所有人,并且是不可撤销的。
开源软件与自由软件基金会关于“免费软件”的概念相冲突。FSF(GNU 项目的监护人)更加意识形态化,并推广那些“免费”的软件许可,这里的“免费”是指“言论自由”,而不仅仅是“啤酒免费”——这里的“免费”一词是法语“libre”的意思。OSI 接受一些“啤酒免费”的许可,这让 GNU 的忠实支持者并不喜欢。GNU 著名的许可协议是 GNU 通用公共许可证(GPL)和 GNU Lesser General Public License(LGPL)。后者是一个更加宽松的“库”版本,允许与专有代码链接。
简而言之
如果我们希望为未来的安全提供保障,我们必须尊重过去,怀疑现在。
--约瑟夫·朱贝特
代码的大小并不重要,重要的是你如何使用它。
在本章中,我们探讨了各种工作方法,以确保我们对所创建的源代码负责,以安全可控的方式进行开发。这些事情确实很重要;在错误的时间发生意外可能会对你的开发项目造成灾难。你必须保护你的关键任务代码库。
版本控制是我们安全开发代码的基本武器。它促进了团队协作,确保团队开发可预测且安全,有助于管理产品修订和配置,并作为所有开发工作的历史档案。它是一个开发安全带,没有它你的生活将会大大恶化。
| 好程序员…… | 坏程序员…… |
|---|
|
-
对他们的工作负责,并了解如何保护代码开发
-
谨慎使用版本控制,确保代码库始终保持一致和可用的状态
-
永远不要将损坏的代码提交到源代码控制
-
谨慎使用所有工具,目的是产生可维护、可访问的代码
|
-
在考虑他们代码的安全性和可访问性之前等待灾难发生
-
假设有人会为他们考虑备份和安全问题
-
不关心更新文档
-
不要考虑他们代码库中的代码状态——他们提交了有缺陷的代码,并为他人留下了清理的烂摊子
|

参考内容
第七章
我们用来有效开发软件的工具。
第十章
代码的可访问性影响构建的难易程度——无论是前沿代码库还是需要重新工作的历史版本。
第十二章
另一个安全问题是——运行程序内的安全问题,而不是在开发过程中的问题。
深思熟虑
这些问题的详细讨论可以在第 539 页的“附录 A”部分找到。
深思熟虑
-
你如何能够可靠地将源代码发布给其他人?
-
在两个仓库文件编辑模型(锁定文件检出或并发修改)中,哪一个更好?
-
分布式和单站点开发团队在版本控制系统要求上有什么不同?
-
选择源代码管理系统的一个合理的理由是什么?
-
在团队开发过程中,你如何将处于活跃开发中的前沿代码与稳定代码分开?
个人化
-
你们开发团队是否有效地使用了源代码控制?
-
你的当前工作是否已备份?备份对你们开发团队来说有多重要?备份是在何时进行的?
-
源代码存储在哪些计算机上?
第五部分。过程的一部分
编写高质量的软件不仅仅是产出好的代码。显然,好的代码有帮助。有一点。但还有更多。好的软件是有意创造的;它需要规划、远见和稳健的战斗计划。我们将在下一节中确切地看到这个战斗计划的样子。然而,在我们集结部队之前,我们必须知道他们应该做什么。指向同一个方向是有帮助的。
本节探讨开发过程中的某些特定部分,我们为帮助有意创造优秀代码而安排的额外活动。我们将看到:
第十九章
如何编写和阅读软件规范。正确记录你将做什么,以及你已经做了什么的方法。本章展示了规范如何使你的生活更轻松,而不是让你感到烦恼。
第二十章
关于代码审查的讨论——这是一个重要的实践,确保你正在编写高质量的代码。
第二十一章
软件时间尺度估计——规划过程中的一个基本活动,但仍然是软件开发社区中的一种神秘的黑魔法。本章破除了一些估计的迷思,并提供了实际的建议,以便在一线使用。
软件工厂无情的压力不断驱使我们更快、更努力地工作。唯一的应对方式是学习更聪明的工作方式。我们需要采用这些实践中的每一个,以在最终决战中有一线机会。
第十九章。具体化
编写软件规范
我从未知道任何麻烦是一小时的阅读不能缓解的。
--查尔斯·德·塞孔达
几乎所有值得使用的东西都有文档。你的 DVD 播放器有说明书。你的车有维护手册。合同有细小字。巧克力蛋糕有食谱。有书籍和杂志致力于人类所知的几乎所有追求。如果你的软件值得使用,它也应该有很好的文档。^[[1]
我们都知道我们给客户的经过仔细测试的软件需要文档。究竟需要多少文档是一个悬而未决的问题。办公套件的用户当然认为应该比出版商认为的更多。如果没有描述你的软件使用机制的说明书,无论它采取什么形式,人们都会错误地假设它能够做比它设计时要多的东西,或者用它来实现任何理智的程序员都不会想象的用途。
开发者在编码过程中同样容易犯同样的错误。就像最终软件产品需要文档一样,中间的开发步骤也需要文档。这是最终用户(通常)永远不会看到的文档。这些是程序将如何设计和构建的定义。这些是软件的规范。
编写和使用规范是实践程序员的重要技能。用英语(或任何其他自然语言)进行沟通与用代码进行沟通一样重要。²] 就像吃蔬菜和定期锻炼一样,规范对你的健康和软件都是有益的。然而,就像卷心菜和健身房一样,我们避免它们,感到内疚,然后后悔后果:我们最终得到的是不健康、松弛的软件开发。
传统的软件规范概念涉及一大块纸张,上面满是密集的文字、晦涩的表格和毫无意义的术语。这是一个非常令人沮丧的前景:一个需要比描述的代码更多维护努力的文档。开发者们永远生活在被迫与规范一起工作的恐惧之中。
但情况不必如此。如果正确使用,规范可以润滑开发过程。它们减少了开发风险,帮助你有效地工作,并使你的生活变得更加容易。在本章中,我们将探讨我们需要什么样的规范,它们应该包含什么内容,以及为什么现实与这个理想相差如此之大。
它们具体是什么?
用心去听教导,用耳朵去听知识的言语。
--箴言 23:12
规范是构成开发过程一部分的正式文件,提供内部软件文档。有许多不同类型的规范(我们很快就会看到),包含不同的信息,针对不同的受众。每一种都适合软件构建过程的特定阶段,从项目的构思到最终交付。我们使用它们来捕捉用户确切需要的内容(或者他们将要得到的内容,如果两者不同——通常是这样的),详细说明软件解决方案的架构、特定代码模块的接口、代码的设计和实现决策,等等。
规范可以帮助你更聪明地工作,并生产出更好的软件。但一个糟糕的规范可能会产生相反的效果。就像你的代码一样,软件规范的质量至关重要。好的规范和文档通常被视为理所当然,而差的规范会迅速变得令人厌恶;成为项目脖子上的一块磨石。
关键概念
软件规范的存在及其质量对于软件开发过程至关重要。
规范是团队间和团队内沟通的一种形式。我们已经看到,项目可能会因为缺乏沟通而失败。因此,我们应该利用规范作为沟通媒介——在适当的时候。(项目同样可能因为花太多时间写文档,而实际编写软件的时间不足而失败!)
随着项目规模的增加,规范变得越来越重要。这并不是因为规范在较小的项目中不重要,而是因为较大的项目有更多可以失去的东西——有更多的人缺乏沟通和协调,这将对软件开发过程的成果产生更大的负面影响。
关键概念
规范是软件开发者的重要沟通机制。使用它们来捕捉必须不丢失或遗忘的信息。
编写规范有助于使你的信息:
更安全
信息不是存储在人们的脑海中,那里可能会丢失、遗忘或错误地记住。将所有重要事实写下来,当人们离开项目时风险更小:信息损失将最小化,并且有一个坚实的基础来帮助任何接替的程序员快速上手。
详尽、完整的规范可以降低两个人做出不同假设的风险——这是两个分别创建的模块在首次集成时无法协同工作的经典原因。规范有助于防止微妙的错误。
可访问
所有信息都方便地记录在已知的位置。新加入项目的人只需阅读文档,就能了解每个组件的功能以及它们是如何协同工作的,无需在成为生产力之前在众多人的脑海中搜寻信息。
更准确
当所有信息都被收集和捕捉时,你更有可能发现问题,识别设计中的缺失部分,以及发现任何不幸的后果或副作用。你大脑中漂浮的几个零散的想法并不容易验证。
^([1]) 当然,这并不是制作糟糕界面的借口;它仍然必须易于使用且直观。
^([2]) 的确,迪杰斯特拉曾说过:“除了数学倾向之外,对本族语的良好掌握是合格程序员最重要的资产。”
规范的类型
每种类型的规范构成了软件流程中的一个中间关卡:开发过程不同部分之间的交接方法。例如,软件组件 API 的规范是由那些界定其功能和界面的团队编写的。程序员根据这个规范工作;它足够完整,可以实施所有代码。同样的规范是一份合同,详细说明了系统集成商如何将其整合到系统中,以及其他程序员如何使用它。它还描述了预期的行为,因此测试部门可以验证软件是否正确运行。
以这种方式,一个规格说明的输出自然地流入下一个内容的组成部分,在快速演变的软件中留下了一串文档。图 19-1 展示了这种文档轨迹的例子。我们看到,随着项目的成熟,生成了一系列自然的文档层次结构——每个子组件都有与整体项目相似的一组文档;其开发可以作为一个迷你项目来管理。
由于软件设计是一个迭代的过程,这不是信息单向流动(否则你将陷入瀑布方法的紧身衣——参见第 427 页的"瀑布模型")。随着你发现缺失的信息或需要调整软件设计,规格说明必须相应地进行更新。如果你的文档不可塑性和不可维护性,你的软件开发将受到影响。官僚主义的发展过程试图通过确保所有工作都按照规格说明执行来压制良好的软件开发,即使它已经 10 年之久了,并且完全过时。好的程序员认为他们的规格说明就像他们的代码一样可塑。

图 19-1.典型的规格说明文档轨迹
让我们来看看不同类型的软件规格说明,看看它们如何提升你的编码生活方式。不幸的是,在现实世界中,这些文档被许多人用不同的名字称呼。一个需求规格说明被不同的人称为用户需求规格说明和功能约束规格说明。
需求规格说明
如果在软件开发过程中发生崩溃,所有其他规格说明都消失了,那么这是你应该争取的文档之一。它是快乐软件开发游行中的领头羊,也是许多失败项目的绊脚石。这里的信息至关重要。它将让你保持理智。
项目的需求一开始通常是不清晰的;客户不能确切地告诉你他们的软件应该做什么(他们不是计算机专家,所以不知道)。这可能会引起各种问题,因此必须有一个单一的文档来明确指出你的软件应该做什么以及可接受实现的特性:需求规格说明。它详细列出(或者至少是适当的详细程度,这通常将是详细的)代码预期如何表现。它必须全面且明确地涵盖系统行为的重要、高风险、高价值领域。
需求通常被写成一系列编号的句子,每个句子包含一个单一的事实信息。例如:
1.3.5 用户界面应包含一个黑色矩形,其中以 13pt 的红色无衬线字体显示单词“不要慌张”。
对每个需求进行唯一编号可以在后续文档中轻松进行交叉引用,并帮助你将特定的设计或实现决策追溯到单个需求。
我们必须考虑:
功能需求
这些需求详细说明了程序必须执行的操作。例如:必须处理 BMP 图像并将它们转换为 JPEG 或 GIF 格式。
性能需求
这些需求显示了它必须工作的速度以及是否存在具有截止日期的操作。例如:用户必须在每一秒内收到每个操作的反馈,并且所有操作必须在五秒内完成。
互操作性需求
这些需求描述了它必须与之交互的其他软件、硬件和外部系统。例如:必须支持与升级服务器的 HTTP 和 RS232 通信。
未来操作需求
这些需求确定现在必须提供的功能,即使它们不是立即实现的。例如:必须提供可定制的用户界面,以便用户可以自定义外观和感觉。
这些需求分为两大类。离散需求是二元的。通过查看源代码可以轻松检查程序是否满足这些需求:将会有专门针对每个功能部分的代码块。你可以编写特定的测试来确保每个离散需求得到尊重。
非离散需求不太具体。仅通过检查源代码无法确定程序是否满足这些需求。这包括系统的所需容错性、服务器的所需正常运行时间、程序的平均故障间隔时间、其安全性或其可扩展性。这类需求可能非常重要,但验证起来却非常困难。
创建需求规范的过程会因公司而异,通常取决于项目特性和客户(他们的智慧和能力)。需求规范由市场营销团队、未来的产品焦点小组或业务分析师整理,其工作职责是理解问题领域并确定所需工作的范围。通常客户或客户的代表会参与其中。
客户必须同意并签署需求规范;它构成了软件开发者与其客户之间有效的合同。供应商同意交付一个功能符合这些要求的产品;客户同意为其付费。如果没有达成一致规范,客户可以随意拒绝产品,而开发者将白白浪费大量努力。遗憾的是,这是我多次看到的软件工厂中的常见问题,尤其是在客户不是技术专家且不知道一个好的软件解决方案是什么样子时。当请求的软件最终建成时,客户意识到它所要求的东西并不是它真正想要的:用粉色重写它。你回到了起点。这类事情经常发生;需求规范是你的保险政策。
很遗憾,许多软件开发工厂跳过了需求收集,或者没有给予它足够的重视。在软件设计开始之前,当然是在任何代码编写之前就达成一致意见是至关重要的。我们使用功能需求规范:
-
为了确保项目按计划并按时完成——通过防止(或至少减少)新功能的迟缓添加,这些新功能将推迟交付。
-
为了提高客户满意度——通过提前设定期望。
-
为了减少错误——通过限制功能蔓延,我们避免在最后时刻添加代码,这有助于避免令人恐惧的错误。
-
为了保持你的理智——没有需求规范,开发者会迅速失去头发。
根据你采用的开发方法类型,单一的整体需求规范可能在任何软件开发开始之前就编写好,或者它可能随着代码的编写而逐步开发。了解你的需求是如何从客户那里收集的,以及这如何影响你的代码开发方式。
关键概念
软件需求必须尽早捕捉,以设定期望,防止功能蔓延,并减少开发者的焦虑。
还要考虑你的开发需求:作为开发者,你必须拥有的东西,以便开发软件。例如,你可能需要一种特定的内部架构来提供足够的未来可扩展性,你需要版本控制来开发软件(这不是可选项)。其中一些可能合理地属于需求规范。
功能规范
可能是程序员最常使用的文档,功能规范描述了软件的可见行为。它源于并必须满足需求规范。一个项目中通常有几个功能规范:一个用于整体产品,然后为各个软件组件提供个别规范。
对于一个软件组件,功能规范包括对其公共接口的完整且明确的描述。这相当于列出模块 API 中的每个方法或函数,以及它们的用途和使用方法。它包含所有外部数据结构和格式的详细信息,以及所有对其他组件、工作包或规范的依赖。
这不仅仅是一份软件的用户指南。其中包含足够详细的说明,可以从它构建组件。两个团队可以阅读这份文档,并分别独立工作。尽管实现会有所不同,但两个组件的行为应该是相同的。
这个事实在实践中得到了利用:一些 NASA 的航天器使用五台计算机来完成一台计算机的工作;四台计算机独立开发并实施特定计算的任务规范。第五台计算机用于平均这四次计算的结果(或者决定是否有计算机与其他计算机有极大的差异)。
如果你正在编写没有功能规范的软件组件,首先自己编写一个。向所有相关方展示,以便他们可以同意你将构建的内容是足够的,这样在交付时他们就不会感到惊讶。
关键概念
如果你的软件任务没有充分说明,不要开始编码,直到你写出一个功能规范,并且人们同意它是正确的。
系统架构规范
架构规范描述了软件解决方案的整体形状和结构。它包括如下内容:
-
物理计算机布局。(是分布式客户端/服务器软件还是单用户桌面应用程序?)
-
软件组件化。(如何分割?我们需要编写哪些部分;哪些可以购买?)
-
并发性。(同时运行多少个线程?)
-
数据存储(包括数据库设计)。
-
系统架构的所有其他方面(冗余、通信通道等)。
在大量开发工作发生之前详细指定这些内容是很重要的。架构会影响开发的后期阶段;这里的错误或歧义将过滤到后期阶段,成为严重的缺陷。当然,没有什么是一成不变的:如果你发现架构规范中的缺陷,那么它必须得到修复,无论已经发生了多少工作。不要接受一个糟糕的架构规范作为你脖子上的磨石。然而,进行充分的架构设计是很重要的。我们在第十四章中详细讨论了软件架构。
用户界面规范
本文档包含有关用户界面的信息:它的外观和它将如何反应。这是我们向用户展示系统功能的方式。它可能描述了一个图形用户界面应用程序或基于 Web 的界面,一个可听电话菜单系统,一个盲文可访问性界面,或者一个简单的单 LED 显示屏。
有时用户的系统视图与光鲜亮丽的表面背后的实现非常不同。这里有两个例子:
-
一个高度网络化的系统可以部署在一个单独的盒子上,并隐藏在统一的用户界面后面。
-
可用的功能可以简化以方便使用或创建一个削减成本更低的版本。
UI 规范描述了界面约定和隐喻,并展示了用户如何看待功能交互。它由文本描述、图片和截图组成。它通常包含 UI 动作的故事板表示——每个 UI 状态的图片映射、其转换以及每个状态中显示的内容。它包括用户将看到的每个屏幕以及所有细节(即所有图形、字段、列表、按钮以及屏幕上的布局)。它还将详细说明每个操作的合理响应时间以及常见错误情况下的行为(这并不是详尽的——试图列举所有可能错误条件是一项实际上无休止的任务!)。
这项工作可能包括或导致一个用户界面原型。原型可以根据应用和测试审查的程度以不同详细程度和准确性制作;这取决于应用和将进行多少测试和审查。不可避免的是,在这个阶段,UI 设计是不完整的,但这是您第一次看到最终产品将是什么样子。虽然原型有助于设想界面将如何表现,但直到系统集成,UI 才能得到适当的审查和调整。
设计规范
设计规范(或技术规范)记录了一个组件的内部设计。它描述了功能规范将如何实现,或者已经如何实现。设计规范描述了所有内部 API、数据结构和格式。它应该详细说明所有关键算法、执行路径和线程交互。它描述了编程语言的选择以及构建代码所使用的工具。所有这些对于代码实现者和维护者来说都是关键信息。
许多重量级开发流程要求在实现之前生产设计规范;在编码开始之前进行审查,以防止工作走向死胡同。然而,在大多数软件开发工厂中,这个文档是与代码一起编写,或者是在代码编写之后。
这听起来是个好主意,但大多数设计规范都是浪费时间!它们需要持续维护以与所描述的代码保持同步。如果不加注意,它们会迅速腐烂,变得不准确和不完整——对粗心大意的读者来说可能是陷阱。因此,我建议您不要编写设计规范!
但等等,在您轻松出发之前,还有更多。用包含相同信息但更容易保持准确性的东西来替换它。文献化编程工具(参见第 66 页的"实用的自我文档方法")是一种优秀的文档机制,可以通过从代码本身生成文档来替代重量级的设计规范。您只需要在特别格式化的代码块中提供任何额外的注释。
关键概念
使用文献化编程工具编写您的技术文档。不要编写一个很快就会过时的文字处理文档。
您不需要完整的生产代码就可以以这种方式使用文献化文档工具。您可以用同样的方式记录您打算的代码结构:模拟一些代码,然后运行工具。这会自动生成设计文档,充当原型验证概念代码,并且经过精心设计,可以演变成生产代码。
测试规范
测试规范描述了特定软件的测试策略。它展示了如何验证实现与功能规范的一致性,以便您知道软件何时可以发布。当然,这个任务的大小和范围取决于正在测试的内容:是单个软件组件、整个子系统、桌面应用程序还是嵌入式消费产品。
测试规范包含必须执行的每个测试的列表。每个测试都在一个测试脚本中详细说明:一系列运行测试的简单步骤,以及其验收标准和测试将运行的环境。这些脚本本身可以写在单独的文档中,也可以包含在这个文档中。
正如我们在第八章中看到的,许多代码级别的测试可以在代码本身中执行,并作为开发过程的一部分自动运行。这些测试与只能通过在最终环境中运行软件并使用脚本化人工输入才能执行的高级测试截然不同。
无论何时你可以为你的软件创建程序单元测试,都优先这样做,而不是创建冗长的测试规范。正如设计规范可以迅速过时一样,在代码级别编写的测试规范也会随着系统的发展而腐化。使用程序测试代码作为你的测试策略的文档——你可以像编写可读的正常代码一样轻松地编写可读的测试代码。自动化的测试周期也将迫使你保持测试与代码的一致性;如果你不这样做,你的测试将会失败!
魔鬼的辩护士
规范是昂贵的:阅读和编写它们需要时间和精力。它们需要额外的工作。所有这些文件真的都是必要的吗?是的,它们是——为了编写高质量的软件,你需要有意识地生成所有这些信息,然后将其记录在可以检索的地方,以便在需要时使用。规范鼓励我们遵循良好的开发实践——跟踪需求、执行设计和构建测试计划——我们已经看到它们如何促进沟通。
敏捷流程(参见第 433 页的“敏捷方法”)对编写规范的关注远低于其他方面,但它们并不提倡凭直觉编码。由于规范不会自己编写,很容易过时,并且需要额外的工作来维护,而程序员已经有很多事情要做,因此只编写必要的文件是明智的。我们应该始终避免冗长的程序障碍。但是,你移除的任何规范都必须被等价的信息库所取代。除非你有意用同等质量且包含相同信息的东西来替换它,否则不要跳过规范。
极限编程不会产生冗长的需求规范,但它通过一组等价的用户故事来捕捉所有需求,这些故事卡堆叠在一起。设计规范被摒弃:代码本身就是其文档。
敏捷实践也促进了测试驱动的设计,其中编码的测试作为代码及其行为的额外文档。这个完整且清晰的单元测试套件可以替代单个组件的测试规范,但很少适合验证最终产品是否符合其验证标准。
规范应包含什么内容?
每种类型规范的 内容自然非常不同。然而,任何规范中的信息必须包括:
正确
这可能看起来很明显,但绝对是至关重要的。一个错误的规范可能导致数天的徒劳无功。它必须保持最新,否则会变得极其误导:它会浪费读者的时间,造成混乱,并可能导致引入错误作为后果。
如果一个规范可以有多种解释方式,那么这个“规范”就不具体——它没有完成它的任务。两个读者可能会对模糊的信息有不同的解读,这不可避免地会导致不幸的后果。确保你的规范只能按照你的意图来解释。
文本必须不自相矛盾。当规范变得相当大时,确保一致性变得困难。当维护者(不同于原始作者)进行修改时,这是一个特别的问题——很容易在某个地方更改信息,而不会更改任何后续引用相同信息的部分。
规范应该仔细编写,以确保符合所有相关标准(例如,语言定义和公司编码标准)。它应该遵循你公司的文档标准/惯例,并使用任何现有的文档模板。
可理解
一个有效的规范应该易于阅读和理解。对每个读者来说都是有意义的。如果它过于技术化,只有工程师才能理解,那么非技术部门(如市场和管理工作)就不会觉得自己是受众的一部分,也不会仔细查看。问题只有在太晚的时候才会被发现。
像好的代码一样,最好的规范是从读者的角度而不是作者的角度编写的。信息组织得让新手也能理解,而不是让作者方便。布莱兹·帕斯卡曾道歉说:“我让这封信比平时更长,因为我缺乏时间让它更短。”好的写作是简洁的,不会在文字的墙壁后面隐藏主要观点。这确实需要更多的工作,并且会花费更多的时间,但如果结果是更容易理解,那么这是值得的。
在规范中,没有必要写一大堆无聊的散文。考虑使用一些工具来压缩它,使其更容易阅读。项目符号和编号列表、图表、标题和副标题、表格以及适度的空白可以帮助打断流程,帮助读者在脑海中构建材料的思维导图。
完整
规范应该是自包含和完整的。这并不意味着它应该包含所有可能的信息;引用其他相关文档是完全可接受的,只要引用准确(考虑你在引用中的文档修订)并且允许读者轻松找到该文档。
规范的详细程度应该远低于实现的详细程度;否则,它要么过于具体,要么过于密集而难以理解。人们倾向于忽略复杂的规范,因此它们变得被遗弃。在角落里腐烂,它们只会让那些没有意识到它们不再是权威的人感到困惑。
可验证
软件组件接口的规范将导致两个东西的产生:软件实现和用于验证它的测试工具。因此,规范的内容必须是可验证的。在实践中,这主要等同于正确、明确和完整。
可修改性
没有什么是一成不变的,无论是代码还是文档。如果规范需要更新(可能是为了纠正事实错误),那么这应该是容易的。一个铁定的规范可以防止世界在你脚下改变。然而,如果规范是错误的,那就毫无用处。文档必须是可编辑的(即,你应该能够访问源文件,而不仅仅是 PDF 副本),并且其发布和更新程序不应过于繁琐。
为了便于修改,文档必须精心结构化,并且不要超过绝对必要的范围。
自我描述
每个规范至少必须包含:
-
一个封面,清楚地显示文档标题、副标题、作者、修订号、最后修改日期和文档发布状态(例如,公司机密、在 NDA 下外部提供,或公开发布)。
-
文档的简介,提供对其目标、范围和目标受众的简要概述。
-
读者为了理解内容所需的所有相关术语和定义。(但不要对读者居高临下:如果你的受众是软件工程师,不要解释 RAM 代表什么。)
-
一组参考其他相关或交叉引用的文档。
-
一个列出所有重要修改和修订信息的历史部分。
可追溯性
应该有一个文档控制程序(类似于源管理系统)和一个中央文件存储库,其中所有文档都驻留。每个规范的发布版本都应该存档在存储库中,并且必须保持可访问,这样你就可以发现一年前你正在工作的规范版本;总有一天你会再次需要它。考虑使用版本控制系统——这是一个为任何类型的文件进行版本管理的优秀工具。
文档封面包含控制信息(版本号、日期、作者等),以便你可以检查你是否拥有最新的副本。
关键概念
在编写规范时,思考其内容。选择一个受众能够理解的结构和词汇,并确保文档是正确、完整且自我描述的。
规范编写过程
没有付出努力写下的文字,通常也是没有乐趣去阅读的。
--塞缪尔·约翰逊
现在我们知道了必须生成的规范类型以及它们应该包含的内容,我们已经准备好了。是时候写点东西了!规范编写过程很简单:
-
选择合适的文档模板开始。这可能作为定义的项目开发过程的一部分提供。如果没有模板,可以基于现有的规范。
-
编写文档。好吧,这是难点。你写的内容自然取决于规范类型。
-
安排对文档进行审查。包括所有对该文档感兴趣的人。
-
一旦达成一致(如果你的流程要求,正式签署),将带有版本号的副本放入文档存储库,并发布给适当的受众。
-
如果有任何后续问题,提出规范变更请求,并确保你理解修改如何影响你的开发工作范围。如果不这样做,编码工作将翻倍,而没有人会注意到。
这是一个简单的列表程序,但做起来并不简单。很容易只关注第 2 步——我们跳过其余部分以简化生活。但没有这些其他行动,你没有创建一个正式可识别的文档;这可能会在以后造成问题。
在创作你的文学杰作时,请考虑以下规范编写指南。前几条与作者身份和你的艺术感相关:
-
写作通常在每份文档只有一个作者时效果最好。协调多个作者和适应不同的写作风格很难。如果你正在记录一个大型系统,那么将规范分成几个部分,并分别给每个人一份去单独工作。创建一个将这些部分联系在一起的伞状文档。
与一些观点相反,在规范的前面只署一个人的名字根本不是自大的。有人需要为它承担责任——当工作做得好时得到表扬,当工作做得不好时受到责备。
如果你显著扩展了别人的文档,不要因为自己被添加到作者名单而感到尴尬。但除非他的原始输入已被删除,否则不要从作者名单中删除任何人。
语言障碍
我讨厌定义。
——本杰明·迪斯雷利
非常仔细地编写你的规范文本。与代码相比,英语语言充满了歧义和复杂性。以下这些真正的报纸标题仅展示了看似简单的英语陈述可以有多大的歧义:“被盗的画作被树发现”,“孩子们制作营养小吃”,“红 tape 拦住了新桥”,“医院被7英尺高的医生起诉。”
规范是正式文档,它们不得闲聊或冗长;这往往会在一堵文字墙后隐藏重要的事实。非英语母语读者可能会感到困难。然而,简短的文档很难跟随。这是一个微妙的平衡,文档审查有助于确定正确的写作风格。
正式文档以第三人称、现在时态编写。准确选择词语非常重要。在互联网 RFC 文档#2119 中定义了一个有用的惯例。这为协议规范(在需求规范中也非常有用)定义了以下关键术语:
必须
词语“必须”(或“应当”或“必须”)意味着以下定义是规范的绝对要求。
不得
词语不得(或应不)表示规范中绝对禁止。
应该
使用应该(或形容词推荐)来表示一个可选的要求——可能被忽略的行为,但只有在充分理解其全部影响并经过仔细考虑的情况下。
不应该
使用不应该(或形容词不推荐)来描述应避免的特定行为,除非有合理的理由选择它——再次,必须完全理解后果。
可能
使用可能(或形容词可选)意味着一个项目确实是可选的。实现者可以选择支持它或忽略它,但当应用于协议时,它必须与做出不同选择的另一个实现进行互操作。
这是在人们写“可以”时应该经常使用的词。可以是规范和标准中常用词的错误用法;它是模糊的,并且根据读者的解释,可能会被理解为必须或可能。
-
作者必须是正确的人。市场营销部门不会编写你的功能规范;它提供需求。经理不会设计代码;具有正确技能和知识的开发者来做。作者必须能够写作——这是一种需要学习的技能,是一种需要锻炼的肌肉。
-
每个文档都必须有一个定义的所有者,该所有者对其负责。所有者可能不同于原始作者;现在主要作者已经离开,它可能是技术权威或文档的维护者。
这里有一些关于文档编写过程的提示:
-
有每种规范的最佳实践示例是好的。这将帮助作者了解他们在写作时应该期望什么。
-
规范的早期草稿应标记为草稿,并在免责声明中指出其不完整。这将防止人们错误地将其视为完整——他们现在还不能对你关于内容的内容抱怨()。在文档内部维护一个不完整部分和开放问题的列表。
-
文档审查很重要:它检查内容是否正确且表述良好。这是一个获取他人对你决定的同意并因此赋予文档权威的机制。这对于发送到项目外的规范尤为重要:发送给客户或其他部门。
-
一旦你完成了规范,不要忘记它。保持其活力并更新。当设计阶段结束时,功能规范并不完整。需求不可避免地会变化,我们继续更多地了解系统的操作。在修订规范中捕捉所有这些。
我们为什么不写规范?
我不明白我在做什么。对于我想做的事情,我没有做,但我讨厌的事情我却做了。
--罗马书 7:15
在现实世界中,良好的规范往往因为缺失而引人注目。我们知道避免它们不是好的做法,所以匆忙的开发者会忽略它们的缺失,假装没有问题。在没有适当的需求或功能规范的情况下接受编码任务并不罕见。(这是一个必须通过持续抱怨、教育和滥用权力来克服的程序问题。)
但粗心大意的程序员回避自己文档编写的情况也同样常见。为什么会出现这种情况呢?我们反复遇到一些借口。开发者不编写规范的原因是:
-
他们不知道他们应该
-
他们忘记了
-
他们没有时间
-
他们有意识地决定不这样做,认为没有它们也能应付过去(“毕竟,谁会阅读规范呢?”)
这些理由都是站不住脚的。经验丰富的开发者如果规范是他或她工作的预期成果,那么他或她不应该犯前两个错误。
程序员喜欢编程,而不是撰写冗长的文档。大多数程序员没有良好的写作技巧;他们能写出优雅的代码,但英语却糟糕。他们试图避免编写规范并不足为奇:这是一项艰苦的工作,没有趣味,或者他们只是不喜欢做这件事。通常,这被视为一种浪费时间且并非真正必要的行为。或者他们认为,“谁会阅读规范呢?”
这种令人沮丧的想法——“没有人会阅读我那美丽的规范”——让许多程序员对将他们的思维火花转化为文字的想法望而却步。这可能也是真的:可能没有其他灵魂会阅读你的文学杰作。但那又如何呢?编写规范的行为迫使你动脑:这是一个非常重要的步骤。当然,一些大师可以边编码边产生优秀的工作。但大多数程序员,无论他们是否承认,都无法这样做。我们需要设计。首先,仔细地设计。然后,应该将这种设计捕捉下来:在文档中。可能,这份文档将仅供你一人阅读。但,如果有一天你听到更高的召唤,跑去做克罗地亚的僧侣,维护程序员如何能接手你的工作?规范将超越你。把它看作是你的遗产。
没有时间是你无法控制的唯一场景:有时编码任务摆在你面前,你真的没有足够的时间为它编写良好的规范。如果你没有时间编写规范,那么你可能也没有时间正确地编写代码。确保你清楚何时在正确地做事,何时在匆忙地编写代码而没有真正的纪律——那种代码根本不适合在生产版本中发布。
通过避免规范来节省时间几乎肯定是一种错误的经济行为;规范有助于节省时间进行沟通。当你编写规范时,你只需要描述程序的工作方式一次。如果你跳过这一步,至少同样数量的沟通仍然会发生,但基于临时性——在更长的时间内以更不控制的方式。这种沟通效率远低于前者,实际上会花费更长的时间,因为你将不得不反复解释相同的事情,每次对不同的受众都有所不同。
关键概念
避免编写规范是危险且不专业的。如果没有足够的时间编写规范,可能也没有足够的时间编写代码。
当然,很少有人会为他们的个人项目详细编写文档。这是一个适当地详细文档的极端例子。任何合理规模的项目(可能由源文件、模块、开发者或客户数量决定)确实需要规范支持。
简要概述
词语当然是人类使用的最强大的药物。
--鲁德亚德·吉卜林
它们不是软件开发者生活中最迷人的部分,但规范是我们代码编写常规的重要组成部分。学会有效地阅读和编写它们——以记录正确信息在正确位置的方式,这样可以节省时间和麻烦。但不要成为官僚主义的奴隶。
| 精通编程的人…… | 不擅长编程的人…… |
|---|
|
-
理解规范的重要性,并使用它们来简化他们的开发生活
-
了解所需的适当文档水平
-
想要提高他们的写作技巧并寻求反馈和练习的机会
|
-
在没有考虑设计、文档或审查的情况下,一头扎进代码任务中
-
他们不会考虑他们正在写的文本;他们产生的文档结构不清晰,难以理解
-
避免编写文档,认为它无聊且无意义
|
参见
第四章
自文档代码是一种有效的技术,有助于消除一些代码文档。好的代码易于使用且直观,不需要冗长的手册。
第十八章
考虑变更控制和备份策略,因为它们与你的代码一样重要,需要保护。
第二十章
就像你的代码一样,你写的任何文档都应该经过审查,以确保其正确性和高质量。
第二十二章
规范是软件开发过程中的一个基本部分,通常是开发阶段之间的关卡。

开始思考
这些问题的详细讨论可以在第 544 页的“附录 A”部分找到。
仔细思考
-
一个糟糕的规范是否比没有规范要好?
-
一个好的规范需要有多详细?
-
在公司/项目中的所有文档都拥有统一的呈现风格是否很重要?
-
你应该如何存储文档?例如,你应该提供它们的索引(按类型或按项目)吗?
-
你应该如何进行规范审查?
-
自文档化的代码会使得所有规范都变得无用吗?具体是哪些?
-
如何让多个作者协作编写文档?
个人化
-
谁决定你文档的内容?
-
考虑你当前的项目。你是否有:
-
需求规范?
-
架构规范?
-
设计规范?
-
功能规范?
-
还有其他规范吗?
它们是最新版本的吗?它们是完整的吗?你知道如何获取最新版本吗?你能访问历史修订版吗?
-
-
你对文档进行版本控制吗?如果是,你是如何做的?
第二十章. 杀死审查
执行代码审查
审查有一个比自杀更好的优势:在自杀中,你伤害了自己;在审查中,你伤害了其他人。
——乔治·萧伯纳
你是如何学习成为一名优秀的木匠的?你成为了一名木匠的学徒。你观察大师的工作,每天帮助他,逐渐承担更多责任,并从他的建议中学习。你不会毫无实际能力地盲目跳入,并期望立即完成高质量的木工作品。
在编码世界中,我们没有这样的版本,尽管编程既是一门手艺,也是一门工程学科(可能更多)。一个好的程序员通过亲身体验,发现哪些在现实生活中有效,哪些无效。这是书籍无法教给你的东西,而且只有少数幸运的人能从导师那里学到这些。代码审查几乎是我们大多数人能接近的理想。
代码审查(也称为检查或走查)类似于开源软件开发模式——为其他人提供一个结构化的机会来审视你宝贵的代码,以及让你检查他人的工作。它们促进了知识交流。但它们的主要目标是提高软件质量。它们帮助你在大灾难发生之前发现错误。
代码审查还有另一个微妙的优势:它们鼓励你对自己的作品承担更大的责任。当你知道代码不仅仅是为了让你自己查看,而是会被其他人查看、使用、维护和批评时,你的态度往往会改变。你不太可能做出那种快速且草率的修复,因为你永远没有时间去修订。代码审查带来的责任感提高了你的编码质量。它们有助于建立第 336 页上描述的“集体代码所有权”文化。
听起来不错,不是吗?让我们揭开盖子看看它们是如何工作的……
什么是代码审查?
审查将源代码置于显微镜下——真正旨在批评和验证它。这并不是为了嘲笑或针对作者,而是为了提高团队生产的软件质量。这个过程通常会产生一个必须修复的问题列表(列表的大小反映了你的编程技能质量!)。有时你会注意到现在不值得做的改进;将这些发现归入未来的经验。
我们寻找错误以及任何可以改进的代码。代码审查在几个层面上排除问题:
-
整体设计(我们检查算法和外部接口的选择)。
-
那种设计在代码中的表达(将其分解为类和函数)。
-
每个语义块中的代码(我们检查每个类、函数和循环是否正确,是否遵循适当的语言习惯,以及是否是实际的选择)。
-
每个代码语句(每个都必须遵循项目编码标准和最佳实践)。
代码审查可以是:
个人
作者仔细而系统地审查自己的工作,以确保其质量。不要将这与其他随意阅读自己代码的行为混淆;个人代码审查是一个更详细和复杂的工作。
一对一
你带着另一位程序员走一遍你的代码。当你在引导过程中,这位程序员检查逻辑并寻找错误。这些审查往往是非正式的,由作者驱动。因此,代码是从作者的角度来处理的:基于他们的假设,而不是从更客观的外部视角。
正式
涉及其他程序员会带来新的专业知识、更多经验和更多关注,从而改变审查的视角。因此,大规模的审查更难协调,需要更大的总体努力,但更有可能找出问题。在个人审查中深入挖掘是困难的;通常作者与代码过于接近,很容易忽略缺陷。
这通常发生在正式会议上,但也可以作为虚拟审查进行:在线上,没有物理会议。
在开发过程中的不同时间可以使用不同类型的审查。一对一的审查可能在整个代码开发过程中每天使用,作为修改提交到主源树之前的集成审查。正式审查通常在代码开发的后期进行,作为最终的软件质量审计。
除了正确的代码带来的明显好处外,审查还有其他有用的副作用。通过查看彼此的代码而产生的交叉授粉确保了整个项目中的编码风格更加统一。审查还传播了关于核心代码内部工作原理的知识,因此当人们离开项目时,信息丢失的风险更小(这是一个非常现实的问题——参见第 343 页的"团队关闭")。
关键概念
代码审查是检测和消除难以发现的错误、提高代码质量、强制集体代码责任以及传播知识的优秀工具。
何时进行审查?
如果你没有受到批评,你可能没有做多少。
--唐纳德·H·拉姆斯菲尔德
在一个理想的世界里,每一块代码在发布之前都应该被仔细审查。根据卡内基梅隆大学软件工程研究所的数据,彻底的代码审查应该至少占用 50%或更多的编码时间(个人代码审查包含在这个统计数据中)。(Humphrey 98)这将比大多数现实世界项目愿意投入的时间更长。([6])
关键概念
当我们编写系统时,我们需要问是否要审查代码,如果是的话,具体要审查哪部分代码。
审查替代方案
有许多开发技术被争论为使正式代码审查变得多余。这些包括:
结对编程
当你在第 319 页描述的"IT'S ALL GOING PAIR SHAPED"中进行结对编程时,你的代码实际上是在实时被审查的。双倍的眼睛比一双更好,并且会发现许多许多更多的错误——因为它们被输入。然而,通过采用与实现工作在物理和情感上分离的审查者,代码审查可以捕捉到更多的问题。
开源
开放和自由地发布源代码允许任何人查看它,判断代码的质量,并修复问题。有些人称之为终极代码审查。然而,这实际上并不能保证任何人会检查源代码。只有真正受欢迎的开源项目才有积极维护的代码库。使你的代码开源不会立即带来类似代码审查的好处。
单元测试
这些方法是一种自动方式,表明修改没有降低你代码输出的正确性(参见第 144 页的"Look! No Hands!"),但它们并不能帮助提高代码语句的整体质量。你的代码可能是一团糟的意大利面,但如果它通过了单元测试,没有人会注意到。如果单元测试不够严格,错误仍然可能通过,无论怎样。
不进行代码审查
或者,你可以只是相信程序员能够正确地完成工作——毕竟这是他的工作。如果这是一个有效的策略,那么你甚至不需要测试代码。祝你好运!
这些方法本身都不能真正取代代码审查。也许将它们与特别有效的开发团队文化结合起来可以减少审查的必要性,但我还没有遇到过这样的情况。
是否进行代码审查
我们已经看到,错误是不可避免的,你可以确信你的代码中包含一些经典的错误。会有一些明显的缺陷你很快就能发现,还有许多更微妙的问题,只有一双没有先入为主的眼睛才能在审视代码时发现。原作者很难看到自己作品中的固有缺陷——因为他离代码太近,遭受着心理上的认知失调(参见 Weinberg 71)。如果你的代码有任何重要性(提示:它确实有,否则你不会写它),并且你关心其质量(提示:你确实关心,否则你就是一个耻辱),那么你必须进行代码审查。
不进行代码审查会大大增加错误滑入生产软件的机会。这可能会给你带来尴尬,大量的昂贵的返工和现场升级,在极端情况下,甚至可能导致你公司的财务崩溃。代码审查的努力与后果相比微不足道。根据 Humphrey 的说法,“学生在设计和编写代码时,每小时通常会注入 1 到 3 个缺陷,而在代码审查时每小时发现 6 到 12 个缺陷。”(Humphrey 97)
人们经常找借口来为避免审查辩护。他们说,“代码太大,无法全面审查,”或者“它太复杂;没有人能理解它——甚至尝试审查都没有意义。”如果一个项目能够调动足够的人时来编写一个大型程序,那么它也能找到足够的时间来审查它。如果代码太复杂,那么它迫切需要审查!实际上,它可能需要一些更彻底的措施。写得好的代码被分解成可以单独审查的自包含部分。
审查哪些代码
任何项目都会迅速产生大量的源代码。对于除了最严格的开发流程之外的所有情况,根本没有足够的时间来审查每一行代码。那么,你如何决定审查哪些部分呢?这并不容易。
你必须选择将最能从审查中受益的代码。这是最有可能出现错误或对系统正确运行至关重要的代码。你可以尝试以下策略:
-
选择核心组件中的核心代码片段。
-
运行分析器以查看 CPU 时间花费最多的地方,并审查这些代码部分。
-
运行复杂性分析工具,并审查最严重的违规代码。
-
针对已经显示出高错误数量的区域。
-
选择你不太信任的程序员编写的代码(代码审查的复仇!)。
最实际的方法可能是上述所有方法的混合。根据你对团队、代码库和当前系统特征(性能、错误数量等)的清醒评估,选择最佳的代码候选者。
关键概念
仔细选择你将要审查的代码。如果你不能审查所有内容,就明智地选择审查候选者。不要猜测——你可能会浪费宝贵的时间。
^([1]) 他们很少准备好在代码审查上投入任何时间,这是一个更严重的问题。
进行代码审查
我们坚持不懈做的事情会变得更容易,不是任务本身变得更容易,而是我们执行它的能力提高了。
--拉尔夫·瓦尔多·爱默生
仅仅进行代码审查是不够的。它本身并不能解决所有问题。你还需要确保你正确地进行审查。接下来的几节将描述如何做到这一点。
代码审查会议
最常见的审查设置(至少在高仪式的开发过程中)是正式的代码审查会议。有一个固定的议程(以确保不会忘记任何行动)和明确的结束(不一定是时间限制,而是确切地定义你要审查哪些代码,哪些不要审查——对此可能很容易产生混淆)。
下面描述了一个示例代码审查会议程序。
在哪里?
举行代码审查会议的最佳地点是在一个安静的房间里。审查者不应被打扰。应该有咖啡(以及那些必须喝的人的茶)。
一套联网的笔记本电脑和代码编辑器可能很有用,连接到投影仪的计算机也可能有用。老式的程序员发誓使用打印稿和笔纸笔记——脱离电脑屏幕可以帮助找到新的错误。这完全取决于你对树木和电力消耗的尊重程度。
何时?
显然,在双方都方便的时间。常识告诉我们,周五下午 5 点不是一个好时间。你需要投入大量时间,所以请确保你不会被打扰或分心。
如果代码太大,将审查分成几个单独的会议。你不能让人们连续几个小时坐在封闭的空间里,并期望他们的审查质量保持高水平。
角色和职责
代码审查会议成功的重要因素之一是参会人员。每位参会者应分配一个特定的角色;在小组中,人们可能会承担多个角色。这些角色包括:
作者
显然,编写代码的人应该参加审查,描述他们所做的工作,反驳不公平或不正确的批评,并听取(随后采取行动)有效的、建设性的反馈。
审查员
应仔细挑选审查员,选择有可用时间和技能进行审查的人。如果代码在其专业领域内或以某种方式参与其中,这会有所帮助。例如,库的编写者应被邀请审查使用该库的程序,以诊断错误的 API 使用。
应有适当数量的经验丰富的软件工程师在场。可能需要 QA 或测试部门的代表(见第 132 页的"QUALITY ASSURANCE"),以确保软件的质量和开发过程的质量。
主席
任何会议都需要一个主席,否则将会陷入混乱(见第 340 页的"MEETING YOUR FATE")。这个人领导审查并指导讨论。他或她确保对话保持主题,会议不会偏离方向。任何不需要在会议中讨论的次要问题应由主席迅速线下处理。如果给程序员一半的机会,他们可能会讨论一个微小的技术细节数小时,而牺牲其他代码审查。
秘书
秘书负责记录会议纪要。这意味着记录所有出现的要点,以确保在审查后不会遗漏任何内容。如果有审查清单(见第 398 页的示例),秘书需要填写它。秘书的角色不应由担任主席的同一个人来履行。
在到达之前,每个人都应熟悉代码。每个人都必须阅读支持性文档(任何相关的规范等)^([2]),并且必须了解任何项目编码标准。组织会议的人应在会议通知中强调这些文档,以防止误解。
会议议程
组织代码审查会议:
-
作者表示他们的代码已准备好接受审查。
-
主席安排会议(预订合适的地点、设定时间,并召集正确的审查员团队)。
-
安排所有必需的资源(电脑、投影仪、打印件等)。
-
会议必须提前足够的时间召开,以便审查员做好准备。
-
在会议通知后,作者不能随意更改代码——这对审查员来说是不公平的。
代码审查会议的流程如下:
-
主席安排在会议前准备房间,以便审查能准时开始。
-
作者花几分钟(不超过!)时间解释代码的目的以及其结构的一些内容。这应该是先验知识,但令人惊讶的是,在这个最初阶段可能会捕捉到许多误解。
-
结构设计评论被邀请。这些评论与实现的结构有关——而不是代码语句级别。这可能包括将功能分解为类、将代码拆分为文件以及函数编写的风格。(是否足够防御性,是否有良好的测试?)
-
欢迎一般性代码评论。这些评论可能涉及一致的错误编码风格、设计模式的错误应用或错误的语言习惯。
-
代码被仔细地逐行或逐块审查,以寻找缺陷。要留意的事项将在稍后描述(在“代码完美”第 395 页)。
-
考虑了代码使用的多个示例场景,并调查了控制流。如果存在完整的单元测试套件(应该有),那么这些测试详细说明了要探索的所有场景。这有助于审查员覆盖所有执行路径。
-
秘书记录所有需要更改的内容(记录文件名和行号)。
-
记录可能渗透到更广泛代码库的任何问题,以便进一步调查。
-
当审查完成后,应商定一个后续步骤。可能的场景包括:
好的
代码没有问题,不需要进一步的工作。
重做并验证
代码需要一些重做,但不需要另一次代码审查会议。主席提名某人担任验证者。重做完成后,验证者将检查它与代码审查会议记录的对比。
应为任何重做设定一个合理的截止日期,以便人们能够记住行动的细节和原因。
重做并重新审查
代码需要大量重做,并且认为有必要进行另一次代码审查。
记住,这里的目的是识别问题,而不是在会议期间修复它们。一些问题需要相当多的思考才能修复,这是审查结束后作者(或修改者)的工作。
在进行审查时,您可能会发现使用本章末尾的代码审查清单很有用。
集成审查
代码审查会议是一种高规格的审查方法。虽然工作量大,但无疑能发现许多否则可能被忽视的问题。
存在着其他不那么强烈的审查程序,它们提供了代码审查会议的大部分好处,但以更容易接受的方式呈现。可能最有效的是集成审查,它在新代码集成到主线代码分支时进行。
这可能发生在:
-
一段新代码即将被检查到源代码控制中
-
一段新的代码刚刚被检查到源代码控制中
-
一个代码包从功能开发分支合并到主发布分支
在这一点上,相关的代码被标记为需要审查,并选择了一位合适的审稿人:要么是负责该模块的人(代码集成者或维护者^([3])),要么是一个影子(或代码伙伴),被分配在一对一的审查会议中验证作者的工作。
这些门控代码检查通常使用与源代码控制系统集成的软件工具实现。手动安排它们相当困难,通常被作为检查纪律:你不应该在代码经过同行评审之前进行检查。这种方法很难执行;在匆忙的最后时刻检查中,错误可能会被忽略。
实际的审查步骤通常比前面描述的会议要正式得多。审稿人扫描代码以检查它没有明显错误,测试它(可能审查可用的单元测试以确保它们有效),然后授权将其包含在主线中。只有在那时,代码集成者才会将经过验证的代码迁移到发布树中。对于更严肃的项目或在更敏感的时间(例如,在重大发布里程碑之前)这个审查步骤可能会变得更加严格——需要更多的眼睛和更多的努力。
由于审稿人和作者实际上不需要面对面地见面(尽管这样做是首选),这可以被认为是一种虚拟审稿过程。
^([2]) 自然,所有支持性文档在之前都已经被彻底审查过了。
^([3]) 将其与开源项目的维护者进行比较,维护者收集其他黑客提交的补丁,并将它们集成到主源代码树中,定期发布软件更新。
审视你的态度
以你希望别人对你做的事情去做。
--路加福音 6:31
代码审查需要建设性的态度——你需要以正确的思维方式进行审查,否则将不会成功。这双向起作用:对作者和审稿人来说都是如此。
作者的态度
许多人因为害怕代码审查会暴露他们的不足而回避它。不要这样做。让代码被审查是学习新技术的良好方式。你必须谦虚到承认自己并不完美,并愿意接受他人的批评。随着你从对工作的修改中学习,你的编码风格将会改进。
疯狂中的方法
代码审查是一种普遍认可的技术,自从人们将程序打孔到卡片堆中以来就已经存在了。我们已经详细探讨了两种审查程序,但还有许多细微的变体。编程团队选择一种审查机制来适应他们的成员和工作性质。(差的团队甚至不做代码审查。)
这里还有两种其他常见的审查方法:
Fagan 审查
这是一个备受推崇的正式审查流程,正如本章所述,由 Michael Fagan 在其《无缺陷流程》中定义。(Fagan 76)Fagan 强调了审查能力的重要性,并展示了如何提高审查技能。Fagan 审查识别了工作产品和创建该过程的问题。
影子审查
这是在结对编程和代码审查之间的一个中间地带。每个代码模块都有一个主开发者负责代码。还分配了一个影子开发者;定期地,影子开发者会与主开发者一起审查模块。随着设计的巩固,影子开发者会验证所做的决策。随着代码的完善,影子开发者会审查进度并提供建设性的建议。
在更正式的场合,影子审查员被赋予批准代码发布的权限。没有任何模块可以在影子开发者同意它准备纳入发布构建之前被集成。
关键概念
没有人的代码是免于审查和同行审查的。积极邀请对你的代码进行审查。
作为作者,不要对你的代码进行防御。有一种自然的倾向是将所有的批评都视为针对个人的,并认为这是对你能力的攻击。为了应对代码审查,你需要减少自我和个人的骄傲。理解没有人的代码是完美的:即使是最好的程序员的代码在代码审查中也会因为一些繁琐的小问题而受到批评。
这是指由 Gerald M. Weinberg 在 1971 年的著作《计算机编程心理学》中描述的无我编程:这是一个永恒的描述,它揭示了使审查工作有效的关键态度。(Weinberg 71)那些不害怕自己代码中的错误或他人发现这些错误的程序员将能够生成更好、更安全、更正确的软件。愿意让他人帮助找出你工作中的错误是高级程序员的一个基本属性。
当你处于热点位置时,尽量不要浪费他人的时间。在你将代码提交审查之前,先自己进行一次模拟审查。想象你正在向其他人展示你的工作。你会惊讶于你将过滤出多少小缺陷,这将帮助你更有信心地进行真正的审查。不要匆忙提交半成品代码,并期望他人帮你审查缺陷。
审查者的态度
在审查代码和提出批评时,你必须敏感。评论必须始终是建设性的,而不是为了归咎。不要对作者进行人身攻击。外交和策略很重要。将你的评论针对代码,而不是程序员;更愿意说“代码这样做……”……而不是“你总是这样做……”……。
代码审查是一个同行过程:每个审查者都被视为平等。资历并不重要,所有观点都被考虑。有趣的是,即使是经验最少的程序员在代码审查中也会有一些值得提及的内容。正如作者可以从审查中学习一样,审查者也可以。
随着时间的推移,你将进行许多、许多次审查(特别是如果你进行集成审查)。小心你的审查过程不要变成一项枯燥的任务;很快它就会变成一个无效的浪费时间的行为。保持对代码审查的积极态度。作为审查员,在每次审查中都要尽力提出有用的意见。有时这很容易;有时很难说出有趣的话。但通过强迫自己做出评论,你不会陷入简单的审查套路,成为一个对过程毫无贡献的应声虫。
关键概念
代码审查的成功在很大程度上取决于作者和审查员采取积极的态度。审查的目的是共同改进代码,而不是分摊责任或证明实现决策的合理性。
代码完美
当完美到来时,不完美就会消失。
--哥林多前书 13:10
我们还没有考虑哪种类型的代码会通过审查,哪种代码会失败。描述良好代码的外观超出了本章的范围——本书的前 15 章描述了高质量代码的重要方面。当我们寻找不良代码设计和寻找软件错误时,有一些反复出现的主旨。审查的代码必须:
无错误
错误是我们的大敌,是良好软件开发的天敌。我们必须对我们的工作质量有信心,并需要在开发过程中尽早找出错误。我们越早尝试找出问题,就越有可能找到并修复,从而减少成本和麻烦(参见第 157 页的“失败的经济学”)。
正确
代码必须满足所有相关标准和其要求。确保所有变量都是正确的类型(例如,没有数值溢出的可能性)。注释必须完全准确。代码必须满足任何内存大小或性能要求(特别是对于嵌入式平台尤为重要)。检查库的使用是否适当,以及所有函数参数是否正确。
代码经过验证,符合其要求和功能规范。其规范的内容被认为是正确的;如果不是这样,那么这项任务将是艰巨的!有时代码审查的评论可能会反馈到规范中(例如,需要澄清的地方),但这不是我们的目标——在代码审查中不要偏离讨论规范是否错误的话题;秘书应在会议记录中记录问题,审查应继续进行。
完整
代码必须实现整个功能规范。它必须经过满意地集成和调试,并通过所有测试套件。测试套件必须全面。
结构良好
检查实现的设计是否合理,代码是否易于理解,以及是否存在重复或冗余代码。寻找任何明显的“剪切和粘贴”编程,例如。
可预测
必须没有不必要的复杂性,也没有意外的惊喜。代码不应该自我修改,不得依赖于魔法默认值,并且不得包含可能导致无限循环或递归的微妙机会。
健壮
代码具有防御性。尽可能保护可检测的运行时错误(除以零、数值越界错误等)。所有输入都应该进行检查(包括函数参数和程序输入)。代码处理所有错误条件,并且是异常安全的。所有适当的信号都被捕获。
数据检查
在 C 风格数组访问时执行边界检查。避免其他类似隐秘的数据访问错误。多线程代码正确使用互斥锁以防止竞争条件和死锁。检查所有系统/库调用的返回值。
可维护
程序员在注释的使用上很明智。代码处于正确的版本控制之下。存在适当的配置信息。代码格式符合内部标准。编译时安静无声,没有虚假的警告。
关键概念
如果你不知道好代码是什么样的,那么你就无法对别人的工作做出有效的判断。
代码审查之外
审查过程对于生产任何高质量的项目至关重要,因此它不仅仅对源代码开发有用。类似的审查过程用于规范文档、需求列表等。
简而言之
批评比正确更简单。
--本杰明·迪斯雷利
代码审查是软件开发过程中的一个重要部分,有助于我们保持代码的高质量。正如学徒从传授的知识中学习一门手艺一样,代码审查传播知识并教授编码能力。作为一种更侧重于同行之间的活动,它们为作者和审查者提供了学习的机会。
编写代码以便进行审查。记住,这不仅仅是为了你自己阅读;其他人也必须能够维护它。作者对其工作的质量始终负责。一个好的程序员更关心编写优秀的代码,而不是自己的骄傲。
| 优秀的程序员 . . . | 次等的程序员 . . . |
|---|
|
-
想要代码审查并且对自己的代码质量有信心
-
接受他人的意见并从中学习
-
能够敏感且准确地评论他人的代码
|
-
害怕代码审查,害怕他人的意见
-
对批评反应不佳;他们具有防御性且容易受到冒犯
-
使用审查来展示他们相对于能力较弱的程序员的优势;他们的评论过于严厉且缺乏建设性
|
看这里
第一章至第十五章
本书的前几章描述了良好代码的重要方面。
第九章
对你代码中可能存在的错误类型的描述。
第十九章
代码将与它的规范进行审阅。规范还要求进行仔细的审阅。

清单
许多审阅过程涉及一个清单——在审阅过程中检查良好(可接受)代码特性的集合。如果你的代码不符合这些标准,那么它就没有通过审阅。这些清单在细节、长度和主题上有所不同。
以下代码审阅清单是一个示例。你可以用它来帮助你指导审阅工作。与一些清单不同,它并没有系统地列出每种可能语言中每个可能的问题;它只是帮助指导审阅过程并确定何时继续到下一个审阅步骤。

开动脑筋
这些问题的详细讨论可以在第 547 页的"附录 A"部分找到。
沉思
-
需要的审阅者数量是否取决于被审阅的代码大小?
-
哪些工具对代码审阅有帮助?
-
你应该在运行源代码检查工具之前还是之后进行代码审阅?
-
代码审阅会议需要哪些准备工作?
-
你如何区分需要立即采取行动的审阅评论和那些可以在下一个项目中积累经验的评论?
-
如何进行虚拟审阅会议?
-
非正式代码审阅有多有用?
个人化
-
你的项目进行代码审阅吗?它进行足够的代码审阅吗?
-
你是否与任何被认为代码无需审阅的程序员合作?
-
你的代码中有多少百分比曾经接受过代码审阅?
第二十一章。一根绳子有多长?
软件时间尺度估计的黑魔法
我从不猜测。这是一个令人震惊的习惯——对逻辑能力具有破坏性。
--夏洛克·福尔摩斯(亚瑟·柯南·道尔爵士)
一根绳子有多长?或者,就我们的目的而言,一根绳子需要多长时间?这是一个简单的问题,但它并没有多少意义。
本章讨论的是软件时间尺度估计,这是专业程序员的重要技能。它是开发中的神秘黑魔法之一,更多地基于直觉而非科学,结果往往不准确。它很复杂,但软件开发过程中的一个基本部分,是每个程序员都必须学会做的事情。
软件工厂的规则必然受经济学的支配:资金的流动。时间估计很重要,因为软件开发的大部分成本是人力——程序员并不便宜。开发环境和硬件成本微不足道。为了制作一个软件产品,我们必须知道涉及的工作量,需要多少人去构建它,以及何时完成并准备好盈利。这告诉我们建设成本是多少。市场营销部门将预测销售收益。这两个预测将进行一场戏剧性的生死之战;财务分析师制定预算来评估项目是否具有财务可行性。
这是一件奇怪的事情,叫做“规划”,大多数程序员都不擅长。别担心:这就是为什么我们有经理。但如果你真的想玩得很好,你必须了解游戏规则。编写商业上成功的软件需要大量的预见性和规划。哦,还有钢铁般的神经。
为了构建一个开发计划,我们进行软件系统的高级设计,将其分解为组件,并估计每个组件编写所需的时间。很少有足够的时间来认真规划和设计每一个,所以这是一个非常粗略的科学。选择一个软件开发模型(参见第 425 页的“开发过程”),我们将估计汇总到一个计划中,分配给多个程序员,并使用这个计划来计算经济性。这个计划的质量显然基于时间估计的质量。灾难性的错误猜测可能导致财务灾难,所以这是一件重要的事情!
没有计划,你是在靠运气创造产品,而不是有目的地。估计是项目规划过程的一个组成部分——但这并不意味着它是项目规划者完成的!能够提供时间尺度信息的人是必须完成工作的程序员。那就是你!这是软件工厂生活中的商业现实的一部分。
黑暗中的一次刺杀
在任何公司,任何项目,任何时间点,软件时间尺度估计不过是基于教育的猜测——否则它们就不是估计了。猜测听起来并不太专业,对吧?但这是你能做的最好的:直到任务完成,你永远不会确切知道它需要多长时间,那时通常为时已晚,信息已经没有用了。^([1)]
估计的质量主要取决于你对被估计任务的了解程度。也就是说,你真正了解的程度,而不是你认为你了解的程度。这也取决于你有多少时间来创建估计,因此你可以在现实设计努力或可行性审查中投入多少努力。有了非常精确的规范,你可以在短时间内做出估计;有了模糊的规范,可能需要很长时间。一个合理、有根据的估计可能需要几个原型来调查实施选择——不同的选项可能会有截然不同的时间后果和固有的风险水平。
如果没有足够的时间来做这件事,你需要制定一个最坏情况的数字,开发工作不应超过这个数字。你投入的时间尺度估计的努力越少,你对这个数字的信心就越少,现实与估计之间的差异就越大。开发可能需要估计的一半时间,整个期限,甚至更糟——可能需要更多的时间。我们通过在开发计划中构建应急计划来管理这种风险,以平衡风险区域。你提供多少应急计划?你必须猜测!我们稍后会讨论这个问题。
关键概念
软件时间尺度估计需要有根据的猜测。每个估计都应该伴随着你对它的信心度量。
虽然好的估计是有理有据的,但糟糕的估计不过是黑暗中的一击。这是一个标准的工程问题,需要敏锐和灵活的管理。这已经是一个持续了数百年的工程问题。2 经理和规划者处理整个项目的估计。这特别困难。我们只看看估计单个编程工作。幸运的是,这并不特别困难,只是真的困难。
^([1]) 当然,除了作为基于未来估计的经验之外。
^([2]) 对于一个圣经上的例子,请参阅路加福音第十四章第二十八部分!
为什么估计如此困难?
我住在英国剑桥;我的家人住在布里斯托尔。软件时间尺度估计就像估计我拜访他们需要多长时间一样。如果有一个强风和没有交通,我可以告诉你开车需要多长时间。但是如果有道路施工或交通堵塞,如果我的车坏了,我出发晚了,或者我在高峰时段旅行,那么这个估计就变得不太可靠了。预见一些这些问题,我会承诺一个可能的到达时间窗口。我知道最佳行程时间;我对最坏情况有一个想法(我有过一些噩梦般的旅行)。我可以在两者之间判断一个预期的到达时间。然而,我永远无法完全考虑到不可预见的情况——如果我的车坏了,我就被困住了。在这种情况下,手机很有帮助:如果我会迟到,我可以打电话告诉我的家人把饭加热(最好是不要放在狗的碗里)。
软件开发过程遵循类似的模式。在规划软件时,需要考虑到可预见的潜在问题,管理第三方依赖,以及应对不可预见情况的需要。你可以为工作的一部分给出最佳情况下的开发时间,并需要考虑最坏情况的时间。当然,错误估计的影响不仅仅是家庭宠物的晚餐——它关系到项目的成功或失败,甚至可能影响你公司的偿付能力。
最薄弱的环节
不可预见的问题可能会在意想不到的地方让你陷入困境。最近,我的链接器无法处理我生成的可执行映像的大小,我需要离开去修复链接器,才能运行我的代码。开发时间超过了最初估计的三倍。
这开始让我们看到为什么估计开发任务的长度是如此困难且至关重要。有许多因素在共同作用,使得这项任务变得棘手:
-
有许多变量需要考虑。它们伴随着问题的固有复杂性、你的代码设计的影响以及必须适应的现有软件生态系统。其中一些变量可能每天都会发生变化。
-
需求会在你脚下发生变化,导致软件范围扩大。随着项目可行性的调查,新的问题和用户级需求以惊人的速度被挖掘出来。这使得估计工作变得复杂——你必须努力跟上这一切(参见第 371 页的"需求规范",了解如何管理这些策略)。
-
如果不知道所有涉及的工作,你无法给出准确的估计。也许你需要重构现有库,因为它们提供的功能不足,或者重构以实现现有代码的安全扩展。如果你还没有发现这一点,那么你的估计将会太低。
-
很少有项目是从一张白纸开始的。在估计工作需要多长时间之前,你必须了解现有系统。在提交估计之前,你很少有时间正确地完成这项工作。
-
如果这项任务是一项以前从未尝试过的事情,那么确定它需要多长时间会更困难。你没有先前的经验可以据此进行估计。
-
许多项目依赖于第三方,而这些依赖关系可能会变得非常糟糕。依赖关系的来源可能是操作系统供应商、一个小但重要的代码库、外部规范,甚至是客户。你无法控制第三方交付;你的估计依赖于它按时交付。这增加了延迟的风险,必须仔细监控。
估计是困难的。但这并不能免除我们的责任。我们必须考虑到真正可预见的事情:就像道路施工或恶劣天气一样,我们可以合理地预期一些这些陷阱。你需要找到悲观、乐观以及——在中间某个地方——现实之间的正确平衡。
关键概念
创建时间表估算是一项真正的困难任务。不要低估所涉及的工作量。欣赏做出糟糕估算的后果。
故事还没有结束:不仅仅是做出估算很难。承受后果可能同样痛苦。
-
估算变成了合同,用于与客户设定交付时间表。一旦确定,这些日期就很难变动,一旦出错代价高昂。
-
按照别人的估算工作很难——如果你错过了截止日期,是不是因为你没有完成任务,或者估算错误?
-
在开发过程中经常会发现新的任务,需要对其进行核算并安排到时间表中,这会将其他所有事情都推后。同样,你只有在开发工作真正开始后才会发现规格问题。这些规格变更将影响所需的工作量,因此也会影响时间估算。
-
总是会有意料之外的问题。你可以通过稍微努力一些来保持进度,从而吸收小问题的冲击。这个月你不需要睡觉,对吧?但是大问题会带来大量的额外工作,并导致进度混乱。
-
估算只是又一项责任:你不仅要对创建的代码负责,确保它是优秀的、设计良好的、可维护的代码,还要按照你承诺的时间表交付它。可怜的程序员们啊!
压力之下
软件工厂不是一个合理的地方,给出乐观估算的诱惑很强。对于估算游戏新手来说,他们尤其容易受到压力。上面有压力要求承诺短时间表,以便我们可以赢得合同、宣布新版本、维持内部政治稳定等等。这是一个可以理解的、令人悲伤的现实;没有公司是孤立存在的,股东们希望得到鱼子酱和香槟。
但压力并不完全来自上面。它也来自程序员个人的骄傲。技术人员喜欢承诺乐观的时间表;我们是一群自豪于我们交付的内容和速度的人。认为“哦,这不应该花太多时间”是很诱人的。但是,快速代码修改或原型工作与完整、生产就绪的工作之间有很大的区别。我们的时间表必须基于现实,而不是基于希望的理想。
关键概念
每个人都(包括你)都希望更短的开发时间表。不要自欺欺人,认为在给定的时间内技术上可能实现什么。当你必须交付生产代码时,不要承诺快速修改的时间表。
我们必须意识到这种压力,并谨慎地做出反应。小心极端相反反应的危险。很容易成为一个悲观的末日预言家,想象任务会无限期地持续下去,并用愚蠢的大时间尺度估算来补偿。高估的真正危险是项目不可避免地会扩展以适应可用的时间!当有几天空闲时,你总会找到一些代码可以润色。
在一个理想的世界里,项目截止日期是在可行性审查之后确定的,该审查证明项目在合理的时间内是可行的。现实世界很少是这样的。相反,你会被给予一个截止日期(“圣诞节前发货”),然后你必须想出如何实现。如果工作不合适,你必须协商如何到达那里:删除功能、增加程序员、外包风险部分,或者可能提供带有更多功能的后续升级。有时这种规划更像是一项营销活动,而且相当有创意!
没有人说过这应该很容易。
一个战争故事
公司刚刚接到了其五年历史中最大、最重要的订单。这个订单是成败的关键。销售部门为了关闭这笔交易而奋力争取,同意了一个严格的客户截止日期:软件必须在年底前发货。合同签署后,每个人都互相拍手称赞。
但没有人有时间(或智慧)与技术人员协商以确保项目可行。它并不可行。经理们开始恐慌,但由于截止日期固定且功能集不变,他们能做的事情并不多。工程师们抱怨并挥舞着他们的项目计划,但被告知“只需让它适应。”他们日复一日地努力工作,深夜仍在工作,很快就筋疲力尽。每周他们都发现自己离那绝望乐观的进度越来越远。
在最后的巨大努力中,他们按期完成了代码,但随后被一个未预见的硬件问题绊倒,导致项目延期两个月。计划中没有任何应对这种灾难的应急措施。
项目失败了,工程师们疲惫不堪,神经紧张,客户也不满意。在下一个项目开始不久,大部分开发团队就辞职了。
估算的实际方法
随着对预言家和程序员的双重压力日益增加,我们如何满足期望?估算,就像许多其他技能一样,是你通过经验变得更好的东西。这不是老年人的游戏,但如果你不与进度和目标为背景工作,那么你在技能上就不会有所增长。熟能生巧。
在现实世界中,我们很少有机会进行实践项目或沙盒来实验时间估算。在从初级程序员到大师的道路上,你必须掌握这项技能!遗憾的是,没有神奇的公式或简单的食谱可以用来得出估算。但遵循这些简单的步骤将极大地提高你的准确性:
-
将任务分解成可能的最小块,有效地进行系统设计的第一遍。
-
当你达到适当的分辨率并且部分内容易于理解时,为每个块提供一个以人时或人日为单位的时间估算。
-
一旦你估算出所有个别的时间范围,将它们并排放置,加总它们的持续时间, voilà:立即得到时间估算。
这种策略有效,因为你可以更容易地完全理解并准确估计一系列较小的活动,而不是一个庞大的任务。估算不应使用大于人日的大单位:这样的大型任务表明你还没有真正理解问题;你的估算根本不可靠。无情地将大型任务分解,直到你得到细粒度——可估算的——工作单元。
关键概念
时间估算应针对小任务,这些任务的单独范围易于理解。测量应以人时或人日为单位。
当然,开发工作通常可以在人们之间并行化;通过将其分解成小的、易于理解的部分,我们可以调整任务,并找出如何并行运行它们,从而提前完成日期。这成为一个项目规划问题。
设定合理的时间来做出估算。所需的高级设计不是立即的;不要假设时间可以轻易猜测。如果你没有基于先前经验或系统设计的依据,仅凭直觉做出估算,你只会欺骗自己。
考虑到将交付软件所需的所有活动至关重要。这意味着包括以下时间:
-
进行充分的深思熟虑的设计
-
所需的任何探索性工作或原型设计
-
实际的代码实现工作
-
调试
-
编写单元测试
-
集成测试
-
编写文档
-
你将在该期间进行的任何研究或培训
这个列表显示,与其他外围活动相比,编写代码所花费的时间比你预期的要少。编程不仅仅是编写代码;不要忘记在你的时间估算中包括测试和文档。它们是必不可少的。没有测试和文档,你将交付无法正常工作且无法修复的代码,因为没有人知道如何使用它。
不要试图计算经过时间(通过结合其他项目的干扰、阅读电子邮件、浏览网页、喝咖啡和回应生理需求)。这不可避免地会与实际花费在任务上的时间有很大不同。任务可能与另一个任务同时进行,或者被中断以腾出空间给另一个项目。我们在项目计划中处理这个问题(在 409 页的"The Planning Game"中描述)。
你的估算应该有多保守?你应该偏向乐观还是悲观?正确的答案是:估算必须是现实的。预测可能的问题并将它们考虑在内,但不要设想简单任务可能失败的 1,000 种方式,并以此作为给出膨胀估算的借口。不要为了掩盖自己的行踪或给自己更多打牌的时间而高估。我们的个人任务估算不能减轻所有可能出错的事情。风险应该在项目层面上管理;调度员将我们的估算纳入一个合理的计划中,并考虑适当的应急措施。
为了做出更准确的估算,考虑以下重要问题:
-
项目越具体、越明确,估算就越容易。你得到的是一个好的规范吗?
没有规范,就没有可追溯性,每个软件包中涉及的大量工作都将被假设。两个人可能对项目范围有不同的假设,并在项目截止日期时期望不同的事情。严格的规范可以避免这个问题。
即使按时交付错误的系统,其损害程度也可能与按时交付正确的系统一样严重。如果没有规范,就编写一个并得到任务利益相关者的批准。[7] 至少,记录下你对工作所做的所有假设。
-
请求的功能越多,估算就越困难。尽量削减所有不必要的任务。一个优秀的方法是将软件的交付分阶段进行,为每个可交付的迭代提供估算。
将估算信息反馈给上游。项目决策者可以据此平衡每个需求的重要性与其技术难度。查看哪些小型功能请求将加倍开发时间是有帮助的。
-
如果你没有完全理解整个问题,那么你将做出一个非常糟糕的估算。花时间了解软件必须做什么。如果你需要更多时间来做出估算,那么请要求,或者表明你对时间值的信心。永远不要猜测一个估算并希望它是正确的——如果你不能证明一个估算,那么不要给出它。
-
如果任务依赖于第三方输入,那么估计会更困难。谁负责追逐第三方以完成交付?你可能需要将这一点纳入你的开发估计中。获取第三方的估计交付日期,然后添加时间以将工作集成到你的代码库中(它永远不会“简单地插入”)。考虑你对第三方的信任程度,并包括适当的安全缓冲,以应对可能出现的问题。
-
不同的人将以不同的速度完成同一项任务。这是自然的;每个人都有不同的技能组合、经验水平、自信程度和相对的干扰数量(例如,争夺注意力的旧项目或家庭责任)。你需要评估你的工作效率,并对你即将着手进行的任务有一个良好的理解。估计是个人化的。
关键概念
理解你是在为你要做的工作(在一个你非常了解的系统上)还是为别人要做的工作(可能需要先学习)创建估计。
-
不要接受来自上面的压力,要保持乐观。不要承诺不切实际的时间表,认为如果你加班就能弥补。对那些说“它必须更快完成”的经理有一个适当的回应。
-
也许最重要的是,永远不要事先计划加班。
改善你的估计的一个简单方法就是寻求帮助。如果你不理解一个问题,那么找到理解它的人,并征求他们的意见。詹姆斯·苏罗维基的书籍《群体的智慧》描述了大型群体如何比少数精英更聪明。采取这种极端方法,让你们团队的所有开发者对计划上的所有任务给出粗略估计,然后取他们个人估计的平均值。这个估计可能不会偏离太远!
关键概念
不要孤立地做出估计。征求其他人的意见以帮助改进你的估计。
^([3]) 当然,这将占用你没有计划的时间!
规划游戏
几个孤立的时间尺度估计对任何人都没有用处。你必须将它们连接起来,并将它们转换成有用的东西:一个你可以用来管理开发进度的项目计划。根据他们各自的时间尺度估计,任务被安排在时间轴上,并分配给开发者。识别任务之间的依赖关系,并将其纳入计划中(显然,依赖的任务不能在它们的依赖完成之前开始)。最终结果是沿着水平轴运行的时间图,任务同时定位在其上,看起来就像图 21-1(经典甘特图的一个变体)。

图 21-1. 一个甘特图
项目规划是关于将任务分配给开发者和制定如何安排开发工作。但这只是游戏的一半。重要的是 风险管理——在不确定性和隐藏陷阱面前制定一个安全和合理的计划。
最安全的计划:
减少 关键路径
这是单行任务,从项目的开始到结束追踪,在上述图中以较暗的块表示。任何这些任务的任何失误都将迫使所有依赖它的任务推迟,并推后最终截止日期。
根据定义,计划中始终存在一条关键路径。这正是让项目规划者头发变白的原因!我们的目标是提供最优的任务排列,以提供最短(或风险最小的)关键路径。
不是大规模并行
当尝试压缩大型项目时,标准的规划误解是投入更多的开发者可以加快进度。这很少有效。管理更多的人会带来额外的负担——有更多的沟通线路,更多的人需要协调,以及更多的故障点。这是 Brooks 的开创性论文“神话般的月份”(Brooks 95)的主题。
你不能过度并行化项目计划,也不应该并行化个别开发者。如果你让一个开发者同时面对两个任务,你不能期望他们以与这两个任务串行相同的时间完成。这听起来很明显,但在实践中经常发生:你可能会被要求支持一个旧项目,并同时开始另一个项目的开发工作。在任务之间切换会占用大量时间,这会降低你的整体效率。如果你将两个任务连续完成,那么你会更快完成(但可能无法满足你组织的业务需求)。
不要太长
过长的项目计划过于雄心勃勃。关键路径上的任何一点出现的小问题都可能危及整个项目。
这就是迭代和增量开发(参见第 432 页的“迭代和增量开发”)带来的好处,通过将大型开发计划分解成更小、风险更低的迭代,这些迭代可以更容易地管理。这使得计划更加动态;它实际上在每个交付点都会被重新创建。尽管这种方法本质上更安全,并且会在开发过程中较早地突出显示问题,但它因此涉及更多的工作。许多管理者不喜欢这种方法——他们喜欢那种无法偏离的前置瀑布计划的错觉。
好的计划不仅仅是将时间表估计并排在一起。它们考虑了软件工厂的现实情况,并建立了重要的风险降低结构。这包括:
休假
每个开发者分配的假期数量是提前知道的,并且必须纳入日程安排。我们还必须包括公共假日和圣诞节期间的任何公司关闭。平均而言,开发者每周休假半天。
负荷
要现实,计划必须考虑到正常的干扰(会议、培训、疾病等)。通常,每个开发者的计划负荷为 80%,以适应这些干扰。需求更高的人会被分配得更薄。你必须对此诚实,否则“受欢迎”的开发人员尽管努力工作,也会违背进度,并很快感到沮丧。
应急
这是重中之重。你必须考虑到即将出现的潜在问题,并为可能阻碍你与发布日期之间的不可预见灾难留出空间。这就是风险管理真正发挥作用的地方。
风险最好在项目层面管理,而不是在个人时间估计范围内。在开发计划中,我们可以通过在整个开发过程中进行明智的判断来容纳潜在的问题。另一种选择,即在计划上放置一系列悲观的估计,必然会导致结果大相径庭。
百万美元的问题:你应该增加多少应急时间?你不能简单地将计划乘以三,然后称之为应急时间!一个好的策略是为每个任务分配一个信心值。基于这个值,为最危险的任务在计划中提供额外的伪任务,作为“危险时间”。根据你的信心值,将其设置为原始任务长度的分数之一。
集成
一个组件完成编码和单元测试并不意味着任务已经完成。要预留足够的时间将所有组件粘合在一起,并测试整个系统是否按预期工作。这将需要调试,并且只有在组件相遇时才会出现的问题(例如性能问题或接口不匹配)。
支持
人们在一个组织中的时间越长,他们就越有可能被要求支持旧项目、回答现场的错误报告等。确保这一点被纳入他们的负荷中,并且他们随后要遵守计划,突出显示当其他项目要求他们更多时间时。
当关键人员被拉向各个方向时,项目会微妙地滑落。
清理
在计划结束时提供整理时间。在发布软件的战斗中,为了赶上截止日期,会牺牲一些角落。这被称为积累技术债务。尽管我们宣扬良好的设计和编码实践,但这并不一定是邪恶的;它相当实用。然而,你必须留出时间来整理和维护一个良好、干净的代码库。否则,下一个开发迭代将在一个破损、杂乱的代码库上构建。如果这个问题得不到解决,这不断增加的短期修补将变成程序员们的负担。
将这项练习视为之前工作的一部分(尽管发生在发布截止日期之后),而不是下一项工作的开始。偿还因项目而产生的债务。
永远不要将这些整理工作视为可选的额外任务;它们是项目的重要组成部分。在软件工厂的繁忙世界中,放在日程表末尾的可选任务根本不会发生。小心保护这些任务。
关键概念
创建将使您的代码库保持干净的开发时间表。计划偿还您的技术债务。
对项目规划的深入调查超出了本书的范围;这是一个庞大而复杂的任务。但是,理解基本原理是非常重要的。你必须能够根据计划开发软件,并且必须理解计划背后的理由,才能真正理解你所要做的任务。
存在许多规划模型:这些是做出有根据的猜测的正式方法。程序评估和审查技术(PERT) 是一种经典的规划方法,由美国海军在 20 世纪 50 年代开发。它就像我开车去布里斯托尔时的到达时间计算。对于每个任务,你估计三个时间,对应于满足交付日期的不同可能性:最佳情况、最坏情况和可能情况。这涉及到一个确定关键路径并计算最佳和最坏情况项目完成时间的调度程序。每个任务估计之间的差距越大,与该任务相关的风险就越大。可能需要更仔细的管理,或者分配给更有经验的团队成员。
Boehm 的构造性成本模型(COCOMO)始于 1981 年,是基于对真实软件项目分析的一种估算模型。它已演变为COCOMO II,更准确地反映了现代软件项目的本质。(Boehm 81)受控环境中的项目(以其相当牵强的缩写PRINCE而闻名)是项目管理形式中体现的经典英国官僚主义;如果它能强制排队,它就会!^([4]) 它的范围是整个项目生命周期,从开始到结束。PRINCE 规划过程包括七个步骤,涵盖设计计划、估算和调度,到计划完成。它也经历了演变,变成了一种富有想象力的方法,称为PRINCE2。
^([4]) 排队是英国人喜爱的消遣方式,就像喝茶和打板球一样。
保持进度!
一个项目是如何推迟一年的?……一天又一天。
--弗雷德里克·P·布鲁克斯小
当工作进度滞后,项目截止日期临近时,工程师们非常努力地工作,却很少得到认可。在疯狂地赶工以按时推出可接受的产品时,严格的测试想法被挤掉了。不良的估计是这种软件杂技秀的主要原因。它们助长了管理者对开发工作难度的错误假设,因为他们根本不知道最初的进度计划是错误的。因此,当我们做出估计时,确保其准确性是至关重要的.^([5])
一切关乎规划
开发团队变得相当庞大,我们的工作空间变得非常拥挤。经过一番努力,找到了一个新的办公室,周五团队被告知我们将在周一在新地点工作。周末,所有的电脑、服务器、电缆、路由器、打印机——所有东西——都会被搬运到货车上,运送到新地点。我们被告知这将无缝进行,并且周一早上一切都会准备就绪。
周一早上,我们到达新办公室,果然一切都已经设置好,并且运行得完美!所有的 IT 基础设施都已经安装。服务器已经上线并完全运行。每个人的工作空间都已经设置好。这是一项真正的艰巨努力。
但有一个小问题:没有椅子。一个也没有。它们在搬迁计划中不知怎么被遗忘了,丢失了,而且在哪里都找不到!我们三天没有椅子可用。
那才是真正的规划。
对于软件任务的现实估计,有一些关键的方法可以保持进度并防止这种最后一刻的挤压:
-
在开始一项新任务时,检查分配的时间表是否真的可行——尤其是如果你没有自己做出估计的奢侈。即使你做出了估计,也要先验证它。不要一头扎进代码编辑器,希望能按时完成;确保你真的能够交付。提前进行一点理智的检查可以让你避免后来的痛苦和尴尬。
-
参考进度表——这很重要。持续关注你与计划时间的差距。写下你的时间表,并随身携带。为任何在主要软件计划中未考虑的中间任务添加个人估计,并将自己作为一个迷你项目来运行。如果你达到了内部里程碑,你就有更大的机会与外部可见的时间表保持一致。反复审查你的清单——至少每天一次。
如果你发现你无法按时完成,尽可能早地让这个事实为人所知,以便调整计划。就像我旅行到布里斯托尔时提前打电话一样,最好是尽快公开这个事实。如果预见到了超出的可能性,那么可以做出不同的调度决策,以最小化超出的影响。
实际上这种情况发生的频率太低了。如果一个重要的项目有五个程序员必须都报告他们的进度,那么没有人愿意成为第一个承认落后于进度的人。这被称为“进度鸡游戏”。结果是似乎一切都很顺利,但突然项目严重延误。它是一天天地晚下去的,但没有人愿意承认。打破这个循环,尽早发出警告。
关键概念
持续监控你的进度与计划的对比。这样你就永远不会惊讶于你的任务已经落后了。
-
做必要的工作,不要多做。添加那个可爱的额外功能可能很有趣。但不要这么做。有更重要——计划中的——事情要做。如果现在并不真的需要,请要求将重要的额外任务安排在以后。在我前往布里斯托尔的路上,选择一条不恰当的路线将会真正推迟我的到达时间——即使这是一段美丽的风景之旅——所以我选择合理的直线路线按时到达。
-
谨慎的设计利用模块化往往可以减少组件依赖,从而减少进度延误和任务在时间表上集中带来的不良影响。尽早就组件接口达成一致,并提供占位组件,以便在构建系统的其他部分时继续开发。
-
编写良好的代码,并附有一套彻底的单元测试。作为熟练的工匠,这一点应该是显而易见的!这有助于极大地减少调试和维护时间。
不要忘记在编码完成后留出时间来彻底地进行文档编写和测试。不要在计划最后几天进行最后的编码冲刺。你需要时间来证明你的代码是可行的。否则,你会在调试中超出截止日期。如果你没有时间完成所有这些工作,请说明并请求延长时间表。不要跳过这些事情——它们会在以后咬你。
-
注意变化的需求和规范,并跟踪这些变化将如何影响你的时间表。如果是一个不利的变化,立即报告。不要默默吸收功能变化。
-
对干扰要严格。除非计划中有安排,否则不要处理其他任务。学会对旧项目、其他部门的额外工作以及电话和电子邮件的干扰说“不”。
防止这些外部干扰,即使是看似无害的短暂干扰;它们实际上会降低你工作的质量。进入“状态”需要时间,这是那种你的心思专注于任务,代码从指尖自由流淌的地方(心理学家称之为“心流”)。即使是短暂的干扰也会打断这种有效性,当你回到工作状态时,你必须花费更多时间重新进入状态。中断的影响可能超过其持续时间的三倍。(DeMarco 99)
培养一种有利于完成工作的开发文化。尊重彼此的思考和工作空间:一个人的思考和工作的时机。确保每次会议都是必要的——不要把开发者拉进随意的、浪费时间的小聚会。
-
保持积极和乐观的态度。相信项目注定失败是确保它在现实中发生的好方法。
^([5]) 5 具有讽刺意味的是,良好的估计也可能导致这个问题。DeMarco 和 Lister 讲述了一个真实的事件,其中项目负责人报告他们对项目按时按预算完成的 100%信心。(DeMarco 99)管理人员对这意外的好消息感到惊讶,因此决定提前截止日期!无论工程师多么优秀,你都可以构建一个更好的经理来破坏他的或她的辛勤工作!
简而言之
幸运是懒惰的人对工人成功的估计。
--匿名
时间尺度估计和规划有助于我们开发商业上成功的软件。然而,没有严格的方法可以准确确定软件时间尺度值。这就是为什么它是估计。
努力提高你的估计技巧,并警惕可能破坏你整洁开发计划的问题。学习如何按计划工作,并识别你的计划何时不切实际。
| 精通编程者 . . . | 不擅长编程者 . . . |
|---|
|
-
通过考虑开发过程的各个方面,基于合理的组件分解来创建良好的时间尺度估计
-
努力编写经过测试且具有完整文档的代码,并在适当的时间尺度内正确集成
-
早期突出时间尺度问题,以便可以处理
|
-
仅基于直觉和直觉产生有希望的估计
-
可以在他们的时间尺度估计内编写一些代码,但不会产生生产质量、无错误的代码
-
认为承认时间尺度问题是软弱的表现,并疯狂地努力追赶,当他们失败时,看起来既愚蠢又疲惫
|
参考内容
第十三章
良好的时间尺度估计只能基于良好的初始代码设计。
第十九章
制定估计需要明确的工作范围,这必须在规范中明确捕捉。
第二十二章
开发方法确定任务如何组合并放置在项目计划中。

开动脑筋
关于这些问题的详细讨论可以在第 550 页的"附录 A"部分找到。
沉思
-
你如何拯救一个滑落的项目并将其重新纳入正轨?
-
在可行性或规划工作开始之前,被强加截止日期的正确反应是什么?
-
如何确保开发计划真正有用?
-
为什么不同的程序员工作效率不同?你如何在计划中反映这一点?
个人感悟
-
你参与的项目中有多少是按计划完成的?
-
对于那些成功的人来说:是什么因素促成了规划工作的成功?
-
对于那些失败的人来说:主要问题是什么?
-
-
你的时间估计有多准确?你通常偏离目标有多远?
第 VI 部分。从高处看
空气越来越稀薄,但视野却越来越开阔。在几百页之前,我们从源代码构建的阴暗角落开始我们的旅程。在这个最后一部分,我们通过攀登软件开发之巅,审视下面的领域来完成我们的旅程。我希望你不会害怕高度。
在这里,我们将探讨拼图最后部分的组合方式。
第二十二章
代码食谱:我们如何在开发团队中实际编写软件。本章描述了软件开发方法和软件开发流程。它展示了我们如何以可预测、及时的方式(或者至少尝试这样做)产生程序。
第二十三章
查看不同的代码编写学科:应用程序编程、游戏编程、分布式编程等等。每个编程分支都有其独特的问题和重要的技能。理解这些将使你能够为每种场合编写最合适的代码。
第二十四章
结局就在眼前……这是最后的、泪眼盈盈的告别。我们来看看在继续学习代码工艺的过程中下一步该去哪里。这本书只是个开始。
第二十二章。程序配方
代码开发方法和流程
他们总是说时间会改变一切,但实际上你必须自己去改变它们。
-- 安迪·沃霍尔
配料
一群程序员(最好是新手)
1-2 茶匙语言
1 个目标平台
1 名项目经理
1 撮运气
1 包脱水训练
各种行业术语
说明
在培训中的程序员。加入语言、目标平台,用项目经理调味。快速搅拌至混合均匀。根据口味添加术语。均匀撒上运气,放入热软件烤箱中烹饪至截止日期。取出,倒扣在铁架上,冷却后再交给客户。
我至少知道四种海绵蛋糕的配方。它们根据你想要无脂或无蛋的蛋糕以及你想要准备的方法而有所不同。编写软件就像这样。没有一种配方或魔法公式;同一个系统可以用许多不同的方式构建,没有一种方式必然比其他方式更好。你可以选择不同的配料来滋养开发过程,以及不同的方法来遵循。很可能会产生略微不同的蛋糕;在功能、结构、稳定性、可扩展性、可维护性等方面有所不同。这些配方描述了软件生命周期:从软件的最初概念化到其最终退役的各个发展阶段。
作为软件工程师,我们应该能够通过遵循定义的程序来可预测地(并在一定程度上可重复地)创建软件。作为软件工匠,我们应该能够利用特定的开发程序作为工具来帮助打造尽可能优秀的软件。在本章中,我们将探讨一些创建软件的配方;我们将比较、对比、批评,并看看它们如何影响我们的编码方式。
我们编程 ZX spectrum 的方式与现代掌上个人数字助理(PDA)不同,与具有高容量网络界面的主机库存控制系统也不同。我们独自编程的方式与我们在对编程的方式不同,与在由 200 人组成的全球项目团队中编程的方式也不同。目标平台和开发团队(以及他们的经验水平)的差异将塑造配方选择。编程的艺术远不止编辑、编译、链接和运行。
关键概念
优秀的程序员意识到他们是如何编程的——塑造他们工作的方法和实践。
这些编程配方是什么?
编程风格
编程风格描述了软件问题是如何被映射出来,以及其解决方案是如何被分解并通过目标语言进行建模的。我们必须建模一个解决方案,因为有用的系统不能完全被单个开发者的心智所容纳。编程风格塑造了我们将项目拆分成可管理部分的方式;它是用来表达代码意图的设计范式。
不同的编程语言支持不同的编程风格。有些是为特定的一种定制的;有些则满足多种。编程风格分为两大阵营:命令式和声明式。
-
命令式(或过程式)语言允许你指定明确的步骤序列以生成程序的输出。这是大多数程序员所习惯的。
-
声明式语言通过推理规则(或函数)来描述变量之间的关系,语言执行器对这些规则应用一些固定的算法以产生结果。(当我们查看函数式和逻辑编程时,这种描述可能变成可理解的英语。)
你选择的编程语言将在一定程度上决定你设计的风格。(然而,选择支持你想要使用的风格的编程语言会更好。)尽管如此,编程语言并不是最终的决定因素。在面向对象的语言中构建结构化代码是完全可能的,就像在任何语言中编写令人讨厌的代码一样。接下来的几节将描述流行的编程风格。
结构化编程
这种常见的命令式设计方法应用了算法分解——一个将系统分解成部分的过程,每个部分代表更大过程中的一个小步骤。设计决策集中在控制流程上,并创建了一个功能结构的层次。正如迪杰斯特拉观察到的那样,“分层系统似乎具有这样的特性:在较高层次上被视为一个不可分割实体的东西,在较低层次上被视为一个复合对象:因此,当我们从较高层次转向较低层次时,适用于每个层次的空间或时间自然粒度减少了一个数量级。我们用砖块来理解墙壁,用晶体来理解砖块,用分子来理解晶体,等等。”确实,是迪杰斯特拉的奠基性论文“Go To Statement Considered Harmful”使结构化编程流行起来。(迪杰斯特拉 68)
结构化编程是一个以控制为中心的模型,并遵循自顶向下的设计技术。你从整个程序的概念出发(例如,do_shopping)。然后将其分解成顺序子块(例如,get_shopping_list、leave_house、walk_to_shop、collect_items、pay_at_checkout、return_to_house、put_shopping_away)。然后,每个子块被进一步分解,直到达到可以轻松在代码中实现的水平。这些块被组装成一个整体,设计完成。
结构化方法的影响是:
-
分解的每一步都应该在程序员的智力理解范围内。(迪杰斯特拉说:“我现在建议我们将自己限制在智力可管理的程序的设计和实现上。”)
-
控制流程应谨慎管理:避免使用令人恐惧的
goto语句(代码中向某个任意位置的未结构化跳转),而应优先考虑函数只有一个入口和一个出口点(这被称为SESE 代码)。 -
循环结构和条件语句在功能块中使用,以提供代码结构。从循环中间或嵌套代码块中跳出的短路跳转与
goto一样受到鄙视。
常见的结构化编程语言有 C、Pascal、BASIC,以及更古老的 Fortran 和 COBOL。大多数其他命令式语言都可以轻松地用来编写结构化代码,尽管这不是它们的设计专长;结构化程序员通常在采用新时尚语言时不会采用新的惯用语。^[[1]
面向对象编程
波斯描述面向对象编程为:“一种实现方法,其中程序被组织为协作的对象集合,每个对象代表某个类的实例,并且这些类都是通过继承关系联合起来的类层次结构中的成员。”(波斯 94)这是另一种命令式风格,但它允许我们在代码设计中更自然地模拟世界;我们关注被模拟的交互实体,而不是特定的执行流程概念。
这是一个非常以数据为中心的模型(与结构化编程的过程中心视角相反)。我们思考我们的数据生活以及它的移动方式,而不是完成任务所需的步骤序列。对象(数据)具有行为(它们做事情)和状态(当它们做事情时发生变化)。这是通过在对象的类上实现方法在语言级别上实现的。我们将面向对象程序视为协作的软件组件集合,而不是作为单一的 CPU 指令列表。面向对象设计使我们能够有效地模拟更大的系统。
面向对象编程利用以下计算机科学概念:
抽象
选择性无知的艺术——抽象使我们能够设计代码,使控制层次可以忽略下面的血腥实现细节。谁在乎get_next_item是否在列表中进行二分搜索,索引数组,或者给法兰克福打电话?它返回下一个项目(无论是什么),这就是调用代码需要关心的所有内容。
迪杰斯特拉早期对层次结构的阐述——回去再读一遍——揭示了一种抽象形式。
封装
封装是将执行单元的紧密绑定包放置在一个只能通过定义良好的 API(代码胶囊)访问的包中:一个代码胶囊。该胶囊的用户只能调用定义好的 API,不能直接篡改内部状态。这提供了一个清晰的关注点分离,帮助我们思考形而上学问题,如什么是对象?,并提供了一些保证,即当您不在看的时候,没有恶意的程序员会篡改您的内部结构。
继承
一种创建对象类型的机制,它是父对象的专业版本。考虑一个名为Shape的父类型,它有继承的子类型Square、Circle和Triangle。继承类型提供了更多细节,专门化行为(例如,知道形状的确切边数)。像任何其他编程概念一样,继承可以被滥用以创建难以理解、令人惊讶的程序,或者被利用来创建逻辑上合理、优雅的代码。好的面向对象程序员知道如何创建适当的继承层次结构。
多态
这允许相同的代码根据它在运行时的上下文使用不同的底层数据类型(大多数面向对象编程语言称为类)。这种技术强调的是针对显式定义的接口进行编程,而不是针对隐式实现——多态在编写代码时提供了明确的关注点分离。有两种类型的多态,动态和静态。
动态多态,正如其名所示,根据操作数或目标对象的类型,在运行时确定要执行的实际操作。这通常利用继承层次结构:处理Shape类型的客户端可能目前正使用Square或Triangle对象——具体使用哪个对象是在运行时确定的。
静态多态在编译时确定要运行的精确代码。提供静态多态的语言特性包括:函数重载(具有相同名称的函数接受不同的参数列表——编译器根据提供的参数推断出要调用的正确函数)、运算符重载(你可以定义类型上的某些操作——包括+、!=、<和&——当操作数的类型匹配时调用这些函数),以及像 C++的模板特化这样的泛型编程设施(你可以根据模板参数类型重载模板)。
这些功能在非面向对象语言中也是可能的,使用非面向对象实践。然而,面向对象语言直接表达它们,面向对象设计利用它们来创建一个统一的系统。
面向对象编程始于 1970 年左右的 Simula,最近由于 C++和 Java 的普及而变得流行。少数纯面向对象编程语言之一是 Smalltalk。如今,面向对象很流行,有众多面向对象的语言;其中一些是结构化语言,带有流行的面向对象功能。
函数式编程
这是一种基于类型化λ演算的声明式编程风格,是一种更数学化的编程模型。你处理的是值、函数和函数形式。函数式程序通常紧凑且优雅,尽管很少被编译。因此,它们依赖于语言执行器。程序的性能由这些执行器控制——它们可能相当慢且占用内存多。^([2)]
结构化和面向对象(OO)风格在主流使用中比任何声明式语言都更受欢迎,尽管这并不减少这种编程类型的有用性。它们有不同的优点和用途。函数式程序需要与过程方法完全不同的代码设计方法。
常见的函数式编程语言包括 Lisp(尽管它包含非函数式元素)、Scheme、ML 和 Haskell。
逻辑编程
这是一种另一种声明式风格,其中你向执行者提供一套公理(规则)和一个目标陈述。一套内置的推理规则(程序员无法控制)被应用于确定公理是否足以确保目标陈述的真实性。程序执行本质上是对目标陈述的证明。
对人工智能的兴趣极大地推动了逻辑编程语言的发展。它们被广泛用于自动定理证明和在专家系统(它们模拟大型问题域并根据积累的知识库生成特定答案)中。
最著名的逻辑编程语言是 Prolog。
^([1]) 这并不一定是一件坏事,除非程序员认为他在没有改变代码设计方式的情况下已经超越了结构化编程。
^([2]) 这不是仅由声明式语言(例如 Java 有一个执行器,JVM)遇到的问题。然而,相对较少的优化工作投入到声明式语言的执行器中——它们更常由学术机构而不是富有的公司支持。
食谱:如何和是什么
我们将研究两个不同的方面。软件“食谱”采用开发过程和编程风格。这两个是分开的,也是相连的:
-
这个过程是更大的图景:它描述了构建软件所采取的步骤。这包括整个开发组织,而不仅仅是程序员。大多数软件构建不是一个人的工作;这个过程解释了如何让许多人构建一个连贯的整体。或者至少,它应该尝试这样做。
-
编程风格是更小的图景:它是分解、构建和粘合软件组件的基本方法。它很可能受到开发过程选择的影响,但不必如此.^([3]) 它更有可能受到目标语言或设计者的先前经验的影响。
你会看到这两个构建方面都被称为方法论,所以很容易混淆它们.^([4]) 我们将依次探讨风格和开发过程。了解不同的开发方法很重要,这可以给你一个更好的编程世界观,并帮助你选择一个过程,如果你有机会的话。
关键概念
我们的软件开发努力受到我们采用的风格和过程的影响。这些不可避免地影响了我们代码的形状和质量。
以下几节并不提供这些主题的教科书描述;它们提供了一个适当的手势性概述,以帮助我们比较和对比。如果你需要或想要更多细节,你可以轻松地找到一本核心软件工程教科书。
^([3]) 例如,面向对象风格通常在“迭代和增量”过程中被选择;这主要是一种惯例。(如果你不知道这是什么意思,不要慌张!所有这些都会在第 432 页的"迭代和增量开发"中解释。)
^([4]) 如果你想做出区分,那么我所说的编程风格通常被称为方法(小写 m)。开发过程通常被称为方法(大写 M);一种高教会/低教会分类。这太微妙了。在本章中,我将坚持使用风格和过程。
开发过程
有多少人想发明它们,就有多少种开发过程。许多是基本开发模型的一两个轻微演变。我们将在这里查看这些基本变体。其中一些非常相关,正如你将看到的。
您选择的发展过程决定了项目如何规划,工作如何在阶段之间流动,以及项目团队如何互动。过程沿着多个轴变化:
繁重/轻量级
繁重的开发过程是重量级的,官僚主义的。它产生了大量的文件工作,规范了开发者的行为,并假设了某种团队结构。它以 ISO 9000 组织模型为特征,其中每个工作程序都详细地、盲目地记录下来,而不考虑该过程是否有缺陷或是否适当。
在过程光谱的另一端,轻量级的开发过程摒弃了不必要的官僚主义,更倾向于更精简、以人为本的原则。在第 433 页的"敏捷方法"中描述的敏捷过程,是围绕轻量级实践构建的。
顺序
一些开发过程合理地认识到世界不是一个可预测的地方,并试图通过在过程循环周围运行多个迭代来对此进行建模和规划。这为开发者提供了将一次迭代中的反馈纳入下一次工作的机会。他们可以适应软件开发过程中发生的自然变化(如变化的需求、意外遇到的问题等)。
其他过程更加规范和线性——预测从一阶段到下一阶段的正式发展进程。它们涉及大量的前期规划工作,并试图详细预见未来。这些预测使得在开发后期改变方向变得困难。
设计方向
自上而下的设计从最初的不详细概述创建系统。每个顶级包被细化并分解为子组件。这个过程迭代,直到软件被充分指定,可以开始工作。自上而下的设计强调规划和对最终系统的良好理解,并假设在途中很少有需求发生变化。
相反,自下而上的设计详细指定系统的各个部分,然后确定如何将它们连接起来以形成一个统一的整体。这有助于我们在新的设计中利用现有的软件组件。现代流程倾向于融合这两个截然相反的方法——需要有一个关于整个系统的概念来开始初步规划,然后通过识别和编码低级组件和对象来推进设计。
没有一种开发流程比其他任何一种更好。对于这些轴上的任何一个正确的位置,都持有极端的宗教观点。任何项目的正确方法是由许多因素决定的,包括组织的开发文化、正在开发的产品类型以及开发团队的经验。
现在,请系好安全带,准备我们的软件开发生命周期之旅。紧紧抓住。
临时性的
这是一个起点,但实际上是一个反流程。这里没有流程,或者流程没有文档记录。每个人都按照自己的议程工作,没有人知道别人在做什么,希望最终能产生一些有用的东西。也许你的团队工作方式就像图 22-1?
如果一个组织不知道如何构建软件,那么它处于不可饶恕的状态,即使它是一个小组织,并且它认为不需要流程。在这种状态下,没有保证软件会按时交付,因为没有问责制。谁能保证所有功能都会实现?
许多开源软件都是使用这种方法创建的.^([5]) 如果你有无穷多的猴子和无穷多的计算机,你最终可能会得到一个程序——然而等待必要的无穷长时间是不切实际的。即使是便条设计也是向更正式、可预测的开发过程迈出的一步。
关键概念
没有开发流程,你的团队处于无序状态。你的软件将是由运气产生,而不是有目的地产生。
这个案例是开发无序。个别程序员可能非常努力工作,他们的英雄行为最终可能产生有价值的东西。但这种结果并不能被严重依赖。团队可能非常低效,并且可能永远不会交付有价值的东西。

图 22-1. 工程发布
水晶球模型
瀑布模型是经典的软件开发生命周期模型。它因其简单性(甚至被认为是过时的)而受到很多批评。然而,实际上其他每个开发流程都以某种方式基于它。它有许多缺陷,但仍然是过程研究中的教学起点。它是根据更传统的工程生命周期模型构建的,由 W.W. Royce 在 1970 年描述。(Royce 70)它是开发流程中最可预测的。
开发阶段
水晶模型描述了软件开发过程生命周期中的五个阶段。
许多其他流程识别出相同的阶段,但它们的顺序不同或改变了它们的相对重点。
需求分析
首先,确定软件项目的需求。这确定了其目标、它将提供的服务以及它需要在其内工作的约束。这一步骤通常在启动项目之前进行可行性研究,或者可行性研究是在同一时间进行的。可行性研究提出诸如:这个项目能行吗?我们应该开发这个软件吗?有哪些替代方案?等问题。
设计和规范
从第一阶段产生的既定需求被转换为软件或硬件需求。然后,软件需求被转换成可以在计算机程序中轻松实现的形式,可能通过将其拆分为单独开发的组件。
实施
这就是创建程序的地方。每个程序或子组件都是一个单元,并且会进行单元测试。单元测试确保每个单元都满足在之前步骤中定义的规格。
集成和测试
所有单元都被组合在一起,整个系统进行测试。我们测试代码是否正确集成,整个系统是否按预期运行,以及它是否实现了所有系统需求。当成功测试后,软件被认为是完整的。
维护
最后,产品被交付。我们永远不应该假设软件在发货时就完成了;这样做是幼稚的。软件生命周期中最大的阶段是维护(参见第 288 页的“现有代码的维护”)。将会有需要修复的错误、需要满足的未注意到的需求、原始需求的演变,以及为在野外部署的软件提供的其他产品支持工作。
这是一个简单的想法;开发过程被分解为多个阶段,这些阶段依次运行。这被比作瀑布,因为从一个阶段到另一个阶段的稳定、不可逆的流动。就像水总是流向河流一样,开发总是通过每个阶段向下流动到发布。
传统的瀑布模型在图 22-2 中展示。^([6]) 你可以看到五个标准阶段;这些在"开发阶段"文本框中有描述。一旦一个阶段成功完成,就会通过某种关卡过程(通常是审查会议)进入下一个阶段。大多数阶段的输出是一个文档;一个需求规范,一个设计规范,或者类似的东西。如果审查发现错误或问题,它会被反馈到上游,使该阶段再次回退。
按照这个模型,你很难回溯进行更改;这就像鲑鱼花费大量时间和能量逆流而上。虽然鲑鱼在基因上被编程来做这件事,但程序员不是。这意味着在开发后期进行更改时,这个过程并不有帮助。需求必须在系统设计之前确定,并且在过程开始后很难容纳太多的变更。通常,设计阶段的问题直到系统测试时才会被发现。
然而,就其辩护而言,它易于管理——至少在概念上是这样——并且是大多数其他开发模型的基础。瀑布模型不适合非常大的项目;对于两周的项目来说效果很好。其他开发模型通过在大型项目的生命周期中运行许多较小的瀑布来利用这一点。

图 22-2.传统的瀑布模型
SSADM 和 PRINCE
虽然SSADM听起来像是只有成年人参与的开发,但它实际上代表的是结构化系统分析与设计方法。它是一种遵循瀑布方法的严格和结构化的方法,可能是你遇到的最有规律的瀑布变体。
它涵盖了分析和设计,而不是实现和测试,是一个定义良好的开放标准,在英国政府机构中被广泛使用。SSADM 由五个主要步骤(每个步骤又细分为许多其他程序)组成,就我们的目的而言,这些步骤是自我描述的:
-
可行性研究
-
需求分析
-
需求规范
-
逻辑系统规范
-
物理设计
受控环境中的项目(PRINCE)及其富有创意命名的继任者,PRINCE2,分别于 1989 年和 1996 年创建,以取代 SSADM。与 SSADM 一样,它们定义了一个重量级、以文档为中心的模型。它们列出了可以遵循的正规步骤(这次分为八个单独的阶段),以生产产品,旨在满足已识别的需求和质量标准。
V 模型
这个过程模型源于经典瀑布模型,是为了规范德国政府和军事部门内的软件开发流程而开发的。它与瀑布模型有很多共同之处(包括倾向于吸引批评),但它不是将过程建模为瀑布,而是将其可视化为一个V,如图图 22-3 所示。

图 22-3. V 模型
在左侧,我们看到软件开发阶段,直到软件构建:规划、设计和实施工作。右侧的流程控制测试和批准。7] 每个测试级别的工作都是与从相应的左侧阶段生成的规范进行衡量的。
V 模型与瀑布模型的不同之处不仅仅是图表的方向。测试阶段(在右侧分支)可以与开发工作(左侧分支)并行开始,并且被赋予同等的重要性。这是好的,因为:
-
传统上,在项目即将结束时,测试会被压缩。这是危险的。强调测试作为开发过程的关键环节,突出了这一事实,并有助于确保产品质量。
-
我们应该始终测试比最终软件更多的内容:从需求规格说明到完成的软件的所有开发阶段,都要进行审查和验证。V 模型强调了这一点。
-
在现实世界中,测试和错误修复通常占项目总时间的一半以上。瀑布模型并不能准确反映这一点。
-
这个模型可以从整个开发过程中节省时间,因为测试计划可以在每个开发阶段完成后立即制定。这种简化和并行化的工作将提前项目的结束日期,因为我们不需要等待瀑布模型的实施阶段结束才开始测试活动。
原型
尽管我们在软件开发流程方面有多年研究和经验,但瀑布模型仍然是一个标准的参考模型,因为它具有清晰的逻辑——显然,在需求分析或任何设计工作之前,你不能进行有用的实施。然而,瀑布模型使得在开发完成之前很难评估软件系统。同样,直到集成阶段完成并且系统准备进行 alpha 测试之前,也很难向客户展示软件。
原型方法试图克服这一限制。它有助于在开发过程中探索和评估实施,并细化未知或模糊的需求(用户永远不知道他们真正想要什么)。
原型过程的本质是创建软件系统的多个可丢弃原型。每个原型都会被评估,展示给客户,并使用客户反馈来塑造下一个原型。这个过程会一直持续到对开发并部署真实产品有足够的了解。
我们在这里看到了与其他行业的类比。如果你在开发一款新车,你会创建许多原型,直到找到完全正确的设计。我们希望我们的软件也能做到这一点。然而,有一个重要的区别必须注意。在制造汽车时,主要成本在于制造,而不是开发。软件的情况正好相反。你可以免费制作代码的多个副本;开发才是成本高昂的部分。因此,原型周期需要得到控制;它不能无限次地重复。
原型之痛
发布原型可能会引起严重的维护问题。
我为一家只有使用一个 GUI 库为其 Java 前端服务的公司工作过。但在实践中,它有一些系统使用了这个库,而有些则没有。每当出现错误时,维护程序员不得不跳过许多障碍来找出前端代码在做什么。他们不了解其他 GUI 库,而且他们经常引入更多的问题。这种情况发生得越多,公司的产品就越不受尊重。
并不需要进行太多的软件考古就能发现这个问题的原因:没有使用正确 GUI 库的前端已经变成了“意外”的产品。花点时间发布正确的代码,就可以节省后续数月的工作,而且不会破坏公司的声誉。
原型是用高级语言快速开发的。有时它们只是简单地绘制出来:使用自动化工具支持^([8])可以极大地加快原型生产。这些原型是概念验证,因此效率、稳定性或完整的功能集不是首要关注的问题。因此,原型设计最适合那些注重用户界面的系统。
原型帮助我们管理风险。我们可以使用它们来确保客户确实想要他们所说的东西。我们还可以使用原型来探索新技术或检查我们的设计决策是否能经得起实际使用。
原型设计的危险在于继续开发低效、快速生产、没有充分考虑的原型代码,并将其开发成真正的发布版本。当项目时间紧迫,实际开发可能不符合进度时,这种情况尤其如此。没有教育,客户会混淆原型和最终产品,并惊讶于完成软件需要更长的时间。它需要非常谨慎的管理才能工作。避免这种问题的最好方法是故意让原型边缘粗糙,并且永远不要让它们接近可发布的状态。功能过多的原型不是原型!
迭代与增量开发
所有关于瀑布方法最近的发展基本上都是主题的变体。主要的改进是以迭代和增量的方式进行开发。也就是说,许多次(迭代)围绕一个小型开发生命周期的连续运行(增量),每个周期都会向系统中添加更多和更多的功能,直到它完整。小型生命周期的每次运行通常遵循瀑布模型,可能持续几周或几个月(取决于项目的规模)。因此,瀑布的每个阶段都会执行多次。每个迭代的结束时都会有一个软件发布。
增量开发既不是自顶向下的方法,也不是自底向上的方法。每次代码发布都会创建一个完整的代码版本,包括所有必需的高层和底层组件。在每次迭代中,系统都会增长,后续的设计工作可以基于现有的设计和实现进行。这里与原型设计有相似之处,但我们并不专注于快速演示的快速破解。采用这种方法,每个阶段都更简单,更容易管理——并且进度更容易监控;你知道系统已经构建和集成了多少。
这种流程对于在开始时对需求理解不充分的项目来说效果很好。让我们面对现实:这包括现实世界中大多数项目。它更能适应变化,并且可以节省在瀑布方法中会遇到的长篇大论的重设计和重实现。迭代和增量开发之所以有效,是因为它符合软件开发的基本性质,因此有助于我们更好地控制固有的混乱。由于迭代周期较短,有更多的机会进行反馈和纠正;你不必等到项目结束时才知道它失败了。
螺旋模型
Barry Boehm 于 1988 年提出的螺旋模型是迭代和增量方法的良好示例。9 开发过程被模拟为一个螺旋,就像图 22-4 所示。它从中心开始,向外扩展到过程的后期阶段。我们开始对系统的非常粗略的概念进行工作,随着时间的推移变得越来越详细,当我们进入螺旋的后期阶段。螺旋的每个 360 度旋转都意味着我们经历了一次单一的水晶瀑布,并且每个迭代通常持续六个月到两年。
特性按优先级递减的顺序定义和实现;最重要的功能尽可能早地创建。这是一种管理风险的方法;它更安全,因为随着你接近交付日期,你可以确信大多数系统已经完成。实际上,这是一种非常务实的方法;程序员不会将 80%的时间花在系统那 20%的琐碎(但有趣)的部分上。

图 22-4. 螺旋模型
Boehm 将螺旋分为四个象限或四个不同的阶段:
目标设定
为此阶段确定具体目标。
风险评估和降低
确定并分析关键风险,并寻求信息以降低这些风险。
开发和验证
为下一阶段开发选择一个合适的模型。
规划
项目被审查,并为螺旋模型的下一轮制定计划。
敏捷方法论
这些方法论是在对官僚主义和重量级方法论的反动中发展起来的,这些方法论试图将软件开发过程束缚起来。敏捷实践者观察到软件开发难以被转化为一个可预测的过程;他们声称这与既定的工程程序,如建造桥梁,非常不同。10 过时的、宏伟的方法论只会妨碍人们编写好的软件,因此它们应该被摒弃。
敏捷方法论是一个总称,描述了包括备受瞩目的极限编程、Crystal Clear 和 Scrum 在内的许多开发过程。敏捷过程侧重于灵活性和风险降低,而不是长期规划或强制(假装有)可预测性。
敏捷过程共享以下核心原则:
-
通过执行许多小的迭代开发周期来最小化风险。在每个周期结束时,软件和所有过程工件都是完整、一致且可发布的质量。尽管软件很少真正发布,但它可以被传递给客户进行审查和评论。这给客户提供了团队进展的保证。
敏捷过程迭代通常比迭代和增量过程循环要小得多(通常持续几周,而不是几个月)。
-
通过在开发结束时进行漫长的测试周期,而不是更加强调一套持续运行的自动化回归测试,来最小化风险。
-
减少那些困扰着重量级过程的文档。敏捷过程将代码本身视为设计和实现文档。好的代码可以自给自足,不需要被官僚主义的文档流程所拖累。
-
强调人员,并努力促进沟通,最好是面对面而不是通过文档。这样可以使客户(或客户代表)尽可能接近开发团队,以便参与实施和优先级决策。
-
将工作软件视为衡量进度和性能的标准,而不是规格编写或管理者对团队在虚构的开发周期中位置的看法。开发者会在开发过程中遇到问题,并通过修改代码来应对变化。
敏捷方法并不总是适用。它最适合于较小的项目,拥有不到 10 名地理上集中在一起的高质量程序员的小团队。敏捷过程在需求变化程度高的领域表现卓越。在具有重过程文化的公司中很难运行。
其他开发过程
存在许多其他开发过程:这些主题的变体,每个都有其独特的特点。有修改后的瀑布流程,它重叠某些阶段或包含子项目,作为迷你瀑布进行管理。进化原型方法从初始概念开始,设计和实现一个原型,迭代地改进原型,直到它被接受,然后发布这个原型,也许计划在过程中包括一些可丢弃的原型。
分阶段交付遵循一个顺序过程,直到架构设计,然后实现单独的组件,在完成每个组件时向客户展示,如果需要,返回到之前的发展步骤。进化交付基本上是进化原型和分阶段交付之间的结合。
快速应用开发(RAD)强调用户参与和小的开发团队,并且大量使用原型和自动化工具。与其他流程略有不同,开发时间表在前期确定,并被认为是不可移动的。然后尽可能地将尽可能多的功能纳入设计,以适应截止日期——某些功能可能需要牺牲。
理性统一过程(RUP) 是一种著名的商业方法论,源于 Ivar Jacobson 的 1987 年 Objectory Process。它是一个重量级但灵活的面向对象过程,高度依赖于 UML 图,具有 用例驱动设计(一个用例描述单个用户活动或与软件系统的交互)。它倾向于迭代开发、持续测试和细致的变化管理。作为一个商业过程,它由一系列商业工具支持。
^([5]) 在那里,也许这并不那么重要,因为没有付费客户和正式的需求集——许多开源软件的开发是因为程序员想要这样做。然而,将一些开发过程应用于临时性的开源工作几乎肯定会产生更好的程序。
^([6]) 这是对罗伊斯原始论文的一种常见简化。罗伊斯 确实 允许在瀑布中向上反馈,但并没有积极鼓励这样做。热情的管理者认为软件开发是一个严格的线性过程,很快就去掉了这些上游路径;瀑布被玷污了。
^([7]) 注意开发流程是如何向下流动的,就像瀑布一样,但测试被视为一项向上的努力——这是软件开发的一个相当准确的模型!
^([8]) 例如,具有简单 GUI 构建器的 快速应用开发(RAD) 工具。
^([9]) 伯姆的过程并不是第一个迭代模型,但他是最先推广并强调迭代重要性的。
^([10]) 这是一个宗教般的辩论:许多程序员认为 确实 可以使软件开发过程变得可重复、可预测,但行业目前还不够成熟或不够自律来实现这一点。
已经足够了!
如果你读到这儿还没有感到厌烦,那么你做得很好。最后,也许更重要的是,我们能从所有这些中提取哪些关键点?一个软件工匠对开发过程和编程风格有良好的工作理解,但任何人都可以从正确的书籍中获得这些知识。我们如何将这些知识有用地应用到我们的工作中?它如何提高我们的技能集?
所有这些过程都存在一些共同点。第 428 页上描述的 "开发阶段" 在每个过程中都存在。这些过程真正不同的是这些阶段的长度和相对位置。每个活动对于生产高质量的软件都是至关重要的。更好的过程确保测试不会被当作事后考虑,而是在整个开发过程中持续进行并得到监控。
比较或评估不同的流程和编程风格是件困难的事情。哪一种最好?哪一种能确保按时且在预算内交付高质量的产品?没有答案,因为这些问题本身就不正确。适合的流程取决于项目的性质和公司的文化。如果你有 20 个程序员,他们对面向对象开发一无所知,而且只使用 C 语言,那么试图构建一个面向对象的 Java 产品显然是个愚蠢的想法。
关键概念
你选择软件配方的原因有很多——确保它们是好的。你对流程选择动机的说明在很大程度上反映了你组织的成熟度。
我们可以看到两种流程的极端:临时方法的混乱与严格流程的严格制度形成对比。在后一种情况下,任何可能产生更优雅架构的实验都被禁止。用户的真实需求可能永远不会过滤到开发者那里,因为它们迷失在官僚主义的海洋中;程序员只是根据从上一个流程阶段传来的规格进行编码。
按照金发姑娘原则,最灵活的方法通常介于两者之间。你需要知道你正在遵循的流程以及它在哪定义。有效的开发需要纪律;你需要一个连贯的策略来确保按时交付产品(拥有一个现实的进度表也是另一个话题——参见第 409 页的"规划游戏")。经验丰富的程序员知道他们开发流程的价值,以及其中的缺陷。他们知道如何与之合作,何时跳出框架。优秀的程序员不仅仅是编程。他们理解他们的方法,并知道如何根据适当的情况进行适应。这就是我们的科学仍然是一门手艺的原因。
重要的是不要对遵循的流程过于紧张和教条,但你必须有一个达成共识的软件生产框架。它必须适合你的开发团队——并不是每个组织都需要一个仪式感强、有许多圈套和障碍需要克服、以及需要填写冗长表格的高仪式流程。
关键概念
你采用的流程不必是仪式感强且难以遵循的。事实上,相反的特征通常是良好流程的标志。尽管如此,你必须有一个定义明确的流程。
新的方法论时不时地出现(或者更确切地说,是演变)。它们往往伴随着巨大的宣传和烟花般的炒作;它们被宣称为银弹,是万应良药,将使我们的后代和孙后代的发展变得更好。遗憾的是,情况并非如此。归根结底,无论遵循哪种生命周期,编程团队的质量仅取决于其程序员。如果没有直觉、没有灵感、没有经验和没有动力,那么,无论使用哪种开发过程,你都无法可靠地编写出高质量的代码。你可能会更好地跟踪进度落后多少。
选择一个过程
许多因素有助于选择合适的开发过程。然而,选择很少基于合理的理由;使用开发过程是因为这是我们一贯的做法,它足够有效,或者它是我们能想到的第一件事。
你如何知道哪种开发方法适合?最终,如果这个过程对你的团队有效——如果你能很好地协作并在规定时间内交付高质量的软件——那么你就有了一个好的开发方法。
选择合适的过程取决于项目的类型和规模。对现有代码库的小修改不需要大型的迭代开发周期;从零开始的三年工业项目可能需要。好的过程选择适合现有团队成员的经验,让开发者愿意(甚至渴望)使用它,并且是项目经理真正理解的东西。
反之,选择开发过程有许多不良的理由。仅仅因为想要改变而转向新的过程是没有意义的;新的过程必须引入以解决当前开发模型中的问题。试图做出政治声明(我知道有些人试图培养开放的开发文化,只是为了推动组织开源其内部代码库)也是没有意义的。选择特定过程的最终不良动机是时尚。更多的 buzzwords 并不一定意味着更有效的过程。
这确实很重要:不合适的过程真的会破坏代码的质量;你将花费更多的时间去迎合程序化紧身衣的需求,而不是交付软件。一个好的过程不会妨碍你。实际上,它使你的团队能够更快、更好地创建更多软件。
关键概念
过程至关重要。大多数项目失败的原因是非技术性的。而且,不良的过程几乎总是原因列表中的首要因素。
简而言之
构建软件就像犯罪:有组织的时候会更好。偶尔,一个无纪律的团队会完成一些惊人的事情,创造出软件杰作。然而,那确实是例外。开发过程需要被定义、理解,并由具备适当技能的团队成员执行,才有机会有效工作。否则,你最终会得到一个像犯罪一样的软件。
我们需要使用经过验证的开发过程和建立的设计风格,以便我们能够在时间表、预算和不断变化的需求背景下构建满足预期的软件。构建软件是困难的——我们刚刚看到了另一种使其更容易的方法。
| 精通编程的程序员... | 编程不好的程序员... |
|---|
|
-
理解他们预期工作的编程风格和开发过程
-
利用他们的开发过程来塑造与其他软件工厂居民的互动;当过程变得限制性时,他们会规避它
-
评估不同开发方案的优缺点,并能够为任何特定情况选择合适的方案
|
-
忽略开发过程问题,试图以自己的方式做事
-
不知道过程如何塑造他们与其他开发者的互动
-
避免思考这类事情——这是经理们需要担心的事情
|
相关内容
第八章
测试是开发过程中的关键阶段。通常,现实世界截止日期的压力试图挤压出它的空间。
第十七章
团队合作:大规模软件开发的基础。
第十九章
规范通常是开发过程各个阶段之间的门槛。

开始思考
关于这些问题的详细讨论可以在第 553 页的"附录 A"部分找到。
沉思
-
编程风格和开发过程的选择如何相互影响?
-
哪种编程风格是最好的?
-
哪种开发过程是最好的?
-
本章中列出的每个开发过程在"开发过程"第 425 页中我们看到的分类轴上处于什么位置?
-
如果开发过程和编程风格是配方,那么软件开发食谱会是什么样的?
-
使用合适的过程,软件构建能否变成一个可预测、可重复的任务?
个人化
-
你目前使用的是哪种开发过程和编程语言风格?
-
是否已经由开发团队正式同意,或者你是按照惯例使用它?
-
它是如何被选择的?是专门为这个项目选择的,还是你总是使用的配方?
-
它是否被记录在某个地方?
-
团队是否坚持流程?当问题出现,你背靠墙壁时,你是否维持流程,还是在匆忙中忽视所有象牙塔理论,只为了生产出一些东西——任何东西?
-
-
你当前的过程和风格是否合适?这是你现在开发软件的最佳方式吗?
-
你的组织是否意识到还有其他可能值得调查的开发模型?
第二十三章。外延
不同的编程学科
我们对他人感到的一切烦恼都可以引导我们了解自己。
--卡尔·荣格
我喜欢广泛的概括和脆弱的隐喻。起诉我吧。我还在做我的研究。我发现我居住的城市有 40 多个教堂。每个教堂都有细微的差别;不同类型的人参加,他们做不同的事情。他们有不同的关注点和工作方式。他们位于不同的地区。然而,他们都在做大致相同的事情。
这究竟与编程有什么关系? 你可能会问。如果你原谅这种脆弱的联系,软件开发基本上是以相同的方式进行。好吧,我们不是每个星期天早上都走进一栋大楼(嗯,我们中的大多数人不是)。但是,对于局外人来说,我们似乎在进行一些奇怪的仪式,召唤一些神秘的仪式来控制那些超出正常人类控制范围的事物。
然而,我真正得出的结论是,没有一种编程方式,没有一种解决所有问题的单一方法。没有一种编程语言。有许多不同领域的问题需要解决,涉及许多不同的领域。每个领域的工作都不仅仅取决于技术(即哪些工具和代码库可用);它们还取决于技术。每个领域都需要不同的技能集,特定的思维方式,以及微妙不同的工作方式。这些差异可能看起来很小,但如果没有特定类型系统的编程经验,就无法替代——如果有,程序员的招聘广告将会更加模糊。了解你的领域并欣赏其独特的关注点非常重要。在特定的编程领域中,工匠知道如何运用他的技艺,如何处理他的媒介,以及如何最好地使用他的工具。
关键概念
编程有多种类型,涉及不同的问题领域。每种类型都提出了其独特的问题,并需要特定的技能和经验。
在本章中,我们将探讨这一点。我们将对计算机编程这个广阔的领域进行一次有指导性的游览,发现我们编程的一些常见问题领域,了解它们之间的差异,并学习每个领域的特定问题和挑战。
这些领域中有一些是重叠的。这是自然的。没有什么事情会像你想象的那样清晰。以下描述必然是概括性的,因为每个领域都是一个很大的领域,其中有很多变化。尽管如此,这应该能让你对那里发生的事情有一个大致的了解。
应用程序编程
当你提到“编程”这个词时,大多数非技术人员会想到这个。这可能是我们在本章中考虑的最广泛的类别。
这是指编程应用程序——独立的程序——通常用于单用户、工作站式计算机。这个世界关注的是最终用户以及他们如何使用他们的桌面机器。出于商业原因,我们通常针对主流平台——目前是 Windows 和 Mac OS。尽管现在你经常听到关于 Linux 编程的消息,但这仍然不是应用程序工作的领域(至少,在写作的时候是这样)。随着便携式设备的变得更加强大,它们的应用程序开发环境变得更加丰富,移动应用程序工作已经从嵌入式领域(参见第 447 页的“嵌入式编程”(Embedded Programming))转移到这一类更通用的应用程序编程;具体的嵌入式障碍在很大程度上已经被消除。
有许多用于这种工作的语言和环境;C 和 C++很常见。我们还看到 Visual Basic 和 Delphi、Java 和.NET 的普遍使用,以及 MFC 和 Qt 等许多库和框架。这种选择是根据开发者的便利性来决定的——一些足够为人所知并提供所有必需的功能。
自从个人计算机时代开始以来,现代应用程序编程已经迅速发展。我们现在拥有丰富的开发环境,可以工作在其中,并带有有助于自动执行大量繁琐模板代码的框架代码。我们有线程支持、标准用户界面组件库和网络透明度设施。为了使应用程序编程更容易,操作系统提供了大量的支持,但这同时也意味着在开始时有很多东西需要学习。你必须知道很多,才能真正理解你周围发生的事情。
所有这些额外的支持提高了衡量一个好的应用程序的标准。几年前可以接受的应用程序行为现在不再适用。人们期望高质量的、健壮的程序,具有标准界面和外观,良好的响应性,用户友好性(能够应对最无能的用户),以及丰富的功能(即使用户只会利用其中的一小部分)。今天市场上销售的庞大专业应用程序是大型开发团队的结果,这些团队专门关注可用性问题。
我们正在看到向基于网络的系统转变,这些系统在浏览器上运行,通过网络。我们将单独研究它们;这也多少影响了企业或分布式编程领域(参见第 450 页的 "分布式编程")。
应用程序编程有两个主要市场:shrink-wrap 软件和定制应用程序。
shrink-wrap 软件
shrink-wrap 软件是为大众市场开发的。它被大量的人使用,或者至少市场营销部门是这样祈祷的。这是关键:市场是投机性的,因此软件必须吸引尽可能广泛的消费者群体以赚钱。由于没有客户委托或支付 shrink-wrap 软件的开发,你必须在开始工作之前建立一个有利可图的市场,否则你就是在浪费时间和精力。软件需要在功能、性能或对问题的独特方法上与竞争产品区分开来。
shrink-wrap 软件可能被作为盒装产品在柜台上购买,这些盒子被透明纸包裹得整整齐齐(因此得名),或者可以从互联网上下载。它甚至可能是一个基于订阅的在线服务。关键点是你如何销售它以及这如何迫使你开发它。
对于 shrink-wrap 应用程序程序员来说,生活是艰难的。你无法控制代码运行的环境。它必须优雅地处理操作系统的所有版本,在不同的机器配置上,安装了不同的库和其他应用程序,并且必须可靠地应对它们。这是一场测试噩梦!Web 应用程序程序员赢得了一半的战斗(我们稍后会看到)——你可以控制服务器部署。但你仍然需要应对浏览器兼容性的头疼问题:你的网页必须在广泛的平台目标上正确渲染。
定制应用程序
定制应用程序是根据特定客户的具体需求定制的——为特定客户开发。因此,重点不是那么多的吸引人的用户界面,一个永无止境的功能列表,甚至是为了使其完美无瑕且无错误。没有商业上的必要性去做这些。让它工作。让它发货。让它带来现金。这是一个更确定的商业模式。
由于客户委托这项工作,他们将使用这个软件或者什么都不用。在没有真正竞争的情况下,软件只需要“足够好”。如果给程序员一半的机会,他们会不断调整和改进他们的代码,直到它达到某种神话般的完美状态。但在这个情况下,这样做从商业角度来说并不合理。程序是否运行良好并不重要,但每周崩溃一次;定期重新启动它比进行漫长的错误搜索成本低(假设它不会在崩溃时破坏任何数据)。
总结
应用程序工作很有趣。现代个人电脑功能强大,因此你不必太担心代码大小或性能,你可以专注于编写整洁、优雅的代码。知道你的应用程序被全世界成千上万的人使用是一种兴奋的感觉。 ——Steve(一家大公司的应用程序程序员)
典型产品
典型的 shrink-wrap 产品是桌面应用程序,如网络浏览器、电子表格等。定制软件可以是任何东西——例如,为大型零售商量身定制的库存管理系统。
目标平台
这通常是你进行开发的那种机器(大多数情况下,是一个 x86 Windows PC)。
开发环境
你通常会在运行程序的工作站上构建代码。现代 集成开发环境 (IDEs) 提供舒适的工作环境,将编辑器、编译器、调试器和帮助系统结合在一个单一的统一点选界面中。许多第三方组件可用于简化常见任务的开发。这里使用了全系列的语言:从低级的 C/C++,通过 BASIC 和 Java,到脚本语言。
常见问题和挑战
用户期望高质量的程序,这些程序符合标准界面原则。比任何人都可能记住的功能要多的是日常事务;这是一个严肃的商业需求,通常也是区分一个产品与下一个产品之间的不同之处。如今,新产品修订通常引入的功能(和错误)比它们可能解决的问题要多。这就是市场需求。
^([1]) 当然,你在聚会上承认你的工作是做这份工作可能会立即结束对话。嗯,除非这是一个满是书呆子的聚会,在这种情况下,你可能正试图逃离,无论如何!
游戏编程
游戏编程这个激动人心且光鲜亮丽的世界是一种特定形式的应用程序工作,通常开发 shrink-wrap 软件。大部分的战斗都是在吸引人的营销和非常好的游戏玩法概念上进行的。这是区分一个伟大、成功的游戏和失败者的细微差别。
这些游戏通常涉及第一人称、大规模、沉浸式、3D 环境。为了提供吸引人的体验,硬件的图形能力被充分利用,CPU 被最大化用于管理地图、敌人和谜题,同时执行移动物体的物理建模。所有这些都必须实时协调,并使硬件达到极限。游戏编程的一个很大部分是优化代码以适应执行平台。随着更快的硬件发布,问题并没有减少;为了与其他游戏脱颖而出,需要更多的优化来从新平台上挤出更好的体验。这个领域非常注重保持前沿,并使用最新的尖端技术来做最酷的事情。
概览
专业游戏开发关于乐趣,但这是一个高度竞争的行业,开发者需要跟上最新的技术、紧迫的截止日期和不可协商的最后一刻变更请求。编写软件需要汗水、鲜血和泪水,只为它能在高度批判性的专业媒体面前经受住严酷的公众审视。但回报可能非常丰厚——一旦完成,你创造出的东西就能被人们看到、理解并享受。——Thaddeus(专业游戏程序员)
典型产品
第一人称、沉浸式、3D 游戏、策略游戏、在线谜题。
目标平台
桌面电脑、游戏机、移动设备(个人数字助理和移动电话)、街机。
开发环境
专用游戏平台(包括标准 PC 中的高端显卡)提供了定制的开发环境来帮助充分利用它们的性能。仍然需要非常才华横溢的开发者才能充分利用平台的功能。
常见问题和挑战
获得优秀的游戏体验;平衡功能、用户反应、美学、氛围和难度。一款好游戏的发展非常像故事一样,并能吸引玩家。
需要优化以充分利用执行平台。
现代游戏开发团队往往更像好莱坞电影制作团队,而不是标准的会计软件团队。我们看到团队包括图形艺术家、关卡设计师,以及故事板、概念艺术和概念设计的发展。
软件可能针对(经过适当升级的)PC 平台或专用游戏机。这些机器具有特定的硬件来加速每秒所需的许多图形操作,并提供了特殊的工具来帮助你利用它们的性能。游戏机制造商提供开发套件(硬件和定制软件工具的特殊版本)来帮助你创建产品,协助代码加载、测试和调试,同时帮助避免生产硬件上的安全特性,这些特性可能会阻碍开发。
多人游戏提供了更丰富的游戏体验。这引入了网络协作,并需要一些技巧才能从缓慢的互联网连接中获得可接受的真实时间性能。
最终产品的质量取决于游戏体验的感觉。一切都会调整,直到游戏感觉正确:关卡设计、物理模型、图形、内衣的颜色。没有什么是不神圣的。你可能编写了世界上最美丽的代码;程序可能永远不会崩溃;它可能完成了一切它被指定要做的事情;它可能非常高效。但如果它缺乏那种使它成为一个引人入胜、上瘾的游戏的特殊火花,它将不会成功。棘手的事情。
系统编程
应用程序位于丰富的系统库之上:用于网络、图形界面、多任务处理、文件访问、多媒体、外围设备控制、进程间通信等代码层。如果应用程序程序员从底层系统获得大量支持,那么就必须有人提供这个底层系统。这就是系统编程。
它通常也适用于工作站,但并不是针对最终用户的。系统软件的目标是应用开发者;面向公众的界面是一套 API,供软件链上更高层的软件层使用。系统软件关注的是与计算机在非常基本的层面上交互的低级逻辑,以及不直接与硬件接口但为系统其他部分提供重要服务的中间层支持框架。
在这个领域的工作通常包括编写设备驱动程序(控制打印机、存储媒体、输出设备等设备)、编写用于管理稀缺资源的通用共享库和实用程序、实现控制计算机的实际操作系统,以及提供文件系统和网络堆栈等组件。编译器和安装工具套件也可以归入此类,因为它们是应用程序程序员的支撑服务,并且通常与程序运行时环境紧密相连。
概览
我为一种专有操作系统编写了 USB 堆栈。我必须理解操作系统、USB 硬件和 USB 协议,因此有很多东西要学习。我必须保持性能,以确保系统运行良好。作为中间人,我抽象化了硬件接口,并为应用程序提供了一个整洁的 API。我必须使这个平台无关,这增加了额外的复杂性。——Dave(系统组件编写者)
典型产品
操作系统、设备驱动程序、窗口管理器或图形子系统。
目标平台
由于每个执行环境都需要某种形式的运行时支持,因此在几乎每个电子设备中都有系统级软件。即使在最小的嵌入式设备和最大的主机计算机中,也需要系统软件。
开发环境
编写设备驱动程序和操作系统组件往往会干扰计算机并使系统不稳定,因此通常在一个机器上开发并在第二个系统上运行代码。C 语言在这个领域是最常见的语言,尽管一些库级工作是用其他语言完成的(C++很受欢迎,因为它旨在成为具有系统能力的语言)。
常见问题和挑战
关键在于稳定性,因为这些是整个计算环境的基础块。虽然应用程序可能会崩溃,有机会保存工作并优雅地恢复,但设备驱动程序很少有这样的奢侈;它要求在整个运行期间始终正确工作。这可能是一个非常长的时间,所以即使是小的内存泄漏也可能成为主要问题。
代码必须在空间和速度上都足够高效,并且需要根据特定的操作环境进行适当的调整。
嵌入式编程
计算机技术无处不在地出现在我们的日常生活中,无论我们是否意识到。我们不断地使用各种设备和小玩意,从微波炉到手表,从收音机到恒温器。这些消费电子产品需要软件来控制和操作。通常情况下,这种软件对设备的用户来说是不可见的。不仅消费电子产品包含嵌入式软件:任何带有微控制器(例如,实验室仪器或发放停车票的机器)的东西都是软件驱动的。我们必须编写嵌入在硬件设备中的程序:嵌入式软件。
嵌入式开发者工作在严格的约束下:
-
通常资源非常有限:有限的 CPU 功率和/或严格的内存限制。内存限制既涉及 ROM(程序映像),也涉及 RAM(代码执行和存储信息的空间)。在容量不大的平台上,你必须将大量软件塞入可用的设备空间。有时这需要相当有创意(和英雄般的)解决方案,比如在运行时动态解压缩程序代码或数据。
-
用户界面的机会相当有限:你如何将所有用户交互压缩到两个按钮和一个 LED 灯中?实际上,可能根本就没有用户界面;可能没有与用户的直接交互——软件预期只是正常工作。
这些限制对您编写的代码的本质产生了深远的影响。遗憾的是,在嵌入式环境中(比其他环境更多),我们最终不得不牺牲代码的纯洁性以实现某种功能。快速且适合设备 ROM 的代码,比神学上正确但庞大且缓慢的软件更重要。
嵌入式系统被设计来执行单一任务,并且要可靠地完成。它应该看起来就像软件不存在一样;嵌入式设备应该始终正常工作。故障很少是可选项;它可能会物理损坏硬件。这与台式计算机形成对比——它是一种通用机器。它必须能够进行文字处理、播放电影、浏览网站、阅读电子邮件、管理账户等等。作为用户,我们已经习惯了接受偶尔的崩溃和一点不稳定。我们会为了性能和灵活性牺牲一点便利。嵌入式工作完全是另一个领域。
一个很好的例子是现代汽车工业。我们看到许多嵌入式系统被用于制造汽车,控制着各种功能:发动机管理、ABS 刹车、安全特性如安全气囊和座椅安全带预紧器、气候控制、里程表等等。然而,用户(在这种情况下是驾驶员和/或乘客)根本不需要意识到引擎盖下有任何微处理器在运转。他们期望汽车能正常工作。当发动机管理系统出现故障时,用户会突然意识到软件的重要性!再想想手机。它们显然是计算机驱动的设备,但很少有消费者把它们当作计算机。我们在这些小包装中放入了大量的力量,但软件仍必须在一个严格的操作限制范围内工作。
嵌入式系统通常是小型计算机、一些专用硬件以及实时操作系统或简单的控制程序的组合。它将直接控制设备上的硬件。嵌入式系统通常是按需定制的:为特定的硬件、特定的目的而开发。简单的嵌入式系统上只运行一个软件;不使用高度复杂的线程编程环境——甚至不使用操作系统。
代码通常存储在固件中,永久保存在只读存储器芯片中。它很少可以更新,所以它必须第一次就正确无误。没有机会出错并发布 1.1 版本。一个简单的错误就可以让你的奇迹产品变成失败。
最近,随着越来越多的大众市场设备被创造出来,内存和 CPU 功率变得便宜多了。嵌入式环境变得更加强大,限制也在扩大。然而,总会有对非常小型的设备的需求,这些设备只需要完成它们需要完成的事情。仅此而已。
你可能会认为为手持设备如 PDA 编写应用程序是嵌入式级别或应用级别的工作,这取决于你的立场。
总结
我喜欢在金属附近工作——这真的迫使你思考正在发生的事情。你需要紧凑的代码和对硬件所做事情的深入了解。调试问题可能会很棘手,但正是这些挑战使得它变得有趣。——格雷厄姆(嵌入式软件开发者)
典型产品
洗衣机、高保真音响、手机的控制软件。
目标平台
小型、定制设备,资源有限,用户界面简陋。
开发环境
由于你使用的是定制设备,工具链也通常是定制的。通常,与应用程序程序员的相对奢华相比,它并不非常先进。(随着市场的扩大,我们在这里看到了一些改进。)代码是在交叉编译环境中开发的,目标平台与主机编译环境不同。(显然你无法在洗衣机上编译 C 语言程序……至少目前还不能。)
我们为每个特定设备编写专门的软件。嵌入式编程几乎普遍使用 C 语言,除了真正低级的工作,这些工作会使用汇编语言。C++正在进入这个领域,ADA 语言也被使用过。
常见问题和挑战
你可能会遇到各种各样的问题,这很大程度上取决于你是否在使用通用、现成的嵌入式平台,或者是在构建自己的平台。有实时编程的问题(例如,及时处理硬件事件和中断),直接硬件接口,以及控制外围连接的问题,还有诸如字节序和物理内存布局等繁琐的低级问题。
为了确保系统健壮,必须非常重视产品测试.^([02])
^([02]) 当然,任何好的软件开发——不仅仅是嵌入式工作——都需要非常重视测试。在所有环境中,测试往往因为过于热情的市场营销和管理部门而受到挤压,他们并不真正了解软件的本质。然而,桌面应用程序比嵌入式设备的固件更容易更新。
分布式编程
分布式系统由多个计算机组成。正如我们稍后将会看到的,万维网实际上是一个巨大的分布式系统,信息存储在许多跨多个大陆的计算机上,并且通过您的网络浏览器远程提供应用程序。但这并不仅限于网络浏览器。在许多情况下都使用多机架构。与分布式系统一起工作和设计引入了一个全新的问题领域。
你可能需要出于多种原因分发一个软件系统。也许某些类型的计算机比其他类型的计算机更适合特定的任务。也许系统需求很高,你可以通过在网络上许多机器之间共享工作负载来提高性能。也许某些机器有物理位置限制,这要求系统进行分布式。也许你需要将新安装与遗留系统或某些旧硬件进行互操作。
目标是设计一个由不同机器上的多个程序组成的系统,这些程序作为一个统一的整体工作。通过网络连接连接在一起,它们可能物理上位于企业服务器室中,或者分布在全球各地,通过互联网进行通信。
不同的部分需要以某种方式粘合在一起;每个程序都需要进行通信,并且希望能够在远程机器上调用函数,就像它们是本地链接到代码一样。这被称为 远程过程调用 (RPC),并且这种功能由许多可用的 中间件技术 提供。这些充当机器间数据传输的经纪人;它们描述了如何发现和与其他机器上的服务进行通信,以及如何发布您的服务供其他程序调用。中间件管理涉及互操作性的策略:存在安全问题(谁允许调用谁?),网络延迟问题(如果远程函数调用耗时过长或计算机崩溃会发生什么?),平衡同步远程函数调用与异步调用的考虑,等等。
一些中间件系统采用面向对象技术;一些则更多地采用过程式方法。中间件仅仅是连接软件,并允许一定程度的平台中立性。只要中间件在某个平台上运行,客户端代码就不必关心它调用的是哪个平台——甚至可能是 ZX spectrum——函数调用看起来都是一样的。当然,在设计分布式系统时,您将为每个任务选择适当的硬件。您不太可能看到 ZX spectrum 四处游荡!
常用的中间件包括 CORBA、Java RMI、微软的 DCOM 和.NET 远程通信。使用这些,我们将系统分为用户界面元素、业务逻辑(真正的代码工作马)和所需的任何存储(例如,数据库和查询引擎)。用户界面客户端可能是一个 GUI 程序或基于 Web 的前端。这是经典的 分层架构方法(在 271 页的"客户端/服务器架构”中描述),我们也看到了 Web API 的出现——使用标准 Web 协议的服务通信方法。
网格计算 和 集群系统 是特定的分布式机制,有助于数值编程工作(关于这一点稍后还会详细介绍),能够创建高性能、分布式计算算法。集群是紧密耦合的系统;通常所有机器都在同一个房间,使用相同的硬件和操作系统,通过特定的集群中间件连接。网格是松散耦合的;它们可能在地理上分散,运行异构环境。它们通过标准 Web 协议(例如,HTTP/XML)进行通信。
概览
天花项目,于 2003 年完成,是一个网格计算项目,旨在通过筛选大量潜在的药物分子来帮助寻找天花的治疗方法。这是一个由科学家、大学和商业机构合作的项目,确定了 44 种治疗该疾病的有力候选药物。
典型产品
在线购物系统,将工作分配给前端应用程序(网页界面、店内自助终端和/或电话订购系统)、业务逻辑(管理库存控制、实现订购系统和安全计费)以及共享存储。
目标平台
许多不同的计算机系统通过中间件连接,几乎总是位于标准网络协议之上。
开发环境
多样化和复杂。这取决于使用的语言、系统中每台计算机的性质以及使用的中间件类型。远程可调用的接口通常以某种形式的接口定义语言(IDL)定义,并编译成实现语言的表示形式,提供所有调用粘合剂,并为每个函数实现提供插槽。
常见问题和挑战
设计计算机间服务正确的分割和简化涉及的通信。这可能会严重影响分布式系统的可扩展性。对于每天只有几笔交易可能有效,但对于每分钟 100 笔交易可能就不高效了。这要求我们精心设计。你还得处理计算机的可用性,并在系统中的某台计算机不可用时优雅地应对。
网络应用程序编程
1990 年,蒂姆·伯纳斯-李创建了第一个 HTML 浏览器和服务器,万维网诞生。今天,它是一种无处不在的技术,服务器不仅可以提供静态信息页面,还可以根据在 web 服务器上运行的程序动态创建页面。这是一种非常具体的分布式计算形式,其中用户界面托管在远程客户端上:网络浏览器。
这种应用的例子包括:
-
在线购物
-
公告板、消息服务和基于网络的电子邮件包
-
票务可用性和预订系统
-
互联网搜索引擎
现在大多数人使用网络应用程序而不加思考;这就像使用本地文字处理器一样自然。这些程序显然具有与普通(所谓“富客户端”)桌面应用程序不同的特性。每项都能做得很好。没有英勇的 JavaScript 编码,基于浏览器的应用程序 UI 的交互性会大大受限。
概览
一个网络应用程序使你将网络浏览器视为操作系统。所有优秀的网络开发者都是从学习客户端浏览器技术开始的。然后你学习编写良好的服务器端代码(即快速、并发、事务性、分布式和正确)。关于网络最好的事情是它始终在发展,用户的期望总是不断提高。关于网络不好的事情是用户的期望总是不断提高,而你的代码永远不会停滞不前。 ——Alan(网络应用程序程序员)
典型产品
需要最新信息和反馈的交互式服务:票务预订或购物系统。
目标平台
后端是一个网络服务器(通常是 Apache 或 IIS)。这个选择在你手中,因为你部署了网络应用程序。客户端是网络浏览器,有众多变体。每个都有自己的怪癖,你无法控制使用什么。你必须生成与大多数这些浏览器兼容的网页。
开发环境
环境包括特定的网络服务器和你在该服务器上编写的系统应用程序语言。常见的语言有 Perl 和 PHP。
常见问题和挑战
应对不同的浏览器;可扩展性。
网络应用程序的操作模型与传统的应用程序编程不同——会话状态存储在远程机器上,该机器必须管理众多同时的客户连接,在 HTTP 交互之间存储它们的状态,并优雅地处理停止连接的客户。为此,一些信息存储在服务器上(例如,每个客户订购的项目存储在数据库中),一些存储在本地客户端上(使用网络浏览器的cookies——存储的会话状态的小块来记录当前用户/会话 ID)。像 ASP.NET 和 Java Servlets 这样的框架存在是为了加速网络应用程序的开发。存在许多现成的系统,如内容管理系统和购物车系统。
许多开放标准协议和编码系统被用来表示和传输信息。HTTP 是常见的数据传输机制,XML 通常用于编码数据包(例如,SOAP 是基于 XML 模式的基于 Web 的通信协议)。
网络应用程序程序员面临的问题主要围绕着与可能使用的各种浏览器的互操作性,处理它们的 HTML 特性和奇特的 JavaScript 怪癖。为了应对流行浏览器中的各种缺陷,开发出复杂的 HTML 输出并不罕见。网络程序员经常需要与遗留系统(客户数据库、现有的订单管理系统等)接口以生成信息;这可能会变得相当混乱。可扩展性是一个真正的担忧:一个系统在测试时可能由五个同时用户使用时运行良好。但是当它上线时,它必须能够承受 500 个用户同时访问。负载测试在这里很重要(参见第 139 页的“负载测试”)。
企业编程
企业是那些令人厌烦的流行词汇之一,它漂浮在周围,更多的是管理术语而不是任何程序员方言。企业字面上是一个商业组织。因此,企业编程为整个公司提供系统,将它们各自独立的系统粘合在一起,形成一个统一、协调的整体。企业编程几乎总是意味着大型分布式系统的开发。
它们通常会在公司内部网络(内部网络)上部署,将业务的不同部门连接起来,以提高工作流程。这些系统可能是面向客户的,也可能不是。一旦组织运行了集成的计算机系统,通常不太难实现自动化的客户互动——例如,通过基于网络的商店界面。也许企业系统还需要与其他公司的系统接口,例如跟踪正在运输的货物的交付状态。
企业编程与定制应用软件有很多共同特征。产品只需要“足够好”,因为它是在合同下为特定客户开发的,而不是为了一般市场发布而投机性开发。在这里,质量不是成功的衡量标准(至少是由一般的稳定性和比任何竞争对手都大的功能集所决定的);满足客户的目标才是。
企业系统是为安装在公司的服务器室或锁定桌面机器上的特定机器编写的。你对执行环境有合理的控制权,因此你不需要担心代码在操作系统的每个版本和每种可能的硬件配置下都能工作。这巧妙地避开了应用程序程序员所遭受的许多头痛问题。
概览
我在大城市的一家银行的 IT 部门工作。我们编写软件来解决特定的商业需求。这是至关重要的任务;我们所做的工作对公司利润有真正的影响,因此我们必须认真对待。每小时有成千上万美元通过系统,没有出错的空间。——理查德(企业程序员)
典型产品
整个公司的商业系统,管理其商业运营。
目标平台
定制的分布式系统。
开发环境
与分布式系统相同。我们可能会处理巨大的数据存储,可能是来自以前内部系统(经理们所说的遗留系统)的各种数据库技术。在这里,XML 非常流行。
常见问题和挑战
与分布式系统相同。
数值编程
这种工作涉及科学性、高度技术性的任务,大量使用数学知识。这是一个非常专业化的领域,需要编写专门针对特定数值问题的应用程序。这些程序通常针对超级计算机,这是最快类型的计算机,能够进行大规模的数值计算操作。尽管我们生活在每年最快计算机都在变化的年代,但这些平台非常昂贵,用于需要巨大数学计算的专业应用。
例如,天气预报需要超级计算机(或者可能是一种预言的天赋!)我们还可以看到超级计算机被用于动画图形、流体动力学计算和其他需要高度复杂数学研究和计算的领域。
超级计算机不是大型机。后者是一种高性能计算机,旨在尽可能并发执行尽可能多的程序,通常在企业环境中用作集中式计算资源。超级计算机将所有力量集中在尽可能快地执行少数几个程序上。有几种不同的超级计算机架构利用不同的技术进步,每种都需要不同的算法方法来充分利用其性能。通用机器现在变得足够强大,可以进行严肃的数值工作——集群化后,它们可以相当体面地成为一个穷人的超级计算机。
数值工作需要高性能算法来快速执行计算,以利用计算平台的性能。通常,人们会使用精心设计、高度优化的数值库,并明确使用并行处理,将这种设计融入计算算法和过程中。这将涉及任务并行和数据并行:要么同时在多个 CPU 上执行许多类似任务,要么流水线化算法,在不同的 CPU 上执行其不同部分。
这个编程分支需要对目标平台的特性进行大量优化,以实现可接受的性能。
概要
我在一家工程公司工作,从事软件开发系统。我们模拟大型机械安装,以确定现在或未来可能存在的物理问题。我必须以数学方式表示现实世界,找出事物(应该如何)工作。一旦我这样做,就是找到合适的数学结构来以可接受、准确的方式表示系统的问题。——安迪(数值编程专家)
典型产品
涉及高度复杂数学研究领域的领域,如核能研究或石油勘探。
目标平台
超级计算机或基于网格的计算集群。
开发环境
虽然有人在推进 C++中的数值编程支持,并且其中一些工作是在 C 中完成的,但大量的数值编程是在 Fortran 中完成的,它具有出色的数值支持(这正是它被设计的目的:公式翻译)。
常见问题和挑战
设计高效的算法,真正利用超级计算机的威力。
那么,这又如何呢?
对答案的渴望的解脱对于理解问题至关重要。
--吉杜·克里希那穆提
这些编程细分领域如何影响我们?它们让我们做什么不同?要成为一名优秀的程序员,真正的工匠,你必须知道:
-
你的学科是什么——你正在生产的软件类型。
-
该学科如何影响你的架构。(是分层的企业系统还是紧密编织的嵌入式代码?见第十四章。)
-
在这个领域,什么样的代码设计是合适的,什么样的不合适。(例如,你是否应该为了性能而牺牲清晰和优雅,试图将可执行映像压缩到尽可能小的尺寸,或者可能为了未来的可扩展性而包含许多钩子?)
-
你使用的工具——什么可用,什么不可用。
-
哪种编程语言的选择最合适,以及你应该采用哪些编码惯例。
关键概念
了解你的学科。学习其复杂性。理解如何编写满足其要求的优秀软件。
简而言之
仍然在拐角处等待,一条新的道路或一扇秘密的大门。
--J.R.R. 托尔金
我们已经涉足其中,品尝了那里正在进行的不同编程风味。当然,除了我们看到的这些领域之外,还有其他领域:一些定义明确,一些则更短暂。例如,安全关键软件驱动着高可靠性系统,如医疗设备和飞机控制系统。在这里,失败不是一种选择,代码必须是可证明的正确;这深刻地影响了你的设计和编写方式。
我们学到了什么?这些领域都有一个共同点:它们的差异。每个领域都需要做出基本的设计决策,以适应软件。应用层代码通常不适合嵌入式环境。工作站应用程序设计在应用于分布式系统时可能无法扩展。
这意味着软件开发者往往会在特定领域专业化,并学会以适合他们世界的特定模式思考。理解每个环境的真实关注点将使你成为一个更灵活和成熟的程序员。最终,你必须了解你的编程教会,并熟练掌握其仪式和仪式。
| 精通编程的人... | 不擅长编程的人... |
|---|
|
-
理解他们面临的问题的本质
-
将他们的代码和设计定制到问题领域
|
-
拥有一个天真狭隘的软件世界观;他们不理解推动其他类型软件开发的力量
-
编写不适合问题领域的代码(选择不友好的架构或不适用的代码惯例)
|
参考以下内容
第七章
不同的细分市场有不同的质量和开发工具的范围。
第十四章
不同的问题领域需要非常不同的软件解决方案。

培养思考
这些问题的详细讨论可以在第 557 页的"附录 A"部分找到。
思考
-
我们在这里探讨的编程细分领域中,哪些特别相似或具有共同特征?哪些特别不同?
-
哪种编程学科最难?
-
成为某个特定领域的专家,或者在没有特定专业的情况下对所有领域都有良好的基础,哪个更重要?
-
应该向训练中的程序员介绍哪个编程细分领域?
个人感悟
-
你现在正在哪个编程领域工作?这如何影响你编写的代码?它引导你做出了哪些具体的设计和实现决策?
-
你在多个编程学科中工作过吗?你切换思维模式和在不同世界中应用适当技术有多容易?
-
你工作中的人是否不了解塑造你所编写特定类型代码的力量?你是否有由只理解应用程序工作的程序员编写的嵌入式软件?你能做些什么来解决这个问题?
第二十四章。接下来是什么?
一切皆好,只要结局圆满
我们称之为开始的东西往往是结束。而结束就是开始。结束是我们出发的地方。
--T.S. 艾略特
恭喜!你已经到达了这本书的结尾。要么是这样,要么你就是那种喜欢先读最后一页来破坏结局的人。(如果你是的话:管家干的。)假设你已经阅读了每一章,到现在你应该已经:
-
学到了许多实用的代码编写技巧,这些技巧已经改进了你的源代码。
-
理解了如何在现实世界中编写代码以及帮助你在软件工厂的混乱中产生有用代码的技巧。
-
制定了一些个人方法来提高你的技能集。(你尝试过这些问题吗?如果没有,现在试试它们。)
-
发现了如何作为团队的一员编写有效的代码,确立了改善你们团队目前合作方式的实际步骤。
-
对卡通猴子的了解比你所需要的要多。
但更重要的是,你现在应该认识到,一个卓越的程序员是拥有正确态度的人:在任何情况下都寻求编写最佳代码的人,与别人合作得很好,在软件工厂的热潮中能够做出实用决策的人。工匠知道如何管理技术债务,并试图在问题成为软件陷阱之前尽早解决它们。
关键概念
成为一名优秀的程序员需要你采取有效的态度*——你对待软件构建的角度。
但接下来怎么办?
重要的是不要停止质疑。好奇心有其存在的理由。当人们思考永恒、生命、现实奇妙结构的奥秘时,不禁会感到敬畏。如果一个人每天只是试图理解这个奥秘的一小部分,那就足够了。永远不要失去神圣的好奇心。
--阿尔伯特·爱因斯坦
作为一名代码工匠,你永远不会达到完美;你所能达到的最好的状态是持续改进。总有更多东西要学习。那么你现在应该做什么呢?你提出这个问题的行为本身至关重要——一个代码工匠最重要的特征之一就是渴望改进。
如果我想成为一名技艺高超的足球运动员,我可能会找一些关于足球的书,买一张足球训练视频,然后坐下来,一边吃爆米花,一边喝几杯啤酒来学习如何踢球。很好。两个月后问我进展如何。如果我说,“我读了很多关于它的东西,我知道顶级球员的所有顶级技巧,”那么你不会感到丝毫的印象深刻:我实际上能踢得怎么样?阅读关于这项运动的内容并研究它是件好事,但沙发土豆式的足球技巧并没有什么实际用处。
我只能通过实践来学习足球——通过在场上踢球,通过参与游戏。熟能生巧。我需要和技艺高超的人一起踢球,他们能很好地指导我。我需要消耗能量,感受痛苦,也许在别人面前出丑。慢慢地,逐渐地,痛苦地,我会变得更好。
我很遗憾地告诉你,这也是成为一名优秀的代码工匠的唯一途径。仅仅阅读这本书是不够的。你必须走出去,真正地去做。所以,我们如何将这个理念转化为实践呢?以下是一些简单的想法:
-
把这本书放在你的书架上。尽可能地将你学到的知识应用到实践中。你以后遇到问题时,可以随时查阅特定章节。
在几个月按照这些建议工作之后,再次拿出这本书,重新阅读一遍。特别注意“个人成长”部分中的问题——找出你为了提高代码质量必须采取的下一步行动。每次你完成这个过程,你都会发现提高技能的新方法。
-
将你的职业生涯引导到伟大程序员的道路上,并尽可能从他们那里汲取所有知识。学习是什么使得他们的代码优秀,他们的态度建设性,以及你如何将这些特点应用到你自己身上。寻求他们的建议、批评、审查和意见。请他们成为你的导师。(如果你需要,可以用爆米花和酒精来贿赂他们!)
-
继续编程,拓展你的视野。编写更多的代码。尝试新的技术。解决新的问题,不同的语言,和不熟悉的技术。
-
不要害怕犯错误;你不可能一夜之间成为完美的程序员。在学习的过程中,你几乎肯定会犯许多令人尴尬的错误。不要让这些错误阻碍你的成长或定义你作为一个程序员的形象。除非你尝试新的技术,否则你永远不会学到东西,也不会有所改进。乔治·萧伯纳写道:“一生都在犯错误的生活比一生无所事事的生活更有价值。”
以建设性的态度接受建议和代码审查意见。回顾你所做的一切,看看如何改进。
-
发展你可以在技术知识中作为参考框架的课外兴趣。如果你只研究编程,那么你将是一个非常二维的人,并且无法将代码工艺融入现实世界的背景中。
-
在你的领域找到经典书籍。(《代码工艺》显然是其中之一!)获取每一本书的副本,并好好消化。每个学科和每种语言都有其著名的宗师——确保你知道他们是谁以及他们写了什么。
阅读经典的软件著作,如:
-
《人月神话》 (Brooks 95)
-
《计算机编程心理学》 (Weinberg 71)
-
《人件集:高效的项目和团队》 (DeMarco 99)
-
《程序员修炼之道》 (Hunt Davis 99)
-
《代码大全》 (McConnell 04)
-
《编程实践》 (Kernighan Pike 99)
-
《设计模式:可复用面向对象软件元素》 (Gamma 等人 94)
-
《重构:改善既有代码的设计》 (Fowler 99)
向你的同行询问他们认为有价值的书籍。寻找相关的杂志、网站和会议。
-
-
教学并指导一个能力较弱的程序员。通过传授你的智慧,你会学到更多。
-
通过加入像英国计算机协会(BCS)、计算机制造协会(ACM)或 ACCU (www.accu.org) 这样的专业组织来拓宽你的技能基础。然后加入其中——做出贡献。你参与的越多,你对自己投资的就越多。例如,ACCU 是一个高度贡献的组织。它运行导师开发者项目,并鼓励成员为其期刊撰写文章。这些组织举办编程竞赛,提供社交网络论坛,并且通常有本地分会,你可以在这里遇到关心编程工艺的志同道合的人。
-
享受乐趣!享受编写代码解决棘手问题。制作让你自豪的软件。孔子说:“如果你喜欢你所做的事情,你将永远不会觉得工作是一天。”
关键概念
承担提高你技能的责任。永远不要失去你对编程的热情或追求卓越的愿望。
附录 A. 答案和讨论
完整心智发展的原则:研究艺术科学。研究科学艺术。发展你的感官——特别是学习如何观察。认识到万物相互联系。
--莱昂纳多·达·芬奇
这一部分包含了我对每章末尾问题的思考。它不是一个直接的答案集——很少有问题是明确的是或否的回答。将你的答案与这些进行比较。
这些问题的目的是简单地让你思考,让你深入每个主题,并激励你提高你的编程技能。
如果你只是想看看“答案”而不先思考问题,我真心建议你不要这样做。花点时间思考问题并形成个人见解会真正带来回报。正如孔子所说:“听而不闻,视而不见,做而能解。”
第一章
深思熟虑
- 防御性编程是否过多?
是的——正如过多的注释会降低代码可读性一样,过多的防御性检查如果做得不好,也会降低代码质量。通过仔细的编码可以避免冗余检查;例如,通过选择合适的类型。
- 你应该为每个找到并修复的错误在代码中添加断言吗?
从本质上讲,这并不是一个坏习惯。但想想你会在哪里添加断言。许多错误都是由于未能正确遵守 API 契约。如果你向函数传递了垃圾数据,你会在该函数内部进行一些前置条件检查,而不是在调用点放置测试。如果函数返回了垃圾数据,你会修复该函数,使其不再发生(并证明其已修复),或者编写一些后置条件。
为每个找到并修复的错误添加一个新的单元测试会更有益。
- 断言是否应该在生产构建中条件编译为空?如果不是,哪些断言应该保留在发布构建中?
人们在这个问题上持有强烈的信念。答案并非非黑即白;双方都有强有力的论据。总有一些非常挑剔的断言实际上在生产构建中并不需要留下。但某些断言的出现可能仍然会吸引你的注意。
现在,如果你在发布版本中留下任何约束检查,它们必须改变行为——程序在失败时不应终止,而应记录问题并继续运行。
记住:真正的运行时错误检查永远不应该被移除;它们本来就不应该在断言中编码。
- 异常是否是比 C 风格断言更好的防御性屏障?
它们可以。异常的行为不同;在向上传播调用栈的过程中,异常可以被捕获并忽略——抑制其效果。这使得异常成为更灵活的工具。你不能忽略一个会终止执行的assert;断言是更低级的机制。
- 前置和后置条件的防御性检查是否应该放在每个函数内部,或者围绕每个重要的函数调用?
在函数中,毫无疑问。这样,你只需要编写一次测试。你想要将它们移出的唯一原因是为了获得灵活性,以便选择在约束失败时会发生什么。这种复杂性激增和潜在失败的可能性并不足以带来令人信服的收益。
- 约束是否是一个完美的防御性工具?它们有什么缺点?
不,它们远非完美。冗余的约束条件充其量是害虫,最坏的情况下是障碍。例如,你可以断言函数参数i >= 0。但将i设为无符号类型,这样它就不能包含无效值,会更好。
对那些可以被编译掉的约束条件持一定程度的怀疑:我们必须仔细检查任何副作用(断言可能具有微妙间接后果)以及在调试构建中改变其行为的计时问题。确保断言是逻辑约束,而不是真正的运行时检查,这些检查不应该被编译掉。在错误防御代码中放入错误是可能的!
但如果谨慎使用,限制条件仍然远比在机会的炽热煤炭上赤脚跳舞要好。
-
你能避免防御性编程吗?
-
如果你设计了一种更好的语言,防御性编程仍然有必要吗?你该如何做到这一点?
-
这表明 C 和 C++因为存在许多问题表现区域而存在缺陷吗?
-
一些语言特性当然可以设计成避免错误。例如,C 不会检查你执行的任何数组查找的索引。结果,你可以通过访问无效的内存地址来崩溃程序。另一方面,Java 运行时在查找之前会检查每个数组索引,因此这种灾难永远不会发生。(坏索引仍然会导致错误,但错误类型定义得更好。)
尽管你可以对宽松的 C 规范做出很多“改进”(我敦促你尽可能多地思考),但你永远无法创建一种不需要防御性编程的语言。函数始终需要验证参数,类始终需要检查不变性,以确保其数据内部一致。
虽然 C 和 C++确实提供了很多出错的机会,但它们也提供了大量的功能和表达能力。这使语言存在缺陷取决于你的观点——这是一个容易引发圣战的议题。
- 你不需要担心编写哪些类型的防御性代码?
我曾与一些人合作,他们拒绝在旧程序中添加任何防御性代码,因为程序太糟糕了,他们的防御措施不会有任何作用。我设法抑制了用大锤敲打他们的冲动。
你可能会争辩说,一个小型独立单文件程序或测试框架不需要这种类型的谨慎防御性代码或任何严格的约束。但即使在这些情况下,不谨慎也只是粗心大意。我们应该始终努力进行防御。
个人感悟
- 你仔细考虑过你输入的每一句话吗?即使你确定一个函数不会返回错误,你也会不懈地检查每个函数的返回代码吗?
我打赌你不会检查每一件事。很容易忽略某些函数返回码,尤其是当一些被认为比其他更重要时。有多少 C 程序员检查printf的返回值?有多少人实际上知道它会返回某些内容?
-
当你记录一个函数时,你会声明前和后置条件吗?
-
它们在函数功能的描述中总是隐含的吗?
-
如果没有前或后置条件,你会明确记录这一点吗?
-
无论你认为合同有多明显(从函数名称或其描述中),明确地陈述约束条件可以消除任何歧义——记住,总是最好消除假设的区域。明确写出先决条件:无将明确记录合同。
当然,你不想让每个函数都明确重申全局先决条件。这将是一项繁重而乏味的工作。如果整个 API 都期望指针值不能为空,那么在全球范围内记录这一点可能更好。
- 许多公司只是口头上提到防御性编程。你的团队推荐它吗?看看代码库——他们真的这样做吗?约束条件在断言中是如何编码的?每个函数中的错误检查有多彻底?
很少有公司拥有具有适当防御级别的优秀代码文化。代码审查是提高团队代码到合理标准的好方法;众人的眼睛能看到更多潜在的错误。
-
你天生就足够偏执吗?过马路前你会两边都看吗?你会吃你的绿色蔬菜吗?你会检查代码中每一个可能出现的错误,无论可能性有多小吗?
-
做到彻底有多容易?你会忘记考虑错误吗?
-
有什么方法可以帮助你编写更全面的防御性代码吗?
-
没有人觉得这很自然容易——认为你精心编写的代码会有问题,这与程序员的直觉相悖。相反,你应该预期任何将使用你的代码的人都会做出最坏的情况。他们远不如你那样尽责的程序员!
一个非常有用的技术是为每个函数或类编写单元测试。一些专家强烈建议在编写函数之前就做这件事,这很有道理。这有助于你考虑所有可能的错误情况,而不是愉快地相信你的代码会正常工作。
第二章
沉思
- 你应该改变遗留代码的布局以符合你最新的代码风格吗?这是代码格式化工具的有价值用途吗?
通常来说,保留遗留代码的原始状态是最安全的,即使它很丑陋且难以操作。我只有在绝对确定原始作者永远不会需要返回时,才会考虑重新格式化。
通过重排,你将失去轻松比较源代码特定版本与之前版本的能力——你将被许多、许多格式更改所困扰,这些更改可能会隐藏你真正需要看到的那个重要差异。你还有在重排中引入程序错误的风险。
就代码重排工具而言,它们是很好的奇观,但我并不提倡使用它们。一些公司坚持在将任何代码检查到他们的存储库之前,通过美化器运行源文件。优点是所有代码都被同质化、消毒并统一格式化。主要缺点是没有任何工具是完美的;你将失去一些作者布局的有用细微差别。除非你团队上的所有程序员都是长臂猿,否则不要使用重排工具。
- 常见的布局约定是在一定数量的列中拆分源行。这种做法的优缺点是什么?它有用吗?
就像许多展示关注点一样,没有绝对的答案;这是一个个人品味的问题。
我喜欢将我的代码拆分,以便它能在 80 列显示中显示。我一直这样做,所以这更多的是一种习惯,而不是其他任何事情。我不反对那些喜欢长行的人,但我发现长行很难处理。我将我的编辑器设置为自动换行而不是提供水平滚动条(水平滚动很笨拙)。在这种环境中,长行往往会破坏任何缩进的视觉效果。
如我看来,固定列宽的主要优势不是可打印性,正如有些人所声称的那样。它是在同一显示上并排打开几个编辑器窗口的能力。
在实践中,C++会产生非常长的行。它比 C 更冗长;你最终会在通过模板容器引用的其他对象上调用成员函数。... 有策略可以管理由此产生的许多、许多长行。例如,你可以将中间引用存储在临时变量中。
-
一个合理的编码标准应该有多详细?
-
偏离风格的严重程度如何?不遵守它应该截肢多少肢?
-
一个标准可以过于详细和限制性吗?如果它确实如此,会发生什么?
-
对于任何编码标准的偏离,应该截肢六肢。
正确答案实际上取决于你所在环境中编码标准的详尽性和编码文化。通常有更大的软件问题需要解决,而不仅仅是括号放置不当的问题,但括号更容易抱怨。我见过许多如此规定性和令人瘫痪的编码标准,以至于可怜的程序员们只是简单地忽略了它们。为了有用和被接受,编码标准应该提供一些操作空间,也许可以通过提供作为例子的最佳实践方法来实现。
- 在定义新的展示风格时,需要多少项目或案例来布局规则?还需要提供哪些其他展示规则?列出它们。
如果你单独写出每个布局规则,将需要考虑大量的情况。编码风格是许多力量的微妙互动:缩进,是的,但还有内部空格、命名、操作符的位置、括号的展示、文件内容、头文件的使用和排序等等,还有更多。
以下列出的展示项目 是 很长的,但远未完整。它是风格检查表的良好起点。在实践中,某些项目比其他项目标准化更重要。当你阅读这个列表时,确保你已经考虑了每个项目的个人偏好。同时,确保你知道你当前软件项目的正确约定。
代码边距
-
每个缩进级别的空格数量决定了代码左侧的边缘形状。通常可以看到两或四个空格的缩进,尽管一些程序员选择三格作为外交策略。较小的缩进意味着你不会很快遇到右边缘,但它们看起来很杂乱,并且难以区分层次。较大的缩进更明显,但你很快就会用完空间。
-
是否使用制表符或空格缩进是一个长期存在的争议,它驱使许多程序员寻求心理治疗。空格更便携;在任何编辑器中都会显示相同的宽度。当使用可变宽度字体显示代码时,^([1]), 制表符可以提供更好的对齐。
-
页面宽度决定了你如何格式化右侧代码边缘。你可以将行限制在固定数量的列中,或者让它们无限增长,需要水平窗口导航。固定页面通常宽度为 79 或 80 个字符。这是历史性的;80 个字符是常见的终端宽度,但最后一列并不总是可用于显示。
-
在对某些结构进行对齐时,有多种选择。在类声明中,你将
public:,private:, 和protected:放在哪个级别?在switch语句中,case标签应该放在哪里?你如何格式化你几乎从不使用的goto语句的标签?^([2])
空格和分隔
-
你可以使用内部表格布局来对齐代码片段;例如,在后续行中对同一列中的操作符进行对齐。这为语句块的功能提供了视觉强调。然而,这需要额外的输入和维护工作,并且一些程序员认为这样做是不合理的。表格式水平布局可能看起来像这样:
int cat = 1; int dog = 2; char *mouse = "small and furry"; -
空白可以几乎出现在任何地方,并且有不同方式来分隔单个代码语句。在操作符周围放置空格是个好主意,例如:
hamster = "cute"。这就像你在写的时候单词之间有空间一样。另一种选择,hamster="ugly",看起来拥挤且密集。 -
类似地,函数调用也可以以各种方式缩进。你可能采用以下格式之一:
feedLion(mouse) feedLion( hamster ) feedLion (motherInLaw)许多人认为后一种选择是错误的——数学方程式在函数名后面不会有空格。(然而,岳母可能真的是一种真正的可食用商品。)
你是否应该遵循类似的约定来处理关键字?
while(lionIsAsleep)看起来如何?拥挤。关键字不是函数;它们读起来更像单词,因此通常在它们周围会有空格。 -
如果代码太长,无法在一行中显示,就必须进行拆分,但拆分的位置又是一个选择。自然,你会在最合理的地方进行拆分,但一个人的逻辑可能是另一个人的愚蠢。通常,代码会在操作符周围进行拆分,但是在操作符之前还是之后——操作符是否出现在上一行的末尾或下一行的开头——这是一个品味问题。
变量
-
经典的 C/C++争论是指针声明中星号的位置(常被称为星球大战)。你可以在这三种选择之间进行选择:
int *mole; int* badger; int * toad;前两种将“指针性”与变量和类型分别关联。与类型关联的问题在于它对于像
int* weasel, ferret;这样的语句并不像预期的那样工作。第三种是一个合理的折中方案,但并不常见。 -
一些 C/C++标准规定,所有常量名称都应该使用大写字母,以便清晰。有些人认为只有预处理宏名称应该大写。
代码行
-
每一行上确切地放什么是一个布局问题;通常要求每个单独的语句都在自己的行上,使得每个语句都清晰且易于区分。
-
这引出了语句中的副作用问题;你是否允许代码如
index[count++] = 2或允许在if中赋值? -
一些展示风格会将代码放在与开括号相同的行上:
for (...) { ostrich++; buryHead(ostrich); }
构造
-
你是否应该始终包含大括号,即使其中只有一个语句?你可能允许当代码在同一行上继续时省略大括号,如下所示:
if (weAreAllDoomed) startPanicking(); -
通常可以看到
else子句与相应的if在同一列对齐,但有时也会看到它们放置在从属缩进级别。 -
使特殊情况清晰有多重要?一些编码标准规定,在
switch语句的case之间应该用注释标记跳过;同样,循环中的no-ops应该标记以避免混淆;否则,这个没有身体的循环可能会让不小心的人困惑,它寻找 C 字符串str的结尾:char *end; for (end = str; *end; ++end); -
C++内联方法应该放在类声明内、外(直接之后),还是单独的源文件中?
文件
-
最基本的决定是如何将项目拆分成文件以及将什么信息放入每个文件中。是每个类或每个函数一个文件吗?或者可以将文件拆分成比这更小或更大的单元,比如每个库或代码部分?如果有许多非常小的相关类怎么办?你真的希望有很多非常小的相关文件吗?^([3])
-
将文件分割成部分的约定不同。一些程序员喜欢插入一些空白行作为分隔符,一些更喜欢注释块,一些喜欢大量的 ASCII 艺术品。
-
在 C/C++ 中,
#include文件的确切顺序可能由展示风格固定。这里有不同的观点。有些人喜欢首先整齐地排列系统包含,然后是项目包含,然后是特定文件包含。其他人认为完全相反的做法更安全;它可以防止一个头文件意外地依赖于通常在其之前包含的头文件。一些标准建议没有任何头文件应该永远#include另一个,将其留给每个实现文件手动完成。
杂项
总会有许多特定于特定编码情况的其他问题。如何在执行数据库访问的代码中格式化内嵌的 SQL 命令?你是否需要在跨不同语言的项目中保持一致的格式?
- 哪个更重要——良好的代码展示还是良好的代码设计?为什么?
这实际上是一个非常人为的问题。两者对良好的代码都是基本的,你不应该被要求为了另一个而牺牲其中一个。如果你被要求这样做,要小心。然而,你刚刚选择的是否能说很多关于你作为程序员的事情。
糟糕的格式化当然比糟糕的设计更容易修复,特别是如果你使用巧妙的工具来统一你的代码格式。
展示和设计之间存在有趣的联系:糟糕的展示往往表明代码是由一个糟糕的程序员编写的,这可能意味着它也遭受了糟糕的内部设计。或者它可能意味着代码是由一系列不同的程序员维护的,随之而来的是初始代码设计的丢失。
个人化
-
你是否以一致的风格编写代码?
-
当你与其他人的代码一起工作时,你会采用哪种布局风格——他们的还是你自己的?
-
你的编码风格有多少是由你的编辑器的自动格式化决定的?这是采用特定风格的一个充分理由吗?
-
如果你不能改变你的编辑器为你定位光标的方式,你不应该使用它(要么你太笨拙,要么你的编辑器有问题)。
如果你不能以一致的风格编写代码,你应该被撤销程序员执照。如果你不能遵循他人的展示风格,你应该被迫在余生中维护 BASIC。
保护你的态度:典型的程序员更关心他的代码、个人实践和个人的布局迷恋,而不是项目的整体健康。过于频繁地,会出现个人与团队的困境。如果一个程序员反抗强加的房屋风格或无法使用现有的展示风格来维护代码,这是一个坏信号。这表明程序员看不到大局。
-
制表符:它们是魔鬼的杰作,还是自从切片面包以来最好的东西?请解释原因。
-
你知道你的编辑器是否自动插入制表符吗?你知道你的编辑器的制表符停止位置是多少吗?
-
一些非常流行的编辑器使用制表符和空格的混合来缩进。这会使代码的可维护性降低吗?
-
制表符应该对应多少个空格?
-
由于这是一个如此宗教性的问题,我只能说制表符很糟糕,然后迅速退却。实际上,我会补充说,比使用制表符缩进更糟糕的是同时使用制表符和空格缩进——这是一个噩梦!
如果你的编辑器正在不让你察觉的情况下插入制表符(以及可能的空间),尝试使用其他编辑器一段时间,以体会这是多么令人沮丧。尝试将你的制表符停止位置设置为不同的值,看看它会把代码搞成什么样子。“每个人都使用相同的编辑器,所以这无关紧要”不是一种专业态度。并不是每个人都使用相同的编辑器,所以这确实很重要。
你会听到人们推荐他们选择的制表符长度,并仔细地为自己的观点辩护。这很好;事实上,一项受尊敬的研究声称,三个或四个空格的制表符停止位置提供最佳的可读性。(我更喜欢四个空格,因为我不喜欢奇数!)然而,制表符应该对应不是固定数量的空格。制表符就是制表符,它不是空格或其任何倍数。对于使用制表符布局的代码,制表符显示的确切空格数并不重要——代码应该易于阅读。不幸的是,我很少看到按照这种方式工作的制表符缩进代码。过于频繁地,制表符和空格被混合在一起,以使代码整齐对齐。当制表符停止位置设置为作者意图时,这没问题。但是,任何其他设置都会造成混乱。
-
你有偏好的布局风格吗?
-
用一系列简单的陈述来描述它。要完整。例如,说明你是如何格式化
switch语句和拆分长行的。 -
需要多少个语句?这是你预期的吗?
-
你的公司有编码标准吗?
-
你知道它在哪吗?它被宣传了吗?你读过它吗?
-
如果答案是肯定的:它是否足够好?进行一次诚实的批判,并将你的评论反馈给文档所有者。
-
如果答案是否定的:它应该吗?(解释你的答案。)是否存在一个大家普遍遵循的未成文代码风格?你能推动标准的采用吗?
-
-
是否使用了不止一个标准,也许每个项目一个?如果是这样,项目之间是如何共享代码的?
-
确保你了解任何你应该努力遵守的风格指南(或未记录的惯例)。
这个问题部分是由个人经验激发的:我在一个大型组织中工作,有几个孤立的部门,每个部门都遵循自己的一套指南。随着单独的产品逐渐合并,将代码库的一些部分合并起来在技术上(以及合理的财务上)是有意义的。结果是代码混乱,接口风格不同,展示方式不同,甚至语言使用也不同。它看起来杂乱无章,不够专业,而且很难工作。这很痛苦。
-
你跟随过多少种不同的布局风格?
-
你感觉哪一个最舒适?
-
哪一个定义得最严格?
-
有没有链接?
-
几年编程之后,很容易陷入自己独特的布局风格,而不真正思考你是如何或为什么达到这种风格的。毫无疑问,这是由于你阅读和合作过的其他代码,以及你自己的个人品味混合的结果。花点时间考虑这个问题,并确保你的编码风格是合理的。也许现在是时候修改和改进它了。
改变你的风格并不简单。你仍然需要处理你的旧遗留代码——你应该将其转换为新的风格,还是让它保持原来的状态?
打开一个文本编辑器,输入这段代码;它计算第 n 个素数。它使用了一种特定的编码风格。以你希望看到的方式呈现它。不要尝试改变实现方式。
`/* Returns whether num is prime.*/`
bool
isPrime( int num ) {
for ( int x = 2; x < num; ++x ) {
if ( !( num % x ) ) return false;
}
return true;
}
`/* This function calculates the 'n'th prime number.*/`
int
prime( int pos ) {
if ( pos ) {
int x = prime( pos-1 ) + 1;
while ( !isPrime( x ) ) {
++x;
}
return x;
} else {
return 1;
}
}
这是一段典型的现实世界代码,所以不要把它当作一个愚蠢而乏味的练习来忽视。
注意,我没有在这里给出任何建议答案。我的重新格式化和你的一样有效,实际上和原始格式一样有效。这就是为什么这是一个个人化问题。
如果你阅读这些答案时完全没有思考过这些问题,那就继续吧——试一试这个。书可以先放一放,等你输入几行文字……
现在,看看你写下的内容。
-
你的版本有多不同?你做了多少具体的变化?
-
对于每一次改变,问问自己:这是个人审美偏好,还是你能用某种理由来证明这个改变是合理的?质疑这个理由——它真的是有效的吗?你准备好多么坚定地捍卫它?
-
你对原始格式有多舒适?阅读它让你感到困扰吗?如果你遇到类似这样的代码,你能在这种编码风格下工作吗?你应该能够适应它?
如果你想要重新实现代码以使其更高效,那么给你加分。如果你抵制了诱惑,那么额外加分。(过早优化是一个坏习惯——参见第 206 页的“螺丝钉和螺栓”)。
^([1]) 在已发布的代码中比在源代码编辑器中更常见。
^([2]) 因为,当然,在这样一个开明的时代,没有高质量的程序员会使用 goto——参见第 421 页的“结构化编程”。
^([3]) Java 通过强制类名到文件名的物理映射来回答这个问题。
第三章
仔细思考
-
这些变量名是否合适?请回答是(解释原因,以及适用情境),否(解释原因),还是无法判断(解释原因)。
-
int apple_count -
char foo -
bool apple_count -
char *string -
int loop_counter
-
名称的质量取决于其上下文,我们无法诚实地判断这些名称是否好或坏。这就是为什么问题要求提供示例上下文。有些明显的上下文中,名称可能是不好的:apple_count对于葡萄柚计数器来说可能不是一个特别好的名称。
foo永远不是一个好的名称。我还没有见过有人计数foos。loop_counter也是不好的;即使一个循环太大,不适合简短的计数器名称,你仍然可以选择一个更具描述性的名称,一个反映变量实际用途而不是其作为循环计数器角色的名称。
我们真的无法判断bool apple_count是否是一个好的名称,但它看起来好像不是——布尔值不能持有数字。也许它是在记录苹果的单独计数是否有效,但如果这是这种情况,它应该被命名为is_apple_count_valid。
-
这些名称何时是合适的函数名称?你可能会期望哪些返回类型或参数?哪些返回类型会使它们变得没有意义?
-
doIt(...) -
value(...) -
sponge(...) -
isApple(...)
-
这些可能意味着什么取决于你找到它们的地方。一个名称依赖于其上下文来赋予意义;这个上下文由函数的封装作用域提供。上下文信息也可以由函数参数或返回变量提供。
-
命名方案应该优先考虑代码的易读性还是易写性?你将如何使其中之一变得容易?
-
你写同一块代码的次数有多少?(想想看。)你阅读它的次数有多少?你的答案应该给出一些相对重要性的指示。
-
当命名约定冲突时,你会怎么做?比如说你正在处理驼峰式命名的 C++代码,需要做 STL(使用下划线)库的工作。处理这种情况的最佳方法是什么?
-
我曾工作过使用这种命名约定冲突的 C++代码库。内部逻辑使用驼峰式命名法,而扩展标准库的库和组件遵循 STL 命名约定。这实际上工作得相当好,清楚地标记了项目的不同部分。
不幸的是,这并不总是那么顺利。我见过很多不一致的代码,其中没有规律或理由来改变风格。
- 在何时你需要给循环提供一个有意义的循环计数器名称?
这取决于你的字符串有多长。然而,很明显,一个有 100 行且计数器名为i的循环不是最佳实践。⁴ 每次你在循环中插入新代码时,都要检查计数器名称,看看是否需要调整。
- 在 C 语言中,如果
assert是一个宏,为什么它的名称是小写的?为什么我们应该给宏起名,让它们突出?
assert 不大写是因为 assert 不大写。在理想的世界里它应该是大写的,但标准就是这样,我们不得不忍受这个二流的宏名称。唉。
火是有用的,但它也可以非常危险。宏也是一样。宏和 #define 定义的常量 确实是危险的——采用大写字母的名称约定将防止与普通名称发生恶劣的冲突。这就像当疯子拿着一根大尖棒走来走去时戴上安全眼镜一样明智。
因为宏可能会很痛苦,你应该选择非常不可能引起头痛的名称。更重要的是,尽可能避免使用预处理器。
通过将中间结果放入临时变量中,可以使长计算更易于阅读。为这些类型的变量提出良好的命名启发式方法。
恶劣的临时名称是 tmp、tmp1、tmp2 等等,或者 a、b、c 等等。不幸的是,这些都是常见的中间名称。
就像任何其他项目一样,临时名称应该是有意义的(例如,在三角计算中使用 circle_radius 或在树形分析例程中使用 apple_count)。事实上,在复杂的计算中,好的名称真的可以用来记录内部逻辑并展示正在发生的事情。
如果你找到一个确实没有可命名用途的值,如果它确实是一个难以命名的任意中间值,那么你就会开始理解为什么 tmp 如此受欢迎。如果可能,避免将任何东西称为 tmp——尝试以更有意义的方式打破计算。
- 遵循你语言的标准库命名约定有哪些优点和缺点?
标准库通常是语言最佳实践的来源,因此遵循它们的约定可能很有价值。其他程序员习惯了这种命名风格,因此他们阅读你的代码时会有更少的意外,并会感到舒适。
另一方面,库可能并不总是提供最佳实践,所以首先要思考!C 语言中可怕命名的 assert 宏就是这样一个例子。
- 你能用尽一个名称吗?在许多不同的函数中重复局部变量名是否可以?使用覆盖(并隐藏)全局名称的局部名称是否可以?为什么?
在许多不同的上下文中重复局部变量名是完全可接受的。有时,这样做是一种好的实践:为什么总是使用不同的循环索引计数器名称呢?这只会让代码更难阅读。
不要 用局部变量名隐藏全局名称;这真的很令人困惑。这是一个脆弱代码的指标。
- 描述匈牙利命名法的机制。这种命名约定的优缺点是什么?它在现代代码设计中有什么位置?
匈牙利命名法是一种命名约定,通过在变量和函数名前加上神秘的缩写前缀来表示类型。它在 C 代码中尤为常见。有几种细微不同的方言,但最常见的匈牙利前缀在表 A-1 中展示。
表 A-1. 常见匈牙利命名法前缀
| 前缀 | 它代表 . . . |
|---|---|
p |
指向 . . . 的指针(lp表示长指针,一个旧架构问题——如果你不知道,就别问) |
r |
. . . 的引用 |
k |
常量 . . . |
rg |
. . . 的数组 |
b |
boolean (bool 或某些 C typedef) |
c |
char |
si |
short int |
i |
int |
li |
long int |
d |
double |
ld |
long double |
sz |
以零结尾的char字符串(注意:不是 p) |
S |
struct |
C |
class(你也可以定义自己的类缩写。) |
匈牙利命名法在 C 语言中相对难以忍受(更不用说一旦语言变得更加强类型化就变得不必要了),在 C++中更是迅速变得令人作呕,因为它实际上无法扩展到你可以引入的许多新类型定义。
如果你真的想混淆维护程序员,使用匈牙利命名法,然后在几个月后,改变所有变量的类型而不纠正每个变量名(因为这会花费太多时间去做那)。这是命名方案的一个真正弱点。
关键概念
像对待瘟疫一样避免使用匈牙利命名法。
一些命名约定已经削弱了匈牙利风格的影响。例如,本章前面提到的foo_ptr和m_foo的概念。还有其他一些类似意图的可爱约定:一些程序员将他们的全局变量称为theFoo,将他们的成员变量称为myFoo。也许这表明在原则上一些匈牙利命名法是一个好主意;但若过度使用,它就像一个独裁的暴君,需要引起警惕。
- 我们看到了许多包含成员函数的类,这些函数充当获取器和设置器;读取和写入某些属性的值。这些函数有哪些常见的命名约定,哪一个是最好的?
虽然有些人认为 get 和 set 方法的存在表明设计薄弱,但我们仍然看到很多类是这样编写的。一些语言实际上对这些操作有内置支持。
有几种命名约定可供选择。如果你正在用 C++编写,使用 camelCase,并且有一个名为foo的属性,其类型为Foo,你可能选择:
Foo &getFoo();
void setFoo(const Foo &) const;
或者
Foo &foo();
void setFoo(const Foo &) const;
或者也许
Foo &foo();
void foo(const Foo &) const;
你的选择可能受到编码标准的约束;否则,就取决于你的审美观。这是一个我会违反“函数名应始终包含动词”的规则而选择第二个选项的情况,因为它在代码中读起来最自然。试一试,看看效果如何。
如果一个“获取器”方法在第一次运行时需要进行长时间的计算(即使它可以缓存答案以供未来的调用),那么我会谨慎行事。它不再是一个简单的检索函数,而这些命名方案并不暗示这一点。Tree::numApples是一个好的获取器名称,除非这个操作可能需要一分钟的时间,而图像识别系统正在检测所有的苹果。在这种情况下,我希望看到名称所暗示的行为。Tree::countApples()暗示了一些更复杂的活动——它是名称中的动词。
个人化
- 你在命名方面有多好?你已经遵循了多少这样的启发式方法?你是否自觉地考虑过你的命名和这些规则,或者你只是自然地**这样做?你在哪些方面可以改进?
回顾第 44 页上的“螺丝钉”部分。将这些指南与您最后编写的代码片段进行比较。您的命名有多少是遵循现有的编码规范(正如第 50 页上所要求的),又有多少是从零开始建立的?
-
你的编码标准是否提到了命名?
-
它涵盖了我们在这里讨论的所有情况吗?它是足够的吗?是有用的,还是只是表面的?
-
编码标准中适当的命名细节有多少?
-
有时,一个具有全面命名要求的编码标准可能会使发明名称变得更难——你有这么多规则要尝试满足,以至于很难记住和协调它们。小心看待比第三章中概述的指南更具有规定性的任何东西。
良好的代码工匠习惯于给出好的命名,不需要编码标准来“帮助”他们。标准的制定者经常声称他们的标准将帮助经验不足的程序员进行良好的命名。但更多的时候,这些标准并不那么有帮助——经验不足的程序员犯的编程错误不仅仅是命名不当。代码审查是确保他们的工作适当的必要手段。
- 最近你遇到过最糟糕的命名是什么?命名是如何误导你的?你会如何修改它们以避免未来的混淆?
你是在对别人的工作进行正式审查时发现这个问题,还是在尝试维护一些早已被遗忘的旧代码时?^([5]) 在编写后不久(当你还知道这个事物应该叫什么时)发现并纠正糟糕的命名是最好的。这需要最少的努力。几个月后解决它有时可能会非常痛苦。
- 你需要在平台之间移植代码吗?这对文件名、其他名称以及整体代码结构有何影响?
旧文件系统限制了你可以用在文件名中的字符数。这使得文件命名变得混乱(且更神秘)。除非你必须将代码移植到这样的古老系统,否则这种限制可以安全地忽略。
基于文件的泛型是利用文件名在构建时实现代码可替换性的巧妙方法。它通常用于在可移植代码中选择特定平台的实现。你可以设置头文件搜索路径,允许一个#include根据当前的构建平台引入不同的文件。
^([4]) 但一般来说,一个 100 行的循环本身并不是最佳实践。
^([5]) 显然,这绝不可能是在你自己的代码中遇到的问题!
第四章
Mull It Over
- 将相关代码分组可以使它们之间的关系变得清晰。我们如何进行这种分组?哪些方法最能体现这些关系?
显而易见的分组方法包括常见的名称前缀和后缀;文件系统位置;将项目放在同一类或结构中,C++/C#命名空间,Java 包,源文件或代码库。你能想到更多吗?
语言强制的关系是最强的——既易于阅读,又自动为你检查。然而,代码布局的邻近性比你想的更有影响力。顺序也暗示了很多——你会认为第一个项目比后续的项目更重要。利用这些事实来记录你的代码。
- 我们应该避免在代码中使用魔法数字。零是魔法数字吗?你应该如何称呼代表零的常量值?
数字零在许多不同的上下文中都具有魔法属性;在 C 代码中,它被用作空指针值,以及大多数循环的初始值。你能用什么来替换 0?
-
单一共享常量
ZERO与直接写0一样,只是同样具有魔法性。这个名称并不暗示任何零的实际含义——它是空指针值,还是循环初始化值?这种方法会适得其反。 -
为每个零常量取不同的名字会变得非常繁琐,因为你必须创建许多类似的主题变体,例如
for (int i = SOME_ZERO_START_VALUE; i < SOME_END_VALUE; ++i)。但无论如何,这些零常量名称都没有提供任何新的有意义的 信息。
你必须仔细思考零常量的名称。显然的选择可能是像NO_BANANAS这样的名字,意味着没有香蕉计数。但这个NO_前缀可能会被误解为数字的缩写(如NUM_)。
- 自文档化的代码充分利用上下文来传达信息。展示你是如何做到这一点的,并给出一个例子,说明特定的名称在不同的函数中会导致不同的解释?
有许多方法可以利用上下文来提高文档的优势。考虑一个Cat类。在其内部,成员函数不需要被命名为setCatName、setCatColor等等;猫的部分从类上下文中是隐含的。
许多英语单词都有双重含义。你期望在搜索函数中的count变量持有与在吸血鬼数据库模式中的count变量不同的信息。更实际的是,在我们的Cat类中,name变量显然持有猫的名字,而在Employee类中,它更有可能持有人的信息——包括名字、姓氏和头衔信息。相同的变量名,不同的内容。尽可能利用上下文信息,但确保你写的上下文真正明显。
- 期望一个新手能够完全理解一些自文档化的代码,这是现实的吗?
是的,这就是我们的目标——这是现实的。然而,读者仍然需要概述和设计文档来描述整个系统、它的功能以及它的结构。如果代码注释试图解释这一点,那么它们就放在了错误的位置(或者是一个非常小的系统)。
良好的代码文档应该让新手清楚地知道特定的代码部分在做什么。全面的 API 文档展示了新手可能遇到的任何函数调用的含义。
- 如果代码确实是自文档化的,还需要多少其他文档?
这取决于项目的规模和范围。你需要功能规范和设计文档。你可能仍然需要一个实现概述,并且肯定需要一个彻底的测试规范。
为了记录单一段代码的设计,良好的文献注释意味着你不需要任何其他文档。
- 为什么必须让比原作者更多的人理解任何一段代码?
这是软件工厂的现实。作为唯一理解某些代码的人,对于不择手段的程序员来说,这是一份很好的工作保障。编写比谜语更难以理解的代码将保证你终身有工作(或者直到公司倒闭,哪个先发生),缺点是你将整天沉浸在自己的糟糕混合物中。
事实上,如果代码不能被其他人理解,那么代码就是危险的。如果你离开公司,转到另一个部门,晋升,或者不再有时间进行维护,那么其他人必须能够接管。而且如果事情没有发展到那一步,在未来的某个时候,当你忘记了你的代码是如何工作时,一个致命的错误将会出现,必须在周二之前修复。
代码审查可以帮助确保代码被充分理解并且有足够的文档。
- 这个简单的 C 语言冒泡排序函数可以进行一些改进。它具体有哪些问题?请写一个改进的、自文档化的版本。
`void bsrt(int a[], int n) { for (int i = 0; i < n-1; i++) for (int j = n-1; j > i; j--) if (a[j-1] > a[j]) { int tmp = a[j-1]; a[j-1] = a[j]; a[j] = tmp; } }`
第一个问题是一个冒泡排序算法永远不应该使用。有足够多的更好的排序算法。可能还有一个更好的通用语言库函数可用;例如,在 C 中你可以调用qsort。我在这里使用冒泡排序作为一个简单的代码示例。
函数的接口完全不清晰。函数名太晦涩,参数名毫无意义。我也想看到提供的 API 文档注释,但在下面的重写中我将省略它。
内部代码一团糟。如果将交换数组值的代码拆分为一个swap函数,其意图将更加清晰。然后读者就能看到发生了什么。稍加整理后,结果如下:
void swap(int *first, int *second)
{
int temp = *first;
*first = *second;
*second = temp;
}
void bubblesort(int items[], int size)
{
for (int pos1 = 0; pos1 < size-1; pos1++)
for (int pos2 = size-1; pos2 > pos1; pos2—)
if (items[pos2-1] > items[pos2])
swap(&items[pos2-1], &items[pos2]);
}
这是一种足够的 C 语言,尽管你可能更喜欢一些其他的变化。根据你的信仰,你可能想在循环周围添加括号。swap可以为了效率而成为一个宏。但这并不是一个聪明的优化;你应该真正选择一个更有效的排序算法。
在 C++中,我会考虑将swap函数定义为内联的,并通过引用传递其参数(记录它们将被更改的事实)。最佳选择是使用语言库中可用的std::swap功能。
-
与代码文档工具一起工作会引发一些有趣的问题。你对这些有什么看法?
-
在审查文档时,你应该进行代码审查,查看源文件中的注释,还是进行规范审查,查看生成的文档?
-
你将协议和其他非 API 问题的文档放在哪里?
-
你文档化私有/内部函数吗?在 C/C++中,你将这个文档放在哪里——头文件还是实现文件?
-
在一个大型系统中,你应该创建一个单一的、大的 API 文档,还是创建几个较小的文档,每个区域一个?每种方法的优点是什么?
-
我对这些问题的看法是:
-
检查生成的规范;不要过于纠结于源文件中注释的布局。你是在审查内容,而不是代码。
-
不要被误导,认为文档必须放在头文件或实现文件中。即使文档工具是好事,但拥有一些独立的“传统”文档也不算邪恶。在那里写关于你的协议。
-
记录任何需要文档的内部函数。你不必对所有私有部分都编写详尽的文档。如果这些文档相当大,应该将它们分离到实现文件中,以保持公共接口整洁简单。
-
两者都要!使用工具的不同调用生成一个单一的大文档和每个子系统的文档。
-
如果你正在处理一个没有详细文档的代码库,并且需要修改或添加新的方法或函数,提供详细的文档注释是一个好主意,还是应该让它们保持未文档化?
工匠希望进行文档化,并且自然而然地感觉到需要写注释块。现在,如果代码有一个单独的规范文档,那么你的文档应该放在那里,与其他所有内容并列。否则,开始添加文献注释也还不错。不过,确保原始程序员不会因此感到冒犯!
- 能否编写自文档化的汇编代码?
你可以尽力而为,但这不会容易。汇编代码并不特别具有表达性;你并不是在意图层面编程,而是在这样做,你这个愚蠢的微处理器的层面编程。你的代码将主要是注释块(无论如何,对于汇编来说,这可能是良好的实践)。除了子程序标签外,没有太多其他内容可以用来自文档化。
个人观点
-
你认为你遇到的最佳文档化的代码是什么?是什么让它如此出色?
-
这段代码是否有大量的外部规范?你读了多少?你如何确保在没有阅读所有这些规范的情况下,你对代码的了解足够?
-
你认为这其中的多少归因于作者的编程风格,又有多少是因为他或她遵循的任何房屋风格或指南?
-
代码文档化良好并不一定意味着有单独的描述文档。内部上,它采用良好的命名、逻辑模块化、简单技术、清晰的布局、文档化的假设和良好的注释。房屋风格有所帮助,但它们不能替代敏锐、敏感的编程。一个白痴可以遵循最严格的指南,仍然能产生糟糕的代码碎片。
- 如果你使用多种语言编写,你的文档策略在每种语言中是如何不同的?
不同的语言表达性或多或少,因此语言语法中可以和不能文档化的内容也有所不同。这在很大程度上会影响你写多少注释。
你可能在你最熟悉的编程语言中更擅长编写自文档化的代码。
- 在你最后编写的代码中,你是如何使重要的内容突出显示的?你是否适当地隐藏了私有信息?
仔细思考这个问题——自然的倾向是轻率地说,“嗯,我写得还可以。”把你自己的代码当作是别人写的,进行批评。
- 如果你在一个团队中工作,其他人多久会来问你某个功能是如何工作的?你能通过更好的文档化来避免这种情况吗?
应对这一问题的良好策略是双管齐下:
-
如果问题真正涉及到你代码中的某个不清楚的地方,在向好奇的程序员解释(并了解他真正需要知道的内容)之后,将信息记录在适当的文档中。之后,你也可以通过电子邮件发送给他,以确保他获得了正确的信息。
-
如果问题已经包含在文档中,就指向它,大声喊 RTFM,^([6]) 并给他一个白眼。
^([6]) 阅读手册(嗯,嗯,)。
第五章
仔细思考
-
以下类型的代码中,注释的需求和内容可能会有哪些不同:
-
低级汇编语言(机器代码)
-
Shell 脚本
-
单文件测试框架
-
大型 C/C++项目
-
汇编语言的表达能力较弱,提供较少的机会来编写自文档化的代码。因此,你可能会预期汇编代码中有更多的注释,并且你可能会预期这些注释的级别比其他语言的注释低得多——汇编语言的注释通常会解释如何以及为什么。
剩下的三种类型之间没有巨大的差异。Shell 脚本可能很难阅读;在这方面,它们是 Perl 的原型。仔细的注释有所帮助。你更有可能在大型 C/C++代码库上使用文献编程技术。
- 你可以运行工具来计算你的源代码行中有多少百分比是注释。这些工具有多有用?这个度量标准对注释质量的衡量有多准确?
这种度量标准将提供对代码的洞察,但你不必过于担心它。它并不是代码质量的准确反映。文档良好的代码可能不包含任何注释。巨大的修订历史或大型公司的版权信息可能会主导小文件,影响这个度量标准。
- 如果你遇到一些难以理解的代码,哪种方法更能提高可读性:添加注释来记录你认为正在发生的事情,还是用更具描述性的名称重命名变量/函数/类型?哪种方法最有可能更容易?哪种方法更安全?
你应该根据适当的情况做两件事。重命名可以说是最好的方法,但如果你不知道函数的确切功能,它就非常危险。你可能会给它另一个同样糟糕的名字。在重命名时,你必须确保你知道你正在更改的项目性质。
使用代码的单元测试来确保你的修改不会破坏任何行为。
- 当你用代码注释块来文档化 C/C++ API 时,它应该放在声明函数的公共头文件中,还是放在包含实现的源文件中?每个位置都有哪些优点和缺点?
这个问题曾在我工作过的一个地方引发了一场大争论。有些人主张描述应该放在.c文件中。靠近函数意味着写一个不正确的注释更难,编写与文档不匹配的代码也更难。注释也更有可能随着任何代码更改而更改。
然而,当放在头文件中时,描述与公共接口并列显示——这是一个逻辑位置。为什么有人需要查看实现才能阅读任何公共 API 文档?
一个有教养的编程文档工具应该能够从任何地方提取注释,但有时直接阅读源代码中的注释比使用工具更快——这是有教养的代码方法的一个优点。我倾向于将注释放在头文件中。
当然,在 Java 和 C#中,源文件只有一个;你通常会使用 Javadoc 或 C# XML 注释格式。
获取个人
- 仔细查看你最近工作的源文件。检查你的注释。这真的好吗?(我打赌当你阅读代码时,你会发现自己做了一些修改!)
当你阅读和审查自己的代码时,很容易跳过注释,假设它们是正确的或者至少是足够的。花些时间仔细查看它们,并评估你写得好不好。也许你可以请一个值得信赖的同事给你提供他或她的(建设性的)意见关于你的注释风格。
- 你如何确保你的注释真正有价值,而不仅仅是只有你自己才能理解的个人杂谈?
对于这一点,可以考虑以下几点:写完整的句子,避免缩写,并保持注释整洁格式化,使用通用语言(包括母语和从问题域中选用的词汇)。避免内部玩笑、无关紧要的陈述或任何你不确定的事情。
代码审查将突出你在注释策略中的弱点。
-
你和你的同事是否都按照相同的标准,以类似的方式注释?
-
谁在编写注释方面最擅长?你为什么这么认为?谁最差?这与这些个人的整体编码质量有多少关联?
-
你认为任何强加的编码标准能提高你团队编写的注释质量吗?
-
使用代码审查来检查你同事的注释质量,并推动你的团队朝着一致的注释质量发展。
-
你在每个源文件中都包含历史记录日志信息吗?如果是的:
-
你是手动维护的吗?为什么,如果你的版本控制系统会自动插入这些内容?历史记录是否保持得特别准确?
-
这真的是一个合理的做法吗?这种信息需要多频繁?为什么将其放在源文件中比放在另一个单独的机制中更好?
-
即使有最好的意图,人类的天性也不太可能保持历史记录准确。这需要大量的手动工作,当时间紧迫时,这些工作往往会跳过。你应该使用工具来帮助维护历史记录,并将正确的信息放在正确的位置(我相信根本不是源文件)。
- 你在别人的代码中添加你的首字母缩写或以其他方式标记你做出的注释吗?你曾经给注释加日期吗?何时以及为什么这样做——这是一个有用的实践吗?有人找到别人的首字母缩写和日期标记是否曾经有用过?
对于某些注释,这是一种有用的实践。在其他地方,这仅仅是不方便——额外的注释噪音,你必须阅读过去才能到达真正有趣的内容。
这在标记临时FIXME或TODO注释,标记正在进行的工作时最有用。发布的生产代码可能不应该有这些;没有完成的代码不应该需要读者来理解作者或特定更改的日期。
第六章
沉思
- 返回值和异常是等价的错误报告机制吗?证明它。
返回值等同于全局状态变量,因为这两种机制都可以通过相同的原因代码信息发送回来(尽管忽略状态变量更容易)。你可以编写使用这两种方法的代码,以类似的方式工作.^([7])
异常是一种非常不同的生物。它们涉及新的控制流,这与简单的理由代码非常不同。它们与语言和程序运行时紧密绑定。虽然你可以通过手动编写传播错误的代码来模拟异常,但你必须仔细考虑:
-
如何将错误表示为任意对象,而不仅仅是整数原因代码
-
支持异常类层次结构并提供通过基类捕获的能力
-
通过任何函数传播异常,即使是没有
try、catch或throw语句的函数
这最后一个观点最清楚地表明了为什么两者并不等价。在语言级别实现,异常对你的代码并不具有侵入性。手工制作的仿制品必须在每个点上管理失败的可能性。每个函数都必须返回一个错误代码——即使它自己不能失败——只是为了传播其他错误信息。这需要对代码进行重大调整。
- 你能想到哪些元组返回类型的不同实现?不要限制自己于单一编程语言。使用元组作为返回值的优缺点是什么?
在 C 中,你可以为每个返回类型创建一个struct,将其与错误原因代码链接。这看起来可能像:
`/* Declare the return type */`
struct return_float
{
int reason_code;
float value;
};
`/* A function using it ... */`
return_float myFunction() { ... }
这很混乱,编写起来很繁琐,使用起来很笨拙,阅读起来也很困难。你可以利用 C++模板或 Java/C#泛型来自动构建这个框架,或者你可以使用 C++的std::pair类。这两种方法都在生产 C++代码中看到。两者都使用起来很繁琐,需要额外的声明和必要的机制来返回这些类型。一些语言,如 Perl,支持任意类型的列表;这是一个更简单的实现机制。函数式语言也提供了这样的功能。
我们刚刚看到了这种技术的一些缺点:它在代码中非常侵入性,并且对读者一点也不友好。它也不是一种惯用的编码实践。当返回多个参数时可能会有性能损失,但这不是一个有说服力的论点,除非你在机器代码级别工作。一个显著的优势是,单独的理由代码不会干扰任何返回值。
- 不同语言中的异常实现有何不同?
我们将要考虑的四种主要实现方式是:C++、Java、.NET 和 Win32 结构化异常。Win32 异常绑定到操作系统平台,而其他的是绑定到它们的语言。语言可能可以通过这样的底层平台设施来实现,也可能不行。
它们都遵循类似的方法;你可以throw一个异常,然后由放置在try块后代码中的catch语句来处理。它们都遵循终止模型的行为。
Java、.NET 和 Win32 也有finally构造。它包含无论执行是否正常或异常离开try块的代码。这可以是一个放置清理代码的好地方,以确保它总是被调用。在 C++中可以模拟finally,但这并不愉快。
原始的 Win32 异常(减去编译器提供的任何语言支持)在栈回溯时不会清理,因为操作系统没有析构函数的概念。它们必须谨慎使用——它们旨在处理类似于信号而不是代码逻辑错误的情况。
Java 异常(从Throwable派生)和 C#异常(从Exception派生)会自动提供诊断回溯——这在后续调试中非常有帮助。.NET 的 CLI 允许抛出任何东西,但 C#没有暴露出这样做的能力(尽管它暴露了捕获它们的能力)。其他.NET 语言可以抛出它们喜欢的东西。
- 信号是一种老式的 Unix 机制。现在我们有了像异常这样的现代技术,它们是否仍然需要?
是的,它们仍然需要。信号是 ISO C 标准的一部分,因此它们不容易被移除。信号起源于(前)System-V Unix 实现。它们是一种异步机制,用于报告系统级问题/事件。异常解决的是不同的问题,报告的是可以渗透到处理器的代码逻辑错误。对于信号类型的事件抛出异常是没有意义的,特别是使用终止模型——它不提供异步处理。
- 错误处理的最佳代码结构是什么?
对于这个问题,根本就没有答案。不同的代码策略在不同的情境下可能效果最佳。重要的是要使用清晰、可读、可维护的代码可靠地检测和处理错误。
- 你应该如何处理在错误处理代码中发生的错误?
在错误处理程序中发出的错误应该像处理任何其他错误一样处理。但这会很快变得棘手——你最终会有嵌套在嵌套在嵌套中的错误处理程序。对此要非常小心,并检查是否有更整洁的方式来结构化你的代码。
一个更好的方法是只在错误处理程序中执行保证成功的操作(或遵守 nothrow 异常保证),这样你的世界就会变得更好。
个人化
- 你当前代码库中的错误处理有多彻底?这如何有助于程序的稳定性?
良好的错误处理与稳定的代码之间存在直接关联。要么你的程序不需要具有鲁棒性,要么它必须系统地检测和处理所有错误条件。如果这不是深深植根于程序哲学中的话,那么你将不会有一个可靠的系统。
- 你在编写代码时是否自然地考虑错误处理,还是觉得它是一种干扰,更愿意稍后回来处理?
不喜欢错误处理是自然的;没有人想一直关注程序功能的负面方面。⁸。然而,请注意这条重要建议:不要推迟到以后。如果你这样做,一些潜在的错误不可避免地会被忽略,最终导致程序行为意外。
-
回到你最近编写或修改的最后一个(合理大小的)函数,仔细审查代码。找出每一个异常情况以及潜在的错误情况。这些中有多少在你的代码中得到了实际处理?
现在让其他人来审查它。不要害羞!他们是否发现了更多?为什么?这对你正在工作的代码有什么启示?
这是对你真正程序员程度的一个揭示性见解。确保你仔细地执行这个练习——并且真的要询问其他人。即使是经验最丰富的程序员也会错过一些错误情况。⁹。如果这些不太可能表现为错误,你可能永远不会注意到,并永远生活在可能奇怪行为阴影之下。
当使用异常时,你无法轻易忽略错误情况——无论你是否处理它们,异常都会强制自己的方式沿着调用栈向上传递。你仍然可以写出糟糕的代码,如果它不是异常安全的(它可能以不良状态退出,或者有资源泄露)或者如果它执行了过于激进的捕获(消耗了在那个级别实际上无法处理的错误——因此,不要写catch(...)来捕获所有异常)。
- 你发现使用返回值或异常来管理和推理错误条件更容易吗?你确定你知道编写异常安全代码涉及哪些内容吗?
在一定程度上,这取决于你习惯什么。异常补充并扩展了返回值。异常用户也可以理解返回值,但反之不一定成立。返回值更明显,因此更容易正确使用。
如果你确实使用异常,了解需要注意的问题很重要。异常安全性影响所有你的代码,而不仅仅是引发和捕获错误的代码部分。异常安全性是一个庞大且复杂的话题,需要深入研究。不要低估它对你编程方式的影响。
^([7]) 虽然它们并不完全相同。在 C++中,你可以返回一个具有其析构函数行为的代理值类型。这为返回代码机制注入了额外的魔力。
^([8]) 如果你倾向于这样做,你可能会成为一个非常好的软件测试员。但不要急于改变职业——真正彻底的程序员是少数。
^([9]) 例如,有多少人经常检查 C 的printf中的错误?
第七章
仔细考虑
- 对于开发团队中的每个人来说,使用相同的 IDE 是否比每个人选择最适合他们的 IDE 更重要?不同的人使用不同的工具会有什么影响?
所有专业程序员都应该足够负责任和有信息量,以选择最使他们高效工作的工具。没有两个程序员是相同的,不同的人自然会偏好不同的工具。只要选择是基于实际考虑的,团队的总体效率就会提高。但强迫有主见的技术人员使用特定的工具很少能激发他们高效工作的热情。
如果团队中的每个人都使用不同的开发环境,那么他们必须正确地合作。他们必须构建相同的代码,并且每次编辑源文件时,每个编辑器都不应该与其他人的布局规则发生冲突。
- 任何程序员应该拥有的最小工具集是什么?
没有至少以下这些,你将无法生存:
-
一些基本的编辑器形式
-
所需的最小语言支持(编译器、解释器或两者兼而有之——这取决于语言)
-
用于运行它们的计算机
但这个最小集合并不能使程序员非常高效。你需要一套其他工具来完成任何严肃的工作。
-
必须有一个版本控制系统,否则工作将非常危险。
-
一个合理的库集合可以防止重复造轮子,并降低引入可避免错误的风险。
-
你还需要一个构建工具来帮助构建软件系统。
这是一个更现实的最低要求。你添加的基础工具越多,开发就越容易,产生的代码质量就越好。
- 命令行工具和基于 GUI 的工具哪个更强大?
如果你甚至开始回答这个问题,我应该打断你的手臂。命令行和 GUI 工具是不同的。这就是故事的结局。
一个有趣的哲学问题是:在这个背景下,你如何定义“强大”? 这意味着拥有更多深奥的功能吗?这意味着工具使用起来有多容易吗?这意味着它运行得多快吗?或者这决定了工具如何融入整个工具链?确定一个定义,然后尝试用那个定义来证明你的答案。这样我可能就不会打断你的手臂。
- 有没有不是程序的构建工具?
我们已经将语言和库分类为工具,所以答案是 是的。其他值得考虑的好例子包括:
-
正则表达式
-
图形组件(GUI "小部件")
-
网络服务
-
常见协议和格式(如 XML)
-
UML 图
-
设计方法(如 CRC 卡)
-
对于工具来说,最重要的是什么?
-
互操作性
-
灵活性
-
定制
-
力量
-
易用性和学习
-
这些中的每一个都很重要。平衡可能因不同类型的工具和您使用它们的情况而有所不同。
力量很重要;你的工具必须足够强大,才能完成你分配给它们的任务,否则你的生活将变得痛苦不堪。如果不是这样,程序员将使用记事本或 vi 编辑源代码。
个性化
-
你工具箱中的常用工具有哪些?你每天都用哪些?你一周用几次?你只是偶尔使用哪些?
-
你了解如何使用它们吗?
-
你是否从每个工具中获得了最大收益?
-
你是如何学会使用它们的?你是否花过时间提高使用它们的技能?
-
这些是你能使用的最佳工具吗?
-
列表中的最后一个问题至关重要。诚实地评估是否有更好的工具可以使用。真的值得花些时间四处看看。如果有更好的工具,就动手尝试一下。
- 你的工具有多新?如果它们不是最新的尖端版本,这有关系吗?
过时的工具可能会引起棘手的问题,但最新的工具版本也可能如此。最棘手的问题发生在工具版本与工具链的其他部分不同步时。由于版本差异,可能会有细微的功能不匹配,导致工具链无法正常协同工作。症状很少是工具链故障,而是代码以令人惊讶的方式运行。
过时的工具可能会错过重要的错误修复。更新可能在你被它解决的错误咬到之前看起来并不重要。事后诸葛亮是件好事。如果你过时了,你可能会依赖不再受支持的工具,这些工具是由不再存在的公司编写的。这可能会在关键项目中成为一个严重问题。
当然,你不可能总是随意下载和安装新的工具版本。由于多种原因,升级可能并不实际。升级可能比你负担得起的花费还要多。升级可能迫使你升级你的操作系统或其他工具链的关键部分,而这并不实际。
- 你更喜欢集成工具集(如可视化开发环境)还是离散的工具链?另一种方法的优点是什么?你对这两种工作方式有多少经验?
在这里给出一个粗心的答案可能会让你付出代价(参见第 491 页“Mull It Over”部分中关于第 3 个问题的答案)。试着列出其他工作方式的优点,以确保你避免狭隘和偏见的观点。
- 你是默认的丹还是调整的汤姆?你接受编辑器的默认设置,还是将其调整到极致?哪种方法是“更好”的?
通过发现如何配置它,你学会了如何使用和最大限度地利用你的编辑器。在这种情况下,汤姆可能有最合理的做法。实用主义立场可能介于两者之间(金发姑娘原则的一个好例子;极端的行为很少是最好的)。配置你永远不会使用的功能是没有意义的。有些事情真的不重要——我对编辑器使用的颜色方案并不那么担心。但有些事情确实很重要——我不想被迫接受一个丑陋的默认代码布局风格。
代码按照你精心选择的布局风格编写比由编辑器的默认设置决定要好得多。事实上,你的家庭编码风格可能要求这样做。我宁愿配置我的编辑器自动格式化代码,而不是每次按下 ENTER 键时都与光标定位作斗争。
这种讨论的范围不仅限于编辑器,还包括任何可配置的软件工具。
- 你是如何确定你的软件工具预算的?你如何知道一个工具是否值得它的成本?
这取决于你为哪种组织工作以及你正在做什么类型的工作。如果你的项目拥有一个小国 GDP 的工具预算,那么工具的成本就无关紧要了——买最好的工具(这不一定是最贵的)并享受它们。但一个在家工作的独立黑客无法为顶级的工具链提供同样的开销。通常,免费提供的工具对于这种家庭使用已经足够了。
事实上,免费提供的工具通常质量非常高,这使得很难确定何时为工具付费是值得的。为工具链付费通常意味着你可以期望良好的产品支持,并要求未来的错误修复或开发工作。然而,这并不总是如此——公司可能会倒闭,产品可能会被停止。这可能或许是选择最受欢迎、最广泛使用的工具的一个论据。数量上有安全。
如果所有合理的标准都失败了,一个工具越贵,它的盒子应该越大。如果它价值连城却装在一个小盒子里,那就不要买了!
第八章
沉思一下
-
为本章前面提到的
greatest_common_divisor代码示例编写一个测试框架。尽可能使其全面。你包含了多少个单独的测试用例?-
有多少通过了?
-
有多少失败了?
-
使用这些测试,找出任何错误并修复代码。
-
尽管无效输入组合非常少,但你应该运行大量的测试。首先考虑无效输入:测试零。它可能是一个无效值,也可能不是(我们没有看到任何规范,所以无法判断),但你希望代码能够合理地处理它。
接下来,编写考虑常规输入组合(比如 1、10 和 100 的所有顺序)的测试。然后尝试没有公倍数的数字,比如 733 和 449。测试一些非常大的数字和一些负数。
你如何编写这些测试用例?编写一个简单的单元测试函数,然后将其放入自动化测试框架中。对于每个测试,不要程序性地计算正确的输出值应该是什么;^([10)),只需检查一个已知的常量值。尽量使你的测试代码尽可能简单:
assert(greatest_common_divisor(10, 100) == 10);
assert(greatest_common_divisor(100, 10) == 10);
assert(greatest_common_divisor(733, 449) == 0);
... more tests ...
对于这个简单的函数,有令人惊讶的大量测试。你可以争辩说,对于这样一小段代码,检查、审查和证明正确性比费力地创建测试集要容易。这似乎是一个合理的论点。但是——如果以后有人进行了修改呢?没有测试,你就必须仔细重新检查和重新验证代码,这是一个容易忽视的任务。
你在greatest_common_divisor中找到错误了吗?线索即将揭晓。如果你不想破坏谜题,现在就请移开目光……尝试给它一个负数参数。这是一个更健壮(且更高效)的 C++版本:
int greatest_common_divisor(int a, int b)
{
a = std::abs(a);
b = std::abs(b);
for (int div = std::min(a,b); div > 0; --div)
{
if ((a % div == 0) && (b % div == 0))
return div;
}
return 0;
}
- 电子表格应用程序和自动飞机驾驶员的测试应该如何不同?
在一个理想的世界里,两者都不会有错误。在这个乌托邦中,它们都会被彻底测试,直到完美才发布。现实世界有些不同。虽然你期望电子表格偶尔会崩溃,^([11)),但你期望自动驾驶仪完全不含错误。当人类生命处于危险之中时,软件的开发方式就非常不同——更加正式,并且更加小心。它被严格测试。这里有一些安全标准在发挥作用。
- 你应该测试你写的所有测试代码吗?
如果你长时间思考这个问题,它会让你头疼。你不能一直测试测试代码——你怎么能确保你的测试代码的测试代码是正确的?诀窍是保持测试尽可能简单。这样,最可能出现的测试错误将是缺少重要的测试用例,而不是测试代码的实际行有问题。
关键概念
尽量保持测试代码尽可能简单,以防止引入错误。
- 程序员的测试与 QA 部门成员的测试有何不同?
测试人员更关注黑盒测试风格,通常只进行产品测试。测试人员很少在代码级别工作,因为大多数产品是可执行软件;相对较少的代码库供应商。
程序员更关注白盒测试,确保他们精湛的创造物按计划工作。
任何编写测试的程序员的秘密目标是证明他的代码是有效的,而不是找出它不工作的情况!我可以很容易地编写一大堆测试来展示我的代码是多么完美,通过故意避开所有我知道有问题的部分。这是让除原始程序员之外的其他人创建测试框架的好论据。
- 是否需要为每个单独的函数编写测试框架?
你不需要如此极端。有些函数很容易通过检查来验证。但要小心不要马虎——记得要批判性地阅读代码。简单的获取和设置函数不需要一大堆单独的测试。
在什么代码规模下测试框架变得有吸引力?通常当代码变得足够复杂需要它时。当单看一眼不能证明代码是正确的时候,就写一些测试用例。
- 测试驱动开发鼓励你在编写任何代码之前先编写测试。你应该编写什么样的测试?
在没有编写任何代码的情况下,这些只能是黑盒测试。要么这样,或者测试驱动开发者需要先知的能力。
- 你应该编写 C/C++测试来检查对
NULL(零)指针参数的处理吗?这种测试的价值是什么?
如果零是一个预期的输入值,那么当然你必须测试它。
但你并不总是需要测试空指针。如果你没有为零指针值指定魔法行为,那么当传递一个坏指针时,你的函数完全有权崩溃。在这种情况下,零可能和指向已释放或无效内存的指针一样糟糕。很少有可能测试代码能够应对所有坏指针。
然而,编写面对零指针时仍然健壮的代码是有价值的,因为它们往往到处乱飞。许多分配例程在失败时返回零指针,未定义的指针通常被设置为零。如果狗可能会咬人,给它带上嘴套是个好主意。
-
你的早期代码测试可能不在最终平台上——你可能还没有访问权限。是推迟测试直到你有一个目标测试平台更安全,还是现在就继续前进?
如果代码打算在不同的环境中运行(可能在高性能服务器上,或者某些嵌入式设备上),你如何确保你的测试是具有代表性和充分的?
这取决于你正在测试的代码的性质——它是一个简单的执行维护工作的函数,还是一些硬件访问逻辑。你必须理解开发平台和目标环境之间的差异。内存限制或处理器速度可能会影响代码的运行。对于你写的绝大多数代码来说,这可能不是什么大问题,对于这些代码,你可以创建本地测试工具。
如果你的代码利用特定的目标平台特性(如并行处理器或特定的硬件设施),那么没有它们你无法进行全面测试。可能有模拟器来检查代码是否运行;它们是有帮助的,但不是最终答案。
将所有测试推迟到你有一个目标平台是一种危险的做法。到那时,你将有一大堆代码,你既没有时间也没有意愿进行全面测试。为了获得最大的信心,尽早进行测试。
- 你如何知道何时结束测试并停止?多少才算足够?
由于测试不能证明错误的缺失,你永远无法真正知道何时结束。这项任务可能是无限的,我们正在尝试制定一个测试计划,使其成为一个现实的练习。
对于黑盒测试下的简单代码块,在 142 页的"选择单元测试用例"中成功运行所有测试用例就足够了。你的代码越大,你需要做的工作就越多。
你可以通过攻击角度来衡量你测试的充分性和彻底性。有几个关键策略:
基于覆盖率的测试
测试计划是以软件的覆盖率来指定的。例如:你可能计划至少执行每一行代码一次,执行每个条件分支的两种情况,或者确保所有系统需求至少被测试一次。
基于错误的测试
这基于消除一定比例的程序错误。你从一个假设的错误数量开始,通常是从以往的经验中挑选的。然后,你旨在检测和移除,比如说,95%的错误。
基于错误的测试
这种方法侧重于错误发生的常见点,在这些点上软件可能比较脆弱。例如,通过测试所有边界值来消除“差一”错误。
基于此,以下是一些停止测试的好理由:
-
回归测试用例完成,通过了一定比例(并且没有剩余的重大阻止性失败)。
-
代码、功能或需求的覆盖率达到了指定的点。
-
显示的错误率低于一定水平。
除了这些,还有一些物理障碍,很少能移动,它们将在确定终点时发挥最终决定作用:
-
击中预定截止日期(测试截止日期或发布截止日期)。开发工作有一个糟糕的习惯,就是超时并消耗预定测试时间;这需要非常仔细的管理。
-
测试预算耗尽(一个很悲伤的停止标准)。
-
测试的 beta 或 alpha 阶段结束。
在大多数组织中,停止测试并发布产品的决定是在截止日期做出的。这是基于剩余已知故障的数量、严重程度和发生频率与进入市场的需求之间的妥协。测试允许做出关于软件可接受性的明智判断。
个人化
- 你为多少代码编写了测试?你对这个满意吗?你的测试是构建过程的一部分吗?你对剩余代码进行什么样的测试?这是足够的吗?你将如何处理它?
不要觉得有必要为每一行代码编写测试框架。但也不忘使用你的大脑。一个小函数的实现通常是显而易见的——所以你倾向于无脑地编写它——然后:愚蠢的错误。由于一个简单的函数只需要一个简单的测试,所以编写它可能是很有价值的。在我的代码店,我们有一个简单的规则:每一段代码都有一个单元测试,否则它就不会在代码库中。
确保你确实在进行你负责的适当和足够的测试,而不仅仅是跳过一个不愉快的任务。问问自己这个问题:最近咬住你的错误中有多少可以通过一组良好的测试来预防?确保你对此采取行动。
如果你的测试不是构建系统的一部分,那么你如何确保测试总是运行,并且所有代码都通过它们?
- 你与 QA 部门的人的关系如何?你认为你在他们心中的个人声誉如何?
在 QA 部门和软件开发者之间建立良好的工作关系至关重要。竞争往往产生;测试部门被视为一群旨在阻碍开发者并阻碍发布进程的人,而不是作为帮助构建稳定产品的团队。通常测试和开发部门相隔很远,只听从各自部落首领的命令。
忘了这件事。
给他们泡咖啡。带他们出去吃午餐。和他们一起去酒吧。做任何能防止培养“他们和我们”的态度的事情。
发展专业的工作关系。确保你给他们提供经过良好测试的好代码——而不仅仅是任何旧匆忙的垃圾。把你的碎片扔给他们清理会给人留下你把他们看作是为你工作的仆人,而不是与你一起工作的同事的印象。
- 你通常对在代码中发现错误有何反应?
有几种可能的反应:
-
恶心和失望
-
想要责怪别人的冲动
-
如果不是直接的兴奋
-
假装你没有发现它,忽略它,并希望它消失(就像这样很可能会发生)
其中一些错误如此明显,以至于我假设你可以超越它们。建议你可能会对发现一个故障感到高兴,这听起来是不是有点疯狂?当然,对于一个质量意识强的工程师来说,这显然是合理的反应——在开发过程中发现故障比用户在野外发现它们要好得多。
你的兴奋程度将取决于在开发生命周期中发现故障的位置。发布前一天发现一个阻止发布的错误不会让任何人笑。
- 你是否为每个发现的代码问题提交故障报告?
并非每个故障都需要这样做:如果还没有人看到你的代码,它还没有集成到更广泛系统中,那么你不需要广播你的无能!如果你没有在数据库中报告故障,那么你必须做系统的笔记,以免忘记。因此,你可能会发现从一开始就使用故障跟踪系统更容易。如果交付延迟得如此之晚,以至于人们需要看到剩余的问题,那么你可能会被迫提出故障报告。
任何代码一经发布,你应该公开其所有故障;必须提交故障报告。这表明你已经识别了每个问题,并有一个计划来处理它。
无论何时你发现代码故障,你应该编写一个测试用例来测试它,并将其纳入你的自动测试套件中,作为回归检查的一部分。这作为故障的一种文档形式,并确保它不会意外地再次引入。
- 项目工程师预期要进行多少测试?
了解对你有什么期望,并交付那个级别的测试是很重要的。但在此之上,不要只做预期要做的事情——做需要做的事情。
为你创建的每一块代码编写单元测试。如果你需要修改他人的工作,如果还没有测试,先为它编写一个测试。这样,你就会知道它目前的工作情况如何,需要修复什么,以及如何证明你的修改没有破坏任何东西。
^([10]) 这将打开更多的编码错误之门——想象一下测试代码中错误带来的痛苦!
^([11]) 很遗憾,我们已经习惯了接受这种情况。
第九章
仔细思考
- 是让编写代码的原程序员修复故障最好,还是让发现问题的程序员更适合进行修复?
以全新的视角处理任何问题总是有帮助的。在调试时,这种方法避免了程序员阅读他打算写的内容,而不是代码实际说了什么——太多的错误就是这样隐藏起来的。
另一方面,原始程序员可能是修复错误的最佳人选。他(希望)对代码了如指掌。他知道特定的更改将产生什么影响。他将是最快定位到错误位置的人。
在现实世界的组织中,谁负责修复可能取决于个人的空闲时间和团队的其他承诺。对于自程序诞生以来就存在的错误,原始程序员可能已经不再可用。他可能已经离开了公司,转到了其他项目,或者(最糟糕的是)被提升为管理层。
- 你如何判断何时使用调试器,何时使用你的大脑?
显然,即使使用调试器也应该让你的大脑保持活跃。(还记得调试的黄金法则吗?)
我的经验法则是:在你确切知道你需要从调试器中获取哪些信息之前,不要启动调试器。危险在于使用调试器在运行中的代码中随意摆弄,而不真正知道你在寻找什么。你可以浪费几个小时做这件事,却没有任何真正的回报。
- 在你开始尝试查找和修复代码中的错误之前,你应该先学习不熟悉的代码。但软件工厂的时间压力通常意味着你无法花太多时间研究和理解你正在修复的程序。最好的前进方式是什么?
在你的梦中,你会责备编写日程表的人,并花尽可能多的时间去正确修复故障。醒来吧,爱丽丝……
你能做的最好的事情就是边走边学习代码。在处理它时格外小心,不要相信你认为正在发生的事情——总是确保代码正在做你期望它做的事情。当你认为你已经找到了错误的根源时,看看你的团队中是否有人知道关于有问题的代码部分。与他们讨论你打算做什么。通常当你描述情况时,你会向自己解释你刚刚忽略的明显问题。
- 描述一些避免内存泄漏错误的良好技巧。
这些是一些好的方法:
-
使用你不太可能被其咬到的语言,例如 Java 或 C#。(你仍然可能在这些语言中遇到内存泄漏。你知道如何吗?)
-
使用“安全”的数据结构来为你管理内存,这样你就不必担心它。
-
使用有助于的语言习语,例如 C++的
auto_ptr,以避免问题。 -
在处理内存时,要严谨和有系统。对于每个分配点,确保有一个平衡的释放点,并且它将始终被调用。
-
将你的代码通过内存验证工具运行,以确保没有错误悄悄溜过。
-
在什么情况下可以合理地快速尝试查找和修复错误,而不是采用更系统的方法?
你总是需要思考你在做什么。即使是快速调整也应该让你的大脑处于工作状态。不要盲目地在代码中设置断点,开始挖掘内部结构;试着思考代码是如何设计的以及它应该做什么。
直觉和你的即时反应可能在非常小的程序(比如几十行)中迅速找到错误。但在成千上万行长的程序中,你真的需要了解发生了什么。洞察力是无可替代的。在调试器中跟踪程序的执行以检查它在做什么并没有什么不妥,但选择测试点时要有条理。
个人化
- 你通常使用多少调试技术/工具?你见过哪些可能对你有用的其他工具?
显然答案是没有。你总是第一次就写出完美的代码!
- 你选择的语言中常见的错误和陷阱有哪些?你是如何在自己的代码中防范这些类型的错误的?
了解这类事情很重要。这是平庸程序员与专家的区别所在。如果你不知道龙在哪里居住,那么你就不知道如何避开它们。
- 你代码中发生的错误大多是粗心的编程错误,还是更微妙的问题?
如果你一次又一次地被小的语言错误所困扰,这表明你应该更仔细地编写代码。花时间在你的代码上。校对它,然后重新阅读它——你总体上会节省时间。一个经典的错误是修复了一个错误,没有测试它是否工作,然后被你“修复”的不希望出现的副作用所困扰。
在你的代码中存在错误并不可耻。每个人都会遇到。只需确保它们不是你可以轻易避免的愚蠢错误。
-
你知道如何在你的平台上使用调试器吗?你通常多久使用一次?描述如何:
-
生成回溯
-
检查变量值
-
检查结构中字段的值
-
运行任意函数
-
交换线程上下文
-
如果你总是使用调试器,那么这就是太多。如果你从不使用调试器,那么这就是太少。不要害怕你的调试器,但也不应该把它当作拐杖。智能地使用调试器将允许你在很短的时间内准确地找到错误的位置。
第十章
仔细思考
- 为什么拥有良好集成开发环境的人还需要担心使用命令行 make 工具,当他们只需按一个按钮就可以构建他们的项目时?
除了学习在构建按钮背后真正发生的事情外,了解如何使用 make 是一种通往更强大、更灵活的软件构建的途径。很少有 GUI 构建工具能与 makefile 的能力和可塑性相提并论。简化通常是好事,GUI 工具可以帮助开发者快速创建软件,但这种简单性是有代价的。
图形界面构建工具在扩展性方面表现不佳,在真正的大型项目中几乎没有什么用处。Make 确实有一个晦涩难懂的语法,但它让你可以做更多的事情。例如,makefile 允许目录嵌套,创建构建层次结构。简单的图形界面工具只提供一层深度,即工作空间内项目的嵌套。
人们抱怨 make 的复杂性,以及使用它可能会搞砸事情。这是一个合理的问题,但与任何强大的工具一样——如果你不正确使用,你可能会伤害到自己。
这并不意味着你应该丢弃所有的图形界面构建工具,开始编写一系列的替换 makefile。相反:使用适合的工具来完成工作。平衡简单性和集成与强大和可扩展性;选择每次所需的工具。
- 为什么将源代码的提取视为与构建分开的单独步骤很重要?
这两个步骤在逻辑上是不同的。在一个精心设计的构建系统中,你应该能够检出软件的任何版本,无论多旧,然后发出相同的 make 指令来构建它。稍后你应该能够清理树并使用相同的指令重新构建,而无需再次检出所有内容。
将这些作为两个单独的步骤并没有损失。你可以轻松地围绕它们编写一个脚本来实现单步检索/构建过程——这将对于夜间构建脚本非常有用。对于这些夜间脚本,每次都从全新的源代码树开始(以避免被从上一个树中携带的问题所困扰)至关重要。这是对源代码树的良好测试;通过删除它并执行完整的重建,你可以检查是否有文件缺失或过时(你可能忘记检查某些内容)。
将源代码提取绑定到构建步骤中存在其他问题,包括以下内容:
-
你不希望构建系统在构建过程中自动从源代码库中检出文件。你很少希望每次重新构建时,周围的环境都在发生变化。控制你正在工作的代码,而不是成为构建系统行为的奴隶,这一点很重要。
-
存在一个引导问题:如果提取是构建过程的一部分,你从哪里获得源代码树以开始构建?你无论如何都需要手动检出!或者,你需要念诵更多的咒语来部分检出树中的构建部分,以便执行真正的检出和构建。不要这么做。
- 构建步骤的中间文件(例如,目标文件)应该放在哪里?
一些构建系统将目标文件丢弃在生成它们的源文件旁边。高级构建系统可以创建一个并行目录树,并将对象构建到那里,同时保持源目录不变。这使事情变得整洁,区分源文件和构建生成的文件。尽管如此,也有一些缺点:在层次结构中搜索会更困难。你可能想通过删除.o文件来强制重新编译源文件,但使用分割树,你必须从源文件导航得更远才能做到这一点。
对于目标文件的位置,另一种整洁的方法是将中间文件放在源树中,但放在它们自己的子目录中;远离源文件,但仍然方便。你最终会得到一个目录层次结构,看起来像图 A-1。
这是一种从单个源树构建多个目标的好方法——每个目标都有自己的构建子目录。没有这个机制,你可能会开始一个调试构建,以发布模式完成它,并且链接阶段会变得一团糟。采用这种方法会导致构建树看起来像图 A-2。

图 A-1. 将构建的目标文件放入子目录

图 A-2. 更好的做法:将目标文件放入命名子目录
- 如果你在构建系统中添加了一个自动测试套件,它应该在软件构建后自动运行,还是必须单独执行一个命令来调用测试?
你可以轻松地提供一个单独的命令(比如一个tests makefile 目标;你会在make all之后输入make tests)。然而,这个额外的步骤可能不太会被执行——没有要求这样做。测试可能会被忽略。这在人类本性如此的情况下是很可能的。未经测试的代码可能会引起各种问题,使得编写测试的努力变得徒劳。确保你的单元测试是主构建过程的一部分。
自动化压力测试和负载测试可能不应该包含在这个构建步骤中。它们可能执行时间太长,只打算在夜间构建上运行。在这种情况下,创建一个自动化脚本来运行它们,但在正常构建期间不要触发它。
- 夜间构建应该是调试构建还是发布构建?
两者都要。尽早测试发布构建配置非常重要。调试构建不应该发布给 QA 部门,更不用说公司外部了。
重要的是要测试发布和开发构建过程是否正常工作——不仅是在构建系统创建时,而且要持续进行。很容易在更新时破坏一个或多个构建。如果直到最后一刻才测试构建,那么当它失败且截止日期临近时,你会非常生气。
由调试和发布构建生成的可执行文件之间可能存在严重差异。一些编译器在调试和发布模式下表现出明显不同的行为。有一种流行的编译器在调试构建中乐于填充数据缓冲区,因此内存溢出无害且不会被检测到——这几乎不是一个好的调试辅助工具。如果你只测试过调试构建,那么在产品发货前切换到发布模式,你很可能会遇到问题。
- 编写一个 make 规则来自动从你的编译器生成依赖信息。展示如何在 makefile 中使用这些信息。
有几种方法可以实现这一点,部分取决于你如何从编译器获取依赖信息。假设编译器接受一个额外的-dep参数,诱使它创建一个依赖文件以及目标文件。假设这个生成文件的格式已经符合 make 的依赖格式。¹²] 使用 GNU Make,你可以指定一个编译规则,该规则具有生成依赖信息的副作用:
%.o: %.c
compiler -object %.o `-dep %.d` %.c
然后,你可以通过在Makefile底部添加以下内容,直接将所有生成的依赖文件合并到 makefile 中:
include *.d
这就这么简单!当然,这是最简单的机制,可以工作。有许多改进可以使其更整洁。例如:
-
你可以将依赖文件直接指向一个单独的目录。这可以防止它们在工作目录中造成混乱并覆盖重要文件。
-
你可以编写一个包含规则,仅包含正确的
.d文件。可能存在其他你不应该包含的.d文件,这使得通配符include行变得危险:从无效文件中包含随机信息会令 make 困惑。这个问题很容易出现:如果你从 makefile 中删除了一个源文件,但未先清理构建树,那么旧的.o和.d文件将保留在工作目录中,直到你手动删除它们。 -
如果编译器允许,你可以编写一个单独的规则来创建
.d文件,使它们成为构建系统的第一公民。这的缺点是会减慢构建过程——现在编译器将针对每个源文件调用两次。
- 递归 make 是创建跨越多个目录的模块化构建系统的流行方法。然而,它存在根本性的缺陷。描述其问题并建议替代方案。
传统智慧认为,所有使用 makefile 构建的大型代码库都应该使用递归 Make 技术。然而,尽管递归 Make 很强大,但它本质上是有缺陷的。尽管如此,不要忽视它。了解递归 Make 是如何(或如何不)工作的很重要,因为它非常普遍(许多代码库都使用递归 Make),你需要了解它的问题,才能理解什么是一个更好的解决方案。
什么使得递归 Make 成为一个缺陷?它有几个陷阱:
速度
它是如此慢。如果你尝试重新构建一个已经是最新的源树,递归构建仍然必须忠实地遍历每个目录。对于一个合理大小的项目来说,这需要很长时间,这在没有必要采取行动的情况下是没有意义的。
每个目录都作为一个独立的 make 调用进行构建.^([13]) 这绕过了许多潜在的优化;共享的包含文件会被反复检查。尽管文件系统可以缓存信息,但这仍然是不必要的开销。一个合理的构建系统只需要检查每个文件一次。
依赖关系
递归 Make 无法正确地跟踪依赖关系;子目录 makefile 没有方法确定所有依赖信息。你的模块 makefile 可以观察到它的本地func1.c源文件依赖于另一个目录中的shared.h头文件。它会在shared.h每次更改时愉快地重新构建func1.c。但是,如果shared.h是由一个基于某些模板文件shared.tmpl的单独模块自动生成的,你的模块就无法知道这个额外的依赖。即使它能够知道,它也不知道如何重新构建shared.h——这不是它的任务。所以如果shared.tmpl被更改,func1.c将不会适当地重新构建。
唯一的方法是安排shared.h在func1.c模块之前构建。程序员必须仔细定义递归的顺序,以确保软件能够正确地重新构建.^([14]) 存在的间接依赖越多,混乱就越严重。
面对这个问题,程序员会设计出一些狡猾的解决方案,比如在树中多次进行构建遍历,或者手动删除某些文件以强制每次都重新构建。这些黑客行为只会使构建过程变得更慢,并无谓地复杂化。
将责任回归给开发者
Make 工具的创建是为了管理重建代码的复杂性。递归 Make 将这个过程颠倒过来,迫使你再次参与到构建过程中。我们看到了程序员如何管理递归的顺序,以及如何通过修改每个 makefile 来绕过限制。
微妙之处
递归 Make 的问题并不明显。这就是为什么许多人仍然认为这是一个好主意。当事情出错时,它们以奇怪的方式出错。问题的原因很少是清晰的,所以它会被视为“那种怪异事件”之一。
这导致了一个危险脆弱的构建系统。
这些都是人们错误地归咎于 make 自身的问题,认为它是存在缺陷的。但在这个方面,make 只是一个无辜的旁观者。真正有问题的是我们对 make 的使用。递归引入了这些问题中的每一个;它阻碍了 make 执行其本职工作。
那么,这个混乱的解决方案是什么?显然,我们不想丢弃源树中的嵌套。我们需要一个支持嵌套但不递归分割构建过程的构建过程。这并不难;我们将这种技术称为嵌套 make。它仅仅涉及将所有构建信息放在一个主 makefile 中。不再需要单独的子目录 makefile。超级 makefile 内部管理所有源嵌套。
关键概念
与普遍看法相反,递归 make 是一种不好的构建技术。为了避免它,选择更健壮的嵌套 make**方法。
你可能会认为这是一个更复杂且灵活性更差的方案。你如何只用一个 makefile 管理一个大的构建树?
一些实用的实现技术使其变得容易:
-
使用 make 的包含文件机制。将每个目录的源文件列表放在那个目录中——这样更易于维护和清晰。将这些列表放在一个名为
files.mk的文件中,并从主Makefile中包含它。 -
你可以通过定义更多的中间目标来保留递归 make 的模块化——进入任何组件子目录并输入
make。这些目标构建项目的特定部分。以这种方式构建模块化构建可能比递归 make 的任意基于目录的方法更有意义,并且它确保每个中间目标总是被正确构建。
嵌套 make 与递归 make 一样不复杂;实际上,它可能更简单。它产生更可靠、准确、快速的构建。
个人化
- 你知道如何使用你的构建系统执行不同类型的编译吗?你如何从相同的源代码、相同的 makefile 构建调试或发布版本的应用程序?
在之前的回答中,我们看到了这个问题的良好解决方案:根据构建类型(一个目录用于调试文件,一个目录用于发布文件),通过构建脚本在不同的子目录中构建对象。
你可以通过对文件名进行操作在 GNU Make 中实现这一点。以下是一个示例:
`# Define the source files`
SRC_FILES = main.c func1.c func2.c
`# Default build type (if none specified)`
BUILD_TYPE ?= release
`# Synthesize the object filenames # (This is a magic GNU Make incantation that swaps # the .c file suffix for .o)`
OBJ_FILES = $(SRC_FILES:.c=.o)
`# Now the clever bit: add the build-type directory # prefix to object filenames (more GNU Make magic)`
OBJ_FILES = $(addprefix $(BUILD_TYPE)/, $(OBJ_FILES))
显然,你将使用选定的 BUILD_TYPE 做更多的事情,比如更改编译器标志。别忘了你需要一个规则来创建子目录,否则当编译器尝试生成输出时,它会抱怨。以下是在 Unix 上的做法:
$(BUILD_TYPE):
mkdir -p $(BUILD_TYPE)
现在,你可以连续输入这两个命令,知道构建系统会完美应对:
BUILD_TYPE=release make all
BUILD_TYPE=debug make all
你可以创建一个没有这种子目录技术的更简单的系统,但它将依赖于每次更改 BUILD_TYPE 时进行清理。
-
你的当前项目构建过程有多好?它与本章中提到的特性相比如何?你该如何改进它?改进它的难易程度如何?
-
向库中添加一个新文件?
-
添加一个新的代码目录?
-
移动或重命名一个代码文件?
-
添加不同的构建配置(比如,演示构建)?
-
在一个源树副本中构建两个配置,而不进行清理?
-
这显示了你对构建过程的了解程度以及其可维护性。将你的构建机制与其他项目的构建机制进行比较是个好主意——这将显示出你的流程在哪里不足并需要改进。
考虑移动和重命名源文件。这两者在重构过程中都很常见,而且很容易被忽视。这些简单的操作可能会导致构建系统错误地计算依赖关系并构建有缺陷的代码。我曾经不止一次被这样的问题困扰;当这种情况发生时,要花一些时间才能注意到。
经常情况下,程序员在忙碌的日程中“没有时间”去改进构建系统;他们都在忙于试图将产品推出市场。这是一个危险的观点。构建脚本是代码的一部分,需要与其他任何源文件一样多的维护和仔细的扩展。一个安全可靠的构建系统如此重要,以至于花时间整理它并不是浪费时间。这是对代码库未来的投资。
- 你是否曾经从头开始创建一个构建系统?是什么驱使你选择了特定的设计?
就像任何编程任务一样,你的解决方案的形状受到许多因素的影响:
-
你以往的经验
-
你所知道的信息
-
你对问题的理解目前
-
可用技术的局限性
-
你用来设置它的时间量
通常,一点时间和一点使用经验就会告诉你设计决策有多好。你一开始永远不会欣赏所有的需求,而且事情会发生变化,没有人能预料到:
-
需求会变化——如果产品真的非常成功,你可能需要构建不同的国际化版本或针对新的处理器架构。构建系统必须能够扩展。
-
当没有人预料到这应该是一个可选选项时,代码可能需要迁移到新的构建工具链。
这些修改是否容易整合是对你设计质量的证明。你会在每次更改中学习,为下一个构建系统积累宝贵的经验。
-
每个人在构建系统中都会时不时地遇到缺陷。当编写构建脚本时,你引入错误的可能性与编写真实代码时一样高。
你遇到过哪些构建错误,你该如何修复,甚至防止它们?
常见的构建错误包括:
-
错误地获取依赖信息
-
无法优雅地处理文件系统故障,如磁盘空间不足或文件权限不正确;构建过程可能继续进行,没有任何迹象表明某个步骤失败
-
版本控制问题:合并出错,或检出了一些源代码的错误版本
-
库配置错误,通常使用不兼容或过时的版本
-
程序员不理解如何使用构建系统,并犯下愚蠢的错误
当事情没有按预期进行时,退一步考虑构建系统是否在问题中扮演了角色。
^([12]) 这些是相当合理的假设;许多系统都是这样工作的。
^([13]) 想想启动所有这些子进程的开销吧!
^([14]) 这是 GUI 工具的一个优势——没有递归 make,它们通常能正确管理依赖关系。
第十一章
深思熟虑
- 优化是一个权衡的过程——为了另一个期望的质量牺牲代码的一个质量。描述导致性能提升的权衡类型。
影响程序性能的决策类型包括:
-
功能数量与代码大小
-
程序速度与内存消耗
-
存储和缓存与按需计算
-
保守的方法与无保护的方法;乐观与悲观
-
近似计算与精确计算
-
内联与函数调用;单一与模块化
-
对数组进行索引与搜索列表
-
通过引用或地址传递参数与传递副本
-
硬件实现与软件实现
-
固定编码,直接访问与间接访问
-
预设的固定值与可变和可配置值
-
编译时工作与运行时工作
-
本地函数调用与远程调用
-
懒计算与急计算
-
"巧妙"的算法与清晰的代码
- 查看第 202 页上"为什么不去优化?"中列出的每个优化替代方案。描述是否做出了权衡。
其中一些替代方案可能被认为是优化,具体取决于你对系统的控制程度。如果你指定了程序将运行的硬件平台,使用更快的机器就是一种优化。如果没有,它更多的是一种权宜之计。
许多替代方案都有隐藏的复杂性成本。例如,依赖于特定的主机平台配置(即,运行的服务或后台程序)会导致特定的环境依赖性,这些依赖性难以捕捉,在安装或后续维护中容易忽略。
-
解释这些术语及其确切关系:
-
性能
-
效率
-
优化
-
代码的效率决定了其性能。优化是指通过提高代码的效率来改善性能的行为。请注意,这些术语中没有一个直接描述执行速度;所需的质量可能不是速度,而是内存占用或数据吞吐量。
- 一个慢速程序可能存在哪些瓶颈?
认为所有东西都在争夺 CPU,并且糟糕的代码会消耗所有处理器时间是一种常见的谬误。有时 CPU 可能几乎空闲,但性能却很糟糕。一个程序可能因为以下原因而停滞:
-
内存正在从硬盘上的交换空间中来回交换。
-
它正在等待磁盘访问。
-
它正在等待缓慢的数据库事务。
-
存在着糟糕的锁定行为。
- 你如何避免需要优化的需求?有哪些方法可以防止你编写低效的代码?
我们已经看到,从一开始就将性能设计到软件系统中是多么重要。只有在你已经对所需性能特征有明确想法的情况下,你才能做到这一点。
一旦你有一个可靠的设计,就明智地编写你的代码。注意在你的语言中哪些构造最有效,并避免使用效率低下的构造。例如,在 C++中,传递const引用而不是昂贵的临时副本.^([15])
了解不同操作的相对成本是有用的。如果我们按比例缩放时间,使得处理器每秒执行一条指令,那么函数调用通常需要几秒钟,虚函数调用需要 10 到 30 秒,磁盘寻道需要几个月,而平均打字员的按键间隔是几年。尝试为内存分配、锁定请求、创建新线程和简单的数据结构查找等操作确定这种类型的度量。
- 多个线程的存在如何影响优化?
线程可能会引起与它解决的问题一样多的问题。天真地设计的线程可能会引入额外的瓶颈,尤其是在锁使用不当的情况下,导致长时间的死锁。
多线程程序更难进行性能分析,除非分析器有良好的线程支持;你需要根据相对线程优先级来解释分析器的结果。如果线程应该协作,你必须弄清楚几个控制线程如何相互交织,以了解整体执行的进展。
- 为什么我们不编写高效的代码?是什么阻止我们首先使用高性能算法?
有很多完全合理的理由,使得第一次尝试编写优化代码变得困难:
-
你不知道最终的使用模式。在没有真实世界测试数据的情况下,你如何选择最佳的代码设计?
-
让程序工作已经足够困难,更不用说让它快速工作。为了证明这是可行的,我们选择易于实现的设计,以便快速完成原型。
-
"高性能"算法可能更复杂,实施起来也更令人畏惧。程序员自然会对它们有所顾虑,因为这是一个容易引入错误的领域。
程序员常常认为运行某些代码所需的时间与编写代码所花费的努力成正比.^([16]) 你可能花了几小时编写一些文件解析代码,但它总是需要很长时间才能执行,因为磁盘很慢。你花了半周时间才正确编写复杂的代码,可能只消耗了数百个处理器周期。实际上,代码的效率以及你需要花费多少时间来优化它,与你在编写代码时花费的时间没有任何关系。
-
List数据类型是通过数组实现的。以下每个List方法的算法复杂度最坏情况是什么?-
构造函数
-
append—将新项放置在列表的末尾 -
insert—在两个现有列表项之间插入一个新项,在指定位置 -
isEmpty—如果列表不包含任何项,则返回true -
contains—如果列表包含指定项,则返回true -
get—返回具有给定索引的项
-
最坏的情况是:
-
构造函数是
O(1),因为它只需要创建一个数组;列表最初是空的。然而,值得考虑的是,这个数组的大小将影响构造函数的复杂度——大多数语言在创建数组时都会用对象完全填充,即使你还没有计划使用它们。如果这些对象的构造函数非平凡,那么List构造函数将需要一些时间来执行。数组大小可能不是固定的——构造函数可以接受一个参数来确定这个大小(实际上设置了最大可能的列表大小)。然后,该方法变为
O(n)。 -
append操作的平均时间复杂度是O(1):它只需写入一个数组条目并更新列表大小。但是,如果数组已满,它将不得不重新分配、复制和释放——最坏情况下的复杂度为O(n),至少(这取决于你的内存管理器的性能)。 -
insert的平均时间复杂度是O(n)。你可能被要求在列表的非常开始处插入一个元素。这需要将数组中的所有元素向下移动一个位置,然后写入第一个元素。List中的项目越多,这需要的时间就越长。然而,最坏的情况,再次涉及内存重新分配,可能比O(n)多得多。 -
除非你有极其糟糕的实现,否则
isEmpty是O(1)。列表大小将是已知的,所以返回值是基于这个数字的单个计算。 -
contains的时间复杂度是O(n),假设列表内容是无序的。在最坏的情况下,你将被迫寻找一个不存在的项,并将不得不遍历列表中的每一个项。 -
get是O(1),多亏了数组实现。数组索引是一个常数时间操作。如果List被实现为链表,那么这将是一个O(n)操作。
个人化
- 在你的当前项目中,代码性能有多重要(老实说)?这个性能要求的动机是什么?
性能要求不应随意选择。它们应该是合理的,而不仅仅是凭空拉出的时间限制。每个性能要求都很重要;没有无关紧要的规范。特定要求引起多少关注取决于满足它的难度。无论难易程度如何,你都必须提出一个满足这些要求的设计。
-
在你最后的优化尝试中:
-
你使用了分析器吗?
-
如果是,你测量了多少改进?
-
如果没有,你是如何知道你做出了任何改进的吗?
-
你在优化后测试了代码是否仍然正常工作吗?
-
如果答案是肯定的,你测试得有多彻底?
-
如果没有,为什么没有?你如何确保代码在所有情况下都仍然正常工作?
-
没有分析器或其他良好的计时测试,只能检测到最显著的性能改进。人的感知很容易被欺骗——当你为了加快程序而辛勤工作时,它对你来说总是会显得更快。
仔细测试性能改进,并丢弃那些不值得的改进。有清晰的代码比微小的速度提升和不维护的逻辑更好。
- 如果你还没有尝试优化你目前正在工作的代码,猜测一下哪些部分是最慢的,哪些部分消耗了最多的内存。现在运行它通过分析器——你的预测有多准确?
你可能会对结果感到非常惊讶。你分析的程序越大,你正确判断这些瓶颈的可能性就越小。
- 你的程序的性能要求有多明确?你是否有具体的计划来测试你是否满足这些标准?
没有明确的规范,没有人真的会抱怨你的程序不够快!
^([15]) 相反,这个引用可能会抑制其他性能提升。副本保证没有别名问题;如果存在潜在的变量别名,一些编译器优化无法执行。一如既往,你必须衡量并找出什么最适合。
^([16]) 当你把它写下来时,这看起来很愚蠢,但这是一个很容易陷入的简单陷阱。
第十二章
仔细思考
- “安全”的程序是什么意思?
一个安全的程序能够抵御滥用、入侵或用于其未打算使用的目的的尝试。这不仅仅是一个健壮的程序;健壮的代码满足其规范,并在施加轻微压力时不会崩溃。然而,健壮的程序可能没有考虑到安全性,并且在某些极端条件下可能会泄露敏感信息。有时,当使用不当时崩溃,而不是提供意外的输出,可能是更好的选择。因此,安全的代码可能会崩溃!
安全程序的定义取决于应用程序的安全要求。这些要求部分由支持服务(操作系统和其他应用程序)所能提供的内容定义。考虑到这些因素,您的应用程序的目标可以是以下任何一种:
机密性
系统不会向错误的人透露信息。他们将会收到一个访问拒绝的消息,或者甚至不知道信息最初存在。
完整性
系统不会允许未经授权的信息更改。
可用性
系统持续工作——即使在受到攻击的情况下。很难防御所有可能的攻击(如果有人切断电源怎么办?),但通过在设计时包含一定程度的冗余或攻击后提供快速重启,可以抵抗许多攻击。
身份验证
系统确保用户是他们所说的那个人,通常是通过登录和密码机制。
审计
系统记录所有重要操作的信息,以捕捉或监控攻击者的活动。
- 在安全程序中,必须验证哪些输入?需要什么样的验证?
所有输入都必须经过验证。这包括命令行参数、环境变量、GUI 输入、Web 表单输入(即使那些带有客户端 JavaScript 检查的)、CGI 编码的 URL、cookie 内容、文件内容和文件名。
您应该检查输入的大小(如果它不是一个简单的数值变量)、其格式的有效性以及数据的实际内容(数字在范围内,并且没有嵌入的查询字符串)。
- 如何防御来自受信任用户池的攻击?
并非很容易。他们被赋予了特定的权限级别,因为他们被认为不会滥用。大多数用户不会故意滥用您的软件,但一小部分人可能会试图为了自己的利益而篡改程序。
有几种技术可以管理这一点:
-
记录每一次操作,以便您知道谁做了什么更改以及何时做的更改。
-
对于所有真正重要的操作,需要两个用户进行身份验证。
-
将每个操作包裹在一个可撤销的事务中,以便可以取消操作。
-
定期备份所有数据存储,以便您可以恢复丢失的数据。
- 在哪里可能会发生可利用的缓冲区溢出?哪些函数特别容易发生缓冲区溢出?
缓冲区溢出可能是最大的安全漏洞,这是一个简单的问题,攻击者很容易利用。它可能发生在任何多位置结构被寻址的地方——无论是通过将其数据复制到或从中复制出来,还是通过索引它来访问特定项。数组和字符串是最常见的罪魁祸首。
这通常出现在用户输入例程中,尽管这并不是唯一的栖息地——它可以在任何数据操作代码中存在。可利用的缓冲区可以位于栈上(函数局部变量放置的地方)或堆上(动态分配内存的池子)。
- 你能完全避免缓冲区溢出吗?
是的——只要你在验证每个函数的输入上勤奋,并且可以确信每个输入(在操作系统输入例程或你的语言运行时库中实现的可能性)的软件堆栈是安全的。
这里有一些关键技术来保护你的代码:
-
使用没有固定大小缓冲区的语言——例如,具有自动扩展字符串的语言。然而,不仅仅是字符串是危险的:寻找边界检查的数组和安全的哈希映射。
-
如果你不能依赖语言支持,你必须对所有输入进行边界检查。
-
在 C 中,始终使用更安全的标准库函数
strncpy、strncat、snprintf、fgets等等。不要使用stdio例程,如printf和scanf——你不能保证它们的安全性。 -
永远不要使用不可证明安全的第三方库。
-
在受管理的执行环境中编写你的代码(如 Java 或 C#)。然后缓冲区溢出攻击几乎不存在——执行器会自动捕获大多数溢出。
- 你如何确保应用程序使用的内存安全?
有三个时刻需要考虑内存安全性:
-
在使用之前。当你请求一些内存时,它包含任意值。不要编写依赖于未初始化内存内容的代码。黑客可以利用这一点来攻击你的代码。为了额外的安全,在使用之前将所有分配的内存清零。
-
在使用期间。锁定包含敏感信息的内存,使其不能被交换到磁盘。显然,你必须使用一个安全的操作系统——如果一个应用程序可以读取任何其他应用程序的内存,那么你已经输了!
-
在使用后。应用程序程序员常常忘记的是,当你释放内存时,应该在将其交回操作系统回收之前进行清理。如果你不这样做,恶意进程可能会挖掘你留下的秘密数据。
-
C 和 C++是否天生比其他语言更不安全?
C 和 C++产生了比其公平份额更多的不安全应用程序,并且允许你编写包含经典安全漏洞的代码。你绝对必须保持头脑清醒;即使是经验丰富的开发者,在编写 C/C++代码时也必须注意以避免缓冲区溢出。这些语言并不鼓励安全的编程。
然而,其他语言也无法避免所有安全问题,只是不像 C 和 C++那样使这些问题变得众所周知。不同的语言可能会避免潜在的缓冲区溢出,但你不应有一种虚假的安全感;许多在语言本身中无法避免的其他问题仍然存在。在使用任何语言时,你必须意识到安全问题——你不能选择一个“安全”的语言而忘记所有关于安全的事情。
事实上,缓冲区溢出是一种可以非常容易地进行审计和规避的漏洞。如果你需要编写安全的应用程序,那么你使用的语言只是所有其他问题中的一小部分。
- C 语言的经验是否使 C++成为一种更好、更安全设计的语言?
C++获得了一个内部管理其内存的抽象string类型。这有助于避免缓冲区溢出,尽管传统的 C 风格char数组仍然存在,供那些仍然想自毁前程的人使用。vector是另一个有用的工具:一个内存管理的数组。然而,这两种结构都有可能发生溢出——你知道如何吗?
C++可能被认为比 C 更危险,因为它在堆上存储了大量的函数指针(这是虚拟函数表存储的地方)。如果攻击者可以覆盖这些指针之一,那么他就可以将操作重定向到自己的恶意代码。
在许多方面,C++更安全,或者说,它更容易安全地使用。然而,它并不是完全为了安全而设计的,并且提供了一套开发者必须意识到的安全问题。
- 你如何知道你的程序已被入侵?
没有检测措施,你将一无所知——你只能密切关注异常的系统行为或不同的活动模式。这几乎不是科学的。被黑客攻击的系统可以无限期地保持秘密。即使受害者(或他的软件供应商)确实发现了攻击,他可能也不愿意发布关于攻击的详细信息,以吸引更多的入侵者。哪家公司会公布其产品存在安全漏洞?如果它足够负责任地发布安全补丁,并不是每个人都会升级,这将导致许多运行中的系统存在一个被充分记录的安全漏洞。
个人观点
- 你当前项目的安全需求是什么?这些需求是如何建立的?谁了解它们?它们在哪里被记录?
坦诚回答这个问题。编造一些听起来合理的东西并不难。但除非安全需求被正式记录,否则你的项目并没有真正解决安全问题。这应该是每个开发者都应意识到并知道如何满足的事情。
- 你发布的应用程序中最严重的安全漏洞是什么?
了解这一点很重要,即使它现在已经成为历史。你必须知道你过去做错了什么,这样你才有可能在未来避免它。如果你不知道任何过去的安全漏洞,那么你可能没有在安全测试中做得彻底——你没有注意,或者你非常幸运,没有发现任何问题。
- 针对你的应用程序发布了多少安全公告?
这些是由于愚蠢的开发者错误,比如愚蠢的代码错误,还是源于更大的设计问题?在公告中记录的最常见问题是前者。
- 你曾经进行过安全审计吗?它揭示了哪些缺陷?
除非你有专业的安全专家进行这项测试,否则肯定会错过一些安全漏洞。然而,审计仍然会揭露许多明显的问题,并且非常有价值。
-
最有可能攻击你当前系统的人是什么样的?这如何受到
-
你的公司
-
用户的类型
-
产品的类型
-
产品的流行度
-
竞争
-
你运行的平台
-
系统的连通性和公开可见性
-
每个人都是某个人的目标:恶意用户、无良竞争者,甚至是恐怖组织。你信任谁?
第十三章
仔细思考
- 项目规模如何影响你的软件设计和创建它的工作?
项目越大,相对于底层代码设计,它需要的架构设计就越多。需要更多的时间在前期确保设计正确,因为错误的选择将会有更严重的后果。
- 详尽的糟糕设计是否比未记录的良好设计更好?
文档是使设计良好的部分原因。一个详尽的糟糕设计提供了进入代码的途径,即使它是一条通往化粪池的明亮的泥泞小道。至少,它会教会你永远不要再触碰这段代码。
一段足够简单的代码不需要大量的文档,但任何合理复杂的软件在没有适当描述的情况下都很难使用。
哪个更好?未记录的良好设计是最好的:如果它是一个真正高质量的设计,那么它应该是显而易见且自我说明的。
- 如何衡量一段代码的设计质量?如何量化其简洁性、优雅性、模块化等?
质量难以量化;它主要是对设计的美学判断。是什么让一幅画变得美丽?这类东西你无法用手握住并计数。事后看来,代码的易读性或可修改性可能会显得很简单。但当你第一次遇到一些代码时,这并不真正有帮助。如果我有两个设计A和B,我认为A更优雅,但在实践中B证明更易于使用,并且更好地应对了重用的压力,那么很难说A是更好的设计。
判断设计质量的唯一方法就是查看代码。阅读一点代码通常可以给人留下对整体质量的良好印象;如果一小部分看起来很好,那么其余部分也很可能是合理的质量。这并不总是成立,但它是一个实用的标准。一种现实的方法是这样的:如果那小部分代码很糟糕,那么整个代码库可能都很糟糕。如果那小部分代码不错,那么只是怀疑代码库中可能隐藏着更微妙的问题。
运行检查源代码的工具,生成图表和文档,也可以帮助评估设计质量。
- 设计是一项团队活动吗?在创造一个好的设计中,团队合作技能有多重要?
非常重要。编程任务很少是单独的活动。在软件工厂中,大多数大规模设计活动涉及不止一个设计师。即使工作被分成不同的区域,这些区域在某个点上也会接口——因此设计师必须接口。如果只有一个设计师,他或她仍然必须能够有效地记录和传达设计。
- 不同的方法是否更适合不同的项目?
是的,某些项目的范围将使某些设计方法变得不必要。如果你正在编写一组设备驱动程序,你不会在全面的应用程序对象设计过程中找到很多用途。
如果你正在为一个非常正式的项目工作,可能是一个政府机构的项目,你可能需要使用一个非常正式的过程,该过程记录每个阶段并提供对每个设计决策的责任。这可能和软件实验室中的探索性研发项目大不相同。
- 你如何确定一个设计是高度内聚还是弱耦合的?
最终,你必须查看代码并观察其如何组合在一起,但这很无聊!通过查看文件顶部的#include语句,你可以很好地了解 C 或 C++项目中的耦合情况。如果有很多这样的语句,那么耦合可能非常糟糕。或者,你也可以运行检查工具,这些工具可以生成你代码的漂亮图片。
- 如果你以前解决过类似的设计问题,这将是衡量这个问题难度的一个多好的指标?
经验教会你如何设计,所以学习和利用你的知识。但运用智慧来运用这些知识;不要自动驾驶。不同的情况会带来不同的挑战——不要仅仅因为表面看起来一样就假设一个问题与另一个问题相同。
如果你知道如何使用锤子,不要把每个问题都变成钉子。
- 设计中有实验的余地吗?
是的,任何设计在实施并被接受之前都是实验性的。考虑 Frederick Brooks 描述的“先做一个扔掉”的方法。(Brooks 95)实验有很多可说的。
设计是一个迭代的过程;在每次迭代中,你可以尝试不同的设计方案,并决定哪个最合理。你经历的迭代越多,每次迭代的范围越小,不良的设计决策带来的痛苦就越少。
个人化
- 回顾一下你是如何学会设计代码的。你如何将你获得的知识传达给一个完全的新手?
你认为你能教多少,有多少需要来自新手的天赋和经验?你能根据你的经验设计一套练习,帮助其他人吗?
你不会一开始就给一个新手一个大的系统来设计。你会让他从一个小的自包含项目开始,然后可能让他对现有的程序进行扩展,同时始终关注他的进展。
大多数程序员在学习设计时没有得到这种帮助。他们通过试错的过程来学习。请考虑教授和指导新手——这真的有助于你在自己的能力上成长。
- 你使用特定设计方法的经验如何?这些经历是好的还是坏的?产生的代码是什么样的?什么可能做得更好?
你的口味是否受到了先前经验和偏好的方法论的影响?如果你不知道如何使用特定的方法论,那将是一项艰巨且不舒服的工作。一个铁杆的 C 程序员可能不喜欢任何形式的面向对象设计,他的 OO 设计可能会令人震惊。但这并不意味着 OO 是一个有缺陷的方法。
- 你认为坚持使用的方法论很重要吗?
设计方法是一种工具,一种实用工具,就像编程语言一样——你应该只用到它仍然有用为止。如果它不再有用,它就不再是实用工具了!如果团队中没有一个人知道如何执行它,那么这种方法就不会起作用;使用他们知道的东西,或者先教他们。
- 你见过的最佳设计的代码是什么?最差设计的呢?
我敢打赌你会很容易记住最糟糕的设计代码。糟糕的代码就像一个明显的痛处,同样也深深地留在你的记忆中。设计良好的代码看起来简单明了,所以你可能不会退后一步说,“这是一个多么伟大的设计!”你可能甚至不会注意到其中涉及了很多设计工作。
- 编程语言本质上是一种实现你设计的工具,而不是一个争论的宗教。真正重要的是了解语言习惯吗?
这非常重要,否则你最终会得到没有意义的代码。
一些架构决策可能是语言无关的,但低级代码设计受到实现语言的严重影响。一个明显的例子:当你用 Java 编程时,不要创建一个扁平的过程式设计——这纯粹是错误的。
- 你认为编程是一门工程学科、一门手艺还是一门艺术?
简单来说,这取决于你如何做。它包含了所有三个要素。
我更喜欢将编程视为一门手艺——它需要技能、工艺、纪律和经验。其产品可以同时具有功能性和美观性。其中包含了一定的艺术性;它是一个创造性的过程。与这种艺术性相伴随的是对工具和技术的掌握。这些都是手艺的标志。
第十四章
沉思
- 定义架构的结束和软件设计的开始。
事实上,这两个术语可以定义为适合你的任何定义。在它们的常见用法中,区别如下:
-
架构是高级结构设计。它考虑其选择的广泛影响,看它将如何影响建设和维护成本、整体系统复杂性、适应未来扩展的能力以及营销问题。架构是在项目开始时制定的。它至少对进一步软件设计有严重后果。
-
软件设计是下一层次的活动,更加精细和专注。它关注代码细节——数据结构、函数签名以及模块中精确的控制流。软件设计是按模块进行的。它的后果对整个系统的影响远远不如架构那么显著。
这两个领域相交的地方部分取决于项目的规模。软件构建是一个迭代和逐步的过程——尽管架构首先创建,但设计结果可以反馈到架构中。
- 糟糕的架构以何种方式影响系统?有没有哪些部分不会受到架构缺陷的影响?
糟糕的架构会破坏编写好软件的任何努力。它是你代码质量的基础。如果某些代码不受有缺陷的架构的影响,那么它可能要么是一个独立的库,要么它从一开始就不应该属于该系统。
- 一旦建筑缺陷显现出来,修复起来有多容易?
在项目的早期形成阶段,调整架构相对容易。但一旦开发承诺了该架构,并且有足够的投资(设计和代码)已经安排在其框架中,就非常,非常难以改变。你不妨尝试从头开始重写整个产品。
这就是为什么第一次做对架构如此重要的原因。你可以重构一小部分代码,但不能重构整个结构基础。
当然,与物理建筑行业相比,我们撕毁软件并重新开始要容易得多,但经济因素决定了我们无法这样做。我们通常只有一次机会做对架构,如果我们没有,我们就必须忍受整个软件系统生命周期中的后果。
-
架构在多大程度上影响以下事物?
-
系统配置
-
日志记录
-
错误处理
-
安全性
-
架构对每一个都有深远的影响,或者更准确地说,每一个都对架构有深远的影响。在开始严肃的架构设计之前,你需要为这些领域建立要求。在以后日期将这些功能嫁接到代码中将会很困难,更不用说嫁接到主导架构中。
-
架构决定了什么应该可配置(很多或很少)以及如何进行配置。配置机制的类型由几个因素决定:共享“配置管理器”组件的重要性、系统是否支持远程配置,以及谁有权执行配置(仅仅是开发者;软件是否应该由安装程序、维护人员或用户进行调整?)。所有这些关注点都是基本架构问题。
-
独立的组件可能使用某些共享设施来记录信息,或者它们可能使用自己的定制机制。架构将定义哪种方法是可以接受的,如何访问日志,以及重要的日志信息类型。这需要解决软件开发人员和软件用户的需求。是否应该由发布版本生成开发日志信息?
-
架构错误管理问题包括是否存在中央错误记录服务以及错误报告方案(错误是如何从污秽的后端组件传播到用户清洁的 GUI 界面?)。它还定义了使用哪种类型的错误机制:可能是一个跨所有组件共享的集中式错误代码表或一个通用的异常层次结构。它将解决第三方代码中的错误如何纳入系统。
-
安全问题将取决于正在开发的软件类型。一个基于分布式互联网的店面系统与仅部署在独立计算机上的小段代码的安全需求不同。安全是一个重要的话题,不能在最后一刻仓促加入;它必须在早期架构设计中得到解决。
-
成为软件架构师需要哪些经验和资质?
你可以决定自称是架构师,但你不能一夜之间获得洞察力和经验,也不能神奇地召唤出做出良好设计决策的智慧。
良好的架构设计需要丰富的先前经验——从真实软件系统中学习、设计和改进。这只能通过实际操作来学习,而不是通过观察他人。要警惕那些在仅完成一个软件版本的工作后自称架构师的人。
你可以从事软件架构工作而不被称为架构师;这个称号的使用通常取决于公司结构和文化。在声称这个称号之前不需要正式的资质——然而,在一些国家,没有专业认证就自称任何类型的架构师是非法的。
- 销售策略应该影响架构吗?如果是,如何?如果不是,为什么?
是的,商业问题不可避免地会影响技术架构。否则,你会构建一个不可行的产品;你很快就会发现自己失业,你的公司陷入清算。
我们必须解决我们设计的商业影响——例如,考虑故障模式的影响以及与返回基地或现场系统支持相关的成本。如果这些问题存在,架构必须最小化这些事件(你可以提供远程访问和丰富的诊断来避免这种强烈的产品支持)。
商业问题也会影响这些架构领域:客户支持设施(包括系统管理的难易程度),安装方法(由受过培训的人员执行或由自动 CD 安装程序执行),以及维护支持和费用结构。
- 你将如何设计可扩展性?你将如何设计性能?这些设计目标如何影响系统,以及它们如何相互补充?
有许多架构决策是从这两个要求中产生的。
-
可扩展性可以通过架构设备如插件、代码的编程访问(反射)、更多语言绑定、脚本能力以及额外的间接层次来支持。
-
性能是通过简化架构,保持其简洁和精炼来实现的。你必须移除所有不必要的组件,并确保提供的连接及时且充足。可能需要包含缓存层来提高数据吞吐量。
正如你所见,这两者几乎没有共同之处;每个扩展的钩子都会消耗一些,无论多小,性能。额外的间接性是有成本的——那就是间接性。如果你的目标是可扩展性,这是你愿意付出的适当代价。一个好的架构会做出正确的高级妥协,以适应感知的需求。
个人感悟
- 你对哪些架构风格最为熟悉?你最有经验的是哪一种——它如何影响你编写的软件?
架构以多种方式影响我们。不同的架构风格导致不同的设计和编码技术。我们是习惯性的生物,这些技术将塑造我们的思维和编码方式,即使是在后来工作于不同的架构时也是如此。
接触多种不同的架构并能够与之合作是健康的。在实践中,你将专注于一种特定的风格。确保你理解你的代码是如何被这种架构塑造的,并在改变架构时确保你编写的是同情的代码。
- 你有什么关于成功或失败的架构的个人经验?是什么使它们成为获胜的解决方案或障碍?
首先,我们必须定义什么是架构的成功。是具有技术优势的架构吗?是实现了商业盈利的系统吗?是两者兼而有之吗?请在这里写下你的答案。
那些因不适当的架构而承受重压的软件通常是因为架构不适合扩展。重要的功能无法容纳。这不可避免地意味着产品会失去市场份额给更灵活的竞争对手。历史上充满了这样的软件产品。
另一个危险是遗留问题;对架构遗留投资的巨大投入是一个巨大的障碍。需要真正的洞察力和相当大的勇气才能丢弃旧系统或架构,从头开始。重做必须总是从上一个版本中学习经验教训。
过度设计的架构和不足的架构一样危险。如果架构支持过多,它会使产品过于复杂、笨重,并且速度无法接受。通常这意味着即使是简单的更改也需要修改许多组件。
- 让你们当前项目上的每个开发者都画一幅系统架构图——单独地(不与任何人交谈)且不参考任何系统文档或代码。比较这些图片。看看每个开发者的努力中有哪些让你印象深刻的地方——除了相对的艺术价值!
如果图片之间没有相似之处,那就感到害怕。如果只有细微的差别,不要担心;不同的人会错过不同的微小组件,每个人可能都专注于系统的不同部分。但如果图表包含截然不同的组件或通信路径不相似,那么团队对代码就没有相同的心理模型。这几乎肯定会导致灾难。召集开发者们一起确保他们知道系统真正的样子。
如果所有图表看起来都相似,那就给自己鼓掌。如果组件在每个纸张上位置相似,你将获得额外的分数。这是一个暗示,表明存在一个中心架构规范,更重要的是,每个人都理解它。
- 你有一个当前项目普遍可用的架构描述吗?它有多新?你使用哪些类型的视图?如果你需要向新来者或潜在客户解释系统,你真正需要记录什么?
注意你的理想文档与现实之间的差距。你有哪些机会来改善这种情况?在一个繁忙的商业环境中,你很少能够安排特定的时间来记录整个架构,但你可以在设计新模块和制定规范的过程中捕捉到部分内容。这样,你可以逐步构建一个良好的架构概述。
- 你的系统架构与市场上竞争对手的架构相比如何?你的架构是如何定义的,以确定项目的成功?
理解你的架构是如何设计以满足所有你的需求并确保你的成功是很重要的。(如果它没有考虑到这一点,那么你就有麻烦了。)我们已经看到架构对软件系统的形状和质量有最根本的影响——因此它确实对你的产品成功或失败有很大的影响。你很少会看到软件产品在糟糕的架构下繁荣。如果你知道一个成功的例子,它可能不会持续很长时间。
架构必须能够支持至少与竞争系统相同的核心功能,并为那些将导致有人选择你的产品而不是其他人的独特功能提供良好的支持。那些不需要架构支持的基本功能通常不如深深嵌入系统中的核心功能有吸引力。
第十五章
深思熟虑
- 软件增长的最好隐喻是什么?
没有答案。用福雷斯特·冈普(Forrest Gump)不朽的话说,“软件就是软件所做。”(Groom 94)代码构建有许多相关性,但没有任何隐喻能够完全传达其微妙之处,就像你永远无法用言语完全描述日出之美。
类比可能会误导;软件与任何物理物品都大不相同,相应地,构建它的方式也不同。物理约束较少,你可以以更多的方式操纵它。
每个隐喻中都有一些真理。从它们中学到你能学到的,但不要被引入对软件的错误看法。
-
通过我在引言中提到的多彩的生命周期隐喻来看一个程序的发展,以下现实世界的事件与程序相对应:
-
构思
-
出生
-
成长
-
成熟
-
送入广阔的世界
-
中年
-
变得疲惫
-
退休
-
死亡
-
虽然我们已经看到隐喻并不完美,但研究这个隐喻确实教会了我们很多关于软件系统生命周期的知识。将一个发展阶段放在前面的步骤之前肯定是不切实际的——你不能在软件成熟之前发布它。好吧,你可以,但后果是严重的。
构思
公司观察到新的产品机会。市场需求得到确定。决定开始构建它。
出生
启动一个项目来构建软件。设计师和程序员被征召。建立了一个架构。代码开始编写。
成长
代码发展,程序成熟。它变得越来越功能完善。截止日期临近。
成熟
最后,代码完成。它通过了所有测试,让 QA 满意。它被认为是一项很好的工作,希望它没有落后太多进度。
送出
程序以 1.0 版本发布。它成功满足了市场需求。
中年
该程序被客户广泛使用,并且已经部署了一段时间。现在,经过几次修订后,它积累了额外的功能,并且出现了一定程度的膨胀。
变得疲惫
最终,更敏捷的竞争者超越了该程序,拥有更丰富的功能和更好的性能。没有新的客户选择我们的程序,但现有客户纷纷要求升级。软件变得难以扩展(甚至不经济)。
退休
最后,公司决定放弃开发并停止支持。它宣布将在x个月后结束支持:一个正式的生命终结声明。开发停止,尽管一些维护工作仍在继续。
死亡
我们不可避免地达到了:所有开发和维护都停止了。不再提供任何支持。世界已经前进;很快,没有人会记得这个程序的名字,更不用说如何使用它了。
- 软件的生命有限吗——在必须从头开始之前,你可以持续开发和维护一个程序多久?
这更多地取决于程序的市场,而不是软件本身的质量。如果代码维护得当并谨慎扩展,它可以无限期地存在。然而,技术迅速过时,趋势在变化。操作系统快速进化,硬件平台变得过时,而最初作为最先进、市场领先的功能,几年后可能会免费提供。你必须努力保持程序的竞争优势。也许你不得不不断添加新功能,或将软件移植到新平台。
开源软件并非对这些竞争和市场相关的问题免疫;在某些情况下,问题更严重。可能涉及的资金很少或没有,但仍然有一个真实的市场,技术不断进步,进入门槛较低,转换产品的机会更大。
- 代码库的大小是否与项目的成熟度相对应?
不。在许多场合,我通过从系统中移除代码大大改进了系统。重复可以导致代码量大幅增长,而功能性收益却很少。使用外部库提供了很多功能,而项目代码的大小却几乎没有增加。
许多人引用代码行数作为衡量开发进度的好指标。除非正确解释,否则此类指标毫无用处。这仅仅是对编写代码的数量的看法,而不是其质量或其设计的纯粹性。这当然不是其功能性的衡量标准。
- 在维护代码时,向后兼容性有多重要?
这取决于具体项目和它的部署方式。通常情况下,当你更改代码时,保持向后兼容性非常重要——尤其是在文件格式、数据结构和通信协议方面。很少有应用程序可以合理地打破这条规则——只有那些部署规模小且不需要存储、检索或通信旧数据的系统。
你还应该考虑向前兼容性。也就是说,设计代码以便扩展,并确保未来的事件不会使其无法运行。Y2K 错误是忽视这一规则的一个很好的例子,它带来了昂贵且可能灾难性的后果。
- 修改代码或让它保持原样,哪种情况代码更容易腐烂?
当你试图修改代码时,代码会最快地腐烂。确实,让一个程序慢慢停滞不前会确保你的竞争对手获得优势,最终使你的代码变得毫无价值。你的产品将听到它的丧钟,但代码本身仍然像以前一样美丽。
粗心大意地维护和草率扩展真的会削弱代码。在清理其他问题时,新错误很容易被引入。快速周转的压力导致了对代码清晰度和结构的修改,这降低了代码的可维护性。
避免这种情况需要优秀的程序员和明智的项目管理。
获取个人
-
你写的代码主要是全新的还是对现有源代码的修改?
-
如果这是全新的代码,你是创建全新的系统还是对现有系统进行新的扩展?
-
这会影响你的写作方式吗?在哪些方面?
-
在这些不同场景中,不同的力量会发挥作用。在扩展现有代码或将新软件适配到旧框架中时,你必须事先进行大量的调查,以了解所有现有内容是如何工作的。如果你不这样做,你最终会写出不合适的糟糕代码,这会在未来造成头疼。
新代码必须考虑到未来的修改。它必须清晰、可扩展和可塑性,以防止以后出现此类问题。
-
你是否有与现有代码库一起工作的经验?如果有,:
-
这对你的当前技能组合有何影响?你学到了哪些教训?
-
代码主要是好的还是坏的?你是如何判断的?
-
几年的经验有助于你判断什么是好的软件,什么是坏的。明显的迹象变得清晰,你能够快速检测出必须小心处理的代码。
虽然有些自虐,但与别人的糟糕代码一起工作可以是一种好的体验——它教会了你不要做什么,一个程序员的短视如何会在未来让其他程序员的处境变得痛苦。这有助于你欣赏对自己所写代码负责的重要性。
- 你是否曾经做出过降低代码质量的更改?为什么?
常见的原因(或借口)包括:
-
我当时不知道更好的方法。
-
我当时时间紧迫,不得不快速发布代码。
-
以其他方式做会花费太多精力。
-
我只能修改我们控制的代码——问题是出在另一个团队的代码或第三方库代码上,我们只有二进制文件。
这些原因都不令人满意。
为了加分,提出针对每个这些借口的反论点,并找到避免每种情况的方法。例如,如果你被迫快速发布代码,你现在可以做出一个简单的快速修复,一旦软件发布,就可以在发布后修订工作,创建一个更工程化的解决方案。
-
你的当前项目经过了多少次修订?
-
在修订之间,功能上发生了多少变化?代码是如何变化的?
-
它是通过运气、设计,还是两者之间的某种东西而成长的?现在这是如何体现的?
-
这里有一些重要的事情需要考虑。
-
这两者不一定相关。即使是一些非常简单的功能更改也可能需要基本的代码重写。我见过很多项目就是这样,系统架构不支持未来的需求,不得不进行根本性的改变。
我也见过相反的情况:与前辈功能上完全相同,但内部几乎一切都发生了变化。如果系统正在螺旋式地向死亡发展,那么进行完整的项目重写是没有意义的,但如果它有一个可行的商业未来,并且当前的代码无法满足未来的需求,那么这样的行动可能是合理的。
如果发布的新版本没有任何新功能,可能会是商业自杀——除非它值得客户升级,否则客户会拒绝升级。因此,通常会添加一些小的功能作为诱饵,或者修订版会带有一定的宣传(即,“这次修订包括重要的错误修复”)。
-
你必须了解你的代码库的历史,才能理解它是如何发展到当前形态的,以及能够进行有见地的修改和适当的整理。
-
你的团队是如何保护代码,使其不能被一个以上的程序员同时更改的?
采用版本控制系统来管理代码更改。阻止文件签出可以防止多人同时修改文件。然而,这还不够。一个更改可以立即跟随一个相反的更改被检查入。你需要仔细管理开发过程,以便每个有权访问源代码的开发者都了解他们的同伴在做什么,以及谁负责做出哪些更改。代码审查有助于检测和纠正这种问题时发生的情况。
一个好的回归测试套件将确保你做出的任何修改都不会破坏功能。
第十六章
仔细思考
- 需要多少程序员才能更换一个灯泡?
这个问题错了。这是一个硬件问题,而不是软件问题。让硬件工程师来修复它。当然,硬件工程师会想通过软件来绕过这个问题……
-
是热情但技术不那么熟练(不是无能)的人更好,还是天才但缺乏动力的人更好?
-
谁会编写更好的代码?
-
哪个程序员更好?(不是同一件事。)
是什么塑造了你编写的代码:你的技术能力还是你的态度?
-
软件系统有多种类型,每种类型的创建都需要不同的技能集。这就是程序员如何在嵌入式编程、网络服务、金融系统等领域中找到自己的位置。编码任务也会随着代码的遗产而有所不同。你可能会写:
-
简单的“玩具”程序
-
从零开始的新系统
-
现有系统的扩展
-
对旧代码库的维护工作
每项任务都需要不同水平的技能和纪律,以及非常不同的开发方法。我们将在下一个问题中看到这一点。并不是每个能够编写个人“玩具”程序的开发者都能创建一个全新的、工业级的系统。
对于所有这些,结果的代码质量不仅取决于你的技术能力,还取决于你对任务的态度——实际上,这两者必须相互补充。如果你缺乏某些技术技能,那么你必须有一种承认这一点并弥补它的态度。
你的态度在塑造你编写的代码方面可能比你的当前技能集更有影响力。如果你技术不够熟练但渴望做好工作,那么你更有可能做得好。你也更有可能学习和提高你的技能。
-
我们编写的程序类型多种多样,通过代码“遗产”来区分。编写以下类型代码有何不同?
-
一个“玩具”程序
-
全新的系统
-
对现有系统的扩展
-
对旧代码库的维护工作
-
这些代码场景之间可能看起来没有太大区别,但它们需要惊人的不同方法。
一个“玩具”程序
这可能是一个仅供你个人使用的小巧的趣味性黑客工具,或者是一个帮助开发更大系统的实用工具。这个程序不需要是万无一失的,不需要深入的设计,也不需要详尽的功能。它只需要足够解决当前问题即可。然后就被丢弃了。
开发速度和易用性可能比设计优雅或构建过程的宗教纯洁性更重要。
一个新系统
从头开始创建全新的专业系统需要严肃的设计和周密的规划。你必须考虑到未来的使用和扩展,并确保整个系统得到充分的文档化。
扩展
很少有项目是从头开始创建一个新系统。更常见的是,我们扩展现有代码,向旧代码库添加新功能。新工作必须正确地融入现有系统。没有对原始代码的彻底理解和做出与现有工作相协调的改变的能力,这是无法正确完成的。
维护
最常见的软件活动是对现有代码的维护,修复任何剩余的错误,并确保它在周围世界变化时保持运行。这需要谨慎的方法论。这可能需要大量的探索性工作;它将考验你的演绎能力,因为很少有系统被充分地文档化,以便轻松维护,尤其是当它们变得陈旧并接近淘汰时。
- 如果编程是一门艺术,那么考虑和计划的正确平衡与直觉和直觉之间的平衡是什么?你是通过直觉还是通过计划来编程?
正如我们所见,有效的程序员使用这两种方法。直觉和艺术家的审美感有助于编写优雅的代码。周密的规划与之并行,以确保代码是可靠的、实用的,并且按时交付。
我们无法制定一个精确的比例或公式来达到最佳平衡。有效的程序员两者兼备,并且知道如何适度地使用每种方法。
第十七章
仔细思考
- 为什么要在团队中编写软件?与独自编写系统相比,真正的优势是什么?
软件开发可能更易于个人进行;你不必与其他古怪的程序员合作,你不需要协调工作或忍受无效的管理。然而,不难看到团队合作在软件开发中的许多好处。
在团队中,你可以通过将问题分解给个人成员来解决更大的问题。你还可以更快地编写代码。开发者群体结合才能,创造出大于其各部分总和的东西。在没有良好建立的设计或先例的情况下,团队的更广泛技能和知识具有明显优势;协作方法将筛选想法并产生更好的解决方案。同行评审确保工作质量。
还有个人动机:技术人员喜欢参与酷项目。在团队开发中,你可以参与远远超出你自身能力的系统。这可能是一个单个个体无法应对的、需要专业技能的软件,或者提供了一个与更有经验的程序员一起工作的机会。
在现实世界的组织中,即使是单独的开发者也是更大团队的一部分。如果你没有与其他软件开发者合作,你仍然属于一个企业团队,致力于创建一个最终精良的产品。没有这些人,你的软件永远不会发布。
- 描述良好和不良团队合作的明显迹象。良好团队合作的先决条件是什么,不良团队合作的特点是什么?
为了有效的团队合作,所有这些因素都必须到位:
-
人员分布要适当,具有各种合适的技术技能。
-
团队成员具有各种经验,每个人都能从他人那里学习。一个由实习生组成的整个团队显然不太可能成功。(然而,他们比那些固执己见的半神半人更容易塑造和管理。)
-
团队成员的性格类型必须互补。为了成功,团队需要激励者和鼓舞人心的人,而不是那些会拖垮士气的人。
-
一个清晰且现实的目标(如果它是一个团队成员真正希望看到完成的项目那就更好了)。
-
动机(无论是财务的还是情感的)。
-
尽快提供合适的规范,以便所有成员都了解他们正在构建的内容,并确保各个部分的工作能够配合。
-
良好的管理。
-
尽可能小但不少于人的团队。增加人员会使团队合作变得更难:有更多的沟通线路,更多的人需要协调,以及更多的失败点。我们应尽量避免使事情变得不必要地困难。
-
一个清晰且被团队普遍理解的软件工程流程,供团队遵循。
-
来自公司的支持,而不是阻碍和不必要的官僚主义。
相反,这些都是一个无法有效工作的团队的确定指标。请注意,这个列表包括内部和外部因素的混合:
-
不切实际的进度安排,截止日期在团队确定工作范围之前就已经设定。
-
项目目标不明确和缺乏项目需求。
-
沟通失败。
-
糟糕或不合格的团队领导。
-
模糊不清的个人角色和责任——谁负责做什么?
-
个人不良态度和个人议程。
-
不称职的团队成员。
-
管理层不重视个人工程师,而是将他们视为仆人。
-
基于不符合团队目标的标准的个人评估。
-
团队成员快速更替。
-
管理程序没有变化。
-
缺乏培训或指导。
- 将软件团队合作与建筑隐喻进行比较(参见第 177 页的"我们真的在构建软件吗?")。这揭示了我们对团队合作的洞察吗?
有许多不同的隐喻可以用来描述我们的工作(例如,DeMarco 的运动队或合唱团以及我们在这里开玩笑的工厂)。(DeMarco 99)任何隐喻的问题在于它只能讲述部分真理。软件工程有其自身的问题和挑战。化学工程与土木工程不同,土木工程与制作电影不同,制作电影与编写软件不同。
虽然不完美,但建筑建设是一个有用的隐喻。毕竟,我们根据计划构建软件,从不同的组件(其中一些是我们自己构建的,其他的是我们购买的或引入的)。这些是有用的类比:
-
你需要一个团队:你不能单枪匹马建造摩天大楼或企业级高度复杂的软件超结构。
-
团队有一个目标:它致力于按时按预算完成建设。
-
有些人委托工作,出于某种目的:这项工作有一个最终目的。
-
每个团队成员都做不同的事情:不同的角色有助于完成工作。有建筑师、建造者、木匠、水管工、电工、工头、办公室工作人员、保安等等。每个人都做出了宝贵的贡献。
-
有责任感的团队成员:工头是人员管理者。
但当然,建筑与程序非常不同。建筑不能以迭代和增量方式开发。对建筑规格的任何更改都将在重建之前导致昂贵的拆除。在我们的纯粹思想领域,我们可以以极低的材料成本(但有时间成本和劳动成本)进行拆除和重建。在软件中,我们更有能力在块之间构建抽象接口。工程学科不同,但这并不意味着我们不能从与其他职业的类比中学习。
- 外部或内部因素哪个最有可能破坏软件开发团队的有效性?
他们都会密谋破坏你的开发工作。内部因素如:
-
无效的团队成员
-
冲突
-
混乱
-
开发后期出现的严重错误
-
不准确的计划
与外部因素混合,如:
-
不明确或不断变化的需求
-
不切实际的截止日期
-
管理不善
-
企业官僚主义
这使得软件开发者的生活极其困难。内部和外部压力同样可能破坏你的团队合作,尽管普遍认为大多数项目失败的原因是非技术性的。
一件事是肯定的:对团队绩效产生负面影响的影响因素比成功因素要多得多。因此,你必须密切保护团队的工作,试图使自己免受内部和外部攻击。
- 团队规模如何影响团队动态?
随着人数的增加,团队成员承受的压力也增加
-
协调努力
-
沟通努力(更多的人引入更多的独立沟通路径;这呈指数增长)
-
协作努力
-
依赖他人(直接和间接)
这些都可能使你的工作更难。然而,很明显,一个程序员团队可以比单个程序员产生更优秀的软件。这意味着团队规模与任务规模之间必须有一个适当的平衡;这会根据正在开发的系统类型而变化。
随着团队规模的扩大,程序员个人可能会放松他们的努力,因为他们可以被团队的其他成员支持。布鲁克斯的《神话般的程序员月》表明,向项目中添加人员并不一定能使项目更快完成。(布鲁克斯 95)
在更大的项目中,管理层更有可能区分成功与失败,管理层有更大的空间引发灾难性的失败。
通常,较小的开发团队更好;但它们必须足够大以完成这项任务。
- 你如何使团队免受缺乏经验的成员造成的问题的影响?
总会有缺乏经验的程序员。这在任何努力领域都是如此。在许多职业中,新员工会经历某种形式的学徒期,并必须完成学术研究阶段。这确保了他们的技能已经磨练到合理的水平。尽管我们的软件行业充满了各种质量的学术编程课程,但我们的软件行业并不承认任何正式的学徒形式。指导新程序员是一种快速将新员工提升到合理标准的方法。
一些技术有助于降低缺乏经验的程序员工作的风险:
-
保持现实的期望;不要期望他们能创造奇迹。为实习生分配适当的工作。
-
监控他们的进度,并确保他们不害怕提出问题和问题。
-
不要要求太多的先前经验:使用流行的语言和工具,这将需要更少的时间来熟悉。
-
不要使用前沿技术和技术。
-
在团队间标准化工具,以便学员只需学习一次工具集。
-
培训他们。
-
审查他们的代码。
-
培养他们。
-
与他们结对编程。
个人化
-
你现在在哪个团队工作?322 至 332 页上的哪些刻板印象最像它?
-
这是否是设计上的?
-
这是一个健康的团队吗?
-
是否需要改变?
你遇到了哪些因素阻碍了良好的团队合作?
如果你还没有这样做,请仔细填写早期的行动清单(见第 347 页的"行动清单")。确保你制定出如何改进团队并开始做出改变的方法。
-
制定如何执行任何必需变更的计划。设定目标,并在几个月后审查团队的健康状况。
常见的团队问题包括:
-
团队构成不平衡
-
无效的团队成员
-
管理不善
-
不切实际的截止日期
-
调整需求
-
沟通失败
- 你是一个好的团队成员吗?你如何与你的团队成员更好地合作并构建更好的软件?
再次查看第 333 页的"良好的团队合作所需个人技能和特点"中的个人特点。确定你与每个特点的匹配程度以及你可以如何改进。
- 你当前团队中软件工程师的职责是什么?
软件开发人员有多少责任和权限?是否有几个程序员职位等级——如果是这样,这些角色有何不同?开发角色是否涉及以下任何活动?
-
制定项目范围和目标
-
分析
-
估算时间表
-
架构
-
设计
-
审查
-
项目管理
-
成为导师
-
调查和实施绩效
-
文档
-
系统集成
-
测试(到什么程度?)
-
与客户的互动
-
规划增强功能或下一个软件版本
这个细节会因公司而异,也会因项目而异。你的团队是否有明确的问责制?是否有技术和管理牧师,开发者被分配给他们?
你有工作描述吗?你有个人目标吗?如果有,你现在是否正在实现它们,或者它们实际上是不正确的?
第十八章
仔细思考
- 你如何可靠地向其他人发布源代码?
对于专有源代码,最简单的方法是不发布它——这样你就可以避免所有问题。如果你必须发布代码,不要忘记整理许可并首先获得保密协议。了解你的受众规模和范围,如果你认为这很重要,采取措施确保代码不会进一步泄露。
对于开源项目,这并不是一个大问题;根据其本质,它们以源代码的形式发布。
在发布之前,确保每个源代码文件中都有清晰的版权和许可声明。
存源代码发布有几种机制,它们在防止你的代码落入错误之手的能力上有所不同:
-
允许外部查看者访问你的源控制系统。你可以通过授予只读访问权限的账户来限制访问,如果你的代码是公开的,可能使用一个共享的匿名账户。
显然,要查看你的版本控制系统,用户必须具有一定的权限和网络访问你的开发环境,因此这必须得到严格控制——既是为了防止他们做出不当行为,也是为了防止黑客入侵查看你的代码。
-
将源树打包成 tarball(创建一个文件的压缩存档——这个术语是以 Unix 的
tar命令命名的)。这个 tarball 可以通过电子邮件、FTP 发送或刻录到 CD 上。确保你的分发方式是适当安全的。
在你的代码中包含一组发布说明,并清楚地显示源树修订信息(通常是一个源控制版本号或构建号),以便日后参考。在你的源控制仓库中用标签标记已发布的代码,这样你就可以在以后检索它。
- 在仓库文件编辑的两个模型(锁定文件签出或并发修改)中,哪个更好?
两种操作模型都没有比对方更好或更差。每种模型都隐藏了不同的文件编辑问题,并在修改可能冲突时迫使用户以不同的方式工作。
-
锁定模型要求你在进行任何修改之前签出一个文件以保留它。你可以确信没有其他开发者的更改会干扰你的工作,并且在你签回或发布未更改的文件之前,你有对该文件的唯一访问权。缺点是保留的文件在所有者放弃控制之前会被阻塞。你无法立即知道这需要多长时间。
如果所有者坐在你旁边的桌子上,那么这很烦人,但并不难解决。然而,如果所有者在另一个大陆,工作时间不同,或者在度假期间意外地将文件签出,那么你就陷入了困境。你能做的最好的事情就是通过篡改所有者的电脑来撤销签出,以释放文件。这无疑会带来麻烦和混乱。
-
并发模型避免了这个问题,并确保你可以在任何时候无障碍地继续编码。隐藏的危险是可能发生冲突的文件修改。如果弗雷德修改了
foo.c的第 10 到 20 行,而乔治修改了第 15 到 25 行,那么就会有一场竞争!第一个检查文件的开发者不会遇到任何问题,所以如果弗雷德赢了,他对第 10 到 20 行的修改将被放入仓库。但当乔治尝试检查时,SCMS 会告诉他他的源树已经过时——他必须首先将弗雷德的更改合并到他的foo.c副本中。需要手动合并的五条冲突行;乔治必须额外工作来理解弗雷德的更改并将其与自己的工作集成。只有在这种情况下,他才能检查自己的工作。这并不理想,但在现实中这种情况很少发生,而且大多数冲突根本不是有争议的。更常见的情况是,当弗雷德修改第 10 到 20 行,乔治修改第 40 到 50 行时;这两个修改不冲突,SCMS 可以自动合并更改。如果你遇到冲突的并发修改,这通常是一个迹象,表明代码需要一些重构。
两种操作模式都不完美;但每种都工作得很好。你选择哪种取决于你的源控制工具的操作、你工作的开发过程和文化。
- 分布式和单个站点开发团队对版本控制系统的要求有何不同?
如果 SCMS 可以容纳远程站点,它肯定能够应对单个站点的开发团队,所以我们主要考虑的是多站点操作的一组额外要求。这些额外要求包括:
-
必须有一个可扩展的客户/服务器架构。
-
工具必须在低带宽网络链路上有效工作(这对于卫星站点来说很常见),或者你的部署必须包括一个真正高质量的低带宽网络链路。低带宽链路需要智能数据压缩和合理的通信协议(例如,工具应该发送小的文件差异,而不是整个文件)。
-
必须有一种集中化的方法来管理用户账户,以便跨站点协作无缝进行。
有两种主要设计:广域网通信和远程仓库复制。第一种在父位置托管的一个中央服务器上与所有客户端进行通信。这需要在站点之间有一个足够快且可靠的通信通道。后者通过在低负载时间将仓库复制到远程服务器来减少通信开销。然而,这给开发过程增加了很多复杂性;你必须理解这两个仓库不是始终同步的,你必须制定合理的分支策略来避免冲突的开发工作。
在评估源代码控制系统时,不要忽略这些要求,即使您只有一个开发站点。将来,您可能需要添加一个辅助站点或支持远程工作者。在规划系统时请记住这一点。
- 选择源代码管理系统的合理理由是什么?
选择 SCMS 的良好标准包括:
可靠性
确保它是经过验证的技术,不会突然丢失您的源文件。服务器必须健壮,不会每隔几天就崩溃。
容量
工具必须很好地扩展,能够处理大型团队、大型项目和小型项目。在更苛刻的情况下,它是否消耗大量磁盘空间,耗尽所有网络带宽,或者运行时间过长?也许您需要多站点存储库同步,或者它是否在低带宽链路上运行良好?
灵活性
它是否提供了您需要的所有操作和报告?它是否处理您想要控制的文件类型?它能否管理二进制文件?它是否支持 Unicode?它是否对目录进行版本控制,允许重命名和移动文件?它是否管理原子更改集,或者每个文件都是单独版本化的?
分支
为了支持多个版本、产品变体、并发功能工作或帮助逻辑开发,该工具必须支持分支。它支持子分支吗?合并是否容易,或者是否难以承受?
平台
确保它在您工作的所有平台、硬件配置和操作系统上都能正常工作。
成本和许可
SCMS 必须满足您的预算限制(记住,有一些非常免费的源代码系统)。考虑是否有额外的按客户端计费的许可费用。有时这些是隐藏的额外费用;随着团队的增长,您必须支付 SCMS 税。
审计
存储库必须记录谁进行了每次更改:不要强迫每个人都使用一个 SCMS 用户账户。系统必须支持您的访问策略,允许您根据需要限制修改权限。您希望它提供更改的自动通知吗?
简单性
工具必须易于使用、配置和部署。如果您没有全职指定的 SCMS 管理员,这一点尤为重要。
- 如何在团队开发中区分处于活跃开发中的前沿代码和稳定代码?
您需要一个策略在源代码存储库中分离这两者。您的选择是:
-
不要将它们分开。每个人都有前沿代码,必须学会应对它。不要提交任何明显损坏或非功能性的内容。
-
使用分支。在每个单独的分支上执行每行开发工作,并在适当的稳定点合并分支。在这种方案中,集成问题仅在合并时被发现;这把维护的负担放在了分支合并者身上(可能是分支上的开发者或单独的系统集成商)。
-
使用一个稳定的标签,将其应用于整个源树作为基线。开发者检出此标记的基线,然后将他们正在开发的组件移动到最新版本。然后他们可以工作并提交更改,而不会影响任何人的稳定源树。当新的开发工作被认为稳定(适合公开消费)时,标签会移动。其他开发者在下次同步到基线时会接收到这个更改。
你选择哪个取决于你的 SCMS 功能和你的开发文化。
获取个人
- 你们开发团队是否有效地使用了源代码控制?
最终,你的 SCMS 是否帮助你轻松地开发软件,并且它是否比任何其他替代方案更好地促进协作?考虑以下工具设置问题:
-
你是否在使用具有正确功能集的正确工具?
-
你是否有 SCMS 管理员,或者它是基于临时管理方式?
-
每个人都知道如何使用它吗?是否有适当的培训方案?
-
存储库是否与你的缺陷管理或故障跟踪工具集成?
考虑资产管理问题,例如:
-
对于提交消息的内容和其他修订元数据的用法是否有共识?
-
你是否有用于标记重要源树修订的一致标签方案?
-
你是否有定义(并记录)的分支策略,并且有可证明的正确合并?
-
你能否从源存储库自动创建发布说明?
-
你能否重新创建旧版本构建?你是否解决了构建工具链更改的问题,这影响了代码兼容性?
-
你能否完全从存储库的内容构建产品,或者你需要提供任何额外的文件?
这些问题中的每一个对你们开发团队来说有多重要?
- 你的当前工作是否已备份?备份对你们开发团队来说有多重要?备份是在何时进行的?
如果你愿意编写一些代码,那么它肯定很重要,因此它必须得到备份。备份可以在几个层面上进行:
-
个人工作站备份。这将确保不会从你的本地硬盘或源树沙盒中丢失任何工作。
-
保存源代码存储库的服务器。这确保你不会丢失中央源树文件及其修订历史。
后者是至关重要的:不备份源存储库是犯罪性的疯狂行为。如果你的工作站只包含沙盒开发区域,那么备份它就不那么关键了;在任何时候,应该很少有未提交的工作(记住要执行少量且频繁的提交),因此本地磁盘的丢失不是关键。
还要考虑你如何备份文档以及你产生的任何其他非源代码树项目。要么将它们检查到某个存储库中,要么确保它们存储在共享文件服务器上的逻辑位置,某个已备份的位置。如果没有版本控制,你将不得不手动进行文档版本管理——保留规格说明的历史版本与版本控制源代码一样重要。
在多用户环境中,系统管理员将决定何时进行备份。这通常是在夜间,那时计算机活动较少,被备份的文件系统上的信息变化也较少。(但多大陆项目存在巨大的时区延迟怎么办?)
- 你的源代码存储在哪些计算机上?
显然,它存储在公司网络内的开发服务器和工作站上。这些设备安全地位于办公室内,背后有企业防火墙的保护。但也要考虑你的代码是否存储在笔记本电脑或远程工作人员的家用机器上。这项工作有多敏感?这些机器应该如何在数字和物理上得到保护?
第十九章:具体化
仔细思考
- 糟糕的规格说明是否比完全没有规格说明要好?
事实错误或过时的规格说明肯定更糟糕。它会让读者误入歧途,浪费大量时间。其中包含的错误信息可能导致代码损坏,修复这些代码将花费大量时间、精力和金钱。
如果一个规格说明含糊不清或遗漏了重要信息,那么你希望读者经验丰富,能够识别问题并仔细解释信息。希望他们都会对缺失的信息做出相同的假设。规格说明应该真正独立,不需要读者的直觉。
如果一个规格说明过于冗长并隐藏信息,那么从长远来看,重写它可能更好。
你公司规格说明中的事实错误数量可能会让你感到震惊!根据我的经验,很少有公司有一套始终如一的优秀规格说明。
- 一个好的规格说明需要有多详细?
答案是:适当详细,其中“适当”的价值取决于项目、团队、内容、相关文档的质量以及月相。过多的细节肯定会产生反效果:显然,如果设计规格说明过于详细,它就会变成代码本身。然而,关键领域的含糊不清是通往灾难的道路。
- 公司/项目中的所有文档是否需要统一的呈现风格很重要吗?
这与统一代码风格的重要性相当。也就是说,还有更多更重要的事情需要担心,即使这是规范中最直接可见的问题。视觉一致性的重要性取决于(部分)文档是否在公司外部发布。发布风格一致、模板相同的文档看起来更专业。
最终,您文档的内容比它们的表面外观更重要。
- 您应该如何存储文档?是否应该提供索引(按类型或按项目)?
您必须能够快速定位和检索已编写的文档。实际的存储方案并不重要,只要它是众所周知的并且被普遍遵循。
通常有道理将所有文档存储在单个中央文件存储中,并按工作包(这可能是由项目、客户、组件或功能)分组。维护所有存储文档的中央列表以帮助检索是有帮助的。然而,这会增加管理开销,如果不维护,它将很快被弃用。
大型公司雇佣人员来处理文档的存储和检索。尽管他们在这项任务上很专业,但他们的存在增加了工作流程的步骤和开发流程链中的更多环节。
必须保持文档在某种形式的版本控制之下,并监控哪些文档版本适用于哪些代码版本。这是配置管理策略的一部分(参见第 356 页的“配置管理”)。
- 您应该如何进行规范审查?
文档审查与代码审查类似。通常在会议中进行,在这种情况下,有一些重要的前提条件:必须选择正确的审查人员,并且审查材料应在足够的时间内分发,以便审查人员有足够的时间准备。
或者,可以通过征求电子邮件反馈或给每位审查人员提供打印副本并接收其标记的副本进行审查来虚拟地进行审查。
审查将涉及许多方面;每个方面的重要性应在事先达成一致:
-
内容的质量。(是否完整、正确等?这是至关重要的。)
-
呈现风格的质量。(文档是否符合项目指南?)
-
写作风格的质量。(作者是否像莎士比亚那样写作,或者像五岁孩子那样写作?对于软件规范来说,两者都不好!)
在会议环境中,最好首先讨论关于材料的一般性评论和整体方法。(但在这里要小心:在这个阶段很容易被更具体的技术问题所分散。)然后可以讨论材料的细节。由于所有审阅者都事先审阅了材料,并且已经积累了他们的评论,因此逐节进行讨论通常是合适的。如果需要,长章节可以逐段遍历。
- 自文档化的代码是否使所有规范都变得无用?具体的规范?
并非完全如此。自文档化的代码可以避免需要设计规范或其他维护文档。放置在代码注释中的文献 API 文档甚至可以在某些情况下替代功能规范,如果文档真的很详尽。但是要小心:如果你试图在文献注释中编写大量文档,你可能会发现直接在文字处理程序中输入相同信息更容易。文献化的代码文档永远不能替代需求规范或测试规范。
一套全面的自动化测试用例可能可以替代软件组件的测试规范,如果测试足够清晰且易于维护。然而,它们很少足够替代最终产品的验证测试。
- 如何让多个作者协作编写文档?
有困难——很少有文档系统提供与源代码控制工具相同的协作功能。如果你能处理你的文档以 HTML 派生形式存在,可以查看 wiki-webs 进行共享文本编辑。
否则,你必须将文档分成几个部分,并将每个部分分配给一个人。每个部分将不可避免地存在写作风格、内容质量的不同,并且基于不同的假设集;在将工作拼凑在一起时要检查这一点。你可能发现将部分分成单独的文档,并在它们上面放置一个总文档更容易。必须指定一个领导者来协调多人的工作——指导写作过程,收集各部分,并鼓励人们按时完成他们的部分。
另一种方法是给一个人负责整体写作,但要有强烈的同行评审元素。在会议之前就文档的内容和结构达成一致,然后作者独自退下撰写文档,在提交给小组评审之前。
在这些方法中要小心,因为集体写作可能会产生繁琐的文档,并且可能需要非常长的时间。
个人化
- 谁决定你文档的内容?
这是由公司的开发流程、文档模板或惯例定义的。但仅仅因为存在惯例并不意味着它实际上是一种好的实践。检查你编写的文档类型及其内容是否真正对你的软件开发过程有价值。
-
考虑你的当前项目。你有:
-
需求规范?
-
架构规范?
-
设计规范?
-
功能规范?
-
还有其他规范吗?
它们是最新的吗?它们是完整的吗?你知道如何获取最新版本吗?你能访问历史版本吗?
-
如果你没有这些或它们质量不高,为什么?你如何解决这个问题?
谁的工作是保持文档更新?文档版本控制是规范生成的重要方面——确保你有一个明确的计划来做这件事。
- 你控制文档的版本吗?如果是这样,你是如何做的?
在该领域可以看到几种管理文档版本的技术:
-
将它们存储在与代码一起的 SCMS 中。
-
使用文档(甚至工作流程)管理系统。
-
使用文件系统:在文件名中编码文档版本(可能将旧版本存档到单独的旧目录中)。
-
将旧版本存储在发送给“魔法”用户的电子邮件附件中(虽然有些荒谬,但——是的,我确实见过公司这样做)。
无论你使用哪种方案,它都必须解决这些问题:
-
易用性和文档可访问性
-
如何防止两个人同时编辑同一份文档
-
区分最新发布版本和当前正在开发的副本
-
如何避免意外删除或覆盖错误的文档版本
-
如何在每次更改时维护文档历史
-
引用特定文档版本的便捷性
第二十章
仔细思考
- 所需的审查者数量是否取决于正在审查的代码的大小?
未必。如果你的代码特别重要,那么你可能会考虑邀请更多的审查者,或者你可能特别努力地选择最有经验的审查者。
然而,如果代码太大,你不需要更多的审查者——你需要重写!
- 哪些工具对代码审查有帮助?
常识、敏锐的双眼和警觉的大脑!
许多软件工具也非常有用。许多不同的工具可以检查你的代码,并帮助你评估其质量以及相对于整个代码库的相对风险。它们可以追踪执行流程,确定哪些代码被执行得最频繁,并为每个函数的代码复杂度计算一个值。这个最后指标在确定哪些代码片段需要尽快审查时非常有用。一个视觉设计程序可以帮助你理解代码结构和其依赖关系(尤其是在面向对象语言中审查类层次结构时特别有用)。
- 你应该在运行源代码检查工具之前还是之后进行代码审查?
之后。审查者可能应该在审查准备期间自己使用这些工具,但作者必须在发布审查之前对他们的代码进行所有可能的检查。不这样做是愚蠢的。没有理由浪费审查者的时间在可以轻松改进的代码上。保留审查时间来寻找更有趣的问题。
如果在审查过程中发现了一个问题,应该考虑是否可以使用工具自动检测到相同的问题。
- 代码审查会议需要哪些准备?
作者已经满意地完成了代码(否则他或她是在浪费审查者的宝贵时间)。主席已经妥善安排了会议,以确保会议顺利进行。更有趣的是,在会议之前,每位审查者必须已经:
-
阅读(并理解)了规范
-
熟悉了代码
-
列出问题清单和问题(这一步骤强制执行纪律;如果你不强迫自己这样做,很容易对代码进行表面上的浏览,而不真正了解它,从而无法进行彻底的审查)
在审查会议中,你总会发现一些在之前检查中遗漏的事情。即便如此,这种前期准备对于防止会议浪费大量人的时间是至关重要的。
- 你如何区分需要立即处理的审查评论和那些可以在下一个项目中积累经验的问题?
你必须根据以下因素做出决定:
-
确定的问题的重要性
-
是个人审美问题还是违反了达成的最佳实践
-
修复涉及的工作量
-
变更对其他代码的影响程度
-
没有修复的情况下,代码的错误(或误导性)程度
-
变更工作的脆弱性或危险性
-
项目在开发周期中的位置——你只想在发布截止日期附近进行必要的变更。
没有简单的规则。如果审查会议中存在任何歧义,那么主席将做出最终决定。有时问题会被评为“必须修复”和“最好有”之间——作者在可用的时间内尽可能实现许多高优先级修复。其他问题可能被推迟到组件开发的下一个迭代。
- 你如何进行虚拟审查会议?
虚拟审查通常通过电子邮件进行。审查由主席组织,通常是沟通的中心。当然,作者绝对不能成为沟通的中心;否则,他很容易选择哪些评论是重要的,并忽略所有他不喜欢的事情。这显然是一个坏主意。
这种方法有一个重要的问题:审查员能看到彼此的评论吗?在虚拟审查中,辩论很难促进,尤其是如果电子邮件只发送给主席。然而,将 1,000 封电子邮件的对话广播给所有审查员很快就会变得令人烦恼和分心。作为替代,您可以在虚拟聊天室会面,使用即时消息、专用新闻组或邮件列表。
一种替代的虚拟审查机制是将有问题的代码打印出来。审查员在他们自己的副本上写评论,然后将其退还给作者。您可以使用 wiki 运行类似的方案:在 wiki 上发布您的代码,并让审查员在页面上添加评论。你如何进行审查的格式不如简单地做它重要。
- 非正式代码审查有多有用?
非正式审查比完全没有审查要好,但由于它们不够彻底,它们不可避免地会发现更少的错误(对于相同质量的代码审查员)。
尽管术语没有正式定义,McConnell 描述了两种非正式审查的类型:(McConnell 96)
代码审查
这些是非正式的聚会,程序员们一起查看代码。这可以在编辑器前进行,即时做出更改。
代码阅读
作者将代码副本分发给一组审查员,他们对其做出评论,并将它们退回给作者。
个人观点
- 你的项目进行代码审查吗?它进行 足够的 代码审查吗?
即使这使代码审查成为一项模糊的常规活动,可能仍然审查不足。对这个实践的价值评价太低;如果代码似乎能工作,那么人们认为没有必要浪费时间进行审查。
这种态度是粗心的。追踪遗留代码错误所需的时间通常远大于审查的努力。代码审查是控制你的开发过程和确保你的软件质量合理和实际的方法。
你能做些什么来改善你当前项目中的这种情况?
- 你是否与任何被认为代码无需审查的程序员合作?
受人尊敬的大师级程序员(参见第 299 页的"The Guru")通常令人敬畏,没有人建议他的工作应该被审查。没有人可能敢这么做。这种敬畏是错误的,也是危险的。
根据我的经验,大师级程序员写的代码中,有一些是最值得审查的:充满了深奥、难以理解、难以维护的魔法。他们从未将代码提交审查的事实说明了他们对这项任务和团队的不正确态度。没有人的代码是高于审查的;所有代码都应该仔细审查。
- 你的代码中有多少比例曾经接受过代码审查?
除非你是一个非常不寻常的生物,否则这个数量无疑很小。审查有多正式?每次审查有多有用?它对代码最终质量贡献了多少?
你有多少未审查的代码是双人编程的?应该审查多少?多少未审查的代码是至关重要的商业代码?有多少错误滑进了生产软件,以及有多少错误导致了后续问题?
即使代码审查不是你项目文化的一部分,也要确保邀请对你的工作进行正式审查。如果其他人没有这样做,也不要担心——与他们的代码相比,你的代码将非常出色!
第二十一章
仔细思考
- 你如何才能挽救一个即将失控的项目并使其回到正轨?
一种保护自己免受失败项目影响的技术是快速逃跑,就像一只从沉船中逃出的老鼠。这并不太专业!
一旦项目落后于进度,通常很难将其拉回正轨——除非已经分配了大量的应急资金。你可能会考虑以下策略:
-
重新安排项目;看看你是否可以与客户协商一个更晚的交付日期。
-
缩小首次发布的范围,可能同意稍后发布带有缺失功能性的版本。最好是承诺做更少的事情,但做得更好,并在规定的时间内完成,而不是实现大量不必要的功能并严重延误。
不要盲目地增加开发者数量来加快项目进度。布鲁克斯清晰地描述了这种想法是多么糟糕,尤其是在项目失败时。(布鲁克斯 95)现有开发者需要时间来让新成员熟悉情况,然后管理更大团队会有额外的开销。任何好处几乎肯定会被新人员成本所抵消。
- 在可行性或规划工作开始之前,对您施加截止日期的正确反应是什么?
策略!固定的交付截止日期可能是一个有效的业务需求:如果你按时交付软件,你会赚钱;如果你不这样做,你将一无所获。你并不总是能做神学上正确的事情,比如移动截止日期或调整工作范围。
早期了解预期的项目截止日期有时有助于你的设计工作。这些信息显示了你的设计有多纯净、思考得多周全,这将帮助你确定所需的代码量以及是否可以考虑未来的灵活性。最终,它将告诉你是否需要快速修复方案,还是你一直想要的优雅工程代码。这可能有助于你做出购买还是开发的决策,并设定最终交付软件的质量期望。
明确指出这不是开发软件的理想方式。希望有人会倾听,管理者会学会停止承诺这样风险极高的截止日期——这是一种对项目成功和组织未来的粗心大意赌博。
- 你如何确保开发计划真正有用?
高质量的发展计划是:
准确
它们包括构建软件所需的所有任务,并且基于稳健的时间尺度估计。
细粒度
没有少数几个大任务和粗略的估计,而是有许多小任务被精心排序。我们对小任务时间尺度的准确性更有信心,因此整体计划的品质会更高。
如果你认为一个任务由几个部分组成(例如,它依赖于第三方,并分为第三方发布里程碑,然后是集成和错误修复的时期),那么在计划中明确这一点。
一致
每个人都接受了这个计划:管理层对固有的风险水平感到满意,而程序员则认为时间尺度准确,没有遗漏的任务,所有依赖关系都正确地绘制出来。
可见
他们习惯于由个人开发人员和经理做出重要决策。时间尺度变化通过计划进行传达。计划有版本号,并且对计划的进度有清晰的记录。
监控
如果进度监控不当,时间尺度估计就变成了毫无价值的统计数据。必须将进度与计划进行核对。开发工作的进程是通过这种测量来引导的。
- 为什么不同的程序员工作速度不同?你如何在计划中反映这一点?
程序员在许多方面都有所不同:
-
他们有不同的技术能力,以不同的方式推理问题。这影响了工作成果的质量。
-
不同级别的经验导致不同的设计选择。
-
人们有不同的承诺程度:对旧项目的责任、对公司或项目的热情程度、对软件构建工艺的尊重,以及外部承诺(家庭压力、社交等)。
-
有些人高度积极,愿意加班以完成项目。其他人则只想工作最少的时长然后去参加派对。
并不仅仅是工作包的持续时间在程序员之间有所不同。他们的代码质量、设计的稳健性以及程序中的错误数量也会有所不同。即使是同一位程序员尝试同一任务多次,情况也会如此——有更多经验后,程序员第二次的工作会更好。
要在项目计划中反映这一点,检查每个任务分配给哪个开发者。如果任务不在他的或她的核心能力范围内,那么增加时间尺度估计,或者添加一个应急块到末尾。考虑添加一个额外的前置任务,让开发者熟悉工作,并确保包括可能需要的任何培训。
个人化
-
你参与的项目中有多少比例是按计划进行的?
-
对于成功者:什么因素导致了规划工作的成功?
-
对于失败者:主要问题是什么?
-
与成功相比,描述失败更容易;你更容易找到导致某件事出错的单个原因,而不是一个事物良好协作的微妙平衡。当项目中的每一件事都健康时,整个项目看起来就像是在正常工作。
迭代和增量开发有助于适应问题和降低计划风险。理解良好的工作包、精细的计划和良好的初始设计也是关键。早期和经常进行的高质量测试使开发更加安全。有才华的开发者也非常有用!
- 你的时间尺度估计有多准确?你通常偏离目标有多远?
这是一个你可以不断改进的技能。经验是一位伟大的老师。希望你的后期估计比早期估计更准确。是这样吗?
如果你还没有被要求做出时间尺度估计,现在就开始练习吧!为你的当前开发任务制定一个迷你计划。估计这个迷你计划的小部分的时间尺度,看看你有多准确。这还有一个额外的好处,就是让你仔细思考你在做什么,把良好的初始设计放在合适的位置。它还会迫使你为测试、调试和文档留出足够的时间——这些都是好事。
第二十二章
思考
- 编程风格和开发过程的选择是如何相互影响的?
它们不需要相互影响,但希望它们是你开始项目时一起考虑的事情。
迭代过程更适合支持组件化的编程方法——面向对象范式。线性过程适合所有类型的编程风格,但并不一定是最佳匹配。
开发者的先前经验和他们对编程风格的个人偏好将对这些选择产生最大的影响。
- 哪种编程风格是最好的?
这是一个陷阱问题!如果你真的给出了答案,就把这本书放下,用湿面条打自己 30 下。
- 哪种开发过程是最好的?
你不可能也上当了吧?用 9 伏电池的电击疗法是你唯一的选择。
- 本章中列出的每个开发过程在“开发过程”页面 425 上看到的分类轴上处于什么位置?
首先,快速回顾一下:繁杂/简洁分类与一个过程中涉及的官僚主义和文件有关,序列描述了过程的线性和预测性,而设计方向决定了设计是从微小的实现细节开始还是从宏观的概述开始:
临时
谁知道如何分类这个混乱?一个临时过程可能在任何轴上任何位置,甚至不断移动。临时开发者通常官僚主义程度较低,但完全没有纪律,事情会不断出错或反复发生。完全没有序列,所以这种反过程评分极高,而且如果真的有任何设计,那么它可能根本与实际建造的东西无关!
瀑布模型
这是一个相当繁杂、非常线性的过程。它通常导致自上而下的设计,尽管它并不强制执行这一点。
SSADM
在繁杂程度量表上,这里得到了满分——有大量的文件和详细记录的步骤。序列轴全速向线性发展。
V 模型
另一个繁杂、线性的过程(尽管这个过程中的某些部分为了效率而明确地并行化)。与其他瀑布变体一样,它倾向于自上而下的设计。
原型
这是一个明确循环的过程(尽管通过固定预期的原型数量,我们可以在开发过程中强制执行一定程度的线性)。这往往倾向于简洁阵营,有时过于如此:原型本身不足以捕捉用户需求或设计决策,所以在原型设计时,很容易避免在规范中捕捉决策。
迭代和增量
设计上也是非线性的,这个过程可以像你喜欢的那么官僚,但某些变体(尤其是在敏捷运动中看到的)可以相当简洁。迭代和增量过程往往停留在设计方向轴的中间——在每个迭代中,我们都会从高级设计一直做到低级设计。这些设计决策将在下一个周期中修订,并且额外的工作会重复顶层和底层设计。
螺旋模型
一个迭代和增量过程的繁杂版本。
敏捷方法
敏捷过程是简洁且非线性的。它们不固定设计方向;你总是在不断调整设计。将设计比作开车去巴黎:在传统过程中,你会把车头指向巴黎然后驾驶;在敏捷过程中,你会开始驾驶并不断调整方向。你甚至可能在确定最佳路线之前,先规划出旅程中段的路线。
记住,一个组织对特定过程模型的实施不可避免地会根据其特定的工作方式进行调整。(这是完全健康的。)这些调整可以产生重大差异。例如,你可能会基于 V 模型进行开发,但目标是使接口移交程序尽可能轻量,以减少不必要的官僚主义。
- 如果开发流程和编程风格是食谱,那么软件开发食谱书会是什么样子呢?
它可能看起来像一本危险的安全工程教科书。可能不会有那么多令人垂涎的图片!正如裸体厨师与拉契尔·雷的食谱不同,你可以想象出许多不同的软件开发食谱方法。
你实际上看不到很多软件开发食谱书,因为人们并不经常寻找新的食谱。这些事情只有在营销机器能够为下一个大事件积累足够的动力时才会出现。
- 有了合适的过程,软件构建能否成为一个可预测、可重复的任务?
我们还没有达到软件行业能够提出这种主张的地步。无论我们多么努力地使开发过程同质化,产生的代码质量最终是由程序员的质量(例如,经验、能力、直觉和风格)以及特定的情绪(例如,集中注意力的能力、处于状态或不断被打断,见第 414 页)决定的。一个大师工匠将比一个新手学徒创造出更优雅、更稳健、更精致的样式。
在这样的差异下,即使是最严格的过程也难以可重复地创建软件。使用相同的程序员、相同的过程,并试图生产相同的软件,你永远不会得到完全相同的结果。在不同的日子里,团队会做出不同的选择,这会导致截然不同的软件,具有不同的固有缺陷和优势。(这个观点本身就是假设性的;同一个团队会在第一次尝试中从错误中学习,并在第二次尝试中创建一个不同——可能更好的——软件。)
敏捷方法利用这一点,并庆祝软件构建的不确定性。他们通过选择实用方法来尝试解决不确定性,这些方法最大限度地减少了不可预测任务固有的风险。
个人化
-
你目前使用的是哪种开发流程和编程语言风格?
-
它是被开发团队正式同意的,还是你按照惯例使用它?
-
是如何选择的?是专门为这个项目选择的,还是你总是使用的食谱?
-
它有被记录在某个地方吗?
-
团队是否坚持流程?当问题出现且背水一战时,你是否会坚持流程,还是在一股脑地赶制任何东西的过程中忽略了所有象牙塔理论?
-
这个问题是在探究你的开发团队是否组织有序——以及你是故意还是偶然地开发软件。你真的知道你是如何产生软件的吗,还是你仍然依赖少数关键团队成员的英雄式努力来完成工作?
你能否指出你工作方式的具体参考文献?它是文档化的吗?它被理解了吗?它被所有开发者、所有流程经理以及所有在构建过程中扮演一定角色的人理解了吗?
- 你们当前的过程和风格是否合适?它们是目前开发软件的最佳方式吗?
如果你不知道你是如何产生软件的,或者如果你没有使用最佳方法,什么会更好,为什么?
注意即兴方法的危险。我见过许多组织没有达成一致的方法;一个人生产完全面向对象的设计,而另一个人避免面向对象并执行结构设计。产生的代码丑陋且不一致。
- 你的组织是否意识到还有其他可能值得调查的开发模型?
理解谁决定这类事情——是开发者、软件团队领导还是管理者?这些人是否充分了解软件开发流程?理解他们为什么选择以当前这种方式工作:他们已经解决了哪些问题?通常,奇怪的开发流程的原因是历史性的——组织会形成一套工作实践,他们并不是有意识地创造这些实践。
要说服你的组织采用另一种流程模型需要什么?
^([17]) 如果你认为这听起来很粗鲁,请参阅 www.jamieoliver.com。
第二十三章
仔细思考
- 我们在这里探讨的编程细分市场中,哪些特别相似或具有共同特征?哪些特别不同?
共同之处比你想象的要多。交叉点包括:
-
游戏和网络应用都可以被认为是特定形式的应用程序编程。
-
网络编程是一种分布式编程。
-
一些企业工作可以采取网络应用的形式。
-
一些系统实现是为嵌入式平台。
-
数值工作有时通过并行化和分布计算来优化。
- 这些编程学科中哪一个是最难的?
每种编程类型都呈现不同的问题,每个程序都是以其独特的方式复杂的。否则,编程将不需要太多技能,任何傻瓜都能做到。(许多傻瓜确实编程的事实在这里不予讨论!)
可以认为“更困难”的编程领域是那些要求更多正式流程以确保达到足够质量的地方。例如,安全关键软件的世界(在第 456 页的"In a Nutshell"中提到)尤其充满挑战。在这个世界中,严密的规格说明、非常正式的开发和测试模型,以及对监管标准的认证是必不可少的,同时还需要包括可靠的保险丝。
对于没有数学头脑和设计复杂算法的人来说,数值工作尤其困难。它需要额外的统计或科学技能。
- 是否重要成为某一特定领域的专家,或者在没有特定专业的情况下对所有领域都有良好的基础?
理解每个领域都有帮助。然而,要在某个特定领域真正出类拔萃,需要特定的技能和专业知识,这些只能从战场上的经验中获得。为了获得这种良好的经验,你可能必须专注于一个特定的工作领域。文森特·梵高曾说过:“如果一个人精通一件事,并且对这件事有深刻的理解,那么他同时也会对许多事物有洞察力和理解。”学习使你的学科与其他学科区分开来的特定复杂性。
- 应该向培训程序员介绍哪个编程细分领域?
这一点很少被编程课程的设计者所考虑。这是一个令人遗憾的疏忽;许多课程并没有针对现实世界的编程进行定制——更多的是针对一些理论上的、中性的编程分支。当然,这使得教授编程变得更加容易,并且减少了让学员感到困惑的问题。但是,当你身处软件工厂的繁忙之中时,了解如何做出适当的编码选择是非常重要的,而且有人必须教授这一点。
与其他编程领域相比,应用程序编程相对不受特定仪式和实践的束缚,因此这可能是向程序员介绍的最容易的领域。由于学员很少欣赏软件开发更广阔的世界,这可能是他们期望学习的内容。
个人见解
- 你现在在哪个编程领域工作?这对你的代码有什么影响?它引导你做出了哪些具体的设计和实现决策?
理解你编写的代码类型很重要,这样你才能做出正确的编程决策。如果你无法解释你的代码是如何被问题域的需求所塑造的,那么你可能没有足够深入地思考你所做的事情。软件必须在其环境中生存——因此必须由其环境来塑造。
- 你在多个编程领域工作过吗?你切换思维模式和在不同领域应用适当技术有多容易?
小心忽视这些差异,毫无目的地从一个领域跳到另一个领域。这可能导致你编写出糟糕的代码。你可能直到游戏结束,当你正在处理乏味的错误或试图优化你的系统以满足原始要求(例如,代码大小或可扩展性)时,才会意识到你的代码不适合其环境。如果你那时意识到你的工作没有适应其环境,那么你将处于困境之中。
- 你工作中是否有人不了解塑造你所写代码特定类型的力?是否有由只理解应用程序工作的程序员编写的嵌入式软件?你能做些什么?
没有根据问题域的要求调整其工作的程序员会危害你的项目。如果他们不理解固有的约束(可扩展性、性能、代码大小、互操作性等),他们的代码将不符合规范,并且将成为开发链中的薄弱环节。
代码和设计审查将有助于捕捉这一点,正如结对编程一样。
BIBLIOGRAPHY
(Alexander 79) 亚历山大,克里斯托弗。 《永恒的建筑方式》。牛津大学出版社,1979. 0195024028。
(Aristotle) 亚里士多德(公元前 384-322 年)。《修辞学》。第 1 卷,第十一章,第二十部分。公元前 350 年。
(Beck 99) 贝克,肯特. 《极限编程之道》. 阿迪森-韦斯利出版社,1999. 0201616416.
(Belbin 81) 贝尔宾,梅雷迪思。 《管理团队:为什么他们成功或失败》。巴特沃斯·海尼曼出版社,1981. 0750659106。
(Bentley 82) 伯顿,乔恩·路易斯。 《编写高效程序》。普伦蒂斯·霍尔专业出版社,1982. 013970244X。
(Bersoff et al. 80) 贝尔索夫,爱德华;维尔拉斯·亨德森;斯坦利·西格尔。 《软件配置管理:产品完整性的投资》。朗曼高等教育出版社,1980. 0138217696.
(Boehm 76) 博姆,巴里。 "软件工程。" 《IEEE 计算机 Transactions on Computers》。C-25,12,1,226-1,241. 1976. http://www.computer.org/tc.
(Boehm 81) 博姆,巴里。 《软件工程经济学》。普伦蒂斯·霍尔出版社,1981. 0138221227。
(Boehm 87) 博姆,巴里。 "提高软件生产力。" 《IEEE 计算机》,20,9. 1987.
(Boehm 88) 博姆,巴里。 "软件开发与增强的螺旋模型。" 《IEEE 计算机》,21. 1988 年 5 月 5 日。
(Booch 97) 博 och,格雷迪. 《面向对象分析与设计应用》. 本杰明/坎明斯出版社,1994. 第二版. 0805353402.
(Briggs 80) 布里格斯,迈尔斯;伊莎贝尔·迈尔斯。 《不同的礼物:理解人格类型》。咨询心理学家出版社,1980. 0891060111。
(Brooks 95) 布鲁克斯,弗雷德里克·P. 小. 《人月神话》. 阿迪森-韦斯利出版社,1995. 周年纪念版. 0201835959.
(DeMarco 99) 德马克,汤姆·蒂莫西·利斯特. 《人件:高效的项目与团队》. 多塞特出版社,1999. 第二版. 0932633439.
(Dijkstra 68) 迪杰斯特拉,埃德加·W. "goto 语句有害。" 《ACM 通讯》,11,3,147-148. 1968.
(Doxygen) 范希施,迪米特里。 Doxygen。 http://www.doxygen.org.
(Economist 01) "敏捷很重要。" 《经济学人》。2001 年 9 月 20 日。
(Fagan 76) FaganMichael. "Design and code inspections to reduce errors in program development." IBM Systems Journal, 15, 3. 1976.
(Feldman 78) FeldmanStuart. "Make—A Program for Maintaining Computer Programs." Bell Laboratories Computering Science Technical Report 57. 1978.
(Fowler 99) FowlerMartin. 《重构:现有代码设计改进》。Addison-Wesley, 1999. 0201485672.
(Gamma et al. 94) GammaErichRichard HelmRalph JohnsonJohn Vlissides. 《设计模式:可重用面向对象软件元素》。Addison-Wesley, 1994. 0201633612.
(Gosling et al. 94) GoslingJamesBill JoyGuy SteeleGilad Bracha. 《Java 语言规范》。Addison-Wesley, 2000. 第二版。0201310082. http://java.sun.com.
(Gould 75) GouldJohn. "Some Psychological Evidence on How People Debug Computer Programs." International Journal of Man-Machine Studies. 1975.
(Groom 94) GroomWinston. 《阿甘正传》。Black Swan, 1994. 0552996092.
(Hoare 81) HoareCharles. "The Emperor's Old Clothes." Communications of the ACM, 24, 2. ACM, 1981.
(Humphrey 97) HumphreyWatts S. 《个人软件过程导论》。Addison-Wesley, 1997. 0201548097.
(Humphrey 98) HumphreyWatts S. "The Software Quality Profile." Software Quality Professional. December 1998. http://www.sei.cmu.edu/publications/articles/quality-profile/.
(Hunt Davis 99) HuntAndrewDavid Thomas. 《实用程序员》。Addison-Wesley, 1999. 020161622X.
(IEEE 84) IEEE 标准软件工程术语词汇表。ANSI/IEEE, 1984. ANSI/IEEE 标准 729.
(ISO 84) ISO7498:1984(E) 信息处理系统—开放系统互联—基本参考模型。信息系统国际标准,1984. ISO 标准 ISO 7498:1984(E).
(ISO 98) ISO/IEC 14882:1998, 编程语言—C++. 信息系统国际标准,1998. ISO 标准 ISO/IEC 14882:1998.
(ISO 99) ISO/IEC 9899:1999, 编程语言—C. 信息系统国际标准,1999. ISO 标准 ISO/IEC 9899:1999.
(ISO 05) ISO/IEC 23270:2003, 信息技术—C#语言规范。信息系统国际标准,2005. ISO 标准 ISO/IEC 23270:2003.
(Jackson 75) JacksonM.A. 《程序设计原理》。Academic Press, 1975. 0123790506.
(Javadoc) Javadoc. Sun Microsystems, Inc. http://java.sun.com/products/jdk/javadoc".
(Kernighan Pike 99) KernighanBrian W.Rob Pike. 《程序实践》。Addison-Wesley, 1999. 020161586X.
(Kernighan Plaugher 76) KernighanBrian W.P.J. Plaugher. 软件工具. Addison-Wesley, 1976. 020103669X.
(Kernighan Plaugher 78) KernighanBrian W.P.J. Plaugher. 《程序设计风格》。McGraw-Hill, 1978. 0070341990.
(Kernighan Ritchie 88) KernighanBrian W.Dennis M.Ritchie. 《C 程序设计语言》。Prentice Hall, 1988. 第二版。0131103628.
(Knuth 92) KnuthDonald. 《文献编程》。CSLI Publications, 1992. 0937073806.
(Kurlansky 99) KurlanskyMark. 《巴斯克世界史》。Jonathan Cope, 1999. 0224060554.
(McConnell 96) McConnellSteve. 《快速开发》。Microsoft Press,1996. 1556159005.
(McConnell 04) McConnellSteve. 《软件构造实用手册》。Microsoft Press,2004. 第二版。0735619670.
(Meyers 97) MeyersScott. 《Effective C++》。Addison-Wesley,1997. 条目 34:最小化文件间的复杂性依赖。0201924889.
(Miller 56) MillerGeorge A. "神奇的数字七,加减二:我们处理信息能力的某些限制。" 首次发表于《心理学评论》,63, 81-97. 1956.
(Myers 86) MyersWare. "软件战略防御倡议的软件能否永远无错误?" IEEE 计算机. 19, 10, 61-67. 1986.
(Page Jones 96) Page-JonesMeilir. 《每个程序员都应该了解的面向对象设计》。Dorset House Publishing Co.,1996. 0932633315.
(Royce 70) RoyceW.W. "大型软件开发管理。" IEEE WESCON 会议论文集,1970 年 8 月。
(Simpsons 91) Simpsons, The. "Do the Bart Man." Geffen,1991. GEF87CD.
(Stroustrup 97) StroustrupBjarne. 《C++编程语言》。Addison-Wesley,1997. 第三版。0-201-88954-4.
(UML) 统一建模语言。对象管理组。http://www.uml.org.
(Vitruvius) Vitruvius PollioMarcus (约公元前 70-25 年). 《建筑十书》。第 1 卷,第三章,第二部分。
(Weinberg 71) WeinbergGerald. 《计算机编程心理学》。Van Nostrand Reinhold,1971. 0932633420.
(Wulf 72) WulfWilliam A. "反对 GOTO 语句。" 第二十五届全国 ACM 会议论文集,1972 年。


浙公网安备 33010602011771号