构造函数、析构函数和赋值操作符

来源:百度文库 编辑:神马文学网 时间:2024/04/28 20:30:39
    所有的类都应有一个或多个构造函数、一个析构函数和一个赋值操作符,因为它们提供的都是一些最基本的功能:构造函数控制对象生成时的操作,并保证对象被初始化;析构函数摧毁一个对象并保证它被彻底清除;赋值操作符给对象一个新的值;


条款1:为需要动态分配内存的类声明一个拷贝构造函数和一个赋值操作符
class String {
public:
String(const char *value);
~String();
private:
char *data;
};
String::String(const char *value)
{
if (value) {
    data = new char[strlen(value) + 1];
    strcpy(data, value);
} else {
    data = new char[1];
    *data = '\0';
}
}
inline String::~String() { delete [] data; }

String a("Hello");
String b("World");

情况一、
b = a;
       因为没有自定义的operator=可以调用, C++会生成并调用一个缺省的operator=操作符。这个缺省的赋值操作符会执行从 a 的成员到 b 的成员的逐个成员的赋值操作,对指针(a.data 和 b.data) 来说就是逐位拷贝:a.data和b.data指向同一个对象。
        这种情况下至少有两个问题。第一,b 曾指向的内存永远不会被删除,因而会永远丢失,这是产生内存泄漏的典型例子。第二,现在 a 和 b 包含的指针指向同一个字符串,那么只要其中一个离开了它的生存空间,其析构函数就会删除掉另一个指针还指向的那块内存。【用 delete 去删除一个已经被删除的指针,其结果是不可预测的】

情况二、
String c = a;
       调用了拷贝构造函数,因为它也没有在类中定义,C++以与处理赋值操作符一样的方式生成一个拷贝构造函数并执行相同的动作:对对象里的指针进行逐位拷贝。这会导致同样的问题。
       这种情况下不用担心内存泄漏,因为被初始化的对象还不能指向任何的内存。比如上面代码中的情形,当 c.data 用a.data 的值来初始化时没有内存泄漏,因为 c.data 没指向任何地方。不过,假如c 被 a 初始化后,c.data 和 a.data 指向同一个地方,那这个地方会被删除两次:一次在c被摧毁时,另一次在a 被摧毁时;
       在传值调用的时候,也会产生问题;【用 delete 去删除一个已经被删除的指针,其结果是不可预测的】

        解决这类指针混乱问题的方案在于,只要类里有指针时,就要写自己版本的拷贝构造函数和赋值操作符函数。在这些函数里,你可以拷贝那些被指向的数据结构,从而使每个对象都有自己的拷贝;或者你可以采用某种引用计数机制去跟踪当前有多少个对象指向某个数据结构。
        对于有些类,当实现拷贝构造函数和赋值操作符非常麻烦的时候,特别是可以确信程序中不会做拷贝和赋值操作的时候,去实现它们就会相对来说有点得不偿失。此时就可以只声明这些函数(声明为 private 成员)而不去定义(实现)它们。这就防止了会有人去调用它们,也防止了编译器去生成它们

条款2:尽量使用初始化而不要在构造函数里赋值
       从纯实际应用的角度来看,有些情况下必须用初始化。特别是 const 和引用数据成员只能用初始化,不能被赋值;
       对有基类的对象来说,基类的成员初始化和构造函数体的执行发生在派生类的成员初始化和构造函数体的执行之前;
       用成员初始化列表比在构造函数里赋值在效率方面要好。当使用成员初始化列表时,只有一个 string 成员函数(拷贝构造函数)被调用。而在构造函数里赋值时,将有两个(默认构造函数、赋值操作符)被调用;
       通过成员初始化列表来进行初始化总是合法的,效率也决不低于在构造函数体内赋值,它只会更高效;
       但有一种情况下,对类的数据成员用赋值比用初始化更合理。这就是当有大量的固定类型的数据成员要在每个构造函数里以相同的方式初始化的时候;
       static 类成员永远也不会在类的构造函数初始化。静态成员在程序运行的过程中只被初始化一次,所以每当类的对象创建时都去“初始化”它们没有任何意义。

条款3:初始化列表中成员列出的顺序和它们在类中声明的顺序相同
         对一个对象的所有成员来说,它们的析构函数被调用的顺序总是和它们在构造函数里被创建的顺序相反。那么,如果允许成员按它们在初始化列表上出现的顺序被初始化,编译器就要为每一个对象跟踪其成员初始化的顺序,以保证它们的析构函数以正确的顺序被调用,这会带来昂贵的开销。所以,为了避免这一开销,同一种类型的所有对象在创建(构造)和摧毁(析构)过程中对成员的处理顺序都是相同的,而不管成员在初始化列表中的顺序如何。
        基类数据成员总是在派生类数据成员之前被初始化,所以使用继承时,要把基类的初始化列在成员初始化列表的最前面。如果使用多继承,基类被初始化的顺序和它们被派生类继承的顺序一致,它们在成员初始化列表中的顺序会被忽略。
        基本的一条是:如果想弄清楚对象被初始化时到底是怎么做的,请确信你的初始化列表中成员列出的顺序和成员在类内声明的顺序一致。

 

 

条款4:确定基类有虚析构函数
        当通过基类的指针去删除派生类的对象,而基类有没有虚析构函数时,结果将是不可确定的;
        虚函数的目的是让派生类去定制自己的行为,所以几乎所有的基类都包含虚函数。如果某个类不包含虚函数,那一般是表示它将不作为一个基类使用。当一个类不准备作为基类使用时,使析构函数为虚一般是一个坏主意: 实现虚函数需要对象附带一些额外信息,以使对象在运行时可以确定该调用哪个虚函数。对大多数编译器来说,这个额外信息的具体形式是一个称为vptr(虚函数表指针)的指针。vptr 指向的是一个称为 vtbl(虚函数表)的函数指针数组。每个有虚函数的类都附带有一个vtbl。当对一个对象的某个虚函数进行请求调用时,实际被调用的函数是根据指向vtbl 的 vptr 在 vtbl里找到相应的函数指针来确定的。因此,包含虚函数类的对象的体积将变大;
       基本的一条是,无故的声明虚析构函数和永远不去声明一样是错误的
       值得指出的是,在某些类里声明纯虚析构函数很方便。纯虚函数将产生抽象类——不能实例化的类(即不能创建此类型的对象) 。有些时候,你想使一个类成为抽象类,但刚好又没有任何纯虚函数。怎么办?因为抽象类是准备被用做基类的,基类必须要有一个虚析构函数,纯虚函数会产生抽象类,所以方法很简单:在想要成为抽象类的类里声明一个纯虚析构函数。同时还必须提供纯虚析构函数的定义:AWOV::~AWOV() {},这个定义是必需的,因为虚析构函数工作的方式是:最底层的派生类的析构函数最先被调用,然后各个基类的析构函数被调用。这就是说,即使是抽象类,编译器也要产生对~AWOV 的调用,所以要保证为它提供函数体。如果不这么做,链接器就会检测出来,最后还是得回去把它添上。
       如果析构函数体内什么事也不做,就可以将析构函数声明为内联函数,从而避免对一个空函数的调用所产生的开销,但必须清楚的是:因为析构函数为虚,它的地址必须进入到类的 vtbl。但内联函数不是作为独立的函数存在的(这就是“内联”的意思) ,所以必须用特殊的方法得到它们的地址。其基本点是:如果声明虚析构函数为inline,将会避免调用它们时产生的开销,但编译器还是必然会在什么地方产生一个此函数的拷贝。

条款5:让operator=返回*this的引用
       在一个类C中,缺省版本的operator=函数具有如下形式:C& C::operator=(const C&);
       一般情况下几乎总要遵循operator=输入和返回的都是类对象的引用的原则,然而有时候需要重载operator=使它能够接受不同类型的参数,例如,标准string类型提供了两个不同版本的赋值运算符:string& operator=(const string& rhs); string& operator=(const char* rhs);需要注意的是,即使是重载的时候,返回类型也是类的对象的引用;
       C++程序员经常犯的一个错误是让operator=返回void,这样会妨碍连续赋值操作,所以不要这样做;
       另一个常犯的错误是让operator=返回一个const对象的引用,这样与固定类型的常规做法不兼容,最好也不要使用;
       采用缺省形式定义的赋值运算符里,对象返回值有两个很明显的候选者:赋值语句左边的对象(被 this 指针指向的对象)和赋值语句右边的对象(参数
表中被命名的对象):
String& String::operator=(const String& rhs)
{
...
return *this;            // 返回左边的对象
}
String& String::operator=(const String& rhs)
{
...
return rhs;              // 返回右边的对象
}
       首先,返回rhs 的那个版本不会通过编译,因为 rhs 是一个const String 的引用,而operator=要返回的是一个String 的引用。当要返回一个非const 的引用而对象自身是 const 时,编译器会给你带来无尽的痛苦。
       那么将函数定义为String& String::operator=(String& rhs)   { ... }呢?此时x = "Hello"将不能通过编译:因为赋值语句的右边参数不是正确的类型——它是一个字符数组,不是一个 String——编译器就要产生一个临时的const的String 对象。而对于没有声明相应参数为 const 的函数来说,传递一个 const 对象是非法的。
      所以,当定义自己的赋值运算符时,必须返回赋值运算符左边参数的引用:*this。如果不这样做,就会导致不能连续赋值,或导致调用时的隐式类型转换不能进行,或两种情况同时发生。

条款6:在operator=中对所有数据成员赋值
       在写赋值运算符时,必须对对象的每一个数据成员赋值;
template          // 名字和指针相关联的类的模板
class NamedPtr {           //
public:
NamedPtr(const string& initName, T *initPtr);
NamedPtr& operator=(const NamedPtr& rhs);
private:
string name;
T *ptr;
};
template
NamedPtr& NamedPtr::operator=(const NamedPtr& rhs)
{
if (this == &rhs)
    return *this;              // 检查给自己赋值的情况
// assign to all data members
name = rhs.name;             // 给name赋值
*ptr = *rhs.ptr;             // 对于ptr,赋的值是指针所指的值,不是指针本身
return *this;                // 见条款15
}
      派生类的赋值运算也必须处理它的基类成员的赋值;
class Base {
public:
Base(int initialValue = 0): x(initialValue) {}
private:
int x;
};
class Derived: public Base {
public:
Derived(int initialValue)
: Base(initialValue), y(initialValue) {}
Derived& operator=(const Derived& rhs);private:
int y;
};
Derived& Derived::operator=(const Derived& rhs)
{
if (this == &rhs) return *this;   
y = rhs.y;                         // 给Derived 仅有的数据成员赋值
return *this;                    
}
       上述赋值代码是错误的,因为Drived对象的Base部分的数据成员x在赋值运算符中未受影响,解决这个问题最显然的办法是在Derived::operator=中对 x赋值。但这不合法,因为 x 是 Base 的私有成员。所以必须在 Derived 的赋值运算符里显式地对Derived的Base 部分赋值:
(1)显式调用Base::operator=:Base::operator=(rhs);
(2)对不支持上述调用的情况:static_cast(*this) = rhs; 【转换的是Base 对象的引用,而不是Base 对象本身】
        类似问题也会发生在派生类构造函数的实现上:
class Base {
public:
Base(int initialValue = 0): x(initialValue) {}
Base(const Base& rhs): x(rhs.x) {}
private:
int x;
};
class Derived: public Base {
public: Derived(int initialValue)
: Base(initialValue), y(initialValue) {}
Derived(const Derived& rhs): y(rhs.y) {}                    // 错误的拷贝构造函数
private:
int y;
};
        解决方法:在Derived的拷贝构造函数的成员初始化列表里对Base指定一个初始化值:Derived(const Derived& rhs): Base(rhs), y(rhs.y) {};

条款7:在operator=中检查给自己赋值的情况
        在赋值运算符中要特别注意可能出现别名的情况,原因基于两点:
(1)保证效率:如果可以再赋值运算符函数体的首部检测到是给自己赋值,就可以立即返回,从而节省大量的工作,否则必须去实现整个赋值操作;
(2)保证正确:一个赋值运算符必须释放掉一个对象的资源(去掉旧值),然后根据新值分配新的资源,在自己给自己赋值的情况下,释放旧的资源将是灾难性的;
        必须定义两个对象怎么样才算是“相同”的,这个问题在学术上称为object identify,它在面向对象领域是个很有名的论题,解决这个问题有以下两种基本方法:
(1)如果两个对象具有相同的值,就说它们是相同的:用值相等来确定对象身份和两个对象是否占用相同的内存没有关系,有关系的只是它们所表示的值;
(2)两个对象相等当且仅当它们具有相同的地址:因为容易而且计算很快,这个定义在C++程序中用的更广泛;
        更复杂的机制:实现一个返回某种对象标识符的成员函数;
class C {
public:
ObjectID identity() const;   
...
};
        对于两个对象指针a 和b,当且仅当 a->identity() == b->identity()的时候,它们所指的对象是完全相同的。当然,必须自己来实现 ObjectIDs 的operator==。别名和 object identity 的问题不仅仅局限在 operator=里。在任何一个用到的函数里都可能会遇到。在用到引用和指针的场合,任何两个兼容类型的对象名称都可能指的是同一个对象