02.constructors-destructors-and-assignment-operators
本文对应原书第二部分,主要记录 C++类中使用构造函数、析构函数以及赋值运算需要注意的事项。
item5 了解 C++ 类中自动生成和调用的函数
编译器会默认为类创建默认构造函数,拷贝构造函数,赋值操作符,以及析构函数,但是这些函数只要在调用时才会生成。
class Empty{};
// 假设调用了以下功能,此类等价于
class Empty
{
public:
Empty(){} // 默认构造函数
~Empty(){} // 析构函数
Empty(const Empty& empty){} // 拷贝构造函数
Empty& operator=(const Empty& empty){} // 赋值运算符
}
Notes:
- 默认生成的析构函数是 non-virtual,除非这个类的基类声明有 virtual 析构函数
- 自动生成的拷贝构造函数和赋值操作符只是单纯的将源对象的所有非静态成员变量拷贝到目标对象,因此当对象中包含引用成员 和const 成员 时,由于 C++中不允许引用被初始化后指向另一个对象,所以 C++编译器会拒绝为其赋值
// 定义一个模板类
template<class T>
class NameObject
{
public:
NameObject(std::string& name,const T& value);
private:
std::string& nameValue;
const T objectValue;
}
// 对类进行以下操作
std::string str1("Persephone");
std::string str2("Satch");
NameObject<int> Dog1(str1,2); // Dog1.nameValue指向了str1
NameObject<int> Dog2(str2,10); // Dog2.nameValue指向了str2
Dog2=Dog1; // 会发生Dog2.nameValue=Dog1.nameValue,C++中引用被初始化后不允许指向另一个对象,编译器无法通过
总结:
- 如果没有进行声明,C++编译器会自动为类创建默认构造函数、拷贝构造函数、赋值运算符,以及析构函数。
item6 若不想使用编译器自动生成的函数,要显式拒绝
在有些特定功能的类中,我们希望其不支持拷贝和赋值功能,比如 RAII 范式编程中。但是,即使不声明拷贝构造函数和赋值运算符,当调用它们时,编译器还是会自动声明。为了屏蔽编译器自动提供的函数,可以将相应的成员函数声明为 private 并且不予实现。
- 所有编译器自动提供的函数都是 public 的
- 将编译器默认生成的函数进行显式声明为 private,可以避免其被外部函数调用
- 对声明的函数不进行定义,可以避免其被类的成员函数或友元函数调用
总结:
- 当不想让编译器为类自动生成某些函数时,可将相应的成员函数声明为 private 并且不予实现。
item7 为多态基类声明 virtual 析构函数
- 析构函数:析构函数是用来释放对象资源的。当对象的声明周期结束或者对象资源通过 delete 被显式释放时,对象的析构函数被显式调用,从而释放对象占用的资源
- 多态:多态是 C++面向对象编程的思想之一。在编程中一个显著的特征就是通过基类指针指向子类对象,实现对子类对象的操作。
C++明确指出,当派生类对象通过一个基类指针被删除,而这个基类的析构函数不是虚函数,其结果没有定义,在实际的执行中表现为只调用了基类的析构函数,派生类的资源没有被释放。 当基类析构函数被声明为虚函数,派生类的析构函数也默认为是虚函数。当派生类对象被销毁时,基类会找到派生类的虚函数表,调用派生类的析构函数,接着调用基类的析构函数,实现资源的完全释放。
总结:
- 用来实现多态的基类应该将其析构函数声明为虚函数。如果一个类中包含有虚函数,那它就是被用来实现多态的,就需要一个虚的析构函数。
- 如果类的设计目的不是作为基类或者不是用来实现多态的基类,就不需要将析构函数声明为虚函数。这是因为当一个类中包含虚函数的时候,编译器会为对象生成一个虚表指针,类也会多一个虚函数表,会增加内存资源的消耗。
item8 不要让析构函数抛出异常
析构函数被调用的情况有两种:1、对象正常结束生命周期时调用;2、在异常发生时,编译器释放对象资源时调用。在第一种情况下,析构函数抛出异常不会出现无法预料的结果,可以正常捕获。但是在后一种情况下,如果对象发生异常,异常模块为了维护系统对象数据的一致性,会去调用对象的析构函数释放资源,析构函数如果抛出异常,异常处理机制无法捕获,只能调用terminate()
函数,系统会崩溃。
总结:
- 从语法上讲,析构函数抛出异常是可以的,但是 C++中并不推荐这一做法。
- 如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕获该异常,然后吞下它们或者不传播。
- 如果需要对某个函数运行期间抛出的异常进行处理,那么类应该提供一个普通函数而不是利用析构函数来进行该操作。主要针对的是资源释放过程中可能出现的异常,如数据库断开连接或者文件句柄的释放等。
item9 不要在构造和析构函数中调用 virtual 函数
构造函数的工作是进行初始化,派生类对象构造期间首先会调用基类的构造函数,对象的类型也变成了基类的类型。如果基类的构造函数中调用了虚函数,虚函数不会绑定到子类的版本,这样 virtual 函数没有任何意义。析构函数书中也是同样的道理。
class Transaction
{
public:
Transaction(){init();} // 默认构造函数
~Transaction(){} // 析构函数
virtual void log() const = 0;
private:
void init(){
log(); // 这种情况更加危险,因为其更难发现
}
}
总结:
- 不要在构造和析构函数中调用 virtual 函数,因为基类中调用的虚函数调用的是其自己的而不属于派生类。
item10 自定义赋值操作符(operator=) 要返回*this 的引用
C++的赋值操作符可以进行链式赋值,为了实现链式赋值,赋值操作符必须返回一个指向当前对象的引用,这是 C++中自定义赋值操作符应该遵循的规则。这个规则对于+=
、-=
、*=
等赋值相关的操作符同样适用。
int x,y,z;
x=y=z=5; // 链式赋值,等效于x=(y=(z=5))
class Widget{
public:
...
Widget& operator=(const Widget& rhs){ //要返回一个当前类的引用
...
return *this; //返回给左边的变量
}
...
};
总结:
- this 是用来指向当前对象的对象,只存在类的成员函数里。
- 自定义赋值操作符要返回一个指向当前对象的引用。
item11 注意 operator= 的“自我赋值”
虽然在代码中我们不会有意去进行自我赋值,但是一些代码还是存在潜在自我赋值的可能性,如
a[i] = a[j]; // 当i=j时就是自我赋值
*px = *py; // 如果px和py恰好指向的是同一个对象,这也是自我赋值
class Base{...};
class Derived:public Base{...};
void doSomething(const Base& rb,Derived* pd); // rb和pd有可能指向同一个对象
我们在类中对资源进行管理要特别关注可能发生的拷贝现象。当我们要手动管理资源时,赋值操作符就就可能是不安全的。在下面的代码中,如果发生了自我赋值,delete 语句不仅会释放*this 自身的资源,rhs 的资源也会被释放,最后返回的是一个损坏的数据。
class Bitmap{...}
Class Widget{
...
public:
Widget& Widget::operator=(const Widget& rhs)
{
delete pb; // 删除当前版本
pb = new Bitmap(*rhs.bp);
return *this;
}
private:
Bitmap *bp;
}
为了避免自我赋值出现的错误,可以采用参数自身验证、重新排列语句等方法。
- 解决方法 1:参数自身验证
Widget& Widget::operator=(const Widget& rhs)
{
if(this==&rhs)
return *this;
delete pb; // 删除当前版本
pb = new Bitmap(*rhs.bp); // 风险是:当该语句抛出异常,返回的仍然是损坏数据
return *this;
}
- 解决方法 2:重新排列语句
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap* pOrigin = pb; // 备份原来的pb
pb = new Bitmap(*rhs.bp); // 赋值rhs的pb
delete pb; // 删除备份
return *this;
}
上述代码中因为对数据进行了备份,复制,删除等操作,如果该赋值操作符频繁使用,其效率是比较低的。
- 解决方法 3:先复制后交换(copy and swap)
Class Widget{
...
public:
void swap(Widget& rhs); // 交换rhs和*this的数据
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs); // 为rhs数据创建备份
swap(temp); // 将*this数据和rhs交换
return *this;
}
}
- 解决方法 4:用传值替换传引用
Widget& Widget::operator=(Widget rhs)
{
swap(rhs);
return *this;
}
上述代码中利用了 C++传值会自动生成一份本地拷贝的特性,有效减少了代码长度,也增加了效率。
总结:
- 使用赋值操作符要充分保证自我赋值发生时程序的安全性。
- 任何函数中操作了一个以上对象时,要保证即使多个对象是同一个对象时,其行为仍然是正确的。
item12 对象进行复制时需要完整拷贝
设计良好的面向对象系统会将对象的内部封装起来,只保留拷贝构造函数和赋值操作符来负责对象的拷贝。当我们自己定义赋值运算符和拷贝构造函数函数时,特别注意:1、复制类中所有的局部变量;2、派生类中要调用基类中相应的函数或操作符。
总结:
- 复制函数要确保复制对象内所有的成员变量以及所有基类成员变量。
- 拷贝构造函数和赋值操作符有相近的代码,两者之间不能互相调用,可以建立一个新的成员函数给两者调用。