条款5是指,在设计C++类时,必须要了解C++默认编写并调用哪些函数。具体来说,C++默认编写并调用的函数有以下几个:
通常情况下,如果代码中没有声明构造函数、拷贝构造函数、拷贝运算符、析构函数,编译器会在需要时创建他们,但这往往只能满足编译器的需求,很可能无法满足程序的需求。
实际的生成规则复杂一些,可以查阅cppreference。
class Empty{};
//上面的空类好比下面的类
class Empty{
public:
Empty() { ... }
Empty(const Empty& rhs) { ... }
~Empty() { ... }
Empty& operator=(const Empty& rhs) { ... }
};
编译器生成的构造函数/析构函数是只是调用基类和非静态成员变量的构造函数/析构函数;生成的析构函数是非虚的,除非基类有虚析构函数。
至于生成的拷贝构造函数和拷贝操作符只是单纯将每个非静态成员变量拷贝;有const成员或者引用成员时,以及基类拒绝拷贝操作符时,默认生成的拷贝操作符没有意义,必须自己定义。
条款6是指,在设计C++类时,如果不想使用编译器自动生成的函数,就应该明确拒绝。具体来说,如果类的某些函数不应该被自动生成(如拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数、析构函数等),则应该通过显式声明和私有化的方式来明确拒绝它们的生成和使用。这种做法可以避免编译器自动生成的函数的使用带来的问题,同时也能够提高程序的可读性和可维护性。 具体的实现方式是,通过在类的定义中声明并私有化某些函数,来防止编译器自动生成它们。例如,可以通过将拷贝构造函数和拷贝赋值函数声明为私有函数来禁止对象的复制。代码示例如下:
class MyClass {
public:
// 默认构造函数和析构函数是必须的,不能拒绝
MyClass() = default;
~MyClass() = default;
// 显式声明并私有化拷贝构造函数和拷贝赋值函数,明确拒绝它们的生成和使用
MyClass(const MyClass&) = delete;
MyClass& operator=(const MyClass&) = delete;
// 正常声明其他函数
void foo();
void bar();
};
在上面的代码中,我们通过=delete
语句来显式地声明并私有化了拷贝构造函数和拷贝赋值函数,从而明确拒绝它们的生成和使用。这样一来,在其他地方调用这些函数时,编译器就会报错,从而提醒程序员需要注意。
很多时候,你并不希望某些类被拷贝,而仅仅不实现拷贝构造/拷贝运算符是不够的,因为编译器可能会自行生成。
为此,可以把拷贝构造/拷贝运算符声明为"=delete",或者声明为private(后者较为过时)。
class Uncopyable{
public:
Uncopyable() = delete;
Uncopyable& operator=(const Uncopyable&) = delete;
};
事实上,对于析构函数中需要释放资源的类,为了防止内存问题,除非真的需要拷贝功能,否则最好都禁止拷贝。
可以让它们继承上面的类,即可禁止编译器生成拷贝操作:编译器试图为它们生成拷贝构造/拷贝运算符时会尝试调用基类Uncopyable的对应操作,而这会被拒绝。
class SomeClass: private Uncopyable{ ... };
条款7是指,在C++中,如果一个类是多态基类,那么应该为它声明虚析构函数。具体来说,如果一个类将会被其他类所继承,并且这些派生类被用作多态基类,那么这个类的析构函数应该被声明为虚函数,以保证在派生类对象被销毁时正确地调用析构函数。因为在C++中,如果一个类的析构函数不是虚函数,那么在销毁一个指向派生类对象的基类指针时,只会调用基类的析构函数,而不会调用派生类的析构函数,从而导致内存泄漏和程序错误。 为了解释这个问题,我们可以看下面这个例子:
class Base {
public:
Base() {}
~Base() { cout << "Base destructor called" << endl; }
};
class Derived : public Base {
public:
Derived() {}
~Derived() { cout << "Derived destructor called" << endl; }
};
int main() {
Base* ptr = new Derived();
delete ptr; // 只会调用Base的析构函数,导致Derived的析构函数没有被调用,内存泄漏
return 0;
}
在这个例子中,我们定义了一个基类Base
和一个派生类Derived
,并在main
函数中创建了一个指向派生类对象的基类指针ptr
。然后,我们调用delete
语句释放ptr
所指向的对象,期望同时调用Base
和Derived
的析构函数。但是,由于Base
的析构函数不是虚函数,所以在销毁ptr
时只调用了Base
的析构函数,而没有调用Derived
的析构函数,从而导致内存泄漏和程序错误。 为了解决这个问题,我们可以将Base
的析构函数声明为虚函数,代码示例如下:
class Base {
public:
Base() {}
virtual ~Base() { cout << "Base destructor called" << endl; } // 声明为虚函数
};
class Derived : public Base {
public:
Derived() {}
~Derived() { cout << "Derived destructor called" << endl; }
};
int main() {
Base* ptr = new Derived();
delete ptr; // 此时会依次调用Derived和Base的析构函数,避免内存泄漏
return 0;
}
在这个例子中,我们将Base
的析构函数声明为虚函数,这样在销毁ptr
时就会依次调用Derived
和Base
的析构函数,从而正确地释放对象的内存。
C++中多态性质体现于虚函数:基类指针或引用调用虚函数时会检查指向的对象是基类还是派生类,再调用对应的函数。
在C++中,实现多态性的核心机制是虚函数。虚函数是在基类中声明的,可以在派生类中重写。在使用基类指针或引用调用虚函数时,编译器会在运行时检查指向的对象是基类还是派生类,然后调用对应的函数。具体实现可以分为两个步骤:虚函数表和动态绑定。
- 虚函数表:每个类都有一个虚函数表,也叫虚表(virtual table),用来存储该类的虚函数地址。
虚函数表是在编译阶段生成的,每个类都有自己的虚函数表,其中存储着该类的虚函数地址。
虚函数表通常被作为类的第一个成员,在对象的内存布局中,虚表指针被放在对象的最前面,以便访问虚函数表中的地址。
对于包含虚函数的类,编译器会在编译阶段自动生成一个虚函数表,其中存储了该类的虚函数地址。
虚函数表是一个数组,每个元素对应一个虚函数的地址。在类的构造函数中,会将该类的虚表指针指向该类的虚函数表。
在调用虚函数时,编译器会通过虚表指针找到该类的虚函数表,并根据函数在虚函数表中的位置调用相应的函数。
- 动态绑定:动态绑定是指在运行时确定函数的调用地址的过程。
当使用基类指针或引用调用虚函数时,编译器会在运行时检查指向的对象是基类还是派生类,然后根据对象的实际类型来确定调用哪个函数。这个过程被称为动态绑定或后期绑定。
**动态绑定的实现需要依赖虚表指针。**当使用基类指针或引用调用虚函数时,编译器会根据指针或引用的静态类型确定该类的虚表指针,然后根据虚表指针找到该类的虚函数表,再根据虚函数在虚函数表中的位置找到对应的函数地址。然后在运行时根据对象的实际类型调用相应的函数。
**总的来说,C++中多态的实现是基于虚函数表和动态绑定的。**虚函数表存储了类的虚函数地址,而动态绑定则根据对象的实际类型来确定调用哪个函数。虽然虚函数表和动态绑定会增加程序的运行时开销,但是它们是实现多态的核心机制,使得程序能够更加灵活和可扩展。
当这样的一个指向派生类的基类指针析构时,如果析构函数不是虚函数,则直接调用基类的析构函数,那么派生类获取的资源未释放,则会造成内存泄漏。
而当析构函数是虚函数时则先调用对应的派生类析构函数,再调用基类析构函数,资源全部释放。
不过这种操作只有在基类是多态用途时才需要注意,也有很多类不是为了多态的用途,例如STL容器和上文的Uncopyable。
C++中抛出异常时会逐步展开其函数调用栈,清空局部资源,直到异常被catch。
如果析构函数可以抛出异常,那么清空局部资源时局部对象的析构函数再次异常时同时存在两个异常,C++无法处理,可能会过早结束或出现不明确行为。因此,析构函数绝对不要抛出异常,应通过try-catch捕获任何异常。
有时,客户需要处理某些异常,那么类应该提供一个普通成员函数执行相关操作,供用户调用并处理异常。
例如数据库连接这样的类中,假设用户需要处理关闭连接时的异常,同时析构函数不能抛出异常,可以这样:
class DBConn{
public:
void close(){
db.close();
closed = true;
}
~DBConn()
{
if(!closed)
{
try{
db.close();
}catch(...){
//记录相关信息
}
}
}
private:
DBConnection db;
bool closed;
};
C++的构造过程是先构造基类再构造子类、先初始化再进入构造函数体;析构过程相反。
对于派生类的构造函数而言,进入其中时基类部分已构造完而派生类部分未构造完,对象类型是基类,故而此时调用虚函数,实际上使用的是基类的虚函数。
析构函数同理。进入析构函数后派生类部分呈未定义值,对象类型是基类,调用的是基类的虚函数。
总而言之,在构造函数与析构函数中虚函数的行为有特殊变化;为了避免出错,不要在其过程中使用虚函数。如果确实需要在构造函数或析构函数中调用虚函数,可以使用非虚函数或纯虚函数代替虚函数,或者在构造函数或析构函数中显式调用基类中的函数。
条款10提醒我们在实现自定义的赋值运算符(operator=)时,应该将其返回类型定义为引用(reference)类型,并返回一个指向当前对象的指针(*this),以便实现连续赋值和链式调用的功能。这种做法被称为“返回一个reference to *this”。
C++中通常支持连锁赋值,采用右结合律:
int a,b,c;
a = b = c = 15;
// a = (b = (c = 15));
具体来说,如果我们将赋值运算符的返回类型定义为值(value)类型,那么在进行多次赋值时,每次都会创建一个新的临时对象,这会导致不必要的内存分配和拷贝,降低程序的执行效率。而如果将赋值运算符的返回类型定义为引用类型,可以避免创建临时对象,提高程序的执行效率。
同时,返回一个指向当前对象的指针(*this)也可以实现链式调用的功能。在链式调用中,每个函数调用的返回值都是当前对象的引用,这样就可以一直链式调用下去,从而使代码更加简洁和易读。
为了支持这个常规,拷贝运算符需要返回一个引用。这一条款并不强制,但约定俗成。
class Widget{
public:
Widget& operator=(const Widget& rhs){
...
return *this;
}
};
条款11提醒我们在实现自定义的赋值运算符(operator=)时,需要特别注意处理“自我赋值”(self-assignment)的情况,即将一个对象赋值给它自身。 如果没有正确处理自我赋值的情况,可能会导致对象的状态出现错误,甚至导致程序崩溃。因此,在实现赋值运算符时,必须处理自我赋值的情况,以保证程序的正确性和稳定性。
处理自我赋值的方法通常有两种:
引入临时对象。在处理自我赋值时,可以先创建一个临时对象,将原对象的状态拷贝到临时对象中,然后再将临时对象的状态赋值给原对象。这样可以避免在拷贝原对象的状态时被自我赋值所影响。需要注意的是,在创建临时对象时,应该使用拷贝构造函数而不是赋值运算符,以避免在处理自我赋值时再次调用赋值运算符。
检查自我赋值。在处理赋值运算符时,可以先检查赋值运算符的左右两边是否是同一个对象,如果是,则直接返回该对象,不进行任何操作。这样可以避免在处理自我赋值时出现错误。 需要注意的是,处理自我赋值时,不仅要考虑到对象的成员变量,还需要考虑到对象可能维护的资源,如内存、文件句柄等。如果对象维护了资源,需要在处理自我赋值时特别注意释放和重新分配资源的顺序,以避免出现资源泄漏或访问非法内存等问题。
因此,在实现自定义的赋值运算符时,需要特别注意处理自我赋值的情况。处理自我赋值的方法可以是引入临时对象或检查自我赋值,需要根据具体情况选择合适的方法。同时,需要注意处理对象维护的资源,以保证程序的正确性和稳定性。
考虑这样一个类,其中管理了一个堆对象
class Widget{
...
private:
Bitmap* pb; //一个堆对象
};
总有些时候,会出现实质上“a=a”这种自我赋值的情况。那么这样简单的拷贝运算符就会出错,先释放了自身的pb,又使用了pb:
Widget& Widget::operator=(const Widget& rhs){
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
传统做法是函数开头添加一个测试:
if(this == &rhs) return *this;
这种做法具备“自我赋值安全性”,但不具备“异常安全性”:例如当new Bitmap异常时,pb指向被删除的内存。
常用的方法有两种,兼顾了“自我赋值安全性”与“异常安全性”:
//第一种
Widget& Widget::operator=(const Widget& rhs){
Bitmap* pb_original = pb;
pb = new Bitmap(*rhs.pb);
delete pb_original;
return *this;
}
//第二种
void Widget::swap(Widget& rhs){
...
};
Widget& Widget::operator=(const Widget& rhs){
Widget temp(rhs);
swap(temp);
return *this;
}
当自己实现拷贝构造/拷贝运算符时,编译器不会警告你遗漏了某些成员变量。因此,必须仔细地复制所有派生类成员,并调用基类的拷贝操作来复制基类成员。
Derived::Derived(const Derived& rhs)
:Base(rhs),some_member(rhs.some_member){}
Derived& Derived::operator=(const Derived& rhs){
Base::operator=(rhs);
some_member = rhs.some_member;
return *this;
}
拷贝构造和拷贝运算符中很可能有相当多重复的操作,但因为两个函数性质完全不同,因此不能用其中一个调用另一个来减少冗余。
可以把共同功能放在第三个函数中,并由两个拷贝操作共同调用。