类可以被认为是 一组数据 + 一组操作数据的方法 + 继承 + 多态. 根据上一文提到 为什么要学面向对象编程, 抽象数据类型(ADT) 又是什么? ADT 其实就是数据 + 操作的集合.
因此考虑类的一种方式, 就是把他看作是抽象数据类型再加上继承和多态. <<代码大全(第二版)>>
前置知识
介于某些小伙伴还不了解什么是 继承 和 多态. 我在这里做一些简单的介绍, 便于后续我们继续下去.
再面向对象式编程中, 有三大特点, 他们分别是 封装, 继承, 和 多态.
继承
继承是一种机制, 能够让某个类在不复制代码的情况下复用代码. 在考虑是否使用继承时, 我们应该考虑 "is a" 是一个 的关系. 例如以下代码:
class Shape {
public:
double area();
private:
...
};
class Circle : public Shape {
public:
double area() {
...
}
};
class Square : public Shape {
public:
double area() {
...
}
};
Circle 和 Square 都 是一个 Shape. 我们可以通过继承 Shape 来绑定相同的公共方法. 这些方法在 Circle 和 Square 中适用.
多态
多态是一种机制.也被称为 动态绑定 机制. 即对一个对象操作的调用, 会根据对象本身而产生不同的效果, 例如如下代码中, 调用这个 Animal 对象的 bark() 方法, 会根据这个 Animal 对象是 Dog 类 还是 Cat 类 而产生不同的结果; 如果是 Dog 就会输出 汪汪, 如果是 Cat 就会输出 喵喵
class Animal {
virtual bark();
};
class Dog {
bark() {
std::cout << "汪汪" << std::endl;
}
};
class Cat {
bark() {
std::cout << "喵喵" << std::endl;
}
};
void someFunction(Animal animal) {
// ...
animal.bark(); // 如果是狗输出 "汪汪", 如果是猫输出 "喵喵"
// ...
}
如何评估一个类?
具有一致的抽象
一个好的类应该对外表达出一致的抽象. 抽象是一种以简化的形式看待复杂操作的能力. 比如下面这个 Employee 类:
class Employee {
public:
// public constructors and destructors
Employee();
Employee(
FullName name,
String address,
String workPhone,
String homePhone,
TaxId taxIdNumber,
JobClassification jobClass
);
virtual ~Employee();
// public routes
FullName getName() const;
String getAddress() const;
String getWorkPhone() const;
String getHomePhone() const;
TaxId getTaxIdNumber() const;
JobClassification getJobClassification() const;
...
private:
...
};
该类实现了 雇员 (Employee) 这一个实体. 其中包括雇员的姓名, 电话号码, 住址等数据, 以及一些用来初始化雇员的子程序. 这个类中的所有 公共子程序 都在为同一抽象 (雇员) 而服务.
而一个坏类可能会包含大量混杂的函数, 整个类的抽象层次不一致, 就像下面这个 EmployeeCensus 例子:
class EmployeeCensus: public ListContainer {
public:
...
// public routes
void addEmployee(Employee employee); // 这些子程序的抽象
void removeEmployee(Employee employee); // 都在 "雇员" 这一层次上
Employee nextItemInList(); // 这些子程序的抽象
Employee firstItem(); // 都在 "列表" 这一
Employee lastItem(); // 层次上
...
private:
...
};
简而言之, 一个好的类应该对外表现出 一致的抽象层次. 比如一个巡航控制系统, 其应该暴露的公共子程序应该在 巡航系统 这个抽象层下, 比如 获取当前巡航速度, 设置巡航速度 等. 而不是 更改列表元素 (抽象层次太低), 获取油箱油量 (不是一个抽象元素) 等.
这是另一个 坏类 的例子. 该 Program 类实现了太多混杂的函数, 其中有用来操作命令栈的, 初始化全局数据的, 用来打印报表的, 等等. 在命令栈, 报表和全局数据之间很难看出什么联系. 最好的方法应该是将下面这个类拆分成几个职能更专一的类里面去.
class Program {
public:
// public routes
void initializeCommandStack();
void pushCommand(Command command);
Command popCommand();
void shutdownCommandStack();
void initializeReportFormatting();
void formatReport(Report report);
void printReport(Report report);
void initialzeGlobalData();
void shutDownGlobalData();
...
private:
...
};
在评估一个类是否是一个良好的类时, 请注意我们应该基于类所具有的公共 (public) 子程序所构成的集合 --- 即类的接口, 暴露给开发者使用的那些方法, 是否具有一个一致的抽象. 即时整个类表现出良好的抽象, 类内部的子程序也未必就能个个表现出良好的抽象. 如何设计可以表现出良好抽象的子程序, 你们感兴趣的话, 我可以再写一篇文章来讲讲.
良好的封装性
一个良好的类应该具有良好的封装性. 封装是一个比抽象更加严格的概念. 抽象通过一个简单的模型来隐藏复杂的实现细节, 而封装则是强制阻止你看到细节 ---- 即便你想这么做.
而封装和抽象往往是紧密联系的, 要么两者接得, 要么两者皆失. 除此之外没有其他可能. <<代码大全(第二版)>> (p. 139)
那么什么样的一个类是具有良好的封装的呢? 一个类在不损失其抽象一致性的前提下, 尽可能少的去暴露任何细节. 在实现一个类时, 如果你在犹豫给某个方法的 可访问性 设置为 public, protected 还是 private 时, 经验之举是应该采用最严格且可行的访问级别.
不要暴露任何成员数据. 因为暴露成员数据会破坏封装性, 比如我们有一个 Point 类暴露了以下三个成员数据:
float x;
float y;
float z;
它就失去了封装性. 因为调用方可以随时访问并修改类的成员数据, 而 Point 类却又无法知道什么时候自己的数据被修改, 这是多么可怕的事情啊! 然而如果 Point 类只暴露这些方法的话:
getX();
getY();
getZ();
setX(float x);
setY(float y);
setZ(float z);
调用者没法知道 Point 类内部是如何存储数据的, 是使用的 double 还是 float ? 会不会把传入的 float 转换为 double 再进行赋值? 是不是使用的三个变量来保存坐标, 还是使用列表或是其他方式. 而调用者想要访问和修改成员数据, 只能通过 Point 类暴露的这些方法. 这样确保了 Point 类的内部数据不会在不知情的情况下被修改. 因为这些方法都是 Point 类实现的, 所以里面有什么样的操作由该类自己定义, 就可以保障数据的安全性.
一个好的类应该具有自解释性, 即通过阅读该类的接口, 就可以知道怎么使用这个类, 而无需去了解其内部具体的实现细节. 例如下面的几个例子就是一个没有被很好设计的类. 这个类过多的假设了它的使用者:
-
请把 x, y, 和 z 初始化为 1.0, 因为如果把它们初始化为 0.0 的话, DerivedClass 就会崩溃
-
不要去调用 A 类的
initializeOperations()函数, 因为你知道在 A 类的PerformFirstOperation()方法中会调用 -
不要在调用
employee.Retrive(database)前调用database.Connect(), 因为你知道在未建立数据库连接时employee.Retrive(database)会去连接数据库 -
等等....
上面的案例的问题都在于, 它们让调用方代码不是依赖于类的接口, 而是依赖于类的内部实现. 每当你自己需要查看类的内部实现来知道如何使用这个类的时候, 就说明你需要重新设计一下类的接口了.
如何去设计一个类
在上面我们聊到了应该怎么判断一个类是否是一个好的类: 1) 具有一致的抽象 2) 具有良好的封装. 在设计和实现类的过程中, 可以遵循以下两个关系来创建高质量的类.
Containment ("has a" Relationships) 包含 ("有一个 ..." 的关系)
当你碰到一种情况需要使用某个类的代码时, 通常有两种做法, 第一种是去继承这个类, 这样就可以随意调用这个类中的方法了; 第二种是包含, 将这个类的对象作为一个数据成员包含在你定义的类中.
那么应该怎么判断使用哪一种呢? 以下就是结论:
我们应该通过包含来实现一个 "有一个/has a" 关系
比如一个雇员 "有一个" 家庭地址, "有一个" 税收ID 等等. 你可以让家庭地址, 税收ID作为 Employee 类的数据成员. 从而建立这种关系.
Inheritance ("is a" Relationships) 继承 ("是一个 ..." 的关系)
上面讨论了 包含 应该用于 "有一个" 关系. 如果 A 类有一个 "XXX", 那么我们应该将 "XXX" 包含在 A 类中. 当 A 类是一个 B 类, 比如 Cat 是一个 Animal时, 我们应该使用继承.
继承的概念是说一个类是另一个类的特殊化 (specialization). 继承的目的在于, 通过 "定义能为两个或更多派生类提供共有元素的基类" 的方式写出更精简的代码. <<代码大全(第二版)>> (p. 144)
当使用继承前, 可以思考以下几个问题:
-
对于每一个成员函数而言, 它应该对派生类可见吗? 它应该具有默认的实现吗? 这一默认的实现能被覆盖吗?
-
对于每一个数据成员而言 (包括变量, 类变量, 常量, 枚举等), 它应该对派生类可见吗?
即在继承前要先思考以下父类中的方法和数据对于子类的可见性.
而上面这些话都在提到一个非常重要的类设计原则 Liskov 替换原则 (Liskov Substitution Principle, LSP).
派生类必须能够通过基类的接口而被使用, 且使用者无须了解两者之间的差异. (Hunt and Thomas 2000)
下面举个例子来理解上面的原则. 假设我们有一个 Account 基类以及 CheckingAccount, SavingsAccount 和 AutoLoanAccount 三个派生类, 那么程序员应该能调用三个 Account 派生类中从 Account 类继承来的任何一个方法, 而无需关心到底用的是哪个 Account 的派生类的对象.
如果程序员必须记得, 在调用 CheckingAccount 和 SavingsAccount的 InterestRate()方法时返回的是银行应该付给消费者的利息. 而调用 AutoLoanAccount 中的 InterestRate() 方法时就必须得变号, 因为它返回的是消费者付给银行的利息. 这个时候根据 LSP原则, AutoLoanAccount 类不应该从 Account 类中继承而来, 因为调用者必须得时刻记得 AutoLoanAccount 的 InterestRate() 在调用方法时和同基类中的 InterestRate() 是不同的语义.
上面的例子在代码中的表示如下:
// 因为 AutoLoanAccount 中 InterestRate() 函数的语义不同, 需要单独处理.
// 所以其违背了 LSP 原则, 不适合继承自 Account 类
void process(Account account) {
...
if (account instanceof AutoLoanAccount) {
rate = -account.InterestRate(); // 要变好
} else {
rate = account.InterestRate();
}
...
}
一个良好的遵循 LSP 原则实现的继承, 可以参考各个编程语言中容器类的实现, 这里使用 Java 中的 ArrayList 和 LinkedList 类来举例子.
在 Java 中, ArrayList 和 LinkedList 都继承自 List 接口. 注意这个不是基类, 但是在 Java8 及以后的版本中, 接口也可以实现默认方法. 与其他编程语言中的基类并无太大差异.
其中 List 暴露了以下公共子程序 (这里只展示部分接口)
boolean add(E e);
boolean contains(Object o);
int size();
...
而遵循 LSP 原则实现的继承, 我们可以在一个程序中将 ArrayList 替换为 LinkedList 而不会对程序造成任何影响
void fn(List<E> elements, List<T> another) {
for (int i = 0; i < elements.size(); i++) {
el = elements.get(i);
if (another.contains(el)) {
...
}
...
}
...
}
无需去关注这个函数具体用来干什么, 但是我想告诉你, 你可以随意将 ArrayList 的对象或是LinkedList 类的对象传入这个函数而不造成任何问题. 因为 ArrayList 和 LinkedList 都继承自 List 并且能够调用任何一个继承自基类 List 中的方法而无需担心派生类是否造成了语义上的不同. 这就是 LSP原则.
总结
好了我们聊了那么多, 现在来做一个短小的总结吧.
一个好的类应该具有一致的抽象, 即一个类中包含的方法应该都在同一个抽象层次上操作, 比如方法都是在员工这个层次上进行操作, 而不是列表.
一个好的类应该具有良好的封装性, 不暴露任何内部数据, 暴露的公共方法应该具有一致的抽象.
在设计一个类的时候, 我们应该考虑了两种关系. 如果某个数据和类是 "有一个" 的关系, 那么应该将该数据作为一个成员数据包含在类中. 如果某个类B和类A是 "是一个" 的关系, 那么类B可以继承自类A.
今天聊到的只是这本书中的冰山一角, 我建议大家可以去看看原版书中第六章的内容. 这里仅作为抛砖引玉, 给大家看个乐子. 你们说, 今天聊完了怎么样设计一个和评估一个类. 那么类中的子程序应该怎么设计呢, 一个程序又应该如何重构呢? 大家感兴趣的话, 我再出一期讲如何重构代码的文章, 告诉大家一些重构时的思路和步骤.
Reference
-
<<代码大全(第二版)>> 英文名 (Code Complete)
本文使用 文章同步助手 同步