07良好的类接口
1. 好的抽象
1.1 类的接口应该展现一致的抽象层次
在考虑类的时候有一个很好地办法,就是把类看做一种用来实现抽象数据类型的机制。每一个类应该实现一个 ADT,并且仅实现这个 ADT。如果你发现某个类实现了不止一个ADT,或者你不能确定究竟它实现了何种 ADT,你就应该把这个类重新组织为一个或多个更加明确的 ADT。
如果把类的公用子程序看中是潜水艇上用来防止进水的气锁阀,那么类中不一致的公用子程序就相当于是漏水的仪表盘。这些漏水的仪表盘可能不会让水像打开气锁阀那样迅速进入,但只要有足够的时间,他们还是能让潜水艇沉没。实际上,这就是混杂抽象层的后果。在修改程序时,混杂的抽象层次会让程序越来越难理解,整个程序也会逐渐堕落直到变得无法维护。
1.2 一定要理解类所实现的抽象是什么
一些类非常相像,你必须非常仔细地理解类的接口应该捕捉的抽象到底是哪一个。我曾经开发过这样一个程序,用户可以用表格的形式来编辑信息。我们想用一个简单的栅格控件,但它却不能给数据输入单元格换颜色,因此我们决定用一个能提供这一功能的电子表格控件。
电子表格控件要比栅格控件复杂得多,它提供了150个子程序,而栅格控件只有15个。由于我们的目标是使用一个栅格控件而不是电子表格控件,因此我们让程序员写一个包裹类,隐藏起“把电子表格控件用作栅格控件”这一事实。这位程序员强烈抱怨,认为这样做是毫无必要地增加成本,是官僚作风,然后就走了。几天以后,他带来了写好的包裹类,而这个类竟然忠实地把电子表格控件所拥有的全部150个子程序都暴露出来了。
这并不是我们想要的。我们要的是一个栅格控件接口,这个接口封装了“肥厚实际上是在用一个更为复杂的电子表格控件”的事实。那位程序员应该只暴露那15个栅格控件的子程序,再加上第16个支持设置单元格颜色的子程序。他把全部150个子程序都暴露出来,也就意味着一旦想要修改底层实现细节,我们就得支持150个公用子程序。这位程序员没有实现我们所需要的封装,也给他自己带来了大量无畏的工作。
根据具体情况的不同,正确的抽象可能是一个电子表格控件,也可能是一个栅格控件。当你不得不在两个相似的抽象之间做出选择时,请确保你的选择时正确的。
1.3 提供成对的服务(参考亚控命名规范)
大多数操作都有和其相应的、相等的以及相反的操作。如果有一个操作用来把灯打开,那很可能也需要另一个操作来把灯关闭。如果有一个操作来向列表中添加项目,那很可能也需要另一个操作来从列表中删除项目。如果有一个操作用来激活菜单项,那很可能也也需要另一个操作来屏蔽菜单项。在设计一个类的时候,要检查每一个公用子程序,决定是否需要另一个与其互补的操作。不要盲目地创建相反操作,但你一定要考虑,看看是否需要它。
1.4 把不相关的信息转移到其他类中
有时你会发现,某个类中一半子程序使用着该类的一般数据,而另一半子程序使用另一半数据。这时你其实已经把两个类混在一起使用了,把他们拆开吧!
1.5 尽可能让接口可编程,而不是表达语义
每个接口都有一个可编程的部分和一个语义部分组成。可编程的部分由接口中的数据类型和其他属性构成,编译器能强制性地要求它们(在编译时检查错误)。而语义部分则由“本接口将会被怎样使用”的假定组成,而这些事无法通过编译器来强制实施的。语义接口中包含的考虑比如“ RoutineA 必须在 RoutineB之前被调用”或“如果 dataMember 未经初始化就传给 RoutineA 的话,将会导致 RoutineA 崩溃”。语义接口应通过注释说明,但要尽可能让接口不依赖于这些说明。一个接口中任何无法通过编译器强制实施的部分,就是一个可能被误用的部分。要想办法把语义接口的元素抓换为编程接口的元素,比如说用 Asserts 或其他的技术。
1.6 谨防在修改时破坏接口的抽象
在对类进行修改和扩展的过程中,你常常会发现额外所需的一些功能。这些功能并不十分适应原有的类接口,可看上去却也很难用另一种方法来实现。举例来说,你可能会发现 Employee 类演变成了下面这个样子。
//在维护时被破坏的类接口
class Employee{
public:
FullName GetName() const;
Address GetAddress() const;
PhoneNumber GetWorkPhone() const;
...
bool IsJobClassificationValid(JobClassification jobClass);
bool IsZipCodeValid(Address address);
bool IsPhoneNumberVaild(PhoneNumber phoneNumber);
SqlQuery GetQueryToCreateNewEmployee() const;
SqlQuery GetQueryToModifyEmployee() const;
SqlQuery GetQueryToRetrieveEmployee() const;
...
private:
...
};
前面代码示例中的清晰抽象,现在已经变成了由一些零散功能组成的大杂烩。在雇工和检查邮政编码、电话号码或职位的子程序之间并不存在什么逻辑上的关联,那些暴露 SQL 语句查询细节的子程序所处的抽象成比 Employee 类也要低得多,他们都破坏了 Employee 类的抽象。
1.7 不要添加与接口抽象不一致的公用成员
每次你向类的接口中添加子程序时,问问“这个子程序与现有接口所提供的抽象一致吗?”如果发现不一致,就要换另一种方法来进行修改,以便能够保证抽象的完整性。
1.8 同时考虑抽象性和内聚性
抽象性和内聚性这两个概念之间的关系非常紧密 —— 一个呈现出很好的抽象的类接口通常也有很高的内聚性。而具有很强内聚性的类往往也会呈现为很好地抽象,尽管这种关系并不如前者那么强。
我发现,关注类的接口所表现出来的抽象,比关注类的内聚性更有助于深入地理解类的设计。如果你发现某个类的内聚性很弱,也不知道该怎么改,那就换一种方法,问问你自己这个类是否表现为一直的抽象。
2. 良好的封装
封装是一个比抽象更强的概念。抽象通过 提供一个可以让你忽略实现细节的模型来管理复杂度,而封装则强制阻止你看到细节 —— 即便你想这么做。
这两个概念之所以相关,是因为没有封装时,抽象往往很容易被打破。依我的经验,要么就是封装与抽象两者皆有,要么就是两者皆失。除此之外,没有其他可能。
2.1 尽可能地限制类和成员的可 访问性
让可访问性尽可能低是促成封装的原则之一。当你在犹豫某个子程序的可访问性设为公有、私有亦或受保护时,经验之举是应该采用最严格且可行的访问级别。我认为这是一个很好地知道建议,但我认为还有更重要的建议,即考虑“采用哪种方式能最好地保护接口抽象的完整性?”如果暴露一个子程序不会让抽象变得不一致的话,这么做很可能是可行的。如果你不确定,那么隐藏通常比少隐藏要好。
2.2 不要公开暴露成员数据
暴露成员数据会破坏封装性,从而限制你对这个抽象的控制能力。一个 Point 类如果暴露了下面这些成员的话:
float x;
float y;
float z;
他就破坏了封装性,因为调用方代码可以自由地使用 Point 类里面的数据, 而 Point 类却甚至连这些数据什么时候被改动过都不知道。然而,如果 Point 类暴露的时这些方法的话:
float GetX();
float GetY();
float GetZ();
void SetX(float x);
void SetY(float y);
void SetZ(float z);
那它还是封装完好的。你无法得知底层实现用的是不是 float x、y、z,也不会知道 Point 是不是把这些数据保存为 double 然后再转换成 float,也不可能知道 Point 是不是把它们保存在月亮上,然后再从外层空间中的卫星上把它们找回来。
2.3 避免把私用的实现细节放入类的接口中
做到真正的 封装以后,程序员们是根本看不到任何实现细节的。无论是在字面上还是在喻义上,它们都被隐藏起来。然而,包括 C++ 在内的一些流行编程语言却从语言结构上要求程序员在类的接口中透漏实现细节。
//暴露了类内部实现细节
class Employee{
public:
...
Employee(
FullName name,
String address,
String workPhone,
String homePhone,
TaxId taxIdNumber,
JobClassification jobClass
);
...
private:
String m_Name;
String m_Address;
int m_jobClass;
...
};
把 private 段的声明放到类的头文件中,看上去似乎只是小小地违背了原则,但它实际是在鼓励程序员查阅实现细节。在这个例子中,客户代码本意是要使用 Address 类型来表示地址信息,但头文件中却把“地址信息用 String 来保存”的这一实现细节暴露了出来。
Scott Meyers 在《Effective C++》一书第 2 版中的第 34 条里介绍了可以解决这个问题的一个管用技法。他建议你把类的接口与类的实现隔离开,并在类的声明中 包含一个指针,让该指针指向类的实现,但不能包含任何其他实现细节。
//隐藏了类的实现细节
class Employee{
public:
...
Employee( ... ));
FullName GetName() const;
Address GetAddress() const;
...
private:
EmplyeeImplementation* m_implementation;
};
现在你就可以把实现细节放到 EmplyeeImplementation 类里了,这个类只对 Employee 类可见,而对使用 Employee 类的代码来说则不可见。
如果你已经在项目里写了很多没有采用这种方法的代码,你可能会觉得把大量的现有代码改成使用这种方法是不值得的。但是当你读到那些暴露了其实现细节的代码时,你就应该顶住诱惑,不要到类接口的私用部分去寻找关于实现细节的线索。
2.4 不要对类的使用者做出任何假设
类的设和实现应该符合在类的接口中所隐含的契约。它不应该对接口会被如何使用或不会被如何使用做出任何假设 —— 除非在接口中有过明确说明。向下面这样一段注释就显示出这个类过多地假定了它的使用者。
//请把 x , y 和 z 初始化为 1.0,因为如果把它们初始化为 0.0 的话,DerivedClass 就会崩溃。
2.5 避免使用友元类
有些场合下,比如说 State 模式中,按照正确的方式使用友元类会有助于管理复杂度。但在一般情况下友元类会破坏封装,因为它让你在同一时刻需要考虑更多的代码量,从而增加了复杂度。
2.6 不要因为一个子程序里仅使用公开子程序,就把他归入公开接口
一个子程序仅仅使用公用的子程序这一事实并不是十分重要的考虑因素。相反,应该问的问题是,把这个子程序暴露给外界后,接口所展示的抽象是否还是一致的。
2.7 让阅读代码比编写代码更方便
阅读代码的次数要比编写代码多得多,即使在开发的初期也是如此。因此,为了让编写代码更方便而降低代码的可读性是非常不经济的。尤其是在创建类的接口时,即使某个子程序与接口的抽象不很相配,有时人们也往往把这个子程序加入到接口里,从而让正开发的这个类的某处调用代码能更方便地使用它。然而,这段子程序的添加正式代码走下坡路的开始,所以还是不要走出这一步为好。
2.8 要格外警惕从语义上破坏封装性
我曾经认为,只要学会避免语法错误,就能稳操胜券。然而很快我就发现,学会避免语法错误仅仅是个开始,接踵而来的时无以计数的编码错误,而其中大多数错误都比语法错误更难于诊断和更正。
比较起来,语义上的封装性和语法上的封装性二者的难度相差无几。从语法的角度说,要想避免窥探另一个类的内部实现细节,只要把它内部的子程序和数据都声明为 private 就可以了,这是相对容易办到的。然而,要想达到语义上的封装性就完全是另一码事儿了。下面是一些类的调用方代码从语义上破坏其封装性的例子。
-
不去调用 A 类的 InitializeOperations() 子程序,因为你知道 A 类的 PreformFirstOperation() 子程序会自动调用它。
-
不再调用 employee.Retrive(database) 之前去调用 database.Connect() 子程序,因为你知道在未建立数据库连接的时候 employee.Retrive() 会去连接数据库。
-
不去调用 A 类的 Terminate() 子程序,因为你知道 A 类的 PreformFinalOperation() 子程序已经调用它了。
-
即便在 ObjectA 离开作用域之后,你仍去使用有 ObjectA 创建的、指向 ObjectB 的指针或引用,因为你知道 ObjectA 把 ObjectB 放置在静态存储空间中了,因此 ObjectB 肯定还可以用。
-
使用 ClassB.MAXIMUM_ELEMENTS 而不用 ClassA.MAXIMUM_ELEMENTS,因为你知道它们两个值是相等的。
上面这些例子的问题都在于,它们让调用方代码不依赖与类的公开接口,而是依赖于类的私有实现。每当你发现自己是通过查看类的内部实现来得知该如何使用这个类的时候,那就不是在针对接口编程,而是在透过接口针对内部实现编程了。如果你透过接口来编程的话,封装性就被破坏了,而一旦封装性开始遭到破坏,抽象能力也就快遭殃了。
如果仅仅根据类的接口文档还是无法得知如何使用一个类的话,正确的做法不是拉出这个类的源代码,从中查看其内部实现。这是个好的初衷,但却是个错误的决断。正确的做法应该是去联系类的作者,告诉他“我不知道该怎么使用这个类。”而对于类的作者来说,正确的做法不是面对面告诉你答案,而是从代码库中 check out 类的接口文件,修改类的接口文档,再把文件 check in 回去,然后告诉你“看看现在你知不知道该怎么用它了。”你希望让这一次对话出现在接口代码里,这样就能留下来让以后的程序员也能看到。你不希望让这一次对话值存在于自己的脑海里,这样会给使用该类的调用方代码烙下语义上额微妙依赖性。你也不想然这一次对话只在个人之见进行,这样只能让你的代码获益,而对其他人没有好处。
2.9 留意过于紧密的耦合关系
“耦合”是指两个类之见关联的紧密程度,通常,这种关系越松越好,根据这一概念可以得出以下一些指导建议:
- 尽可能地限制类和成员的可访问性。
- 避免友元类,因为它们之间是紧密耦合的。
- 在基类中把数据声明为 private 而不是 protected,以降低派生类和基类之间耦合的程度。
- 避免在类的公开接口中暴露成员数据。
- 要对从语义上破坏封装性保持警惕。
- 察觉“ DEmeter 法则”
耦合性与抽象性和封装性有着非常密切的联系。紧密的耦合性总是发生在抽象不严谨或封装性遭到破坏的时候。如果一个类提供了一套不完整的服务,其他的子程序就可能要去直接读写该类的内部数据。这样一来就把类给拆开了,把它从一个黑盒子变成一个玻璃盒子,从而事实上消除了类的封装性。