C++ 多态
触发条件:
调用虚函数。
有以下几种情况:
- 通过对象调用虚函数;
- 通过指针或引用调用虚函数;
- 通过成员函数调用虚函数。
其中,通过对象调用调用虚函数,由于对象和实际类型已经是相同的,所以观察不到多态的效果。但是调用过程依然是动态绑定的。
例子:
#include <iostream>
using namespace std;
class Base {
public:
virtual void f() {
cout << "Base::f()" << endl;
}
// 通过成员函数调用虚函数
void g() {
cout << "Base::g()" << endl;
// 只有非 static 函数才能调用虚函数
//! 只要调用虚函数,就会有多态。
//! 通过 Base::this 指针调用虚函数
f();
}
};
class Derived : public Base {
public:
void f() override {
cout << "Derived::f()" << endl;
}
};
int main() {
Derived son;
// 通过成员函数调用虚函数。
son.g(); // 打印 Base::g() 和 Derived::f()
// 通过指针调用虚函数
Base *pSon = new Derived;
pSon->f(); // 打印 Derived::f()
delete pSon;
return 0;
}
虚函数实现机制
32 bit 机器测试:
#include <iostream>
using namespace std;
// A 有一个虚函数,于是 A 对象会生成一个虚函数表指针
// sizeof(A) = 4
class A {
public:
virtual void a() {};
};
// sizeof(B) = 4
class B {
public:
virtual int b() { return 3; }
};
// 多重继承,会有两个虚函数表指针
#include <iostream>
using namespace std;
// A 有一个虚函数,于是 A 对象会生成一个虚函数表指针
// sizeof(A) = 4
class A {
public:
virtual void a() {};
void f() {
cout << "A::f()" << endl;
}
};
// sizeof(B) = 4
class B {
public:
virtual int b() { return 3; }
};
// 多重继承,会有两个虚函数表指针
// sizeof(C) = 8
class C : public A, B {
public:
virtual int c() { return 4; }
};
// 单继承,来自父类的虚函数表指针会被覆盖,只有一个虚函数表指针
// sizeof(D) = 4
class D : public A {
public:
};
int main() {
cout << "size A: " << sizeof(A) << endl; // 4
cout << "size B: " << sizeof(B) << endl; // 4
cout << "size C: " << sizeof(C) << endl; // 8
cout << "size D: " << sizeof(D) << endl; // 4
// malloc 没有进行对象构造,虚函数表指针未定义、未初始化。
A* pa = (A*)malloc(sizeof(A));
pa->f(); // 调用非虚函数,正确运行。
// pa->a(); // 调用虚函数,引发运行时异常。因为试图解引用未定义的虚函数表指针。
getchar();
return 0;
}
虚函数表的图示可以参考 虚表指针、虚函数指针位置。
- 只要 class 有 virtual 函数,那么该类就会生成一张虚函数表(可以简称“虚表”),linux 放在 rodata 段,windows 放在 data 段。父类和子类的虚函数表是两张不同的表,各有一张,互不干扰。
- 虚函数表存放的是虚函数指针。注意:非虚函数的指针不会出现在该虚函数表中。
- 带有虚函数的类的对象至少有一个虚函数表指针。在单继承下,子类只有一个虚函数表指针,但是多重继承就会有多个虚函数表指针。
- 单继承体系下,子类将会拥有一张从父类复制过来的虚函数表。 当子类对父类的 virtual 函数进行重写时,子类虚函数表中对应的虚函数指针就会被覆盖成子类的函数指针。如果子类新增了父类中没有的虚函数,那么新增的虚函数指针将追加加在子类虚函数表里。
- 多重继承体系下,例如上面的 C 类,会复制所有父类的虚函数表。如果子类新增了父类中没有的虚函数,那么新增的虚函数指针会追加在第一张虚函数表里,也就是从第一个父类复制过来的虚函数表里。
虚函数表指针存放在哪里?
-
必然存放在对象的内存空间。
唯一可能的实现方案,就是放在对象的内存空间中。因为在对象存在之前,这个 vptr 是不可知的;在对象存在之后,这个 vptr 是确知的。即使放在全局区(类 static 空间),在对象存在之前 vptr 又不能确知,没有意义。 -
是放在对象的数据成员的前面、中间还是后面?
答:测试结果表明,作为类的数据成员的一部分,放在最前面。
#include <iostream>
using namespace std;
class A {
public:
virtual void* get() {
cout << "A::get()" << endl;
return NULL;
}
};
class B : public A {
public:
// 构造函数的初始化列表中调用虚函数
B() : ptr_(get()) {
}
void* get() override {
cout << "B::get()" << endl;
return NULL;
}
private:
void* ptr_;
};
int main() {
// 子类的构造函数可以安全地调用虚函数,说明在构造函数的初始化列表执行之前,
// 虚函数表指针已经被初始化了。
// 这说明:虚函数表指针必然是第一个数据成员。
B b; // 打印 B::get()
getchar();
return 0;
}
- 虚函数表指针放置位置有什么影响?
放在数据成员的最前面,则会最先被初始化。这样即使是在子类初始化列表中调用虚函数,也能运行期正确调用。
虚函数表指针的构造和析构过程
#include <iostream>
using namespace std;
class Base {
public:
// 在父类构造函数中调用虚函数
Base() {
cout << "Base(): ";
f();
}
// 在析构函数中调用虚函数
virtual ~Base() {
cout << "~Base(): ";
f();
}
virtual void f() {
cout << "Base::f()" << endl;
}
};
class Derived : public Base {
public:
Derived() {
cout << "Derived(): ";
f();
}
~Derived() {
cout << "~Derived(): ";
f();
}
void f() override {
cout << "Derived::f()" << endl;
}
};
class DerivedDerived : public Derived {
public:
DerivedDerived() {
cout << "DerivedDerived(): ";
f();
}
~DerivedDerived() {
cout << "~DerivedDerived(): ";
f();
}
void f() override {
cout << "DerivedDerived::f()" << endl;
}
};
int main() {
{
cout << sizeof(DerivedDerived) << endl; // 4
DerivedDerived son;
// 打印如下:
// 4
// Base(): Base::f()
// Derived(): Derived::f()
// DerivedDerived(): DerivedDerived::f()
// ~DerivedDerived(): DerivedDerived::f()
// ~Derived(): Derived::f()
// ~Base(): Base::f()
}
return 0;
}
测试结论:
构造过程:
- 父类:1、虚函数表指针初始化;2、初始化默认值、初始化列表(如果有的话);3、执行构造函数的函数体。
- 子类:1、虚函数表指针初始化,即修改虚表指针指向,指向子类本身的虚表;2、初始化默认值、初始化列表(如果有的话);3、执行构造函数体。
析构过程:
- 子类:1、执行析构函数体;2、虚表指针重置为上级父类指针。
- 最顶层父类:1、执行析构函数体;2、虚表指针释放。
构造函数能否调用虚函数?
说明:这里的虚函数指的是继承体系中的虚函数,如果在构造函数中调用一个与父类、子类都无关的无关类的虚函数,当然不会有问题。
参考文章:构造函数和析构函数中可以调用调用虚函数吗。
答案:从上面的例子可以看出来,可以构造函数是可以调用虚函数的,并且不会发生运行期错误。但是,既然是虚函数,我们希望其能够达到动态绑定的多态,可以保证吗?
- 在子类的构造函数调用虚函数,能保证多态。
#include <iostream>
using namespace std;
class A {
public:
virtual void* get() {
cout << "A::get()" << endl;
return NULL;
}
virtual void release() {
cout << "A::release()" << endl;
}
};
class B : public A {
public:
B() : ptr_(get()) {
}
~B() {
release();
}
void* get() override {
cout << "B::get()" << endl;
return NULL;
}
void release() override {
cout << "B:: release()" << endl;
}
private:
void* ptr_;
};
int main() {
{
B b; // 打印 B::get()
// 析构时打印 B::release()
}
return 0;
}
我们讨论一下构造函数中调用虚函数的过程:
构造顺序:调用父类构造函数 → 调用子类的构造函数(列表初始化 → 执行构造函数的函数体)。
首先,既然我们讨论的是子类的构造函数,显然父类部分已经通过父类的构造函数构造好了,父类部分是完整的,其包含了虚函数表的指针。
接下来分两种情况:
- 在子类构造函数的初始化列表中调用虚函数;
- 在子类构造函数的函数体中调用虚函数。
有了前面的讨论,我们知道,虚函数表指针最先被子类对象初始化,然后执行列表初始化其他成员,最后执行构造函数的函数体。所以能够保证多态。
- 在父类构造函数中调用虚函数,不能保证多态。
《Effective C++》《C++编程思想》不建议在构造函数和析构函数中调用虚函数。
#include <iostream>
using namespace std;
class Base {
public:
// 在父类构造函数中调用虚函数
Base() {
f();
}
virtual void f() {
cout << "Base::f()" << endl;
}
};
class Derived : public Base {
public:
void f() override {
cout << "Derived::f()" << endl;
}
};
int main() {
// 先构造 Base 部分,Base 构造函数调用了虚函数 f(),
// 由于此时只有 Base 部分,所以只能通过 Base 的虚函数表指针查找虚函数,
// 于是 Base::f() 被调用。
// 这与我们的预期不符,因为我们希望调用的是 Derived 的虚函数。
Derived son; // 打印 Base::f() ,不符合预期。
return 0;
}
析构函数的情况类似:如果在父类析构函数中调用虚函数,那么由于进入父类析构函数之前,子类部分已经析构了,并且会将虚函数表指针重置为父类的虚函数表指针,那么父类的析构函数调用的将是父类的虚函数。这与我们的预期不符,因为一个子类对象,却调用了父类的虚函数。
建议:
不要在构造 / 析构函数中调用虚函数。
因为你无法保证当前类不被其他类继承,发生继承时,无法保证多态。
构造函数能否是虚函数?
答案:不能。编译器会报错。
参考文章: 构造函数为什么不能是虚函数。
-
概念
- 从概念上理解,所谓“虚函数”,就是在编译期不能知道函数入口,只有在运行期才能通过虚函数表指针查找虚函数表才能得到。
- 所谓“构造函数”,就是对象构造时,需要调用的函数。
-
矛盾
那么矛盾来了:一个对象构造时,需要通过虚函数表指针查找对应的构造函数;但是由于这个对象还没有构造,虚函数表指针还不存在。 -
结论:
所以,构造函数不能是虚函数。
虚析构函数的好处?
在继承体系中,建议使用虚析构函数。
先看看析构过程:首先析构子类部分,然后析构父类部分。
如果对象的实际类型和绑定类型是相同的,可以正确析构。
但是如果子类对象绑定的是父类类型呢?此时如果析构函数是非虚函数,那么将不存在动态绑定的多态机制。此时将按照其绑定类型析构,也就是说子类对象析构时,仅仅调用了父类的析构函数(TODO:会不会造成内存泄露,因为子类部分没有析构,是不是意味着内存没有释放?)。
析构函数能否是纯虚函数?
参考文章:析构函数可以是纯虚函数。
答案:可以。但是必须同时在类外提供该纯虚函数的定义(这算哪门子的“纯虚函数”)。
可以这样理解:“纯虚函数” 仅仅是一种声明,我们可以同时提供这个“纯虚函数”的定义。
由于析构函数在子类对象析构时,一定会被调用,所以,这个纯虚析构函数的定义是有效的。
但是,由于纯虚成员函数必须被子类重写,提供基类的纯虚成员函数的定义虽然语法上不报错,但是这个定义永远不会被调用到,没有意义。
结论:
我们可以将析构函数声明为“纯虚函数”,但是必须提供析构函数的定义。
声明纯虚析构函数的好处是,该类将被认为是抽象类,不允许定义其对象。
示例1:
#include <iostream>
using namespace std;
class A {
public:
virtual ~A() = 0; // 声明式
};
// 定义式
// 必须提供定义,否则 B 继承 A 时,会报错。
// A 仍然被视为抽象类,不允许构建 A 的对象。
A::~A(){
cout << "~A()" << endl;
}
class B : public A {
public:
~B() {}
};
int main() {
{
// A a; // 报错
B b;
}
}
示例2:
#include <iostream>
using namespace std;
class A {
public:
virtual void f() = 0; // 声明式
};
// 定义式
// 由于不允许定义抽象函数 A 的对象,这个函数定义永远不会被调用。
void A::f() {
cout << "A::f()" << endl;
}
class B : public A {
public:
// 重写父类的纯虚函数
void f() override {
cout << "B::f()" << endl;
}
};
int main() {
{
B b;
b.f();
}
return 0;
}
重写(override)仅仅适用于虚函数
- override 必须与 virtual 配对:父类使用 virtual ,子类才能使用 override。
- 重写(override)仅仅针对虚函数。
- 如果子类定义和父类相同的签名的非虚函数,只能叫“隐藏”了父类的对应函数。
- 析构函数也可以重写。
参考文章:重写、重载、重定义三者的概念。
#include <iostream>
using namespace std;
class Base {
public:
Base() {
cout << "Base()" << endl;
}
virtual ~Base() {
cout << "~Base()" << endl;
}
// 父类使用 virtual
// 子类才能使用 override
// 否则报错。
virtual void f() {
cout << "Base::f()" << endl;
}
};
class Derived : public Base {
public:
Derived() {
cout << "Derived()" << endl;
}
// 析构函数也可以重写
~Derived() override {
cout << "~Derived(): ";
}
// 重写仅仅针对虚函数
// 如果子类定义和父类相同的签名的非虚函数,只能叫“隐藏”了父类的对应函数。
//
// 如果父类函数不是 virtual,那么使用 override 关键字将报错:
// member function declared 'override' does not override a base class member
// “声明为 override 的成员函数没有覆盖(重写)父类的成员”
void f() override {
cout << "Derived::f()" << endl;
}
};
int main() {
{
Derived son;
}
return 0;
}
虚函数类的对象 double free 的情况
《Effective C++》写道:“当类的对象调用虚函数时,调用的是与自身类型匹配的虚函数”。
子类对象析构时,使用子类对象的虚函数表指针,释放掉子类部分,于是剩余的对象变成了父类的类型;然后虚函数表指针会被修正为父类的虚函数表指针,于是接着调用父类的虚析构函数。
在运行时可能出现,一个线程已经释放了对象,但是另外一个持有对象指针的线程又释放一次的情况。
子类继承抽象函数时,没有重写纯虚函数,那么如果能成功编译,这个虚函数指针在虚表中是什么?
答:该函数指针为 NULL,也就是 0。
一般情况下,编译都会报错,但是,在 dynamic 的情况下,可能存在这种情况。