文章目录
- 1、Terms 9:Never call virtual functions during construction or destruction
- 1.1为什么不要在构造、析构函数中调用 virtual 函数
- 1.1.1经典错误
- 1.1.2 隐藏错误
- 1.2优化做法:
- 2、面试相关
- 3、总结
- 4、参考
1、Terms 9:Never call virtual functions during construction or destruction
1.1为什么不要在构造、析构函数中调用 virtual 函数
1.1.1经典错误
假设你有个 class 继承体系,用来塑膜股市交易如买进卖出的订单等等。这样的交易一定要经过审计,所以每当创建一个交易对象,在审计日志中也需要创建一笔适当的记录。
#include <iostream>
// 所有交易的基类
class Transaction {
public:
Transaction();
virtual ~Transaction() {} // 虚拟析构函数确保正确释放派生类资源
virtual void logTransaction() const = 0; // 日志记录,因类型不同,自身会有不同的操作
// ... 其他成员函数和成员变量 ...
};
// Transaction 类的构造函数实现
Transaction::Transaction() {
// ... 构造函数的实现代码 ...
// 注意:通常不建议在基类的构造函数中调用虚函数,因为这将不会调用派生类的实现
// std::cout << "Transaction constructed" << std::endl; // 示例输出
logTransaction(); // 这将导致编译错误,因为 logTransaction 是纯虚函数
}
// 派生类 BuyTransaction
class BuyTransaction : public Transaction {
public:
BuyTransaction() : Transaction() {} // 确保基类构造函数被调用
virtual void logTransaction() const override {
// 记录此交易类型的日志
std::cout << "BuyTransaction logged" << std::endl;
}
// ... 其他成员函数和成员变量 ...
};
// 派生类 SellTransaction
class SellTransaction : public Transaction {
public:
SellTransaction() : Transaction() {} // 确保基类构造函数被调用
virtual void logTransaction() const override {
// 记录此交易类型的日志
std::cout << "SellTransaction logged" << std::endl;
}
// ... 其他成员函数和成员变量 ...
};
int main() {
// 创建派生类对象
BuyTransaction buyTx;
SellTransaction sellTx;
// 这里不能直接调用 Transaction 的 logTransaction,因为它是纯虚函数
// 但可以通过派生类对象调用
buyTx.logTransaction();
sellTx.logTransaction();
return 0;
}
编译提示错误信息:
main.cpp: In constructor ‘Transaction::Transaction()’:
main.cpp:18:19: warning: pure virtual ‘virtual void Transaction::logTransaction() const’ called from constructor
18 | logTransaction(); // 这将导致编译错误,因为 logTransaction 是纯虚函数
| ~~~~~~~~~~~~~~^~
/usr/bin/ld: /tmp/ccNYEPdb.o: in function `Transaction::Transaction()':
main.cpp:(.text+0x26): undefined reference to `Transaction::logTransaction() const'
collect2: error: ld returned 1 exit status
报错原因:因为logTransaction函数在Transaction内是一个纯虚函数(pure virtual),
程序无法链接,因为连接器找不到必要的Transaction::logTransaction()的实现代码。
无疑,会有一个 BuyTransaction, SellTransaction构造函数被调用,但是 Transaction 构造函数一定会更早的调用,因为基类会先于派生类构造。
当执行基类的构造函数时,基类的构造函数调用了虚函数logTransaction()
由于C++多态的机制,我们实际上想让基类的构造函数调用的是派生类的虚函数logTransaction()(多态:使用一个基类的指针/引用指向于派生类,且派生类重写了基类的虚函数,当用该指针/引用调用虚函数时,调用的是派生类的虚函数)
但是事实并非如此:当父类的构造函数执行,派生类此时还没有进行构造,因此基类中对logTransaction()的调用不会下降至派生类中,也就是说,此处我们在父类的构造函数中调用的实际上是基类的虚函数logTransaction(),但是由于基类中的logTransaction()函数是纯虚函数,因此程序编译错误。
在派生类执行基类的构造函数时,派生类此时还未初始化。如果此时在基类的构造函数调用虚函数,调用的实际上是基类的虚函数,对虚函数的调用不会下降到派生类中。
用一句话总结就是:在 base class 构造期间,virtual 函数不再是 virtual 函数。
析构函数
不要在析构函数中调用virtual函数的原理也是相同的:
对象在析构时会先执行自己的析构函数,接着再去执行基类的析构函数
如果在基类的析构函数中调用了虚函数,那么调用的实际上也是基类的虚函数,而不会是派生类的(因为派生类已经在先前被释放了)
1.1.2 隐藏错误
为了避免代码重复的一个优秀做法是把共同的初始化代码(其中包括对logTransaction的调用)放进一个初始化函数init()内:1.1.1中是一个纯虚函数,当pure virtual函数被调用,大多执行系统会中止程序,但是如果是impure virtual函数并在Transaction()函数内部有一份实现代码,那么尽管你是derived的对象,调用的仍然是base class的实现。
class Transaction {
public:
Transaction() { init(); } // 初始化
virtual void logTransaction() const = 0; //记录交易日志, 是个虚函数
private:
void init() {
// 做一些初始化, 比如记录日志等
logTransaction();
}
};
1.2优化做法:
解决上述问题的关键,就是将base class内将logTransaction()函数改为non-virtual,然后要求derived class构造函数传递必要的信息给Transaction构造函数,从而更安全地调用non-virtual实现函数。
#include <string>
#include <iostream>
using namespace std;
class Transaction {
public:
// 注意:这里添加了分号
explicit Transaction(const std::string& logInfo) { logTransaction(logInfo); }
// 初始化日志信息
void logTransaction(const std::string& logInfo) const {
// 这里是日志记录的逻辑,例如打印到控制台或写入日志文件
// ...
std::cout << "Base Transaction constructed" << std::endl; // 示例输出
std::cout << "Base_construct —— "<< logInfo << std::endl; // 示例输出
}
};
class BuyTransaction : public Transaction {
public:
// 假设BuyTransaction需要商品名称和价格作为参数
BuyTransaction(const std::string& itemName, double price)
: Transaction(createLogString(itemName, price)) {
// 这里可以添加BuyTransaction特有的初始化代码
std::cout << "BuyTransaction logged" << std::endl;
std::cout << "Buying: " << itemName << " at $" << price << std::endl;
}
private:
static std::string createLogString(const std::string& itemName, double price) {
// 假设我们创建了一个日志字符串,包含了购买的信息
return "Buy: " + itemName + " at $" + std::to_string(price);
}
};
class SellTransaction : public Transaction {
public:
// 假设SellTransaction需要商品名称和价格作为参数
SellTransaction(const std::string& itemName, double price)
: Transaction(createLogString(itemName, price)) {
// 这里可以添加SellTransaction特有的初始化代码
std::cout << "SellTransaction logged" << std::endl;
std::cout << "Selling: " << itemName << " at $" << price << std::endl;
}
private:
static std::string createLogString(const std::string& itemName, double price) {
// 假设我们创建了一个日志字符串,包含了售卖的信息
return "Sell: " + itemName + " at $" + std::to_string(price);
}
};
int main() {
// 创建一个BuyTransaction对象
BuyTransaction buyTxn("Apple", 1.99);
// 创建一个SellTransaction对象
SellTransaction sellTxn("Orange", 2.49);
// 输出一些信息到控制台以确认对象已经被创建
std::cout << "BuyTransaction and SellTransaction objects have been created." << std::endl;
return 0;
}
输出:
Base Transaction constructed
Base_construct —— Buy: Apple at $1.990000
BuyTransaction logged
Buying: Apple at $1.99
Base Transaction constructed
Base_construct —— Sell: Orange at $2.490000
SellTransaction logged
Selling: Orange at $2.49
BuyTransaction and SellTransaction objects have been created.
并且此处的createLogString()函数被设置为static函数是比较有意义的,因此静态函数不能调用非静态成员,因此就不会担心createLogString()函数中有未初始化的数据成员
2、面试相关
在构造/析构函数中使用虚函数是一个常见的面试问题,因为这里涉及到一些C++的特性和潜在的问题。以下是关于这个问题的五个高频面试题及其解答:
面试题1:在构造函数中能否调用虚函数?
解答:在构造函数中可以调用虚函数,但此时调用的不是子类覆盖的版本,而是基类自身的版本。这是因为在构造函数执行时,对象的类型还完全是基类的类型,子类部分还没有被构造出来,所以此时调用的虚函数是基类的版本。
面试题2:为什么在构造函数中调用虚函数通常不是一个好主意?
解答:在构造函数中调用虚函数可能导致预期之外的行为,因为此时调用的不是子类覆盖的版本。这可能导致逻辑错误或不符合设计初衷的行为。此外,如果在基类的构造函数中调用虚函数,而该虚函数在子类中又被重写为抛出异常,那么在构造子类对象时可能会抛出异常,这可能导致资源泄露或其他问题。
面试题3:析构函数中能否调用虚函数?
解答:在析构函数中可以调用虚函数,此时调用的是子类覆盖的版本(如果存在的话)。因为在析构函数执行时,对象已经是一个完整的对象,包括基类和子类部分,所以此时调用的虚函数会根据对象的实际类型来确定。
面试题4:析构函数中调用虚函数需要注意什么?
解答:在析构函数中调用虚函数时,需要确保虚函数的实现不会导致任何资源泄露或无效的内存访问。因为析构函数的主要任务是清理资源,如果虚函数的实现不当,可能会破坏这个过程。此外,如果虚函数在子类中被重写为抛出异常,那么在析构函数中调用该虚函数可能会导致程序异常终止,这是需要避免的。
面试题5:如何安全地在析构函数中调用虚函数?
解答:为了安全地在析构函数中调用虚函数,可以采取以下策略:
- 确保虚函数的实现是安全的,不会导致资源泄露或无效的内存访问。
- 避免在虚函数中抛出异常,特别是在析构函数中。
- 如果可能的话,考虑将需要在析构函数中执行的操作封装到另一个非虚函数中,并在基类的析构函数中调用该函数。这样可以确保无论对象的实际类型是什么,都会执行相同的操作。
通过遵循这些原则,可以更安全地在析构函数中调用虚函数,并避免潜在的问题。
3、总结
天堂有路你不走,地狱无门你自来
4、参考
4.1《Effective C++》