Code Complete 阅读笔记-创建高质量的代码(1)

最近在看Code Complete(中文译作代码大全),一本关于代码构建的书。虽然研究生阶段做的东西与算法结合比较紧密,找工作的岗位也叫算法工程师,但是始终觉得算法工程师首先也得是个工程师,而不应该仅仅是调参师,因此一些基本的工程能力还是不可或缺的。本文主要是创建高质量的代码部分的的两章笔记:第 6 章(可以工作的类)、第 7 章(高质量的子程序),主要给出了在构建类和子程序过程中的一些建议。

可以工作的类(Working class)

良好的类接口

创建高质量的类,第一步也可能是最重要的一步就是创建一个良好的接口,而这又涉及到两部分:抽象和封装

对于抽象,有以下建议

  1. 类的接口应该展示一致的抽象层次, 也就是说如果某个类实现了不止一个 ADT(abstract data type),那么就应该把这个类重新组织为一个或多个定义更加明确的 ADT,如下所示的 cpp 代码中混合了不同层次抽象的类接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class EmployeeCensus: public ListContainer {
public:
...
// The abstraction of these routines is at the “employee” level.
void AddEmployee( Employee employee );
void RemoveEmployee( Employee employee );

//The abstraction of these routines is at the “list” level.
Employee NextItemInList();
Employee FirstItem();
Employee LastItem();
...
private:
...
};

这个类展现了两个 ADT: Employee 和 ListContainer,原因是使用容器类或其他类库来实现内部逻辑,但是却没有把使用容器类或其他类库这一事实隐藏起来,如下是修改过有着一直抽象层次的类接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class EmployeeCensus {
public:
...
// The abstraction of all these routines is now at the “employee” level.
void AddEmployee( Employee employee );
void RemoveEmployee( Employee employee );
Employee NextEmployee();
Employee FirstEmployee();
Employee LastEmployee();
...
private:
// That the class uses the ListContainer library is now hidden.
ListContainer m_EmployeeList;
...
};

没有采取 EmployeeCensus 继承 ListContainer 的方式,是因为 EmployeeCensus 与 ListContainer 不是 “is a” 的关系, 因为 EmployeeCensus 中可能还会有排序、统计等 ListContainer 不支持的操作。

  1. 提供成对的服务。大多数操作都有与其相对应、相等以及相反的操作,如上面的第一个和最后一个,添加和删除等。所以在设计一个类的时候,要检查每一个公共子程序,决定是否需要另一个与其互补的操作,但是也不要盲目地创建

  2. 把不相关的信息转移到其他类。如果某个类中一半子程序使用着该类的一半数据,而另一半子程序则使用另一半的数据,这时其实已经把两个类混在一起使用了,需要拆开。

抽象通过提供一个可以让你忽略实现细节的模型来管理复杂度,而封装则强制阻止你看到细节。两个概念比较相似,关于封装,有以下建议

  1. 尽可能限制类和成员的可访问性。当犹豫着子程序的可访问性应设为 public、protected、private 中哪一种时,经验之举是采用最严格且可行的访问级别。

  2. 不要公开暴露成员数据。即不要直接访问成员数据,而是通过 GetSet 子程序进行访问和修改。

  3. 留意过于紧密的耦合关系。耦合指的是两个类之间关联的紧密程度,这种关联通常是约松越好,因此可以有以下一些指导建议

  • 尽可能限制类和成员的可访问性
  • 避免友元类/友元函数
  • 避免在公开接口中暴露成员数据

设计和实现问题

包含(has a)与继承(is a)

从英文上便可区分两者,包含指的是将某个对象作为类的成员,而继承则是在原来的对象之上进行拓展。

关于包含,需要警惕有超过约 7 个数据成员的类,因为某些研究表明人们在做事情时能记住的离散项目的个数是 7 ± 2,如果一个类包含超过约 7 个数据成员,可考虑将其分解为几个更小的类。

关于继承,其目的是通过定义能为两个或更多的派生类提供共有元素的基类从而写出更精炼的代码,在使用继承时,需要考虑

(1)成员函数是否应对派生类可见?是否应该有默认实现?默认实现能够被覆盖? (2)继承需要遵循 Liskov 替换原则(Liskov Substitution Principle,LSP), 简单来说就是对于基类中定义的所有子程序,用在它的任何一个派生类中时的含义都应该是相同的,而不应该存在着不同派生类在使用同一个基类方法时需要区分其返回的值的单位等细节。 (3) 派生类中的成员函数不要与基类中不可覆盖的成员函数重名 (4)使用多态来避免类型检查, 对于下面的代码,应该用基类的 shape.Draw() 的方法来替代 shape.DrawCircle()shape.DrawSquare(),从而避免这些类型检查。

1
2
3
4
5
6
7
8
9
switch ( shape.type ) {
case Shape_Circle:
shape.DrawCircle();
break;
case Shape_Square:
shape.DrawSquare();
break;
...
}
(5)避免让继承体系过深,建议继承的层次在 2-3 层,派生类的个数在 7±2 (6)让所有数据都是 private,如果派生类需要访问基类的属性,应该提供 protected 的 accessor function。

那么,何时使用包含,何时使用继承

  1. 如果多个类共享数据而非行为,应该创建这些类可以包含的共用对象
  2. 如果多个类共享行为而非数据,应该让它们从共同的基类继承而来,并在基类里面定义公用的子程序
  3. 如果过多个类既共享数据也共享行为,应该让它们从一个共同过的基类继承,并在基类里面定义共用的数据和子程序

成员函数、数据成员、构造函数

关于成员函数和数据成员有以下建议

(1) 让类中的子程序的数量尽可能少 (2) 减少类调用的不同子程序的数量(也叫扇入/fan in,因为类用到其他类的数量越高,其出错率也越高 (3) 对其他类的子程序的间接调用要尽可能少,比图说 A 对象中创建了 B 对象,应该避免 A 对象直接调用 B 对象中的方法,即 A.B().b_action() (4) 减少类和类之间的互相合作的范围,应尽量让下面这些数字尽可能小,包括实例化的对象的种类、实例化对象上直接调用的不同的子程序的数量、调用由其他对象返回的对象的子程序的数量。

关于构造函数有以下建议

(1)应该尽可能在构造函数中初始化所有的数据成员 (2)用 private 构造函数来强制实现单例模式。单例模式指的是一个类只有一个对象,实现的具体方法是把类的所有构造函数都隐藏起来,然后对外提供一个static 的 GetInstance() 子程序,如下所示(Java 示例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//Java Example of Enforcing a Singleton with a Private Constructor
public class MaxId {
// Private Constructor
private MaxId() {
...
}
...

public static MaxId GetInstance() {
return m_instance;
}
...
// Here is the single instance.
private static final MaxId m_instance = new MaxId();
...
}

(3)优先使用 deep copies,除非论证可行,才采用 shallow copies。因为 deep copies 在开发和维护方面都比 shallow copies 简单,shallow copies 需要增加很多代码用于引用计数、确保安全地复制对象、安全地比较对象以及安全地删除对象等。而这些代码是很容易出错的,除非有充分的理由,否则就应该避免它们。

高质量的子程序(High-Quality Routines)

创建子程序的必要性不言而喻:能够重用代码、提高可移植性、良好子程序命名甚至能够达到自我注解的作用等等。

内聚性

对子程序而言,内聚性指的是子程序中各种操作之间联系的紧密程度。像 Cosine() (余弦函数)这样的函数就是内聚性很强的,因为整个程序只完成了一项功能;而CosinAndTan() (余弦与正切) 这个函数的内聚性就比较弱,因为它完成了多余一项的操作。我们的目标是让每一个子程序只把一件事做好,不再做其他事情,这也是功能上的内聚性,虽然也有一些其他的内聚性,但是功能上的内聚性是最佳的一种内聚性。

命名

好的子程序命名非常重要,命名应该遵循以下原则

(1)描述子程序所做的事情,一般采用动宾结构;除了面向对象语言中的类可以忽略宾语,因为对象本身已经包含在调用语句总了,如 document.Print(), orderInfo.Check() 等。 (2)避免使用无意义、模糊或表达不清的动词。有些动词的含义非常灵活,可以延伸到涵盖几乎任何含义。像HandleCalculation(), PerformServices(), OutputUser(), ProcessInput(), DealWithOutput() 这些子程序名称根本不能说明子程序是做什么的。这时候要采用更具体的词语,比如说将HandleOutput 改成 FormatAndPrintOutput 就会清晰很多;假如是子程序本身的设计问题而导致了无法采用更具体的词,那么就需要重新组织这个子程序了。 (3)给函数命名时要对返回值有所描述。这里的有所描述并不是显式地描述返回值类型,而是通过函数名体现,如 customerID.next(),printer.isReady() 等都较好地体现了返回值 (4)准确适用对仗词。命名时遵循对仗词的命名规则有助于保持一致性,从而也提高可读性。下面是一些通用的对仗词。

对仗词

(5)给常用的操作建立命名规则。如下是作者列举的某个例子,这些方法是某个工程里面获取对象 id 的所有方法,其作用一致,但是到了后来,没人能记住哪个对象应该用哪些子程序了。所以应该一开始就应该统一获取 id 的子程序名称,

1
2
3
4
employee.id.Get()
dependent.GetId()
supervisor()
candidata.id()

参数与返回值

关于参数和返回值有以下建议

(1)按照输入-修改-输出的顺序排列参数。而不是按照字母顺序排列,还可以考虑采用某种表示输入、修改、输出的命名规则,如可以给这些参数名字加上 i_, m_, o_ 前缀。 (2)如果几个子程序都用了类似的一些参数,应该让这些参数的排列顺序保持一致。这样可以产生一定的记忆效应 (3)把状态变量或错误变量放到最后。就是将那些表明发生了错误的变量放到函数的最后,这些参数只是附属于主程序的主要功能,而且是仅用于输出的参数。 (4)不要把子程序的参数用于工作变量。如下的代码中 inputVal 就不应该被这么用, 在 C++ 中可以用 const 参数来做这一限制。

1
2
3
4
5
6
int Sample( int inputVal ) {
inputVal = inputVal * CurrentMultiplier( inputVal );
inputVal = inputVal + CurrentAdder( inputVal );
...
return inputVal;
}
  1. 关于返回值,要检查所有可能的返回路径。也就是要确保在所有可能的情况下该函数都会返回值。在函数开头用一个默认值来初始化返回值是一个很好的做法,这种方法能够在未正确地设置返回值时提供一张保险网。