Home 构造-析构-赋值
Post
Cancel

构造-析构-赋值

了解 C++ 默默编写并调用哪些函数

C++ 能够自动生成默认的构造函数, 析构函数, 复制构造函数和赋值运算符. 这些函数都被声明为 publicinline, 且仅当它们需要被调用时才会被生成. 示例代码如下:

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_casttypeid, 也会将对象视为基类类型, 因此也不应该在构造/析构函数中使用它们.

关于赋值运算符

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;
}

不要 尝试用某个复制函数去实现另一个, 而应该将共同的部分放进第三个函数中, 并由两个复制函数共同调用.

用赋值运算符函数调用复制构造函数是 不合理 的, 因为赋值运算符左边的对象是早已被构造完成的, 这时再调用复制构造函数, 相当于去构造一个已经存在的对象. 反之, 也不应该在复制构造函数中调用赋值运算符, 因为此时对象尚未构造完成.

This post is licensed under CC BY 4.0 by the author.