类和函数:设计与声明
在程序中声明一个新类将导致产生一种新的类型:类的设计就是类型设计。可能你对类型设计没有太多经验,因为大多数语言没有为你提供实践的机会。在C++中,这却是很基本的特性,不是因为你想去做才可以这么做,而是因为每次你声明一个类的时候实际上就在做,无论你想不想做。
设计一个好的类很具有挑战性,因为设计好的类型很具有挑战性。好的类型具有自然的语法,直观的语义和高效的实现。在C++中,一个糟糕的类的定义是无法实现这些目标的。即使一个类的成员函数的性能也是由这些成员函数的声明和定义决定的。
那么,怎么着手设计高效的类呢?首先,必须清楚你面临的问题。实际上,设计每个类时都会遇到下面的问题,它的答案将影响到你的设计。
·对象将如何被创建和摧毁?它将极大地影响构造函数和析构函数的设计,以及自定义的operator new, operator new[], operator delete, 和operator delete[]。(条款M8描述了这些术语的区别)
·对象初始化和对象赋值有什么不同?答案决定了构造函数和赋值运算符的行为以及它们之间的区别。
·通过值来传递新类型的对象意味着什么?记住,拷贝函数负责对此做出回答。
·新类型的合法值有什么限制?这些限制决定了成员函数(特别是构造函数和赋值运算符)内部的错误检查的种类。它可能还影响到函数抛出的例外的种类以及函数的例外规范(参见条款M14),如果你使用它们的话。
·新类型符合继承关系吗?如果是从已有的类继承而来,那么新类的设计就要受限于这些类,特别是受限于被继承的类是虚拟的还是非虚拟的。如果新类允许被别的类继承,这将影响到函数是否要声明为虚拟的。
·允许哪种类型转换?如果允许类型A的对象隐式转换为类型B的对象,就要在类A中写一个类型转换函数,或者,在类B中写一个可以用单个参数来调用的非explicit构造函数。如果只允许显式转换,就要写函数来执行转换功能,但不用把它们写成类型转换运算符和或单参数的非explicit构造函数。(条款M5讨论了用户自定义转换函数的优点和缺点)
·什么运算符和函数对新类型有意义?答案决定了将要在类接口中声明什么函数。
·哪些运算符和函数要被明确地禁止?它们需要被声明为private。
·谁有权访问新类型的成员?这个问题有助于决定哪些成员是公有的,哪些是保护的,哪些私有的。它还有助于确定哪些类和/或函数必须是友元,以及将一个类嵌套到另一个类中是否有意义。
·新类型的通用性如何?也许你实际上不是在定义一个新的类型,而是在定义一整套的类型。如果是这样,就不要定义一个新类,而要定义一个新的类模板。
这些都是很难回答的问题,所以C++中定义一个高效的类远不是那么简单。但如果做好了,C++中用户自定义的类所产生的类型就会和固定类型几乎没什么区别,如果能达到这样的效果,其价值也就体现出来了。
上面每一个问题如果要详细讨论都可以单独组成一本书。所以后面条款中所介绍的准则决不会面面俱到。但是,它们强调了在设计中一些很重要的注意事项,提醒一些常犯的错误,对设计者常碰到的一些问题提供了解决方案。很多建议对非成员函数和成员函数都适用,所以本章节我也考虑了全局函数和名字空间中的函数的设计和声明。
条款18: 争取使类的接口完整并且最小
类的用户接口是指使用这个类的程序员所能访问得到的接口。典型的接口里只有函数存在,因为在用户接口里放上数据成员会有很多缺点(见条款20)。
哪些函数该放在类的接口里呢?有时这个问题会使你发疯,因为有两个截然不同的目标要你去完成。一方面,设计出来的类要易于理解,易于使用,易于实现。这意味着函数的数量要尽可能地少,每一个函数都完成各自不同的任务。另一方面,类的功能要强大,要方便使用,这意味着要不时增加函数以提供对各种通用功能的支持。你会怎样决定哪些函数该放进类里,哪些不放呢?
试试这个建议:类接口的目标是完整且最小。
一个完整的接口是指那种允许用户做他们想做的任何合理的事情的接口。也就是说,对用户想完成的任何合理的任务,都有一个合理的方法去实现,即使这个方法对用户来说没有所想象的那样方便。相反,一个最小的接口,是指那种函数尽可能少、每两个函数都没有重叠功能的接口。如果能提供一个完整、最小的接口,用户就可以做任何他们想做的事,但类的接口不必再那样复杂。
追求接口的完整看起来很自然,但为什么要使接口最小呢?为什么不让用户做任何他们想做的事,增加更多的函数,使大家都高兴呢?
撇开处世原则方面的因素不谈——牵就你的用户真的正确吗?——充斥着大量函数的类的接口从技术上来说有很多缺点。第一,接口中函数越多,以后的潜在用户就越难理解。他们越难理解,就越不愿意去学该怎么用。一个有10个函数的类好象对大多数人来说都易于使用,但一个有100个函数的类对许多程序员来说都难以驾驭。在扩展类的功能使之尽可能地吸引用户的时候,注意不要去打击用户学习使用它们的积极性。
大的接口还会带来混淆。假设在一个人工智能程序里建立一个支持识别功能的类。其中一个成员函数叫think(想),后来有些人想把函数名叫做ponder(深思),另外还一些人喜欢叫ruminate(沉思)。为了满足所有人的需要,你提供了三个函数,虽然他们做同样的事。那么想想,以后某个使用这个类的用户会怎么想呢?这个用户会面对三个不同的函数,每个函数好象都是做相同的事。真的吗?难道这三个函数有什么微妙的不同,效率上,通用性上,或可靠性上?如果没有不同,为什么会有三个函数?这样的话,这个用户不但不感激你提供的灵活性,还会纳闷你究竟在想(或者深思,或者沉思)些什么?
大的类接口的第二个缺点是难以维护(见条款M32)。含有大量函数的类比含有少量函数的类更难维护和升级,更难以避免重复代码(以及重复的bug),而且难以保持接口的一致性。同时,也难以建立文档。
最后,长的类定义会导致长的头文件。因为程序在每次编译时都要读头文件(见条款34),类的定义太长会导致项目开发过程中浪费大量的编译时间。
概括起来就是说,无端地在接口里增加函数不是没有代价的,所以在增加一个新函数时要仔细考虑:它所带来的方便性(只有在接口完整的前提下才应该考虑增加一个新函数以提供方便性)是否超过它所带来的额外代价,如复杂性,可读性,可维护性和编译时间等。
但太过吝啬也没必要。在最小的接口上增加一些函数有时是合理的。如果一个通用的功能用成员函数实现起来会更高效,这将是把它增加到接口中的好理由。(但,有时不会,参见条款M16)如果增加一个成员函数使得类易于使用,或者可以防止用户错误,也都是把它加入到接口中的有力依据。
看一个具体的例子:一个类模板,实现了用户自定义下标上下限的数组功能,另外提供上下限检查选项。模板的开头部分如下所示:
template<class T>
class Array {
public:
enum BoundsCheckingStatus {NO_CHECK_BOUNDS = 0,
CHECK_BOUNDS = 1};
Array(int lowBound, int highBound,
BoundsCheckingStatus check = NO_CHECK_BOUNDS);
Array(const Array& rhs);
~Array();
Array& operator=(const Array& rhs);
private:
int lBound, hBound; // 下限, 上限
vector<T> data; // 数组内容; 关于vector,
// 请参见条款49
BoundsCheckingStatus checkingBounds;
};
目前为止声明的成员函数是基本上不用想(或深思,沉思)就该声明的。一个允许用户确定每个数组上下限的构造函数,一个拷贝构造函数,一个赋值运算符和一个析构函数。析构函数被声明为非虚拟的,意味着这个类将不作为基类使用(见条款14)。
对于赋值运算符的声明,第一眼看上去会觉得目的不那么明确。毕竟,C++中固定类型的数组是不允许赋值的,所以好象也应该不允许Array对象赋值(参见条款27)。但另一方面,数组似的vector模板(存在于标准库——参见条款49)允许vector对象间赋值。在本例中,决定遵循vector的规定,正如下面将会看到的,这个决定将影响到类的接口的其他部分。
老的C程序员看到这个接口会被吓退:怎么竟然不支持固定大小的数组声明?很容易增加一个构造函数来实现啊:
Array(int size,
BoundsCheckingStatus check = NO_CHECK_BOUNDS);
但这就不能成为最小接口了,因为带上下限参数的那个构造函数可以完成同样的事。尽管如此,出于某些目的去迎合那些老程序员们的需要也可能是明智的,特别是出于和基本语言(C语言)一致的考虑。
还需要哪些函数?对于一个完整的接口来说当然还需要对数组的索引:
// 返回可以读/写的元素
T& operator[](int index);
// 返回只读元素
const T& operator[](int index) const;
通过两次声明同一个函数,一次带const一次没有const,就提供了对const和非const Array对象的支持。返回值不同很重要,条款21对此进行了说明。
现在,Array模板支持构造函数,析构函数,传值,赋值,索引,你可能想到这已经是一个完整的接口了。但再看清楚一些。假如一个用户想遍历一个整数数组,打印其中的每一个元素,如下所示:
Array<int> a(10, 20); // 下标上下限为:10到20
...
for (int i = a的下标下限; i <= a的下标上限; ++i)
cout << "a[" << i << "] = " << a[i] << '\n';
用户怎么得到a的下标上下限呢?答案取决于Array对象的赋值操作做了些什么,即在Array::operator=里做了什么。特别是,如果赋值操作可以改变Array对象的上下限,就必须提供一个返回当前上下限值的成员函数,因为用户无法总能在程序的某个地方推出上下限值是多少。比如上面的例子,a是在被定义后、用于循环前的时间段里被赋值的,用户在循环语句中就无法知道a当前的上下限值。
如果Array对象的上下限值在赋值时不能改变,那它在a被定义时就固定下来了,用户就可能有办法(虽然很麻烦)对其进行跟踪。这种情况下,提供一个函数返回当前上下限值是很方便,但接口就不能做到最小。
继续前面的赋值操作可以改变对象上下限的假设,上下限函数可以这样声明:
int lowBound() const;
int highBound() const;
因为这两个函数不对它们所在的对象进行任何修改操作,而且为遵循“能用const就尽量用const”的原则(见条款21),它们被声明为const成员函数。有了这两个函数,循环语句可以象下面这样写:
for (int i = a.lowBound(); i <= a.highBound(); ++i)
cout << "a[" << i << "] = " << a[i] << '\n';
当然,要使这样一个操作类型T的对象数组的循环语句工作,还要为类型T的对象定义一个operator<<函数。(说得不太准确。应该是,必须有一个类型T的operator<<,或,T可以隐式转换(见条款M5)成的其它类型的operator<<)
一些人会争论,Array类应该提供一个函数以返回Array对象里元素的数量。元素的数量可以简单地得到:highBound()-lowBound()+1,所以这个函数不是那么真的必要。但考虑到很多人经常忘了"+1",增加这个函数也不是坏主意。
还有一些其他函数可以加到类里,包括那些输入输出方面的操作,还有各种关系运算符(例如,<, >, ==, 等)。但这些函数都不是最小接口的一部分,因为它们都可以通过包含operator[]调用的循环来实现。
说到象operator<<, operator>>这样的函数以及关系运算符,条款19解释了为什么它们经常用非成员的友元函数而不用成员函数来实现。另外,不要忘记友元函数在所有实际应用中都是类的接口的一部分。这意味着友元函数影响着类的接口的完整性和最小性。