1. 为什么 C++ 构造函数不能是虚函数?
1.1 从语法层面来说
虚函数的主要目的是实现多态,即允许在派生类中覆盖基类的成员函数。
举个例子:
#include <iostream>
class Base {
public:
virtual void show() {
std::cout << "Base show" << std::endl;
}
Base() {
show(); // 在构造函数中调用虚函数
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
void show() override {
std::cout << "Derived show" << std::endl;
}
};
int main() {
Derived d;
return 0;
}
在这段代码中,Base
类中有一个虚函数 show()
,Derived
类重写了这个虚函数。在 Base
类的构造函数中调用 show()
时,实际调用的是 Base
类的 show()
,而不是 Derived
类的 show()
。这就说明了为什么构造函数中不需要使用虚函数来实现多态。
构造函数负责初始化类的对象,每个类都应该有自己的构造函数。在派生类中,基类的构造函数会被自动调用,用于初始化基类的成员。因此,构造函数没有被覆盖的必要,不需要使用虚函数来实现多态。
构造函数的职责是初始化对象,确保所有成员变量都得到正确初始化。因此,每个类都需要自己的构造函数,构造函数不需要被覆盖。
1.2. 从虚函数表机制回答
虚函数使用了一种称为虚函数表(vtable)的机制。然而,在调用构造函数时,对象还没有完全创建和初始化,所以虚函数表可能尚未设置。
在对象的构造过程中,虚函数表(vtable)并未完全初始化。在基类的构造函数中,虚函数表指向基类的虚函数实现,而不是派生类的虚函数实现。这就解释了为什么在基类构造函数中调用虚函数时,不会调用派生类的虚函数。
这意味着在构造函数中使用虚函数表会导致未定义的行为。只有执行完了对象的构造,虚函数表才会被正确的初始化。
举个例子:
#include <iostream>
class Base {
public:
virtual void show() {
std::cout << "Base show" << std::endl;
}
Base() {
std::cout << "Base constructor" << std::endl;
show(); // 调用的是 Base::show()
}
virtual ~Base() {
std::cout << "Base destructor" << std::endl;
show(); // 调用的是 Derived::show() 如果对象是 Derived 类型
}
};
class Derived : public Base {
public:
void show() override {
std::cout << "Derived show" << std::endl;
}
Derived() {
std::cout << "Derived constructor" << std::endl;
}
~Derived() {
std::cout << "Derived destructor" << std::endl;
}
};
int main() {
Derived d;
return 0;
}
输出:
Base constructor
Base show
Derived constructor
Derived destructor
Base destructor
Derived show
解释:
- 在
Base
的构造函数中调用show()
时,调用的是Base
的show()
,因为此时对象还没有完成Derived
部分的构造,虚函数表指向Base
类的show()
。 - 在
Derived
的析构函数中调用show()
时,调用的是Derived
的show()
,因为对象已经是完整的Derived
对象,虚函数表指向Derived
类的show()
。
通过这个例子,可以清楚地看到,虚函数表的初始化在对象构造过程中是动态变化的,只有在对象完全构造完成后,虚函数表才会指向正确的派生类实现。这也解释了为什么构造函数中不适合使用虚函数。
更准确的理解:
- 多态的意义在于运行时能够根据对象的实际类型调用正确的函数实现。构造函数不参与这种多态,因为在对象构造期间,虚函数表还未完全初始化。
- 构造函数即便是虚函数,基类构造时仍然调用基类自己的函数,不会调用派生类的函数,因此在构造函数中实现多态是没有意义的。
2.为什么 C++ 基类析构函数需要是虚函数?
让我们来看一个例子来解释为什么析构函数需要定义为虚函数。
#include <iostream>
class Base {
public:
// 注意,这里的析构函数没有定义为虚函数
~Base() {
std::cout << "Base destructor called." << std::endl;
}
};
class Derived : public Base {
public:
Derived() {
resource = new int[100]; // 分配资源
}
~Derived() {
std::cout << "Derived destructor called." << std::endl;
delete[] resource; // 释放资源
}
private:
int* resource; // 存储资源的指针
};
int main() {
Base* ptr = new Derived();
delete ptr; // 只会调用Base的析构函数,Derived的析构函数不会被调用
return 0;
}
执行结果
Base destructor called.
由于基类 Base
的析构函数没有定义为虚函数,当通过基类指针 ptr
删除一个派生类 Derived
的对象时,只有基类 Base
的析构函数被调用(这里没有多态的实现,因为多态的必要条件是虚函数)。
派生类 Derived
的析构函数不会被调用,导致资源(这里是 resource
)没有被释放,从而产生资源泄漏。
修改后的代码
让我们将基类的析构函数定义为虚函数:
#include <iostream>
class Base {
public:
virtual ~Base() { // 将析构函数定义为虚函数
std::cout << "Base destructor called." << std::endl;
}
};
class Derived : public Base {
public:
Derived() {
resource = new int[100]; // 分配资源
}
~Derived() {
std::cout << "Derived destructor called." << std::endl;
delete[] resource; // 释放资源
}
private:
int* resource; // 存储资源的指针
};
int main() {
Base* ptr = new Derived();
delete ptr; // 现在会调用Derived的析构函数
return 0;
}
执行结果
Derived destructor called.
Base destructor called.
在这个例子中,基类 Base
的析构函数是虚函数,所以当删除 ptr
时,会首先调用派生类 Derived
的析构函数,然后调用基类 Base
的析构函数,这样可以确保对象被正确销毁,资源被正确释放。
为什么默认的析构函数不是虚函数?
既然基类的析构函数如此有必要被定义成虚函数,为何类的默认析构函数却是非虚函数呢?
这是因为虚函数不同于普通成员函数,当类中有虚成员函数时,类会自动进行一些额外工作。这些额外的工作包括生成虚函数表和虚表指针。每个类都有自己的虚函数表,虚函数表的作用就是保存本类中虚函数的地址。
虚函数表虽然很有用,但会占用额外的内存。当类不被其他类继承时,这种内存开销就是浪费的。因此,C++ 语言设计者默认将析构函数定义为非虚函数,以节省内存。
但是,当我们定义一个基类时,系统相信程序开发者会显式地将基类的析构函数定义成虚函数,以确保派生类对象能正确销毁并释放资源。
零成本抽象原则
这也就是 C++ 的一个设计哲学:zero overhead abstraction:
不需要为没有使用到的语言特性付出代价。
使用某种语言特性,不会带来运行时的代价。
放在这个地方就是,如果我们知道一个类不会被其它类继承,那么也就没必要将析构函数设置为虚函数,因为一旦引入虚函数就会引入虚表机制,这会造成额外的开销。