《代码大全》笔记 —— 模块二:高质量的代码
软件构建的设计
- 软件的首要技术使命是管理复杂性。以简单性作为目标的设计方案对此最有帮助。
- 简单性可通过两种方式来实现:一是尽量减少任何人在大脑在任何时候必须处理的本质上复杂性的数量;二是防止偶然的复杂性无谓地扩散。
- 设计是一种启发式过程。固守于某种单一的方法会抑制创新能力,进而损及程序。
- 优秀的设计都是迭代而来的。尝试的设计可能性越多,最终的设计方案越好。
- 信息隐藏是一个非常有价值的概念。通过询问 “我应该隐藏什么?” 来解决许多非常困难的设计问题。
设计挑战
“软件设计” 是指构思、发明或设计将计算机软件规范编程可工作的软件的一种方案。“设计” 是将需求与编码和调试联系起来的活动。好的顶层设计提供了可以安全地包含多个低层设计的结构。好的设计对小的项目有用,对大型的项目,更是不可或缺的刚需。
设计工作的一个关键部分是权衡相互竞争的设计特性,并从中取得平衡。
关键设计概念
好的设计取决于对少数几个关键概念的理解。
软件架构层面,问题的复杂性通过将系统系统划分为子系统来降低。
保持子程序简短,有助于减轻心智负担。从问题领域的角度来写程序,而不是从低层次的设计细节的角度来写,并在最高的抽象层次上工作,可以减少大脑的负担。
高成本、低效率的设计有三个来源:
- 用复杂方案解决简单问题
- 用简单的、不争气的方案解决复杂问题
- 用不合适的、复杂的方案解决复杂问题
复杂性的管理要双管齐下:
- 尽量减少任何人的大脑在任何时候都必须处理的本质上的复杂性的数量
- 防止偶然的复杂性无谓的扩散
一条好的常规原则是,系统层次的图应该是无环图,程序不应包含任何循环关系。
设计构建基块:启发式方法
“按部就班” 的面向对象方法,侧重于确定现实世界对象和合成对象。
抽象是指在使用一个概念时,安全忽略其部分细节的能力,也就是在不同的层次处理不同细节的能力。
封装是抽象的延续。抽象的意思是 “你允许从高的细节层次观察对象”。封装的意思是 “除此之外,你不可以从其他任何细节层次观察对象”。
信息隐藏是结构化设计和面向对象设计的基础之一。(暴露有限的部分,其它皆隐藏)
- 隐藏复杂性,这样大脑不必处理,除非特别关注它。
- 隐藏变化源,当变化发生时,其影响被限制在局部。
适应变化是优秀程序设计最具有挑战性的一个方面。目标是隔离不稳定性的区域,这样变化的影响就会被限制在一个子程序、类和包中。
状态变量:
- 不要用布尔变量作为状态变量。改为使用枚举类型。
- 使用访问器子程序而不是直接检查变量。
松散耦合的意义在于,有效的模块提供了一个额外的抽象层次,写好保证能用。
为测试而设计:这个很重要,以前常忽略。
考虑使用蛮力:不要轻易使用不易理解的算法,或者解法。
画图时另一个强大的启发式工具。一图胜千言。
有些问题可以尝试先累积经验、再解决。
设计实践
分而治之:增量改进时管理复杂性的一个强有力的工具。
自上而下和自下而上策略设计方法的关键区别在于,一个是分解策略,另一个是合成策略。
团队的经验、系统的预期寿命、期望的可靠水平以及项目和团队的规模都应考虑在内。
设计文档需要精简。细节程度与队伍人员经验关切起来。
可以工作的类
- 类的接口应提供一致的抽象。许多问题都是由于违反原则而引起的。
- 类的接口应隐藏一些信息,包括系统接口、设计决策或实现细节。
- “包含” (has a)往往比 “继承” (is a)更可取,除非必须建模 is a 关系。
- 继承是有用的工具,但它会增加复杂性,这违背了软件的首要技术使命,即 “管理复杂性”。
- 类是管理复杂性的首选工具。只有在设计类时给予足够的关注,才能实现这一目标。
类的基础:抽象数据类型(ADT)
抽象数据类型(Abstract Data Type, ADT)是数据和对这些数据进行的操作的一个集合。
良好的类接口
尽可能使接口可编程而不是表达语义:每个接口都是由一个可编程的部分和一个语义部分构成。
把抽象和内聚放在一起考虑:抽象和内聚的概念密切相关,呈现良好的抽象的类接口通常有很强的内聚性。
最小化类和成员的可访问性:最小化可访问性是旨在鼓励封装的若干规则之一。
避免将私有实现细节放在类的接口中:如果是真正的封装,程序员根本看不到实现细节。
不要对类的用户做出预设:类的设计和实现应遵守类接口所隐含的契约。除了接口文档所提供的内容,不要对接口的使用方式做出其他预设。
不要因为子程序只使用了公共子程序就把它放到公共接口中:考虑其合理性,而不是实现的需要性考虑。
面向接口实现与封装,需要看到接口文档就会明白该怎么使用。
警惕过于紧密的耦合,几个常规的指导原则:
- 最小化减少类和成员的可访问性。
- 避免使用
friend
类,因其紧密耦合。 - 将基类中的数据变成
private
而不是protected
, 使派生类和基类的耦合不那么紧密。 - 避免的类的公共接口中公开成员数据。
- 警惕在语义上破坏封装。
- 遵守 “得墨忒耳法则”。
设计和实现的问题
除非万不得已,否则不要通过私有继承实现 has a 关系。
警告数据成员超过 7 个的类:7+-2 被认为是一个人在执行其他任务时能记住的离散项目的数量。如果一个类包含的数据乘员超过 7 个,就要考虑该类是否应分解成多个小类。
要么设计继承并提供文档说明,要么禁止继承。继承需要谨慎,太多的继承会难以理解。
确保只继承想要继承的东西:派生类可以继承成员子程序接口、实现或同时继承两者。
继承而来的子程序有三种基本形式:
- 抽象且可覆盖的子程序是指派生类只继承子程序的接口,但不继承其实现。
- 可覆盖的子程序是指派生类继承子程序的接口及其默认实现,并且可以覆盖该默认实现。
- 不可覆盖的子程序是指派生类继承子程序的接口及其默认实现,但不允许覆盖该默认实现。
不要 “覆盖” 不可覆盖的成员函数。不要在派生类中重用不可覆盖的基类子程序名称。
避免过深的继承树。建议将继承层级限制在最多 6 层。大多数人都很难在大脑中同时处理超过两到三层的继承关系。
何时使用 “继承”(is a)以及何时使用 “包含”(has a):
- 如果多个类有共同的数据,但没有共同的行为,就创建一个共同的对象供这些类包含。
- 如果多个类有共同的行为,但没有共同的数据,就从定义了共同子程序的一个共同基类中派生出这些类。
- 如果多个类有共同的数据和行为,就从定义了共同数据和子程序的一个共同基类中继承。
- 如果希望由基类控制你的接口,就选择继承;如果想控制自己的接口就选择包含。
禁止隐式生成不需要的成员函数和操作符。需要的时候才生成,不需要则不要生成此代码。
通常情况下,要尽量减少一个类与其他类的协作程度。要尽量减少以下数值:
- 实例化的对象种类。
- 在实例的对象上进行的各种直接子程序调用的数量。
- 在其他实例化的对象所返回的对象上的子程序调用。
尽可能在所有构造函数中初始化所有成员数据。初始化需要逻辑收敛。
除非论证可行,否则使用深拷贝。凡不清楚逻辑时即用深拷贝。在深拷贝和浅拷贝的问题上,一个好的办法是除非证明浅拷贝更佳,否则索性无脑使用深拷贝。
创建类的理由
- 避免创建万能类
- 消除无关紧要的类
- 避免以动词命名的类
语言特定问题
超越类:包
高质量子程序
- 创建子程序最重要的原因是为了提高程序的智能可管理性,还可以出于许多其他的原因创建子程序。节省空间往往只是次要原因,而提高可读性、可靠性和可修改行是更重要的原因。
- 有时把一个十分简单的的操作改写为一个单独的子程序也能受益匪浅。
- 可以将子程序的内聚性分为各种类型,但通过努力可以让大多数子程序实现功能内聚性,这是最理想的内聚性。
- 一个子程序的名称代表了其质量。如果名称不好,但却能准确地描述该子程序,那么很有可能是因为这个程序本身设计得不够好。如果名称不好,而且不够准确,它没有告诉人们程序到底做了什么事情。无论怎样,一个糟糕的子程序名称往往意味着程序需要更改。
- 只有当子程序的主要目的的是返回其名称所描述的特定返回值时,才应该使用函数。
- 细心谨慎的程序员会小心使用宏子程序,并且不到万不得不会使用。
子程序时单一目的而可被调用的单个方法或过程。
创建子程序的正当理由
降低复杂性。一个子程序需要从另一个子程序中单独剥离出来的一个征兆时代码中一个内部循环或条件判断中有深层嵌套。
避免重复代码。
提高性能。只把代码放在一个位置可以更容易地剖析问题并发现低效率的代码。
子程序级别的设计
功能内聚性:功能内聚性是最强的、最好的内聚类型,发生在一个子程序执行一个且只执行一个操作的时候。
顺序内聚性:当子程序包含必须按照特定顺序执行的操作时,即依照顺序逐步进行数据共享,并且所有步骤都完成后才能构成一个完整的功能,这种情况下,就存在着顺序内聚性。
通信内聚性:当一个子程序中有多项操作都使用了相同的数据但彼此没有任何其他关联时,就会存在通信内聚性。
瞬时内聚性:有时,因此在同一时间会完成多项操作而把这些操作合并为一个子程序,这时就会产生瞬时内聚性。
好的子程序名称
创建子程序有效名称的一些指导原则:
- 描述子程序所做的一切事情:在子程序的名称中应该描述其所有的输出和副作用。
- 避免使用无意义、模糊或空泛的动词。
- 不要只用数字来区分子程序名称。
- 根据需要为子程序名称选取合适的长度:研究表明,变量名的最佳平均长度为 9 到 15 个字符。
- 命名函数时,请使用对其返回值的描述。
- 命名过程时,请使用强势动词后接一个对象宾语。“动词 + 对象” 这样的动宾结构。
- 准备使用反义词。命名时使用一套反义词命名规范有助于实现名称的一致性表达,从而提高名称的可读性。
- 为常用操作建立命名规范。使用一套命名规范来明确指示这些区别往往时最简单和最可靠的。
一个子程序应该有多长
理论上,子程序的最理想的长度通常被描述为一屏显示或打印出一到两页的代码,这大概是 50 到 150 行的代码。
并不需要对子程序强制实行一个长度限制,而是应该结合子程序的内聚性、嵌套深度、变量数量和决策点的数量、解释子程序所需的注释数量以及其他一些与复杂性相关的考量因素来综合判断子程序的长度。
如何使用子程序参数
如果有几个子程序使用了相似的一组参数,请将这些相似的参数按一致的顺序排列。子程序参数的顺序有助于人们产生记忆效应,而不一致的顺序会使参数难以记忆。
请使用所有参数。如果给一个子程序传递了一个参数,就应该使用它。如果不使用它,则应该从子程序接口中把该参数删除掉。
将状态或错误变量放在最后。按照惯例,状态变量和指示错误发生的变量应当排在参数列表的最后面。
不要把子程序参数作为工作变量使用。
将输入值赋给一个工作变量可以强调该值的来源。消除参数列表中的变量被意外修改的可能性。
将子程序的参数限制在 7 个作用。
考虑为参数的输入、修改和输出使用命名规范。在一个函数参数中既有入参也有出参,可以用特殊命名办法区分。
使用有具化名称的参数。在某些编程语言(Python)中,可以显示地将形参与实参关联起来。
函数使用中的特别注意事项
如果子程序的主要目的是返回由函数名指示的返回值,那么就应该使用函数。否则,请使用过程。
不要返回指向局部数据的引用或指针。一旦子程序执行结束,局部数据就会超过作用域,指向局部数据的引用或指针随之失效。
宏子程序和内联子程序
把宏表达式放置在圆括号之内。
使用宏来代替函数调用,这种做法通常被认为是有风险的,而且还会使代码变得难以理解,这并不是一种好的编程实践,所以只限于在特定环境需要且万不得已时才用。
少使用内联子程序。内联子程序违反了封装原则。
防御式编程
- 生产代码应该以更复杂的方式处理错误,而不只是 “垃圾进,垃圾出”。
- 防御式编程技术使错误更容易发现,更容易修复,并能减少对生产代码的损害。
- 断言有助于尽早发现错误,尤其是在大型系统、高可靠性系统和快速发现的代码中。
- 关于如何处理错误输入的决策是一项关键的错误处理决策,也是一项关键的高层级设计决策。
- 异常提供了一种与代码正常工作流维度不同的错误处理手段。如果谨慎使用,它可以成为程序员知识工具箱的一个宝贵的补充,同时它应该与其它错误处理技术进行权衡比较后使用。
- 适用于生产系统的约束不一定适用于开发版本。可以利用这一优势,在开发版本中添加有助于快速排查错误的代码。
防御式编程的主要思想是:即使向子程序传入错误数据,它也不会受到破坏,哪怕这些错误数据是由其它子程序产生的。
保护程序,使其免受无效输入的影响
- 检查来源于外部的所有数据的值:当从文件、用户、网络或其它外部接口获取数据时,请检查数据以确保它们在允许的范围内。
- 检查子程序所有输入参数的值
- 决定如何处理错误的输入数据
断言
断言在大型复杂程序和可靠性要求很高的程序中特别有用。
断言可以用于检查以下类型的假设:
- 入参或出参的值在预期范围内。
- 子程序开始或结束执行时,文件或流处于打开或关闭状态。
- 子程序开始或结束执行时,文件或流处于开始或者结束的位置。
- 文件或流以只读、只写或读写的方式打开。
- 只读属性的入参值没有被子程序修改。
- 指针不为空。
- 传入子程序的数组或其它容器至少可以容纳 X 个数据元素。
- 表已经使用真实的值进行了初始化。
- 子程序开始或结束执行时,容器是空的或满的。
- 一个经过高度优化的复杂子程序的运算结果与一个较慢但条理清晰的子程序的结果是一致的。
使用断言的指导原则:
- 用错误处理代码来处理预期会发生的情况;用断言来处理永远不应该发生的情况:断言检查的是永远不应该发生的情况;而错误处理代码检查的是可能不会经常发生的非正常情况。
- 避免将要执行的代码放在断言中
- 使用断言来声明和验证前置条件和后置条件:前置条件和后置条件是 “契约式设计” 这种程序设计和开发方法的一部分。
- 对健壮性要求很高的代码,应先使用断言再处理错误
错误处理技术
正确性意味着永远不会返回不准确的结果;不返回结果显然胜于返回不准确的结果。
异常
使用异常来通知程序其它部分不应忽略的错误:异常机制和优越之处在于它能以一种无法被忽略的方式通知有错误发生。
异常的应用情形与断言相似,都是用来处理那些不仅罕见甚至永远不应该发生的情况。(Java 里 Error 和 Exception 是有区别的)
避免使用空的 catch 块:有时可能想要有意敷衍一个不知该如何处理异常。至少需要打印日志进行记录。
一定要知道所用的库代码都会抛出哪些异常。
隔离程序,使之包容由错误造成的损害
隔离是一种容损策略。
以防御式编程为目的而进行隔离的一种方法是,指定某些接口作为 “安全” 区域的边界。
隔栏外部的子程序应使用错误处理技术,因为对数据进行任何假定都是不安全的。而隔栏内部的子程序里应该使用断言,因为传递给它们的数据应该在通过隔栏之前就已经被清理过。(隔栏内部增加断言保证子系统的防御)
调试辅助代码
辅助工具需要隔离到开发版本,不能带到生产版本上。
进攻式编程:
- 确保断言语句可以使程序终止运行。不要让程序员养成遇到已知问题只知道按回车键绕过的习惯。要让问题痛苦到必须进行修复。
- 完全填充分配到的所有内存,以便可以检测内存分配方面的错误。
- 完全填充分配到的所有文件或流,以便可以排查任何文件格式错误。
- 确保每个 case 语中的 default 分支或 else 分支都能产生严重错误,比如说让程序终止运行,或者至少让这些错误不会被忽视。
- 在删除对象之前使其填满垃圾数据。
- 让程序把错误日志文件通过电子邮件发送给你,让你可以看到已发布软件中发生的各种错误。
避免调试代码和程序代码纠缠不清。
增加检查点。(checkpoint)
确定在生产代码中保留多少防御式代码
防御式编程中存在这么一种矛盾的观念,在开发阶段希望错误是显而易见的,宁愿看到它心生厌恶,也不愿冒险忽视它。但在生产阶段,你却想让错误尽可能不显山不露水,让程序能优雅地恢复或失败。
保留用于检查重要错误的代码:确定程序的哪些部分能够承受未检测出错误而造成的后果,哪些部分不能。
删除用于检查微不足道错误的代码:如果一个错误带来的影响确实微不足道,可以考虑删除用于检查它的代码。
保留有助于程序优雅崩溃的代码。
尽可能快速找出问题。
对防御式编程采取防御的姿态
要考虑好需要在什么地方进行防御,然后因地制宜地调整防御式编程的优先级。(限制防御的地方、数量)
伪代码编程过程
- 类和子程序的构建通常是一个迭代过程。构建子程序过程中所获得的认知常常会反过来影响类的设计。
- 编写好的伪代码需要使用容易理解的自然语言,要避免使用特定编程语言才有的特性,同时要在意图层级上编写伪代码,说明设计应该做什么,而不是具体怎么做。
- 伪代码编程过程(PPP)是进行详细设计的一种有用的工具,它也使编码工作变得更容易。伪代码可以直接转换为注释,从而确保了注释的准确性和实用性。
- 不要只停留在自己想到的第一个设计方案上。可以反复使用伪代码迭代出若干个方案,选出其中最好的再开始着手编程。
- 每一步完成后都检查自己的工作,并鼓励其他人帮忙检查。这样就能够在投入精力最少的时候,用最低的成本发现错误。
类和子程序构建步骤总结
面向专家的伪代码
使用 PPP 构建子程序
当出现代码错误时,需要先思考自己代码问题。
大量警告往往意味着代码质量低,应尝试理解显示的每一个警告。检测到任何错误都必须消除。
PPP 的替代方案
测试优先开发:测试优先(或测试先行)是一种流行的开发风格,它是指在写任何代码之前,先写好测试用例。
重构是指通过一系列保留了语义的转换来改进代码。