一、继承
1. 继承概念
继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类/子类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
我们先简单看一下继承的使用,如以下代码:
class Person { public: void Print() { cout << "name:" << _name << endl; cout << "age:" << _age << endl; } protected: string _name = "Mike"; int _age = 18; }; // 继承 Person 父类 class Student : public Person { protected: string s_id; }; // 继承 Person 父类 class Tercher : public Person { protected: string t_id; };
继承后,父类的 Person 的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了 Student 和 Teacher 复用了 Person 的成员。下面我们使用监视窗口查看 Student 和 Teacher 对象,可以看到变量的复用;调用 Print 可以看到成员函数的复用;如下代码:
int main() { Person p; Student s; Tercher t; p.Print(); s.Print(); t.Print(); return 0; }
我们先观察监视窗口:
我们可以看到,s 和 t 都继承了父类的成员变量;再尝试调用父类的成员函数:
2. 继承定义
(1)继承的格式定义
继承的定义格式如下,Person 是父类/基类,Student 是子类/派生类,public 为继承方式:
(2)继承父类成员访问方式的变化
继承父类成员访问方式的变化如下表:
其中以 public 继承方式我们称父类和子类是一种 is-a 关系,也就是“我是一个你”。
总结:
- 父类 private 成员在子类中无论以什么方式继承都是不可见的。这里的不可见是指父类的私有成员还是被继承到了派生类对象中,但是语法上限制子类对象不管在类里面还是类外面都不能去访问它。
- 父类 private 成员在子类中是不能被访问,如果父类成员不想在类外直接被访问,但需要在子类中能访问,就定义为 protected。可以看出保护成员限定符是因继承才出现的。
- 父类的私有成员在子类都是不可见。父类的其他成员在子类的访问方式 == min(成员在基类的访问限定符,继承方式),其中比较规则:public > protected > private.
- 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显示的写出继承方式。
- 在实际运用中一般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡使用 protetced/private 继承,因为 protetced/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
下面演示 public 继承关系下父类成员的各类型成员访问关系的变化 :
// Print() 函数在父类中是 pubilc 成员 class Person { public: void Print() { cout << _name << endl; } protected: string _name = "Mike"; // 姓名 }; // public 继承父类 class Student : public Person { public: Student() :_stuid(000) { Print(); } protected: int _stuid; // 学号 }; int main() { Student s; s.Print(); return 0; }
如上,Print() 函数在父类中是 pubilc 成员,子类继承方式也是 public,所以此时 Print() 在类内类外都可访问,如下:
当我们将 Print() 的访问限定符改为 protected 后,如下:
// Print() 函数在父类中是 protected 成员 class Person { protected: void Print() { cout << _name << endl; } protected: string _name = "Mike"; // 姓名 };
那么此时 Print() 函数只能在子类中使用,在类外不可使用:
3. 父类和子类对象赋值转换
- 子类对象可以赋值给父类的对象 / 父类的指针 / 父类的引用。这里有个形象的说法叫切片或者切割。寓意把子类中父类那部分切来赋值过去,如下图所示:
- 父类对象不能赋值给子类对象,因为子类就是继承父类下来的,父类有的子类也有。
如下示例:
class Person { protected: string _name; int _age; }; class Student : public Person { public: string s_id; }; int main() { Student s; // 1.子类对象可以赋值给父类对象/指针/引用 Person p = s; Person* pp = &s; Person& rp = s; // 2.基类的指针可以通过强制类型转换赋值给派生类的指针 pp = &s; Student* ps1 = (Student*)pp; // 这种情况转换时可以 ps1->s_id = 7; pp = &p; Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问 ps2->s_id = 3; return 0; }
4. 继承中的作用域
- 在继承体系中父类和子类都有独立的作用域。
- 父类和子类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用
父类::基类成员
显示访问) - 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
例如以下示例:
class A { public: void fun() { cout << "func()" << endl; } }; class B : public A { public: void fun(int i) { A::fun(); // 可显示调用 A 类的 fun 函数 cout << "func(int i)->" << i << endl; } }; int main() { B b; b.fun(10); // 默认调的是 B 类的 fun 函数 b.A::fun(); // 可显示调用 A 类的 fun 函数 return 0; }
B 中的 fun 和 A 中的 fun 不是构成重载,因为不是在同一作用域,而是构成隐藏,成员函数满足函数名相同就构成隐藏。
5. 子类的默认成员函数
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在子类中,这几个成员函数是如何生成的呢?
- 子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员。如果父类没有默认的构造函数,则必须在子类构造函数的初始化列表阶段显示调用。
- 子类的拷贝构造函数必须调用父类的拷贝构造完成父类的拷贝初始化。
- 子类的 operator= 必须要调用父类的 operator= 完成基类的复制。
- 子类的析构函数会在被调用完成后自动调用父类的析构函数清理父类成员。因为这样才能保证子类对象先清理子类成员再清理父类成员的顺序。
- 子类对象初始化先调用父类构造再调子类构造。
- 子类对象析构清理先调用子类析构再调父类的析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成 destrutor(),所以父类析构函数不加 virtual 的情况下,子类析构函数和父类析构函数构成隐藏关系。
下面演示继承中构造函数和析构函数的调用情况:
class A { public: A() { cout << "A()" << endl; } ~A() { cout << "~A()" << endl; } private: int _a; }; class B : public A { public: B() { cout << "B()" << endl; } ~B() { cout << "~B()" << endl; } };
上述代码中,我们实例化B对象,编译器会先调用父类的构造,即先调A的构造,然后再构造子类B,析构顺序按照先子后父,例如下图:
6. 继承与友元
友元关系不能继承,也就是说父类友元不能访问子类私有和保护成员。简单一句话说就是,父类的友元并不是子类的友元,所以不能继承下来。
7. 继承与静态成员
父类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个 static 成员实例。
8. 复杂的菱形继承及菱形虚拟继承
(1)继承类型
1. 单继承:一个子类只有一个直接父类时称这个继承关系为单继承。
2. 多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。
3. 菱形继承:菱形继承是多继承的一种特殊情况。
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。以上图为例,在 D 的对象中 A 成员会有两份,如下图:
用代码来说明如下代码为四个类以以上的方式继承:
class A { public: int _a; }; class B : public A { public: int _b; }; class C : public A { public: int _c; }; class D : public B, public C { public: int _d; };
我们尝试实例化一个D类对象d,先尝试修改d对象中的 _b 值:
如上图是没有问题的,我们再尝试修改d对象中的 _a 值:
如上图,可以看出编译不通过,理由就是对 _a 的访问不明确,就是因为存在二义性,在 d 中存有两份 _a,编译器不知道我们访问的是哪一个;
但是我们可以指定访问域,如下图:
因为在 B 中和 C 中各有一份 _a,所以我们可以指定 B 中的 _a,或者 C 中的 _a 进行指定操作,这样就解决二义性问题了;但是数据冗余还没有解决。
但是有一种方法可以既解决二义性,也解决数据冗余问题,就是虚拟继承。虚拟继承可以解决菱形继承的二义性和数据冗余的问题。 如上面的继承关系,在 B 和 C 继承 A 时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
我们用代码演示证明一下,首先我们在 B 和 C 继承 A 时加上 virtual 关键字,说明是虚拟继承:
class A { public: int _a; }; class B : virtual public A { public: int _b; }; class C : virtual public A { public: int _c; }; class D : public B, public C { public: int _d; };
接下来我们直接实例化 D 对象,并访问 _a,如下图:
如上二义性问题就解决了,那么我们看看 d 对象中 B 和 C 中的 _a 的值 :
我们观察可知,d 对象中继承下来的类里面的 _a 都是 1,也就是说,这个 _a 在这个 d 对象中,只有一份!那就说明虚拟继承也解决了数据冗余问题了!
(2)虚拟继承解决数据冗余和二义性的原理
首先我们先通过调试观察一下普通菱形继承中,d 对象中的内存分布,如下图所示:
我们可以观察到 d 对象中有两份 _a ,明显的数据冗余。
接下来我们加上虚拟继承,继续观察 d 对象中的内存分布,如下图:
上图是菱形虚拟继承的内存对象成员模型:这里可以分析出 D 对象中将 A 放到的了对象组成的最下面,这个 A 同时属于 B 和 C,那么 B 和 C 如何去找到公共的 A 呢?这就和 B 和 C 中多了两个地址有关系了,这两个地址是什么呢?我们可以取它们的地址到内存窗口去观察一下:
这里是通过了 B 和 C 的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的是偏移量,通过偏移量可以找到下面的 A。例如上图中 B 中的指针指向的虚基表中,它的偏移量是十六进制的 14 字节,化作十进制就是 20 字节,那么从虚基表指针开始,往下 5 个单位就是 A,每个单位 4 个字节,那么就刚好是 20 个字节就能找到 A.
9. 继承的总结
继承和组合
- public 继承是一种 is-a 的关系。也就是说每个子类对象都是一个父类对象。
- 组合是一种 has-a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。
什么是组合呢?如下代码就是组合,其中是 B 组合了 A:
class A { public: int _a; }; class B { public: int _b; A a; };
- 优先使用对象组合,而不是类继承 。
- 继承允许我们根据父类的实现来定义子类的实现。这种通过生成子类的复用通常被称为白箱复用(white-box reuse)。术语 “白箱” 是相对可视性而言:在继承方式中,父类的内部细节对子类可见 。继承一定程度破坏了父类的封装,父类的改变,对子类有很大的影响。子类和父类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以 “黑箱” 的形式出现。组合类之间没有很强的依赖关系,耦合度低。 优先使用对象组合有助于我们保持每个类被封装。
- 实际尽量多去用组合。组合的耦合度低,代码维护性好。 不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
二、多态
1. 多态的概念
多态的概念: 通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
2. 多态的定义及实现
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如 Student 继承了 Person。Person 对象买票全价,Student 对象买票半价。
(1)多态的构成条件
那么在继承中要构成多态还有两个条件:
- 必须通过父类的指针或者引用调用虚函数;
- 被调用的函数必须是虚函数,且子类必须对父类的虚函数进行重写;
我们先简单看一下多态的使用,如以下代码:
class Person { // 让 BuyTicket 成为虚函数 public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; class Student : public Person { // 对 BuyTicket 进行重写 public: virtual void BuyTicket() { cout << "买票-半价" << endl; } }; // 父类的指针或者引用调用 //void func(Person& p) void func(Person* pa) { pa->BuyTicket(); }
下面开始多态的调用,如下结果:
如上就是多态的简单使用,下面开始详细介绍多态的条件。
(2)虚函数
虚函数: 即被 virtual 修饰的类成员函数称为虚函数。
如下代码中的 BuyTicket 函数:
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } };
(3)虚函数的重写
虚函数的重写(覆盖):子类中有一个跟父类完全相同的虚函数(即子类虚函数与父类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了父类的虚函数。
如下段代码中,子类 Student 类对 BuyTicket 函数完成了重写:
class Person { public: virtual void BuyTicket() { cout << "买票-全价" << endl; } }; class Student : public Person { public: // void BuyTicket() // 子类对 BuyTicket 进行重写时,也可以不加 virtual virtual void BuyTicket() { cout << "买票-半价" << endl; } };
在重写父类虚函数时,子类的虚函数在不加 virtual 关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议。这也和下面要说的析构函数的重写有一定的关联。
虚函数重写的两个例外:
- 协变(父类与子类虚函数返回值类型不同)
子类重写父类虚函数时,与父类虚函数返回值类型不同。即父类虚函数返回父类对象的指针或者引用,子类虚函数返回子类对象的指针或者引用时,称为协变。(了解即可)
如以下这段代码中,也构成多态:
class Person { public: virtual Person* BuyTicket() { cout << "买票-全价" << endl; return nullptr; } }; class Student : public Person { public: virtual Student* BuyTicket() { cout << "买票-半价" << endl; return nullptr; } };
- 析构函数的重写(父类与子类析构函数的名字不同)
如果父类的析构函数为虚函数,此时子类析构函数只要定义,无论是否加 virtual 关键字,都与父类的析构函数构成重写,虽然父类与子类析构函数名字不同。这样看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor .
如果在父类中没有加上 virtual 即析构函数不构成多态,当下面这种情景时,不能正确调用析构函数:
// 析构函数不构成多态 class Person { public: ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: ~Student() { cout << "~Student()" << endl; } };
如上图,当我们 new 的是一个子类对象,将它赋值兼容给父类,会发生切片操作,此时我们释放 p 指针,由于析构函数不构成多态,因此只会调用父类的析构函数,此时子类部分的空间就没有被释放,就会发生内存泄漏。
当我们在父类的析构函数加上 virtual,此时就构成多态了,子类的析构加不加 virtual 都无所谓,就是为了防止这种情况,我们在子类中忘记对析构函数进行重写,所以才会有上面的例外,在子类中进行重写时可以不加 virtual;所以我们在父类中加上 virtual 即可构成多态,如下段代码:
class Person { public: virtual ~Person() { cout << "~Person()" << endl; } }; class Student : public Person { public: ~Student() { cout << "~Student()" << endl; } };
(3)override 和 final
从上面可以看出,C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此:C++11提供了 override 和 final 两个关键字,可以帮助用户检测是否重写。
- final:修饰虚函数,表示该虚函数不能再被重写;
如下段代码:
- override:检查子类虚函数是否重写了父类某个虚函数,如果没有重写编译报错。
如下段代码:
此时将B类中的func函数的返回类型改为 void 即可通过编译。
(4)重载、覆盖(重写)、隐藏(重定义)
重载、覆盖(重写)、隐藏(重定义)的对比如下图所示:
3. 抽象类
(1)概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。子类继承后也不能实例化出对象,只有重写纯虚函数,子类才能实例化出对象。纯虚函数规范了子类必须重写,另外纯虚函数更体现出了接口继承。
class A { public: virtual void func() = 0 { cout << "A::func()" << endl; } }; class B : public A { public: virtual void func() { cout << "B::func()" << endl; } }; class C : public A { public: virtual void func() { cout << "C::func()" << endl; } };
如上段代码,A类是抽象类,不能实例化出对象,B类和C类继承了A类,并完成重写 func 函数,所以B类和C类可以实例化对象;下面我们简单使用一下:
(2)接口继承和实现继承
普通函数的继承是一种实现继承,子类继承了父类函数,可以使用函数,继承的是函数的实现。
虚函数的继承是一种接口继承,子类继承的是父类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
4. 多态的原理
(1)虚函数表
我们先看一下下面这个类的大小是多少?
class A { public: virtual void Func() { cout << "Func()" << endl; } private: int _a = 3; };
根据我们以前学的知识,成员函数不占A类的空间,而是放在公共代码区,而成员变量则根据内存对齐计算大小,所以 sizeof(A) = 4
,这样算对吗?我们看一下结果:
如上图,答案是 8,为什么会是 8 呢?我们实例化一个对象出来观察它里面到底有什么:
通过上图我们发现,除了 _a 成员,还多一个 __vfptr 放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么子类中这个表放了些什么呢?我们接着往下分析;
我们写一个 Base 类,Derive 类继承Base,Base 类中Func1 和 Func2 是虚函数,Func3 不是虚函数; Derive 中重写 Func1函数,如下段代码:
class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } void Func3() { cout << "Base::Func3()" << endl; } private: int _b = 1; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; };
下面我们实例化 b 对象和 d 对象:
int main() { Base b; Derive d; return 0; }
然后我们通过调试窗口观察对象中的内存:
我们看到,两个对象的虚函数指针是不一样;我们再进一步观察虚函数指针中虚表的内容:
通过观察和测试,我们发现了以下几点问题:
- 子类对象 d 中也有一个虚表指针,d 对象由两部分构成,一部分是父类继承下来的成员和虚表指针,另一部分是自己的成员。
- 父类 b 对象和子类 d 对象虚表是不一样的,这里我们发现 Func1完成了重写,所以 d 的虚表中存的是重写的 Derive::Func1 的地址,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数地址的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- 另外 Func2 继承下来后是虚函数,所以放进了虚表,Func3 也继承下来了,但是不是虚函数,所以不会放进虚表。
- 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
- 总结一下子类的虚表生成:
a.先将父类中的虚表内容拷贝一份到子类虚表中;
b.如果子类重写了父类中某个虚函数,用子类自己的虚函数覆盖虚表中父类的虚函数 ;
c.子类自己新增加的虚函数按其在子类中的声明次序增加到子类虚表的最后。
(2)多态的原理
有了上面的基础,我们就可以分析一下多态的原理了;
首先我们有以下两个类实现的多态:
class Base { public: virtual void Func1() { cout << "Base::Func1()" << endl; } private: int _b = 1; }; class Derive : public Base { public: virtual void Func1() { cout << "Derive::Func1()" << endl; } private: int _d = 2; }; void Test(Base* pa) { pa->Func1(); }
接下来我们分别使用Base和Derive实例化出对象 b 和 d,再调用 Test 函数:
int main() { Base b; Test(&b); Derive d; Test(&d); return 0; }
下面我们从汇编的角度分析多态的原理,如下图:
从上图可以看出,多态调用是运行时,去虚表里面找到函数地址,确定地址再调用。
但如果是对象调用,如下代码,就不构成多态,我们同样在汇编角度分析:
void Test(Base pa) { pa.Func1(); } int main() { Base b; Test(b); Derive d; Test(d); return 0; }
从上图可以看出,普通调用是编译链接时确定地址。
所以,我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数;其中虚函数覆盖就不多说了;
父类的指针或引用接收对象的指针或引用调用虚函数,本质就是父类指针指向子类对象中切割出来父类的那一部分,然后再取虚函数指针找到虚函数的地址。
那么我们使用对象调用为什么不能实现多态呢?其实使用子类对象赋值给父类对象,会切割出子类对象中父类那一部分成员拷贝给父类,但是不会拷贝虚函数指针。为什么呢?我们假设:子类对象会拷贝虚函数表指针给父类对象,我们看一下以下代码:
Person* p = new Person; Student s; *p = s; delete p;
此时出现的问题就是,多态调用指向父类,调用的不一定是父类的虚函数了,因为我们中间将 s 赋给了 *p,现在 p 中的虚函数指针是 s 的;所以这种方法不可取。
(3)动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载;
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
5. 虚函数和虚表存在于哪里?
虚函数和虚表存在于哪里?有人会说虚函数存在虚表,虚表存在对象中。但是上面的回答的错误的。
想要知道虚函数和虚表存在于哪里,我们可以打出各个区域的地址观察,观察虚函数和虚表离哪个区域比较近,下面我们开始验证,如下段代码:
class Base { public: virtual void func1() { cout << "Base::func1" << endl; } virtual void func2() { cout << "Base::func2" << endl; } private: int a; }; void func() { cout << "void func()" << endl; } int main() { Base b1; static int a = 0; int b = 0; int* p1 = new int; const char* p2 = "hello"; printf("静态区:%p\n", &a); printf("栈:%p\n", &b); printf("堆:%p\n", p1); printf("代码段/常量区:%p\n", p2); printf("虚表:%p\n", *((int*)&b1)); printf("虚函数地址:%p\n", &Base::func1); printf("普通函数地址:%p\n", func); return 0; }
运行结果如下:
我们可以看到,在 vs2019 中,虚表和虚函数的地址都是离代码段/常量区最近的,所以我们认为,在 vs2019 中,它们是在代码段中的。
6. 单继承中的虚函数表
需要注意的是在单继承和多继承关系中,下面我们去关注的是子类对象的虚表模型,因为父类的虚表模型前面我们已经看过了,没什么需要特别研究的。我们这里就只看单继承的中的虚函数表。
例如有以下代码:
class Base { public: virtual void func1() { cout << "Base::func1" << endl; } virtual void func2() { cout << "Base::func2" << endl; } private: int a; }; class Derive :public Base { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } virtual void func4() { cout << "Derive::func4" << endl; } private: int b; }; int main() { Base b; Derive d; return 0; }
我们通过监视窗口观察子类对象中的虚表模型:
观察上图中的监视窗口中我们发现看不见 func3 和 func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小 bug。那么我们如何查看 d 的虚表呢?下面我们使用代码打印出虚表中的函数。
class Base { public: virtual void func1() { cout << "Base::func1" << endl; } virtual void func2() { cout << "Base::func2" << endl; } private: int a; }; class Derive : public Base { public: virtual void func1() { cout << "Derive::func1" << endl; } virtual void func3() { cout << "Derive::func3" << endl; } virtual void func4() { cout << "Derive::func4" << endl; } private: int b; }; // typedef 函数指针类型 typedef void (*VFUNC)(); void PrintVFT(VFUNC* a) { for (size_t i = 0; a[i] != 0; i++) { printf("[%d]:%p->", i, a[i]); VFUNC f = a[i]; f(); // 相当于调(*f)(); 调函数指针对应的函数 } } int main() { Base b; PrintVFT((VFUNC*)(*((int*)&b))); cout << endl; Derive d; PrintVFT((VFUNC*)(*((int*)&d))); return 0; }
打印的结果如下:
此时我们就可以看到 d 对象中的虚函数了。