软件构建(三)——高质量的类和子程序

在计算时代的早期,程序员基于语句思考编程问题。到了20世纪七八十年代,程序员开始基于子程序去思考编程。进入21世纪,程序员以类为基础思考编程问题。类是由一组数据和子程序构成的集合,这些数据和子程序共同拥有一组内聚的、明确定义的职责。由此可见类和子程序在现代编程中的地位。本文将就如何创建高质量的类和子程序提供一些建议。

1. 高质量的类

1.1 类的基础:抽象数据类型(ADT)

要想理解面向对象编程,首先要理解ADT(Abstract Data Type)。ADT是指一些数据以及对这些数据所进行的操作的集合。这些操作既向程序的其余部分描述了这些数据是怎么样的,也允许程序的其余部分改变这些数据。抽象数据类型可以让你像在现实世界中一样操作实体,而不必纠结在低层如何实现上。

1.1.1 使用ADT的好处

  • 可以隐藏实现细节
  • 改动不会影响到整个程序
  • 让接口能提供更多信息
  • 更容易提高性能
  • 程序更具自我说明性
  • 无须在程序内到处传递数据
  • 可以像在现实世界中那样操作实体,而不用在底层实现上操作它

1.1.2 使用ADT的指导建议

  • 把常见的底层数据类型创建为ADT并使用这些ADT,而不再使用底层数据类型(要尽可能选择最高的抽象层次)
  • 把像文件这样的常用对象当成ADT
  • 简单的事物也可当做ADT(如灯的“开”“关”操作可以放到单独的方法)
  • 不要让ADT依赖于其存储介质

1.2 良好的类接口

上一节所讲的ADT构成了类的基础,类可以看成是抽象数据类型再加上继承和多态两个概念。创建高质量的类最重要的一步是创建一个好的接口,包括通过接口来展现良好的抽象,并确保细节被很好地封装在类中。封装是一个比抽象更强的概念:抽象通过提供一个可以让你忽略实现细节的模型来管理复杂度,而封装则强制阻止你看到细节。一般来说,抽象和封装要么两者皆有,要么两者皆无。

1.2.1 良好的抽象

  • 类的接口应该展现一致的抽象层次:每一个类应该只实现一个ADT
  • 一定要理解类所实现的抽象是什么
  • 提供成对的服务:例如开和关,添加和删除,但一定要考虑是否真的需要,不要盲目创建相反的操作
  • 把不相关的信息转移到其他类
  • 尽可能让接口可编程,而不是表达语义:可编程的部分能被编译器检查,但语义部分是由“本接口将会被怎样使用”的假定组成(比如“ RoutineA必须在RoutineB之前被调用”或“如果dataMember未经初始化就传给RoutineA的话,将会导致RoutineA崩溃”)
  • 谨防在修改时破坏接口的抽象:在对类进行修改和扩展时要特别注意
  • 不要添加与接口抽象不一致的公用成员
  • 同时考虑抽象性和内聚性

1.2.2 良好的封装

  • 尽可能地限制类和成员的可访问性
  • 不要公开暴露成员数据:使用get、set访问器来访问
  • 避免把私用的实现细节放入类的接口中(特指C++):应尽量避免在类的头文件中查看private的内容。《Efective C++》第2版第34条建议,把类的接口与类的实现隔离开,并在类的声明中包含一个指针,让该指针指向类的实现,但不能包含任何其他实现细节。
  • 避免使用友元类(特指C++)
  • 不要因为一个子程序里仅使用公用子程序,就把它归入公开接口
  • 不要对类的使用者做出任何假设,要格外警惕从语义上破坏封装:每当你发现自己是通过查看类的内部实现来得知该如何使用这个类的时候,你就不是在针对接口编程了,而是在透过接口针对内部实现编程了

1.3 有关设计和实现类的问题

通常类和类之间的关系,有“包含”和“继承”两种。继承需要更多的技巧,而且更容易出错,包含才是面向对象编程中的主力技术。以下是一些关于包含和继承技术的参考原则:

  • 警惕包含超过约7个数据成员的类
  • 要么使用继承并进行详细说明,要么就不要用它:如果某个类并未设计为可被继承,要明确声明不可被继承
  • 遵循里氏替换原则
  • 确保只继承需要继承的部分:注意父类方法的默认实现和子类方法的覆盖(override)性
  • 不要“覆盖”一个不可覆盖的成员函数:换种说法,即子类方法不要和父类的private方法同名
  • 把共用的接口、数据及操作放到继承树中尽可能高的位置
  • 只有一个实例的类是值得怀疑的:可以考虑用单例模式
  • 只有一个派生类的基类也值得怀疑:不要“提前设计”任何非绝对必要的继承结构
  • 派生后覆盖了某个子程序,但在其中没做任何操作,这种情况也值得怀:这表明基类的设计有错误
  • 避免让继承体系过深
  • 尽量使用多态,避免大量的类型检查:警惕频繁重复出现的switch case语句
  • 避免创建万能类/避免用动词命名的类/消除无关紧要的类

关于类的数据和方法(包括构造函数)的参考原则:

  • 让类中子程序的数量尽可能少
  • 禁止隐式地产生你不需要的成员函数和运算符
  • 减少类所使用的不同子程序的数量
  • 对其他类的子程序的间接调用要尽可能少
  • 如果可能,应该在所有的构造函数中初始化所有的数据成员
  • 用private构造函数来强制实现单例属性
  • 优先采用深拷贝,除非论证可行才使用浅拷贝

核对表:类的质量

2. 高质量的子程序

2.1 创建子程序的理由

创建子程序的理由包括但不限于:降低复杂度,引入中间的、易懂的抽象,避免代码重复,支持子类化,隐藏顺序,隐藏指针操作,提高可移植性,简化复杂的逻辑判断,改善性能。同样很多创建类的理由也是创建子程序的理由。

注意一些过于简单的看上去似乎没必要写成子程序的操作,写一个只有两三行代码的子程序看起来有些大才小用,经常会成为心理障碍。但实际上小的子程序有许多优点,可以看看以下这个例子。

/* 不使用子程序:经常可以看到以下代码出现在十几处地方 */
// 从设备单位(device unit)到磅数(point)的转换计算
points = deviceUnits * (POINTS_PER_INCH / DeviceUnitsPerInch());

/* 使用子程序:不仅更具可读性(甚至达到自我注解的程度),可以更易于维护和测试 */
int DeviceUnitsToPoints(int deviceUnits) {
    if (DeviceUnitsPerInch() != 0)
        return deviceUnits * (POINTS_PER_INCH / DeviceUnitsPerInch();
    else
        return 0;
}
points = DeviceUnitsToPoints(deviceUnits);

2.2 好的子程序名字

一个子程序由名字、参数列表和程序体组成。一个好的名字能清晰地描述子程序所做的一切,是一个好的子程序的起点。子程序命名应该遵循如下原则:

  • 描述子程序所做的所有事情
  • 避免使用无意义的、模糊或表述不清的动词:像HandlePerformProcess等等动词都没有精确地描述操作,例如把HandleOutput()改为FormatAndPrintOutput()就能更容易看清楚子程序的功能。
  • 不要仅通过数字来形成不同的子程序各字:不要出现像OutputUser1OutputUser2子程序然后将它们组成一个大程序的情况。
  • 根据需要确定子程序名字的长度:变量名的最佳长度是9到15个字符,而子程序要更复杂些,长短要视该名字是否清晰易懂而定。
  • 给函数命名时要对返回值有所描述:customerid.Next()printer.IsReady()pen.CurrentColor()等都是不错的例子。
  • 给过程起名时使用语气强烈的动词加宾语的形式:如PrintDocument()CheckOrderInfo()。对于面向对象语言是特例,因为对象(宾语)本身已经包含在调用语句中了,如document.Print()
  • 为常用操作确立命名规则:在实际项目中,约定一套语义命名规则。避免出现不同人写出像employee.id.Get()employee.GetId()employee.id()这样难以记住的细节。
  • 准确使用对仗词:有助于保持一致性和可读性
    • add/removeopen/closebegin/endinsert/deleteshow/hidecreate/destroysource/targetfirst/lastmin/maxstart/stopget/setnext/previous

2.3 写好子程序的参数

  • 按照输入-修改-输出的顺序排列参数:不要随机地或按字母顺序排列参数
  • 考虑自己创建IN和OUT关键字(特指C++):定义无值的宏扩展C++语言,在项目中要极其谨慎
  • 如果几个子程序都用了类似的一些参数,应该让这些参数的排列顺序保持一致:如C语言中的fprintf()/printf()strncpy()/memcpy()
  • 使用所有的参数:既然往子程序中传递了一个参数,就一定要用到这个参数。使用条件编译而忽略了某一些参数的情况除外。
  • 把状态或出错变量放在最后
  • 不要把子程序的参数用做工作变量:不要修改任何输入参数,应明确引入一些中间的工作变量
  • 在接口中对参数的假定加以说明:用注释甚至断言,说明参数的单位、状态或错误值的含义、数值的范围、不该出现的特定数值等等
  • 把子程序的参数个数限制在大约7个以内:若传参过多,说明子程序之间的耦合过紧,应重新设计子程序
  • 考虑对参数采用某种表示输入、修改、输出的命名规则:如i_、m_、Output_等前缀
  • 为子程序传递用以维持其接口抽象的变量或对象:考虑传入的是若干底层数据,还是一个包装对象
  • 确保实际参数与形式参数的类型相匹配:注意传入参数时可能发生的隐式类型转换

2.4 子程序的内容

关于子程序的长度没有定论。与其对子程序的长度强加限制,还不如让下面这些因素——如子程序的内聚性、嵌套的层次、变量的数量、决策点的数量、解释子程序用意所需的注释数量以及其他些跟复杂度相关的考虑事项等——来决定子程度的长度。但是,这里要引用一下《代码整洁之道》3.1节“短小”中的内容来辅助参考子程序应该达到的长度。我个人总结,决定子程序的长短的唯一标准,是一个函数只做一件事

……经过漫长的试错,经验告诉我,函数就该小……函数也不该有100行那么长,20行封顶最佳……每个函数都一目了然。每个函数都只说一件事。而且,每个函数都依序把你带到下一个函数。这就是函数应该达到的短小程度!……通常来说,应该短于代码清单3-2中的函数(该函数有10行)……

在子程序的设计上,应该注重其内聚性,即子程序中各种操作之间联系的紧密程度。我们的目标是让每个子程序只把一件事做好,不再做任何其他事情。理解下面几个关于内聚性层次的概念有助于思考如何让子程序尽可能地内聚。

  • 最好的内聚
    • 功能的内聚性:如GetCustomerName()EraseFile(),前提是子程序所执行的操作与其名字相符。
  • 不够理想的内聚
    • 顺序上的内聚性:子程序内包含有需要按特定顺序执行的操作,这些步骤需要共享数据,而且只有在全部执行完毕后才完成了一项完整的功能。
    • 通信上的内聚性:一个子程序中的不同操作使用了同样的数据,但不存在其他任何联系。
    • 临时的内聚性:含有一些因为需要同时执行才放到一起的操作的子程序,如Startup()中塞进一堆互不相关的初始化代码,应该把临时性的子程序看做是一系列事件的组织者,并去调用其他子程序。
  • 不可取的内聚
    • 过程上的内聚性:一个子程序中的操作是按特定的顺序进行的。
    • 逻辑上的内聚性:若干操作被放入同一个子程序中,通过传入的控制标志(由if/else或switch/case控制)选择执行其中的一项操作。这种情况下子程序唯一的功能应该是发布各种命令调用底层子程序,其自身并不做任何处理。
    • 巧合的内聚性:指子程序中的各个操作之间没有任何可以看到的关联。

核对表:高质量的子程序

参考文献:电子工业出版社《代码大全(第2版)》第6、7章

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器