最有价值条款:51
47.以同样的顺序定义和初始化成员变量
这在Inside object model和effective C++里面都讲到了。
成员变量总是按照在类定义中的声明顺序进行初始化,构造函数中的初始化列表的顺序被忽略。这么做的原因是需要确保用唯一的顺序销毁成员。一个成员的初始化尽量不要依赖于其他成员。 如果违反这条规则,很多编译器会发出告警。
48.构造函数中应使用初始化列表而不是赋值。
构造函数悄无声息地生成初始化代码。
实际上构造函数的代码会按照以下代码生成:
A() : s1_(), s2_() {s1_ = "Hello, "; s2_ = "world"; }
也就是说你没有显式初始化的对象将会使用缺省构造函数进行初始化,并调用赋值操作符赋值,通常一个非平凡对象的赋值操作符比构造函数开销大,因此它需要处理一个已经构造好的对象。使用初始化列表使得代码更小,更快。
A() : s1_("Hello, "), s2_("world") {}
例外
在构造函数体但不是初始化列表中获取非托管资源,例如通过new分配内存,其结果并未马上传递给智能指针构造函数。当然,最好不要这么做。
49.避免在析构和构造中调用虚函数
虚函数在构造和析构中并不表现为虚的。从构造和析构中间接或直接调用一个未被实现的纯虚函数会导致行为未定义。
一个基类B和继承于B的类D,在构造D对象时,调用B构造函数,此时处于构造中的对象的动态类型是B,对虚函数B::fun的调用将调用B的版本,不管D是否对fun进行override。这是很自然的事情,在D对象还没有完全构造好之前调用D的函数会导致混乱。
在构造中,B对象无法知道它是单独的对象或者其派生类对象的一部分。
另一方面,有一些设计需要"post-construction",即在构造完成后即刻调用虚函数,以下列出一些实现方法:
1.踢皮球:通过文档记录要求用户代码在构造对象后马上调用post-initialization函数
2.延迟调用post-initialization函数:在第一次调用一个成员函数时调用。用一个boolean标志表示是否已经完成post-construction。
3.使用虚拟基类语义:语言规则规定最低层派生类构造函数可以指定调用哪个基类构造函数。
4.使用工厂函数:可以很容易迫使post-construction函数一定会被调用。
没有一种方法是完美的。
例:使用工厂函数。
这种方法的代价:
1.派生类D必须不能暴露公有构造函数。否则,D的用户可以创建一个D对象而不需调用PostInitialize。
2.内存分配只能局限于operator new。然而B可以override new。
3.D构造函数必须和B定义的使用同样的参数。对Create的重载可以缓解这个问题,或者可以通过参数类型模板化进行重载。
4.如果以上要求能满足,构造任何派生于B的对象都会调用派生类D必须不能暴露公有构造函数。否则,D的用户可以创建一个D对象而不需调用PostInitialize。PostInitialize不必是虚函数。
50.基类的析构应为公有且虚的,或者protected且非虚的
如果通过一个基类指针删除对象,基类的析构必须为公有且虚的,否则应为protected且非虚的。
应为基类编写一个析构函数,因为隐式生成的析构是公有且非虚的。
为了方便,策略类常用于作为基类,而不是为了多态调用,因此推荐将他们的析构设置为protected且非虚的。
例外
有些组件架构(COM, CORBA)不使用常规的删除机制,释放对象采用不同的机制。考虑以下这种少见的情况:
B既是基类也是具体类,它能被实例化,因此析构必须是公有的。而且B没有虚函数,即不多态使用(尽管析构是公有,不必是虚函数)。这种情况下,可以设置析构为公有且非虚,但是必须说明其派生对象必须不能多态使用.std::unary_function就是这么做的。
通常还是应该尽量避免具体基类。unary_function就是一组typedef,它不会单独进行实例化。给他一个公有析构没有意义,更好的设计应该将析构设置为protected和非虚。
51.析构,释放和交换绝对不能失败
析构,资源释放函数(如operator delete)或交换函数不允许报告错误。特别是,C++标准库中绝对禁止使用析构可能抛出异常的类型。
在事务编程的两个关键操作取消和提交中,他们是关键函数,因此必须不能失败。
C++标准的建议和要求:
C++03 15.2(3):如果在堆栈展开时调用的析构由于异常而退出,将调用terminate。因此,析构应捕获异常,不让他们传播到析构之外。
C++03 17.4.4.8(3):C++标准库定义的析构操作不抛出异常(包括用于实例化标准库模板的类型的析构)。
如果你编写一个析构可能失败的类Nefarious,会导致以下结果:
1.Nefarious对象难于在普通函数中安全使用:如果一个作用域可能会因为异常而退出,难于在此作用域中可靠地实例化Nefarious对象。Nefarious对象可能会抛出一个异常,调用std::terminate导致整个程序突然终止。
2.带有Nefarious成员或以Nefarious作为基类的类同样难于安全使用。
3.不能可靠地创建全局或静态的Nefarious对象。
4.不能可靠地创建Nefarious对象数组:对象数组在析构时产生异常,导致行为未定义。编译器无法针对这种情况产生处理代码:设想一下,在构造一个有10个对象的数组时,如果在第四个对象的构造时产生异常,代码应该放弃并在其清理代码中调用已经构造对象的析构,而析构又抛出异常。
5.标准容器中不能使用Nefarious对象。
释放函数,特别是重载的operator delete, operator delete[]也是同样的道理。
除了析构和释放函数,常用的错误安全技术还依赖于绝不失败的交换函数,不是因为他们用于实现回滚而是用于实现提交。以下是一个实现operator=的惯用实现:
T& T::operator=( const T& other ) {
T temp( other );
Swap( temp );
}
幸运的是,释放资源时,失败的作用域相当小。如果使用异常作为错误报告机制,确保这些函数要处理内部处理可能产生的所有异常和错误(对于异常,将析构所执行的动作置于try/catch()中)。
当使用异常作为错误处理机制,通过使用空异常规约/* throw() */来声明函数,来说明函数的行为。
52.一致地复制和销毁
如果定义了拷贝构造,拷贝赋值或析构之一,就应定义另外两者之一或全部。
1.如果要编写或禁止拷贝构造和拷贝赋值两者之一,另外一个函数也需要同样的对待:如果一个函数做了特别操作,另者也同样,因为两者具有类似的作用。
2.如果显式地编写了拷贝函数,可能需要编写析构函数。
3.如果显式地编写了析构,可能需要显式编写或禁止拷贝。
很多情况下,使用RAII持有被封装的资源可以消除自己编写这些操作的必要。
优先考虑编译器生成的特殊成员,这些成员可以归类为平凡成员,至少有一家主要STL厂商对拥有这些成员的优化。这已经成为通用做法了。
例外
少数情况下,带有特别类型成员的类是例外(如引用,std::auto_ptr)。他们有特殊的拷贝语义,一个持有引用或auto_ptr的类,可能需要写拷贝构造和赋值操作符,但是确实的析构就足够了。
53.显式地启用或禁止拷贝
有意识地使用编译器生成的拷贝构造和赋值操作符,或者编写自己的版本,或者在不允许拷贝时禁止他们。
定义类时常见的错误(不仅仅是初学者)是忘记考虑拷贝和赋值的语义。
确保你的类提供有意义的拷贝或者完全禁止。
1.显式禁止拷贝构造和赋值操作符
2.显式编写拷贝构造和赋值操作符:如果语义与编译器生成的版本不同
3.使用编译器生成的版本,最好加上注释:如果拷贝有意义,而且缺省版本是正确的,就不必声明自己的版本。并加上注释说明,避免代码的读者以为你忽略了拷贝构造和赋值操作符。
禁止拷贝构造和赋值操作符意味着不能将对象存储于容器中(但是通过智能指针仍然可以)。
54.避免切片,用clone代替基类拷贝
如果客户需要进行多态(完全,深)拷贝,应禁止基类的拷贝构造和赋值操作符,并提供一个虚Clone成员函数。
我们构造类的层次体系通常是为了多态,但是这和C++的对象拷贝语义有冲突,因为拷贝构造不能是虚函数。
以上代码中对Transubstantiate的调用会导致切片。如果允许切片,但是不希望调用者容易发生这种错误,有一种写法(对于需要支持移植的代码不推荐):B的拷贝构造使用explicit,它可以避免隐式切片,但是同时也阻止了所有的传值操作(对于不能实例化的基类是很好的做法)。
如果需要,调用者仍然可以切片,但是必须是显式操作:
如果确实需要进行深拷贝,而且不知道参数对象的具体派生类型,一种更习惯性地做法是将基类的拷贝构造声明为protected(避免了像Transmogrify之类函数的调用)。
这样,切片会产生编译错误,而且由于Clone是纯虚函数派生类必须override。这个方法仍然存在两个编译器无法检测的错误:更深层次的派生类可能忘了实现Clone;或者Clone的实现不正确,使得拷贝的数据不是真实的原始类型。可以考虑采用条款39的非虚接口模式(NVI, Non-Virtual Interface),将public和virtual分开:
这样派生类只需要对DoClone进行override,assert对拷贝的数据进行类型检测,对那些没有实现DoClone()的派生类报错。
例外,
有些设计要求基类的拷贝构造必须是public的(例如,你的类层次中部分是第三方类库)。这种情况下,应传指针而不是引用。传指针不易于产生切片,而且避免了不必要的临时构造。
55.使用赋值的标准形式
声明一个类型的赋值应采用以下形式之一:
T& operator=( const T& ); // 经典形式
T& operator=( T ); // 潜在的优化
如果无论如何需要在你的operator中进行复制,可以考虑使用第二种形式,例如基于Swap的惯用法。
任何赋值操作符不能为虚,如果赋值必须为虚,应采用虚命名函数。
不要返回const T&,尽管这可以避免像(a=b)=c的比较奇怪的代码,但是在标准库容器中不能使用。
确保赋值操作符对于自赋值是安全的。不要去写一个依赖于自赋值检测的赋值操作符,不够安全,应该使用Swap惯用法。
显式调用所有基类赋值操作符,对所有成员数据赋值,返回*this。
56.只要可能就提供不会失败的swap(并且要正确地提供)
应提供一个swap函数有效且绝对无误的进行对象内部交换。它在很多惯用法中很有用,从平滑移动对象到实现对象赋值到实现有保证的commit函数。
通常一个swap函数实现如下:
对于原类型和标准容器,std::swap就可以了。
考虑根据拷贝构造使用swap实现赋值。以下operator=的实现提供了强保证,尽管其代价是创建了一个额外的对象。
上述代码中,如果U没有实现一个不会失败的swap函数,T是否仍然需要支持这样一个swap函数?答案是肯定的:
1.如果U的拷贝构造和赋值都不会失败,std::swap可以用于U对象。
2.如果U的拷贝构造可能失败,可以用指针成员指向一个U对象而不是直接存储这个对象,指针更容易交换,它只增加了一次额外的动态分配和访问;如果将所有成员存储于一个Pimpl成员中,对于所有私有成员只增加一次开销。
在使用拷贝构造实现拷贝赋值是,不要使用显式析构后面跟一个new的做法,即:
当你的类型有一种更有效的值交换方式时,应在和你的类型相同的命名空间中提供一个非成员的交换函数而不是使用简单粗暴的赋值,例如类型拥有自己的swap函数或类似的函数。对于非模板类型,可以考虑对std::swap的特化。
特化只是一种选择,最主要的是在和类型相同命名空间中提供一个类型特定的swap。
例外,
swap对于具有值语义的类非常有价值,但是对于基类则不是,因为基类通常通过指针使用。