返回

[Effective C++ 笔记]09. 不要在构造和析构过程中调用 virtual 函数

简介

不要在构造和析构期间调用 virtual 函数,因为调用时只会调用该层次(基类)的定义而不会下降至调用派生类的定义。

基本示例

假设我们有如下类:Transaction 类作为基础交易类,其包含了一个 virtual 函数 logTransaction 用来记录交易。其子类 BuyTransactionSellTransaction 分别有各自的实现。

class Transaction {
public:
    Transaction();
    virtual void logTransaction() const = 0;
};

class BuyTransaction : public Transaction {
public:
    virtual void logTransaction() const;
};

class SellTransaction: public Transaction {
public:
    virtual void logTransaction() const;
};

目前看上去没什么问题,假设我们在 Transaction 的构造函数中调用 logTransaction() 表示我们希望每一次交易初始化时就进行记录。为了方便演示我们补充一下 BuyTransactionSellTransaction 的相关函数进行一些有意义的输出,如下所示 ( 完整代码见 Ex1 ):

Transaction::Transaction() {
    logTransaction();
}

void BuyTransaction::logTransaction() const {
    std::cout << "Logged a buy transaction" << std::endl;
}

void SellTransaction::logTransaction() const {
    std::cout << "Logged a sell transaction" << std::endl; 
}

我们初始化一个 BuyTransaction 对象,编译运行后输出如下信息:

BuyTransaction b;
Transaction.cpp: In constructor ‘Transaction::Transaction()’:
Transaction.cpp:21:20: warning: pure virtual ‘virtual void Transaction::logTransaction() const’ called from constructor
     logTransaction();
                    ^
/tmp/ccMUkjK5.o: In function `Transaction::Transaction()':
Transaction.cpp:(.text+0x22): undefined reference to `Transaction::logTransaction() const'
collect2: error: ld returned 1 exit status

程序提示我们找不到 Transaction::logTransaction() 的定义,并警告一个纯虚函数被调用了。这意味着,**当一个派生类 (derived class) 中的基类 (base class) 成分被构造时,其调用的所有 virtual 函数都只会调用基类中的定义,而非派生类中的定义!**这其实并不难理解:

  • 从安全性来说,由于基类的构造函数先于派生类中的构造函数被调用,因此在基类的构造函数被调用时,派生类中独有的成员变量还未被初始化,假如这个时候调用派生类的虚函数定义,有可能会因为调用未初始化的成员变量而导致程序发生未定义行为。因此 C++ 禁止了此类行为,规定在基类的构造函数中调用虚函数中只会调用基类的定义,如果未定义(例如纯虚函数的情况)就会在链接阶段由于找不到定义而失败。
  • 从远离来说,在基类的构造函数调用阶段,对象的类型实际上时基类的类型(在上例情况下是 Transaction)。因此,不只是调用虚函数时会被编译器认为是基类,如果我们对其使用运行期类型信息(runtime type information,例如 dynamic_cast 和 typeid),也会将对象视为基类类型。

对于析构函数也是类似的情况。因此为了避免这种情况以及防止出错,我们不应该在构造和析构过程中调用 virtual 函数。

示例 2

通常情况下,不在构造和析构过程中调用 virtual 函数看似很简单,但是我们也需要其内调用的所有函数也保证不调用虚函数,否则也会出现类似问题,如下所示 ( 完整代码见 Ex2 ):

class Transaction {
public:
    Transaction() {
        init();
    }
    virtual void logTransaction() const = 0;

private:
    void init() {
        logTransaction();
    }
};

class BuyTransaction : public Transaction {
    // ...
};

class SellTransaction: public Transaction {
    // ...
};

// ...
BuyTransaction b;
//...
pure virtual method called
terminate called without an active exception
Aborted (core dumped)

可以发现,上述代码逻辑上和第一个例子比较类似,但是用了一个 init() 函数来进行对象初始化(我们经常会使用这个方法)。但是在它内部同样也调用了virtual 函数。并且,此段代码可以顺利通过编译(不会引起编译器和链接器的错误)!而只是在运行时由于纯虚函数被调用而自动终止。假设我们将纯虚函数改为基类的实现版本:

class Transaction {
    //...

    // virtual void logTransaction() const = 0;
    virtual void logTransaction () {
        std::cout << "Logged a base transaction" << std::endl;
    }

    // ...
};

//...
Logged a base transaction

程序会直接调用基类的版本而不报任何错误!但实际上我们期待的是调用派生类的定义,这会给我们的调试带来极大的困扰。而唯一能避免此种情况的方法是,我们确保构造函数及其所有调用函数中都不调用 virtual 函数。

替代方法

针对上面例子,我们有没有什么办法可以既实现我们想要的效果(对不同的 Transaction 派生类对象的构造过程,进行独有的 logTransaction() 调用)并且不会出现上述问题呢?答案是有的,其中一种比较简单的方法是将 logTransaction() 改为 non-virtual 并要求派生类中的构造函数传递必要信息进而传递 logTransaction() 来调用,如下所示 ( 完整代码见 Ex3 ):

class Transaction {
public:
    explicit Transaction(const std::string& logInfo);
    void logTransaction(const std::string& logInfo) const;
};

class BuyTransaction : public Transaction {
public:
    BuyTransaction() : Transaction(createLogString()) {}
private:
    static std::string createLogString();
};

class SellTransaction: public Transaction {
public:
    SellTransaction() : Transaction(createLogString()) {}
private:
    static std::string createLogString();
};

Transaction::Transaction(const std::string& logInfo) {
    std::cout << logInfo << std::endl;
}

std::string BuyTransaction::createLogString() {
    return "Logged a buy transaction";
}

std::string SellTransaction::createLogString() {
    return "Logged a sell transaction";
}

既然我们不能从基类构造函数中往下调用,我们可以先在派生类中将需要的信息生成并传递至基类构造函数中。这里使用了 private static 函数来创建信息,因此信息不会受成员变量影响,避免了可能会出现读取未定义成员变量的问题。

初始化两个派生类,编译运行结果如下所示,可以发现运行效果和我们的预期的一样。

// ...
BuyTransaction b;
SellTransaction s;
//...
Logged a buy transaction
Logged a sell transaction

结论

不要再构造和析构期间调用 virtual 函数,因为调用时只会调用该层次(基类)的定义而不会下降至调用派生类的定义。

完整可运行代码地址:Effective-Cpp-Reading-Note Efftive C++ 阅读笔记:Effective C++

Built with Hugo
Theme Stack designed by Jimmy