chapter 4 设计与声明
item19:设计class犹如设计type
事实上,设计每个类都需要面临以下几个问题,so,在定义一个新类型之前,最好把这些问题想清楚。
1)新的类如何被创建和销毁。这就涉及到类的构造函数和析构函数,以及它申请内存和释放内存的函数。比如说new和delete operator
2)对象的初始化Initialization和对象的赋值assignment有什么区别。这个问题决定了构造函数和赋值操作符之间的区别。一定不要把初始化和赋值搞混淆了,因为他们是由不同的函数来实现的。详见item4.
3)新的对象如果是以值传递(pass by value) ,意味着什么。因为拷贝构造函数决定了一个类型的以值传递该怎么实现。
4)什么是新类型的合法限制。对类的成员变量而言,通常只有某些值的组合是有效的。这些组合决定了你的类必须去维护的不变量。而这些不变量决定了在成员函数中必须要做错误检查。特别是在构造函数,复制函数和setter函数。这可能会影响函数抛出异常,以及函数异常明细列(exception specifications)(极少被使用,我也没搞懂)。
5)你新建的类需要配合(fit into)一个继承图系(inheritance graph)么?如果你的类是继承了一个已有的类,那你就被已有的这些类的设计限制了,特别是它们的函数是虚函数或者不是虚函数。如果你希望允许其它的类来继承你的类,那就决定了你定义的虚函数是否应该是虚函数,特别是析构函数,最好定义为虚函数,否则会导致部分析构。详见item7。
6)你的类需要什么样的类型转换。你的类是在类型的海洋中存在,因此你的类与其它的类应该有转化行为么。如果你希望你的类允许类型T1被隐式的转换成类型T2,那就必须在T1中写一个类型转换函数(比如说operator T2)或者在T2中写一个non-explicit-one-argument构造函数。如果你只希望有显式的转换,那就得写一个函数去实现这个转换,并且不能为类型转换操作符(type conversion operators)或non-explicit-one-argument构造函数。详见item15。
7)什么样的操作符和函数对新类型而言是有意义的。这就决定了你需要为你的类声明哪些函数。其中一些事成员函数,另一下未必。详见item23,24,46。
8)什么样的标准函数是不被允许的。这就说明哪些函数是你应该声明为private的。详见item6。
9)谁具有你新类型成员的访问权。这个问题帮助你决定哪些变量应该声明为public,哪些为protect,哪些为private。这也决定啦哪些类或者函数可以成为友元,或者说它们嵌套于另一个之内是否合理。
10)你的新类型未声明的接口(undeclared interface)是什么。它对效率、异常安全性(详见item29)以及资源运用(多任务锁定和动态内存)提供了何种保证?你在这方面提供的保证将为你的类的代码实现加上相应的限制条件。
11)你的新类型有多一般化。也许你并没有真正定义一个新的类型。只是定义了一整个类型家族。如果你不想定义一个新类型,那就定义一个新的类型模板。
12)你真的需要一个新type么?如果你定义了一个派生类,只是想为已有的类添加一些功能。那么你只需定义一些非成员变量或者模板就可以达到你的目标。
item20:以pass-by-reference-to-const替换pass-by-value
1)pass-by-value在参数进入函数的时候,函数会自动copy一份参数,这需要传递参数的时候调用参数的构造函数,在函数返回的时候调用对应的析构函数。而调用端得到的也是函数返回值的一个复件。比如说:
如果是pass-by-value,那么函数在调用的时候需要调用Student和Person的copy构造函数的析构函数一次,调用string的copy构造函数很析构函数四次。这肯定不是我们想要的,但是pass-by-reference-to-const就没有这个问题,并且也可以保证对象被可靠的初始化和销毁了。这里没有调用任何构造函数和析构函数,因为这里没有创建新对象。
PS:这个const在这里很重要。如果是pass-by-value,那么用户知道不能改变参数的值,即使有,也只是改变其副本而已。而reference就可以轻易被改变,所以声明为const就不用担心它在函数中被改变了。
2)pass-by-reference还可以避免对象切割(slicing)问题。当一个派生类对象当做一个基类对象被传递(by value),这时调用基类的copy构造函数,那么派生类的一些特有的性质就被切掉了,构造出来的就只是一个基类的对象。比如说:
class Window {
public:
...
std::string name() const; // return name of window
virtual void display() const; // draw window and contents
};
class WindowWithScrollBars: public Window {
public:
...
virtual void display() const;
};
如果现在需要写一个函数来打印窗口的name,然后显示窗口,下面的写法就是错误的。
void printNameAndDisplay(Window w) // incorrect! parameter
{ // may be sliced!
std::cout << w.name();
w.display();
}
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
像上面主要调用printNameAndDisply会发生什么?注意display是一个虚函数,如果是pass-by-value的话,只是调用了window的copy构造函数,然后产生的是window的对象,所以只会调用window::display而不是windowWithScrollBars::display。
void printNameAndDisplay(const Window& w) // fine, parameter won't
{ // be sliced
std::cout << w.name();
w.display();
}
但是,如果是以引用传参的话,就不会发生上述问题。
PS1:如果是对于built-in type,那选择pass-by-value会更高效一些。这同样适用于STL中的iterator和函数对象,因为习惯上它们都被设计成pass-by-value了。
PS2:由于一般的built-in type都非常小,那是不是小的类型,甚至是用户自己定义的就一定适合于pass-by-value呢?这是一个不可靠的结论。因为一个对象小并不能说明调用它的copy构造函数unexpensive。许多对象,包括STL容器,它包含的只是比一个指针多一些,但是复制它就需要复制所有它指向的东西,这是very expensive的。就算是有inexpensive的copy构造函数的小对象,也有可能有效率问题。一些编译器对待built-in type和用户定义type是不一样的,即使它们的底层表述是一样的。比如说,某些编译器拒绝把只由一个double组成的对象放进缓存器内,却很乐意在一个正规基础上对一个光秃秃的doubles这么做。在这种情况下,就医改用by reference的方式传参,因为编译器当然会将指针(references的实现体)放进缓存器内。
PS3:另外一个小的用户定义的类型不适合pass-by-value的原因是其大小容易发生变化。现在很小的类型,将来有可能会变得很庞大,因为它的内部实现可能会改变。甚至你改用另一个C++编译器都有可能改变type的大小。比如说某些标准的程序库实现版本中的string类型是其它版本七倍那么大。
总结一下:只有built-in type和STL的iterator和函数对象可以用pass-by-value,因为对于它们可以合理的假设它们pass-by-value时inexpensive的。其它的类型都优先选pass-by-reference-to-const。因为它更高效,并且可以避免切断(slicing)问题。