概述
早起的程序员基于语句思考编程问题。八十年代的程序员基于子程序思考编程。进入21世纪,程序员以类为基础思考问题。
类是由一组数据和子程序构成的集合,这些数据和子程序共同拥有一组内聚的,明确定义的职责。类也可以只是由一组子程序构成的集合,这些子程序提供一组内聚的的服务,哪怕其中并未涉及共用的数据。成为高效程序员的一个关键就在于,当你开发程序任一部分的代码时,都能安全忽视程序中尽可能多的其余部分,而类就是实现这一目标的首要工具。
类的基础:抽象数据类型(ADTs)
Benefits of Using ADTs
- 可以隐藏实现细节
- 改动不会影响到整个程序
- 让接口能提供更多信息
- 更容易提高性能
- 让程序正确性更显而易见
- 程序更具有自我说明性
- 无须在程序内到处传递数据
- 你可以像在现实世界中那样操作实体,而不用在底层实现上操作它
更多的ADT示例
- 把常见的底层数据类型创建为ADT并使用这些ADT,而不再使用底层数据类型。
- 把像文件这样的常用对象当成ADT
- 简单的事物也可当做ADT
- 不要让ADT依赖于其存储介质
在非面向对象环境中用ADT处理多份数据事例
- 每次使用ADT服务子程序时都明确地指明实例
- 明确地向ADT服务子程序提供所要用到的数据
- 使用隐含实例(需要倍加小心)
良好的类接口
创建高质量的类,第一步,可能也是最重要的一步,就是创建一个好的接口。这也包括了创建一个可以通过接口来展现的合理的抽象,并确保细节隐藏在抽象背后。
抽象是一种简化的形式来看待复杂操作的能力。类的接口为隐藏在其后的具体实现提供了一种抽象。类的接口应能提供一组明显相关的子程序。
展现良好抽象的类接口
class Employee{
public:
//public constructors and destructors
Employee();
Employee(
FullName name,
String address,
String workPhone,
String homePhone,
TaxId taxIdNumber,
JobClassification jobClass
);
virtual ~Employee();
//public routines
FullName GetName() const;
String GetName() const;
String GetAddress const;
String GetWorkPhone() const;
TaxId GetTaxIdNumver() const;
JobClassification GetJobClassification() const;
...
private:
...
};
在类的内部还可能会有支持这些服务的其他子程序和数据,但类的使用者并不需要了解它们。类接口的抽象能力非常有价值,因为接口中的每个子程序都在朝着这个一致的目标而工作。
不良抽象的类接口
class Program{
public:
...
//public routines
void InitializeCommandStack();
void PushCommand( Command command);
Command PopCommand();
void ShutdownCommandStack()
void InitializeReportFormatting();
void FormatReport(Report report);
void PrintReport();
void InitializeGloalDate();
void ShutdownGlobalData();
...
private:
...
}
这么一个类。其中有很多子程序,有用来操作命令栈,有用来格式化报表的,有用来打印报表的,还有用来初始化全局数据的。在命令栈,报表和全局数据之间很难看出什么联系。类的接口不能展现出一种一致的抽象,因此它的内聚性就很弱。应该把这些子程序重新组织到几个职能更专一的类里去,在这些类的接口中提供更好的抽象。
如果这些子程序是一个叫做Program类的一部分。
class Program {
public:
...
//public routines
void InitializeUserInterface();
void ShutDownUserInterface();
void InitializeReports();
void ShutDownReports();
...
private:
...
};
在清理这一接口时,把原有的一些子程序转移到其他更适合的类里面,而把另一些转为InitalizeUserInterface() 和其他子程序中使用的私有子程序。这种对类的抽象进行评估的方法是基于类所具有的公用(public)子程序所构成的集合——即类的接口。
混杂的抽象层次会让程序越来越难以理解,整个程序也会逐步堕落直到无法维护。
好的抽象
- 类的接口应该展现一致的抽象层次
- 一定要理解类所实现的抽象是什么
- 提供成对的服务
- 把不相关的信息转移到其他类中
- 尽可能让接口可编程,而不是表达语义
- 谨防在修改时破坏接口的抽象
- 不要添加与接口不一致的公有成员
- 同时考虑抽象性和内聚性
良好的封装
封装是一个比抽象更强的概念。抽象通过提供一个可以让你忽略实现细节的模型来管理复杂度,而封装则强制阻止你看到细节——即便你想这么做
- 尽可能地限制类和成员的可访问性
- 不要公开暴露成员数据
- 避免把私用的实现细节放入类的接口中
- 不要对类的使用者做任何假设
- 避免使用友元类
- 不要因为一个子程序仅使用公用子程序,就把它归入公开接口
- 让阅读代码比编写代码更方便
- 要格外警惕从语义上破坏封装性
- 留意过于紧密的耦合关系
有关设计和实现的问题
给类定义合理的接口,对于创建高质量程序起到了关键作用。然而,类内部的设计和实现也同样重要。这一节就来论述关于包含,继承,成员函数和数据成员函数,类之间的耦合性,构造函数,值对象与引用对象等的问题
包含(“有一个……的关系”)
(Containment(“has a” Relationships))
包含是一个非常简单的概念,它表示一个类含有一个基本数据元素或对象。与包含相比,关于继承要比包含更好。包含才是面向对象编程的主力技术。
- 通过包含来实现”有一个/has a”的关系
- 在万不得已时通过private继承来实现“有一个”的关系
- 警惕超过约7个数据成员的类
继承 (“是一个…..”关系)
(Inheritance (“is a” Relationships))
继承的概念是一个类是另一个类的一种特化(specialization)。继承的目的在于,通过“定义能对两个或更多派生类提供共有元素的基类”的方式写出更精简的代码。其中的共有元素可以是子程序接口,内部实现,数据成员或数据类型等。继承能把这些共有的元素集中在一个基类中,从而有助于避免在多出出现重复的代码和数据。
当决定使用继承时,你必须要做如下几项决策
- 对于每一个成员函数而言,它应该对派生类可见吗?它应该有默认的实现吗?这一默认的实现能被覆盖吗?
- 对于每一个数据成员而言(包括变量,具名常量,枚举等),它应该对派生类可见吗?
详细考虑如下:
- 用public继承来实现“是一个……”的关系
- 要么使用继承并进行详细说明,要么不用它
- 遵循Liskov替换原则(Liskov Substitution Principle LSP),总结为:“派生类必须通过基类的接口而被使用,且使用者无须了解两者这件的差异。”换句话说,对于基类中定义的所有子程序,用在它的任何一个派生类中时的含义都应该是相同的
- 确保只继承需要继承的部分
- 不要“覆盖”一个不可覆盖的成员函数
- 把共用的接口,数据及操作放到继承树中尽可能的位置
- 只有一个实例的类是值得怀疑的
- 只有一个派生类的基类也值得怀疑
- 派生后覆盖了某个子程序,但在其中没有任何操作,这种情况也值得怀疑
- 避免继承体系过深
- 尽量使用多态,避免大量的类型检查
- 让所有数据都是private(而非protected)
多重继承
继承是一种强大的工具,就像用电锯取代收据伐木一样,当小心使用时,它非常有用,但在还没能了解应该注意的事项的人手中,它会变得非常危险。“如果继承比做电锯,那么多重继承就是20世纪50年代的那种既没有防护罩,也不能自动停机的危险电锯”。
就我个人而言,多重继承的用途主要是定义“混合体”,也就是一些能给对象增加一组属性的简单类。之所以称其为混合体,也就是一些能给对象增加一组属性的简单类。之所以称其为混合体,是因为它们可以把一些属性”混合”到派生类里面。“混合体”可以是形如Displayable(可显示),Persistant(持久化),Serializable(可序列化),或Sortable(可排序)这样的类。
为什么有这么多关于继承的规则
这一节给除了许多规则,它们能帮你远离与继承相关的麻烦。所有这些规则背后的潜台词都是在说,继承往往会让你和程序员的首要技术使命(即管理复杂度)背道而驰。下面来总结一下何时使用继承,何时又该使用包含:
- 如果多个类共享数据而非行为,应该创建这些类可以包含的共用对象
- 如果多个类共享行为而非数据,应该让它们从共同的基类继承而来,并在基类里定义共有的子程序
- 如果多个类既有共享数据也共享行为,应该让它们从一个共同的基类继承而来,并在基类里定义共用的数据和子程序
- 当你想由基类控制接口时,使用继承;当你想自己控制接口时,使用包含
成员函数和数据成员
- 让类中子程序的数量尽可能少
- 禁止隐式地产生你不需要的成员函数和运算符
- 减少类所调用的不同子程序的数量
- 对其他类的子程序的简接调用要尽可能少
- 一般来说,应尽量减少类和类之间相互合作的范围
构造函数
- 如果可能,应该在所有的构造函数中初始化所有的数据成员
- 用私有构造函数来强制实现单件属性
- 优先采用深拷贝,除非论证可行,才采用浅拷贝
创建类的原因
- 为了现实世界中对象建模
- 为抽象的对象建模
- 降低复杂度
- 隔离复杂度
- 隐藏实现细节
- 限制变动的影响范围
- 隐藏全局数据
- 让参数传递更顺畅
- 建立中心控制点
- 让代码更易于重用
- 为程序族做计划
- 把相关操作包装到一起
- 实现某种特定的重构
避免的类
- 避免创建万能类
- 消除无关紧要的类
- 避免用动词命名的类
与具体语言相关的问题
超越类:包
类是当前程序员实现模块化的最佳方式。不过模块化是个很庞大的话题,其影响范围远远超过类。过去几十年间,软件开发的进行在很大程度上要归功于我们编程时进行工作的粒度的增长。首先是语句,这在当时算得上是自机器指令以来迈进的一大步。接下来就是子程序,再后来则是类。
很显然,如果我们能有更好的工具来把对象聚合起来,我们就可能更好地朝着抽象和封装的目标迈进。