了解 C++ 默默编写并调用哪些函数
C++ 能够自动生成默认的构造函数, 析构函数, 复制构造函数和赋值运算符. 这些函数都被声明为 public
和 inline
, 且仅当它们需要被调用时才会被生成. 示例代码如下:
1
2
3
4
Empty e1; // 生成默认构造函数
Empty e2(e1); // 生成默认复制构造函数
Empty e3 = e2; // 生成默认复制构造函数
e1 = e3; // 生成默认赋值运算符
而一旦在类中声明了一个构造函数, 无论其是否带有参数, 编译器都不会再为这个类生成默认构造函数.
编译器生成的复制构造函数和赋值运算符中, 通常只负责把对象中的每一个 non-static 成员拷贝到目标对象中. 因此一旦类的成员中包含 引用或常量类型 等不可修改的成员变量时, 编译器将无法生成默认的函数.
拒绝编译器自动生成函数
如果需要实现一个不支持拷贝操作的类, 一种方法是将这两个函数声明为 private
并且不实现它们, 这样如果有人试图拷贝该类的对象时, 就会得到一个链接错误.
而另一种方法是声明如下的一个基类 Uncopyable
:
1
2
3
4
5
6
7
8
class Uncopyable {
protected:
Uncopyable() {}
~Uncopyable() {}
private:
Uncopyable(const Uncopyable&);
Uncopyable& operator=(const Uncopyable&);
};
这个基类允许子类生成其对象, 但一旦试图拷贝, 就会发生编译错误. 为了阻止某个类的对象被拷贝, 只需让该类继承 Uncopyable
类即可.
关于虚析构函数
当类中包含至少一个虚函数时, 表明该类带有多态性质, 那么就 应该 为它声明虚析构函数; 而在其它情况下, 都 不应该 为它声明虚析构函数.
在 C++ 中, 标准 string
类和 STL 中的容器类都不带有虚析构函数, 因此不能将其用于多态的用途, 更不应该试图继承它们.
有时我们需要拥有一个抽象类, 但手上没有任何纯虚函数, 这时可以将析构函数声明为纯虚函数. 示例代码如下:
1
2
3
4
class AWOV {
public:
virtual ~AWOV() = 0;
};
需要注意的是, 必须 为这个纯虚析构函数提供一个定义, 否则当其被调用时会出现链接错误.
别让异常逃离析构函数
应该尽可能避免在析构函数中抛出异常, 或是调用会抛出异常的函数. 而当这些情况不可避免时, 可以考虑下面的两种方法:
- 如果抛出异常, 就调用
std::abort
直接结束程序. - 吞下发生的异常并进行记录, 之后让程序继续执行.
这两个方法都不太靠谱, 前者直接杀死了本来或许还能继续运行的程序; 而后者则埋下了安全性隐患. 另一个策略是重新设计接口, 使得用户有机会来处理在析构过程中出现的异常, 例如下面的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class DBConn {
public:
...
void close()
{
db.close(); // 关闭数据库连接, 如果失败则抛出异常
closed = true;
}
~DBConn()
{
if (!closed) {
try {
db.close();
}
catch (...) {
/* error log */
...
}
}
}
private:
DBConnection db;
bool closed;
};
上面代码的核心思想在于, 用户能够在对象被析构之前主动调用 close()
函数来关闭连接, 并自行处理可能出现的异常; 而一旦用户没有这么做, 就意味着用户自己放弃了处理风险的机会, 那么将由析构函数来完成关闭连接的工作, 并吞下可能出现的异常.
不要在构造和析构过程中调用虚函数
考虑下面这个模拟股票交易的类和其子类的代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Transaction {
public:
Transaction();
virtual void log() const = 0;
...
};
Transaction::Transaction()
{
...
log(); // 在基类的构造函数中调用了虚函数
}
class BuyTransaction: public Transaction {
public:
virtual void log() const;
...
}
这时, 我们创建一个 BuyTransaction
类的对象, 那么其基类 Transaction
的构造函数一定会首先被调用, 而当执行到 log()
函数时, 将会运行基类而非子类的版本, 这里由于该函数被声明为纯虚函数, 因此将会出现错误. 更加糟糕的一种情况是, 这个 log()
函数只是一般的虚函数, 并且在基类中进行了实现, 那么程序就将会直接执行基类的版本而不产生任何问题, 这可能造成潜在的隐患.
在子类对象的基类构造函数执行期间, 对象的类型将被 视为基类 . 对于析构函数来说同理.
值得一提的是, 在上述情况中, 不止虚函数会被编译器解析到基类, 如果使用其他的运行期类型信息, 如 dynamic_cast
和 typeid
, 也会将对象视为基类类型, 因此也不应该在构造/析构函数中使用它们.
关于赋值运算符
operator=
函数应该返回一个对象自身的引用. 对于+=
这类的相关运算同样适用.
这条原则主要是为了处理像 x = y = z
这样的连续赋值形式. 标准的赋值运算符形式如下:
1
2
3
4
5
6
7
8
9
class Widget {
public:
...
Widget& operator=(const Widget& rhs)
{
...
return *this;
}
}
我们还需要考虑对象 自我赋值 的情况, 下面的代码都有可能导致这种情况的发生:
1
2
3
x = x;
a[i] = a[j]; // i == j
*pi = *pj; // pi, pj 指向同一个对象
当自我赋值的对象 持有资源 时, 这将会导致严重的问题, 例如下面的代码:
1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
...
private:
int* p; // 指针, 指向一个从堆中分配的对象
}
Widget& Widget::operator=(const Widget& rhs)
{
delete p;
p = new int(*rhs.p);
return *this;
}
这个赋值操作看起来合理, 首先删除自身原来的指针, 并重新初始化得到一个资源的副本. 但在进行自我赋值时问题就会出现, 显然开始的 delete
操作释放了所持有的资源, 在下一步的代码中 rhs.p
就成为了一个野指针.
能够解决这个问题的标准赋值运算代码如下:
1
2
3
4
5
6
7
Widget& Widget::operator=(const Widget& rhs)
{
int* t = p;
p = new int(*rhs.p);
delete t;
return *this;
}
上面代码的关键在于, 在复制之前不要删除. 这一点同样也保证了代码的 异常安全性, 即当 new
分配内存因为某些情况失败时, this->p
不会变成野指针.
复制对象时勿忘其每一个成分
复制函数应该确保复制 对象内的所有成员变量 以及 所有基类的成分 .
当我们为一个子类编写复制函数时, 一定不要忘记复制其基类的成分, 否则编译器将会自动调用默认的复制函数, 这将可能导致隐藏的问题. 同时, 由于基类成分很多时候被继承为 private
而无法直接访问, 我们需要间接的调用基类的复制函数. 示例代码如下:
1
2
3
4
5
6
7
8
9
10
11
Derive::Derive(const Derive& rhs) : Base(rhs) // 调用基类的复制构造函数
{
...
}
Derive& Derive::operator=(const Derive& rhs)
{
Base::operator=(rhs); // 调用基类的赋值运算符函数
...
return *this;
}
不要 尝试用某个复制函数去实现另一个, 而应该将共同的部分放进第三个函数中, 并由两个复制函数共同调用.
用赋值运算符函数调用复制构造函数是 不合理 的, 因为赋值运算符左边的对象是早已被构造完成的, 这时再调用复制构造函数, 相当于去构造一个已经存在的对象. 反之, 也不应该在复制构造函数中调用赋值运算符, 因为此时对象尚未构造完成.