《代码大全》第二版 (Code Complete 2)
作者: Steve McConnell
一本旨在缩小业界大师知识与普通商业实践之间差距的软件构建手册。
第一章: 欢迎进入软件构建
核心要点
第二章: 用隐喻来更深刻地理解软件开发
核心要点
- 写代码(Software Penmanship): 这是最简单的隐喻,将编程比作写信。它适用于个人或小型项目,但忽略了软件开发的协作性、迭代性和长期维护的本质。Fred Brooks "Plan to throw one away" 的观点源于此,但对于大型系统来说成本过高。
- 农耕(Software Farming): 将开发比作种植庄稼,暗示逐步、增量地构建系统。但这个隐喻的弱点在于,它暗示了开发者对过程缺乏直接控制,仿佛只能等待代码“自然生长”。
- 增殖/堆积(System Accretion): 将开发比作牡蛎造珍珠,逐步地、一点点地增加功能。这是对增量式开发(Incremental Development)更准确的描述,强调了先构建骨架,再逐步添加血肉的过程。这是现代敏捷开发方法的基础。
- 软件构建(Software Construction): 这是本书采用的核心隐喻。它将软件开发比作建造房屋或摩天大楼。
- 它强调了不同规模项目需要不同程度的规划、设计和质量保证(建狗窝 vs. 建摩天大楼)。
- 它突出了“前期准备”的重要性,如同建筑的地基。糟糕的基础无法支撑高质量的建筑。
- 它解释了“复用”的概念,如同购买预制门窗而不是自己从头打造。
第三章: 前期准备:度量两次,切割一次
核心要点
成本对比: 需求阶段修复成本为1,架构阶段为3,构建阶段为5-10,系统测试阶段为10,发布后为10-100。
- 瀑布式/顺序式方法(Sequential) 适用于需求稳定、设计直接、团队熟悉应用领域的项目。
- 迭代式方法(Iterative) 适用于需求不稳定、设计复杂、团队不熟悉应用领域或风险较高的项目。
三大构建前提
- 问题定义(Problem Definition):
- 一个清晰的、一到两页纸的陈述,用用户的语言描述要解决的“问题”是什么,而不是“解决方案”是什么。
- 错误的问题定义会导致团队花费大量时间和金钱去解决一个错误的问题。
- 需求(Requirements):
- 详细描述系统应该做什么。明确的需求有助于确保用户驱动功能、避免争论,并最大限度地减少后期变更。
需求变更的普遍性: 典型项目在开发过程中会经历约25%的需求变更,这占到了项目中70%到85%的返工工作量。因此,必须有系统的变更控制流程。 - 软件架构(Software Architecture):
- 系统的高层设计,如同建筑的框架。它决定了系统的概念完整性,并为程序员提供指导。
- 糟糕的架构会使构建变得几乎不可能。架构变更的成本与需求变更同样高昂。
- 一个好的架构应该定义主要的构建模块(子系统)、关键类、数据设计、业务规则、用户界面、资源管理、安全、性能、可扩展性等。
第四章: 关键的构建决策
核心要点
- 程序员使用熟悉的语言比不熟悉的语言生产率高约30% (Cocomo II)。
- 高级语言(如C++, Java, C#)的生产率比低级语言(如C,汇编)高5到15倍。
- 编程“在”一种语言中(Programming IN a language): 思想受限于语言直接支持的结构。
- 编程“到”一种语言中(Programming INTO a language): 先决定想表达的思想,然后利用语言提供的工具来实现它。这意味着你可以通过约定、标准、类库来弥补语言的不足。
- 浪潮早期(Early Wave): 语言和工具有很多bug,文档稀少,大量时间花在解决环境问题上。
- 浪潮晚期(Late Wave): 拥有丰富的开发基础设施,如强大的IDE、成熟的库、稳定的编译器和大量的文档。
第五章: 构建中的设计
核心要点
- 设计是“邪恶问题”(Wicked Problem): 问题的定义只有在解决(或部分解决)之后才能完全清晰。
- 设计是一个凌乱的过程: 充满了错误的尝试和盲目的探索,但这是必要的,因为在设计阶段犯错比在编码后修复成本低得多。
- 设计是关于权衡和优先级: 现实世界中没有完美的方案,好的设计是在相互冲突的目标(如速度、空间、开发时间、可维护性)之间做出明智的权衡。
- 设计是启发式的(Heuristic): 它依赖于经验法则和“有时奏效的技巧”,而不是确定性的算法。
- 设计是涌现的(Emergent): 好的设计不是一次成型的,而是通过评审、讨论和编码实践逐步演化和完善的。
关键设计概念
- 理想的设计特征: 最小化复杂度、易于维护、松散耦合、可扩展性、可重用性、高扇入(Fan-in)、低到中等的扇出(Fan-out)、可移植性、精简、分层、标准化。
- 设计的层次:
- 软件系统级
- 子系统/包级:将系统划分为大的功能块,并严格定义它们之间的通信规则。
- 类级:定义类的职责、接口和关系。
- 子程序级:设计类的内部实现,包括私有方法。
- 子程序内部设计:使用伪代码等方法规划子程序的具体逻辑。
设计构造块:启发法
- 寻找现实世界的对象: 面向对象设计的起点。
- 形成一致的抽象: 让你可以在忽略不相关细节的情况下处理概念。
- 封装实现细节: 强制隐藏细节,使得客户代码无法依赖于它们。
- 识别最可能变化的地方: 将易变的部分隔离起来,以最小化变更带来的影响。
- 保持松散耦合: 最小化模块之间的连接。
- 寻找常见的设计模式: 使用经过验证的解决方案来解决常见问题,可以减少复杂性、减少错误,并改善沟通。
第六章: 可以工作的类
核心要点
好的类接口
- 提供一致的抽象层次: 一个类的所有公共方法应该服务于同一个抽象概念。不要在接口中暴露底层的实现细节(例如,一个
EmployeeList
类不应该暴露GetNextItemInList()
这样的方法,而应该是GetNextEmployee()
)。 - 提供成对的服务: 如果有
Add()
,通常也需要Remove()
;有Enable()
,通常也需要Disable()
。 - 良好的封装:
- 最小化可访问性: 默认将成员设为
private
,只有在必要时才放宽限制。 - 不要暴露成员数据: 永远不要将成员变量声明为
public
。应该通过访问器方法(accessor methods)来提供对数据的访问。 - 警惕语义上的封装破坏: 不要编写依赖于类内部实现细节的代码,即使这些细节是通过公共接口间接暴露的。
- 最小化可访问性: 默认将成员设为
设计与实现问题
- 包含(“有一个”关系): 这是面向对象编程中最常用的技术。当一个类“拥有”或“包含”另一个对象时,使用成员变量来实现。
- 继承(“是一个”关系):
继承是把双刃剑: 继承会增加复杂性,因为它打破了封装。只有在派生类确实是基类的一个特殊类型时(遵循里氏替换原则LSP)才使用公有继承。
- 里氏替换原则(LSP): 子类的对象必须能够在不破坏程序正确性的前提下,替换掉基类的对象。
- 继承树要浅: 深度超过2到3层的继承树就很难理解了。
- 优先使用多态,而不是类型检查: 重复的
case
语句常常是使用继承和多态的信号。 - 将所有数据成员设为private,而不是protected: 以保护封装性。
为什么要创建一个类?
创建类的首要原因是降低程序的复杂性。其他原因包括:
- 为现实世界或抽象的对象建模。
- 隔离复杂性,隐藏实现细节。
- 限制变更的影响。
- 隐藏全局数据。
- 简化参数传递。
- 创建集中的控制点。
- 促进代码复用。
第七章: 高质量的子程序
核心要点
高质量子程序的设计
- 强内聚(Strong Cohesion): 这是子程序设计的核心。一个高质量的子程序应该只做一件事,并且把它做好。功能内聚(Functional Cohesion)是最好的,即子程序的所有操作都服务于一个单一、明确的功能。
- 良好的命名:
- 准确描述子程序所做的所有事情。
- 避免使用模糊、无意义的动词,如
Handle...
,Process...
。 - 对于函数(返回值的子程序),用其返回值来命名,如
printer.IsReady()
。 - 对于过程(不返回值的子程序),使用“动词 + 对象”的格式,如
PrintDocument()
。 - 精确使用反义词对,如
add/remove
,start/stop
。
- 合适的长度: 大多数子程序可以保持在半屏到两屏代码(约25-100行)之间。过长的子程序通常意味着内聚性差,应该被分解。
- 参数的使用:
- 参数数量应尽可能少,通常不超过7个。
- 按“输入-修改-输出”的顺序排列参数。
- 不要将输入参数用作工作变量;应该创建一个新的局部变量。
- 确保接口的抽象一致性。
第八章: 防御式编程
核心要点
关键技术
- 保护程序免遭非法输入:
- 检查所有来自外部源(文件、用户、网络)的数据。
- 检查所有子程序的输入参数。
- 决定如何处理坏数据(返回中立值、记录日志、返回错误码、关闭程序等)。
- 断言(Assertions):
断言用于检查绝不应该发生的情况,而错误处理代码用于处理预期可能发生的异常情况。断言是可执行的文档,用于在开发期间暴露代码中的逻辑错误。
- 用断言来文档化和验证前条件(preconditions)和后条件(postconditions)。
- 避免在断言中放入有副作用的可执行代码,因为在生产环境中,断言可能会被关闭。
- 错误处理技术: 根据软件的类型选择合适的错误处理策略。
- 健壮性(Robustness) vs. 正确性(Correctness): 健壮性意味着软件持续运行,即使有时结果不准确;正确性意味着绝不返回不准确的结果,宁愿不返回结果。消费类应用倾向于健壮性,而安全攸关系统倾向于正确性。
- 异常(Exceptions):
- 仅用于处理真正异常的情况,即那些无法在本地处理且不应被忽略的错误。
- 在与接口相同的抽象层次上抛出异常。例如,一个
GetEmployeeData()
方法不应抛出底层的IOException
,而应抛出更抽象的EmployeeDataNotAvailableException
。 - 避免在构造函数和析构函数中抛出异常,因为这会使资源管理变得异常复杂。
- 避免空的
catch
块。
- 隔离舱(Barricades): 在程序中建立“安全”区域。在数据跨越“隔离舱”边界时进行检查和清理。一旦数据进入安全区,内部代码就可以假定数据是合法的,并使用断言来验证这一假设。
- 调试辅助代码:
进攻式编程(Offensive Programming): 在开发版本中,让错误尽可能早地、明显地暴露出来。例如,让断言直接终止程序,而不是允许程序员忽略它。
第九章: 伪代码编程过程 (PPP)
核心要点
PPP的步骤
- 设计子程序(Design the Routine):
- 清晰地定义子程序要解决的问题,包括输入、输出、前条件和后条件。
- 为子程序起一个精确的、描述其所有功能的名字。
- 用类似英语的、高层次的伪代码来描述子程序的逻辑。
- 编写好的伪代码: 描述“做什么”(意图),而不是“怎么做”(实现细节)。避免使用目标编程语言的语法。
- 迭代求精: 从高层伪代码开始,逐步细化,直到伪代码的每一步都足够简单,可以直接翻译成几行代码。
- 在伪代码层面进行思考和评审,这比在代码层面修改要容易得多。
- 编写代码(Code the Routine):
- 将伪代码转换成代码中的注释。
- 在每条注释下面编写对应的代码。
- 这个过程应该是机械的、自然的。如果感觉困难,说明伪代码设计得还不够详细,应该返回上一步。
- 检查代码(Check the Code):
- 在心中默想一遍代码的执行路径。
- 打开编译器的最高警告级别,并修复所有警告。
- 在调试器中单步执行代码,确保每一行都如预期那样工作。
- 用之前构想的测试用例进行测试。
- 清理并重复(Clean Up and Repeat):
- 重构代码,检查接口、变量名、格式等是否符合标准。
- 移除那些因为代码本身已经足够清晰而变得多余的注释。
- 这是一个迭代的过程,如果发现代码质量不高,就回到伪代码阶段重新设计。
- 简化设计: 让你在不受语法束缚的情况下专注于逻辑。
- 简化编码: 编码变成了一个简单的翻译过程。
- 自动生成高质量注释: 伪代码直接成为注释,解释了代码的“意图”,而不是简单重复代码。
- 易于评审: 评审伪代码比评审源代码更高效。
- 提高代码质量: 在成本最低的阶段(设计阶段)发现并修复错误。
第三十一章: 布局与风格
核心要点
布局的目标
- 准确地表现逻辑结构: 缩进和分组应与控制流和数据结构一致。
- 一致地表现逻辑结构: 风格规则应普遍适用,避免太多例外。
- 提高可读性: 使代码易于浏览和理解。
- 易于修改和维护: 格式不应在代码修改时变得难以维护。
关键技术
- 空白(White Space):
- 分组: 用空白将逻辑上相关的代码块组织在一起,如同文章中的段落。
- 空行: 用空行来分隔不同的代码“段落”,突出注释,或分隔子程序。
- 缩进: 这是展示逻辑从属关系最重要的方式。研究表明,2到4个空格的缩进最有利于理解。
- 括号: 在复杂的布尔或算术表达式中,不要依赖操作符优先级规则,应使用括号来明确意图,减少读者的认知负荷。
- 块布局风格:
- 纯块(Pure Blocks): 像Visual Basic中的
If...End If
,块的开始和结束关键字对齐。 - 模拟纯块(Emulating Pure Blocks): 在C++/Java中,将开括号
{
放在控制语句的同一行,关括号}
与控制语句对齐。这是Java的官方风格,也是一种非常好的风格。 - 使用花括号界定块: 将开括号
{
放在控制语句的下一行,并与控制语句对齐,关括号}
与之对齐。这也是一种清晰且可维护的风格。
- 纯块(Pure Blocks): 像Visual Basic中的
- 单个语句: 每行只写一条语句。这有助于调试、编辑和清晰地展示代码的复杂性。
第三十二章: 自说明代码
核心要点
注释的类型与价值
有效的注释解释代码的“意图”(Why),而不是“如何做”(How)。
- 应该避免的注释:
- 重复代码的注释: 只是用更啰嗦的语言复述了一遍代码,增加了阅读量而没有提供新信息。
- 解释烂代码的注释: 如果一段代码需要长篇大论来解释,那么应该重写代码,而不是添加注释。名言:“不要给坏代码加注释——重写它。”
- 有价值的注释:
- 总结性注释: 将几行代码的逻辑总结为一句话,帮助读者快速浏览。
- 意图性注释: 解释代码背后的目的和设计思想。这是最有价值的注释,因为它提供了代码本身无法表达的上下文。
- 无法用代码表达的信息: 版权声明、版本号、对算法来源的引用、对语言或环境的陷阱的说明等。
高效注释的技巧
- 在伪代码编程过程中编写注释: 在编写代码之前先用伪代码设计,然后将伪代码直接转为注释。这使得注释工作几乎没有额外成本。
- 关注代码本身: 与其花时间写一个复杂的注释,不如花时间改进变量名、重构逻辑,让代码自己说话。
- 为数据声明添加注释: 解释变量的单位(米、秒)、允许的数值范围、或特殊值的含义。
- 为控制结构添加注释: 在每个循环或复杂的
if
语句前,用一句话说明其目的。 - 为子程序和类编写头注释: 简要描述其职责、接口假设和局限性。使用像Javadoc这样的工具可以规范化这个过程。
第三十三章: 个人性格
核心要点
优秀程序员的关键性格特质
- 谦虚(Humility): 最优秀的程序员是那些认识到自己大脑容量有限的人。他们不会试图将整个复杂程序装进脑子里,而是通过各种实践(如模块化、代码审查、编写短小的子程序)来弥补自己智力上的局限。承认自己会犯错是进步的前提。
- 好奇心(Curiosity): 软件行业技术日新月异。一个有好奇心的程序员会持续学习,探索新的工具和技术,阅读书籍和文章,并乐于通过实验来理解事物的工作原理。没有好奇心,程序员很快就会变成“技术恐龙”。
- 诚实(Intellectual Honesty):
- 不假装自己是专家,坦然承认自己的无知。
- 坦率地承认自己的错误,并从中学习。
- 不通过“编译运行看结果”的方式来编程,而是在提交代码前真正理解它。
- 提供真实的项目状态报告和进度估算,即使这可能不是管理者想听到的。
- 沟通与合作(Communication and Cooperation): 编程首先是与人沟通,其次才是与计算机沟通。编写可读性强的代码是团队合作精神的核心体现。
- 创造力与纪律的平衡(Creativity and Discipline): 纪律(如遵循标准和约定)并不会扼杀创造力,反而会解放它。通过在不重要的事情上遵循约定,你可以将创造力集中在真正需要解决的核心问题上。
- 开明的懒惰(Enlightened Laziness): 这不是指拖延,而是指为了从长远上减少总体工作量而付出的努力。例如,编写一个工具来自动化重复性任务,或者精心编写文档以减少未来回答问题的次数。
不太重要的特质
- 经验(Experience): 在快速变化的软件领域,“多年的经验”可能意味着“一年经验的多次重复”。持续学习和反思比单纯的年头更重要。
- 坚持(Persistence): 面对难题时,顽固的坚持(“牛角尖”)往往是低效的。知道何时该放弃一种方法,稍作休息,或寻求他人帮助,是更明智的策略。
第三十四章: 软件工艺的主题
本章提炼了贯穿全书的核心思想,它们是区分“黑客”与“软件工匠”的关键。
软件工艺的核心主题
- 征服复杂性(Conquer Complexity): 这是软件开发的首要技术使命。所有优秀实践,如模块化、信息隐藏、编写短小的子程序、避免全局变量等,都是为了将复杂问题分解成人类大脑可以处理的小块。
- 选择你的过程(Pick Your Process): 你采用的开发过程(无论是敏捷、瀑布还是其他)对最终产品的质量有巨大影响。优秀的程序员会有意识地选择并改进他们的工作流程。
- 为人写程序,其次才是为计算机(Write Programs for People First, Computers Second): 代码的主要读者是人。可读性不是可选项,它直接影响代码的可理解性、可维护性、调试效率和最终质量。
- 编程“到”一种语言中,而不是“在”一种语言中(Program into Your Language, Not in It): 不要让你的思维被特定语言的特性所局限。先想清楚你希望如何表达解决方案,然后利用语言的工具来实现它,甚至通过约定和自建工具来弥补语言的不足。
- 利用约定来集中注意力(Focus Your Attention with the Help of Conventions): 约定(如命名约定、格式约定)通过将任意性决策标准化,解放了你的脑力,让你能专注于解决更重要的问题。
- 基于问题领域编程(Program in Terms of the Problem Domain): 尽可能地使用与现实世界问题相关的词汇和抽象(例如,使用
Employee
,Invoice
等类),而不是计算机科学的底层术语(如链表、哈希表)。这使得代码更易于理解和修改。 - 警惕“掉落的岩石”(Watch for Falling Rocks): 编程中充满了预示着问题的“警示信号”。例如,“这段代码很tricky”、一个模块的缺陷率异常高、深度嵌套、糟糕的变量名等。优秀的程序员对这些信号保持警惕,并将其视为改进代码的机会。
- 迭代,再迭代(Iterate, Repeatedly, Again and Again): 无论是需求、设计、编码还是估算,迭代都是一个强大的工具。第一次的尝试很少是最好的,通过多次迭代和改进,可以逐步逼近更优的解决方案。
- 将软件与宗教分离(Thou Shalt Rend Software and Religion Asunder): 避免对任何单一方法论、工具或风格的盲目、教条式崇拜。保持开放和怀疑的心态,采取兼容并蓄(eclecticism)的态度,通过实验来验证哪种方法最适合当前的问题。
开发者之道 (The Developer's Way)
像代码工匠一样思考。
这并非仅仅因为你编写代码,而是因为你将世界视为一个可以被理解、改进和重建的系统。这是《代码大全》所揭示的软件工艺精神。
开发者之道,是一场征服复杂性的终身战争。你承认自己头脑的容量严格有限,因此你保持谦逊。你将庞然大物分解为可管理的模块,隐藏信息以便你能安心遗忘,并构建抽象让你能只见森林,不被树木所困。
你为人类编写程序,其次才是为机器。每一行代码都是一次与未来的对话——与你的团队,也与未来的你。因此,清晰不是一种特性,而是基础。好的命名不是奢侈品,而是必需品。你的代码,就是它自身最好的文档。
你编程“到”一种语言中,而不是“在”一种语言中。你不被工具所限,而是驾驭工具。当语言有缺陷时,你用约定和纪律来弥补,创造出你需要的结构。
开发者之道,是迭代之道。你深知第一次的尝试很少是最好的。你设计、编码、测试、重构——在一次次的循环中,将粗糙的石头打磨成宝石。你的基本法则是:每一次演进都必须提升代码的内部质量,而不是降低它。
你对“警示信号”保持警惕。一段“tricky”的代码、一个难以命名的变量、一个过深的嵌套——这些都是宇宙在低语:“这里有问题。” 每一个缺陷都是一堂课,让你更深刻地理解你的系统和自己。
你拥抱诚实与谦逊。你坦然承认错误,因为你知道错误是通往真知的必经之路。你拒绝通过“编译运行”来猜测,而是先在头脑中构建确定性。
这不仅仅是关于构建软件。
这是关于你如何对待你的手艺:
带着谦逊、精确,以及对质量毫不动摇的承诺。