文章目录
- 1. 多态的概念
- 2. 多态的定义及实现
- 2.1 多态的构成条件
- 2.1.1 实现多态还有两个必须重要条件:
- 2.1.2 虚函数 (Virtual Function)
- 定义:
- 特性:
- 示例代码:
- 代码分析
- 1. 类定义部分
- 2. 主函数部分
- 运行结果
- 重点讲解
- 1. 虚函数的作用
- 2. 动态绑定
- 3. override
- 如果没有虚函数会发生什么?
- 虚函数表(vtable)机制
- 总结
- 关于虚函数的疑问
- 1. 虚函数的意义:应对多态的需求
- 场景:处理多种子类的统一接口
- 示例代码:
- 输出:
- 为什么需要虚函数?
- 2. 虚函数的意义:提升代码的可扩展性
- 如果没有虚函数会怎样?
- 示例代码(没有虚函数的实现):
- 问题:
- 虚函数的解决方案:
- 3. 虚函数的意义:提供统一的接口
- 4. 为什么要通过父类的指针或引用调用?
- 举例:
- 5. 虚函数在实际开发中的应用场景
- 总结:虚函数的核心意义
- 2.1.3 虚函数的重写/覆盖
- 2.1.4 多态场景的一个选择题
- 2.1.5 虚函数重写的一些其他问题
- 2.1.5.1 协变(了解)
- 2.1.5.2 析构函数的重写
- (1) A* p1 = new A;
- (2) A* p2 = new B;
- (3) delete p1;
- (4)delete p2;
- 2.1.6 override 和 final关键字
- 2.1.7 重载/重写/隐藏的对比
- 3. 纯虚函数和抽象类
- 定义:
- 特性:
- 示例代码:
- 输出:
- 4. 多态的原理
- 4.1 虚函数表指针
- 答案解析
- 问题分析
- 类的结构分析
- 1. 虚函数和虚函数表指针
- 2. 成员变量
- 3. 内存对齐
- 内存布局计算
- 运行结果
- 额外说明
- 4.2 多态的原理
- 4.2.1 多态是如何实现的
- 4.2.2 动态绑定与静态绑定
- 4.2.3 虚函数表
1. 多态的概念
多态(polymorphism
)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态,编译时多态(静态多态)和运行时多态(动态多态)。
编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。
运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”~o( =∩ω∩= )m喵
“,传狗对象过去,就是"ฅʕ•̫͡•ʔฅ汪汪
"。
2. 多态的定义及实现
2.1 多态的构成条件
多态是一个继承关系的下的类对象,去调用同一函数,产生了不同的行为。比如Student
继承了Person
。Person
对象买票全价,Student
对象优惠买票。
2.1.1 实现多态还有两个必须重要条件:
-
必须是基类的指针或者引用调用虚函数
-
被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。
说明:要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象;第二派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。
2.1.2 虚函数 (Virtual Function)
定义:
- 虚函数是一个在基类中用关键字
virtual
声明的成员函数。 - 它允许在派生类中被重写,并且通过基类指针或引用调用时,能够执行派生类的实现。
特性:
- 动态绑定:虚函数调用在运行时决定,而不是在编译期(即运行时多态)。
- 如果函数不是虚函数,则静态绑定,调用的是指针或引用的实际类型的函数。
示例代码:
#include <iostream>
using namespace std;
class Base {
public:
virtual void show() { // 虚函数
cout << "Base class show() called" << endl;
}
};
class Derived : public Base {
public:
void show() override { // 重写虚函数
cout << "Derived class show() called" << endl;
}
};
int main() {
Base* basePtr; // 基类指针
Derived derivedObj;
basePtr = &derivedObj;
basePtr->show(); // 调用 Derived 的 show()
return 0;
}
这段代码是一个关于 虚函数 的简单示例,主要展示了 C++ 中 运行时多态 的概念。以下是代码的逐步讲解:
代码分析
1. 类定义部分
class Base {
public:
virtual void show() { // 虚函数
cout << "Base class show() called" << endl;
}
};
- 定义了一个基类
Base
。 show()
函数被关键字virtual
修饰,表明这是一个虚函数。- 虚函数的作用是允许在子类中重写,并通过基类指针或引用调用时,能够动态绑定到子类的实现。
class Derived : public Base {
public:
void show() override { // 重写虚函数
cout << "Derived class show() called" << endl;
}
};
- 定义了一个派生类
Derived
,它继承自Base
。 - 在派生类中,
show()
函数被重写,提供了新的实现。 override
是 C++11 引入的关键字,用于显式表示当前函数是重写基类的虚函数。虽然可以不写override
,但建议使用它来提高代码的可读性和安全性(编译器会检查是否真的重写了基类的虚函数)。
2. 主函数部分
int main() {
Base* basePtr; // 基类指针
Derived derivedObj;
basePtr = &derivedObj; // 基类指针指向派生类对象
Base* basePtr
:定义了一个基类的指针basePtr
。Derived derivedObj
:创建了一个派生类对象derivedObj
。basePtr = &derivedObj
:将基类指针basePtr
指向派生类对象derivedObj
。
basePtr->show(); // 调用 Derived 的 show()
return 0;
}
- 通过基类指针
basePtr
调用虚函数show()
。 - 动态绑定生效:由于
show()
是虚函数,basePtr
实际指向的是派生类对象derivedObj
,因此调用的是派生类Derived
中重写的show()
函数,而不是基类Base
的版本。 - 输出结果为:
Derived class show() called
。
运行结果
Derived class show() called
重点讲解
1. 虚函数的作用
- 虚函数是实现 运行时多态 的核心。
- 如果
show()
不是虚函数,那么basePtr->show()
会调用Base
类中的实现,而不是Derived
类中的实现(即静态绑定)。
2. 动态绑定
- 当一个虚函数通过基类指针或引用调用时,程序在运行时会根据指针或引用指向的对象类型决定调用哪个版本的函数。
- 在本例中,
basePtr
指向派生类对象derivedObj
,因此调用了Derived
类的show()
方法。
3. override
- 使用
override
明确表示派生类的show()
是对基类虚函数的重写。 - 如果基类的函数签名发生了变化,或者派生类的函数签名不匹配基类时,编译器会报错,提高了代码的安全性。
如果没有虚函数会发生什么?
如果将 Base
类中的 show()
函数去掉 virtual
关键字,例如:
class Base {
public:
void show() { // 非虚函数
cout << "Base class show() called" << endl;
}
};
运行结果会变成:
Base class show() called
原因是函数调用在编译时就绑定了(静态绑定),basePtr
是一个 Base
类型的指针,因此调用的是 Base
类的 show()
函数,而不是 Derived
类的 show()
函数。
虚函数表(vtable)机制
- 虚函数是通过 虚函数表(vtable) 实现的。
- 每个含有虚函数的类在编译时会生成一个虚函数表,表中存储了虚函数的指针。
- 对象在运行时通过虚函数表找到正确的虚函数实现。
- 在本例中:
Base
类的虚函数表中存储了Base::show
的地址。Derived
类的虚函数表中存储了Derived::show
的地址。- 当
basePtr
指向derivedObj
时,通过虚函数表调用了Derived::show()
。
总结
- 虚函数 是实现多态的关键,通过动态绑定实现基类指针调用派生类的重写方法。
- 如果没有
virtual
,调用会发生静态绑定,无法实现多态。 - 本例演示了虚函数的基本用法,以及通过基类指针调用派生类实现的经典场景。
关于虚函数的疑问
但是我想输出子类的直接写不就行了吗?为什么要通过父类的指针和引用来呢?虚函数的意义在哪里?
为什么不用直接调用子类的方法,而要通过父类的指针或引用来调用?这个问题的核心是 多态 和 程序设计的灵活性。下面会逐步解释虚函数的意义和它解决的问题。
1. 虚函数的意义:应对多态的需求
在开发中,直接调用子类的方法当然可以,但这是在你明确知道自己要操作哪一个子类时才有效。然而,当程序需要处理一组不同类型的对象,并且这些对象之间有一个共同的父类时,虚函数就显得非常重要了。
场景:处理多种子类的统一接口
假设我们有一个程序需要处理不同的图形,比如矩形、圆形和三角形。每种图形都有自己的绘制方法 draw()
,但我们希望用一个统一的接口来调用这些方法,而不需要去关心具体是哪种图形。
示例代码:
#include <iostream>
using namespace std;
class Shape { // 基类:所有图形的共同父类
public:
virtual void draw() const { // 虚函数
cout << "Drawing Shape" << endl;
}
};
class Circle : public Shape { // 子类:圆形
public:
void draw() const override {
cout << "Drawing Circle" << endl;
}
};
class Rectangle : public Shape { // 子类:矩形
public:
void draw() const override {
cout << "Drawing Rectangle" << endl;
}
};
int main() {
Shape* shapes[2]; // 基类指针数组
Circle circle; // 圆形对象
Rectangle rect; // 矩形对象
// 基类指针指向不同的子类对象
shapes[0] = &circle;
shapes[1] = ▭
// 使用统一的接口调用子类的方法
for (int i = 0; i < 2; i++) {
shapes[i]->draw(); // 动态绑定,调用对应子类的 draw 实现
}
return 0;
}
输出:
Drawing Circle
Drawing Rectangle
为什么需要虚函数?
- 如果没有虚函数,
shapes[i]->draw()
调用的永远是基类的draw()
,即输出Drawing Shape
,而不是子类的实现。 - 虚函数通过动态绑定,允许程序在运行时根据指针指向的对象类型调用正确的函数(
Circle::draw()
或Rectangle::draw()
)。 - 关键意义:不需要知道具体是哪个子类,也能调用子类的实现。
总结:虚函数的意义在于通过基类指针或引用调用子类的重写方法,从而实现运行时多态,使代码更加灵活、可扩展。
2. 虚函数的意义:提升代码的可扩展性
如果没有虚函数会怎样?
假如我们不用虚函数,而是直接调用子类的方法,那么每次增加一个新的子类,我们都需要修改代码。
示例代码(没有虚函数的实现):
#include <iostream>
using namespace std;
class Circle {
public:
void draw() const {
cout << "Drawing Circle" << endl;
}
};
class Rectangle {
public:
void draw() const {
cout << "Drawing Rectangle" << endl;
}
};
int main() {
Circle circle;
Rectangle rect;
// 分别处理每种子类
circle.draw();
rect.draw();
return 0;
}
问题:
- 如果后来新增了一个
Triangle
类(代表三角形),你必须修改main
函数,手动添加对它的处理逻辑。 - 这违背了 开闭原则(对扩展开放,对修改封闭),代码的可维护性变差。
虚函数的解决方案:
用虚函数后,你只需要在基类中定义一个 draw()
接口,所有子类都去实现它。新增一个子类,比如 Triangle
,只需实现自己的 draw()
方法,而不需要修改调用的代码(如 main
函数中的 shapes[i]->draw()
代码不变)。
class Triangle : public Shape {
public:
void draw() const override {
cout << "Drawing Triangle" << endl;
}
};
然后在 shapes
数组中加入 Triangle
对象,程序自动调用正确的 draw()
函数,而无需修改调用逻辑。
总结:虚函数让代码更具扩展性,新增功能时不需要修改现有代码,只需定义新的子类并实现虚函数。
3. 虚函数的意义:提供统一的接口
虚函数允许设计一种统一的接口,让不同的子类实现自己的逻辑。比如:
- 图形系统中的
Shape
类提供draw()
接口,不同的图形(圆形、矩形等)实现自己的绘制逻辑。 - 游戏开发中的
Character
类提供attack()
接口,不同角色(战士、法师等)实现不同的攻击方式。
通过统一的接口,可以轻松实现多态,简化代码调用。
4. 为什么要通过父类的指针或引用调用?
主要是为了实现抽象化,屏蔽子类的细节,让代码更加通用和灵活。
举例:
假如程序中直接调用子类的方法:
Circle circle;
circle.draw();
- 这种方式只能处理
Circle
类型,无法扩展到其他类型。 - 如果新增了
Rectangle
或Triangle
,调用代码需要修改。
通过父类的指针或引用调用虚函数:
Shape* shape = new Circle();
shape->draw();
- 这种方式不需要知道具体是哪个子类,代码可以处理任意类型的
Shape
。 - 只需要保证所有子类都继承自
Shape
并重写draw()
,调用代码无需任何修改。
5. 虚函数在实际开发中的应用场景
- 游戏开发:基类
Character
定义attack()
,不同角色实现不同的攻击方式。 - 图形界面:基类
Widget
定义draw()
,不同控件(按钮、文本框等)实现不同的绘制方法。 - 文件处理:基类
File
定义read()
和write()
,不同文件类型(文本文件、二进制文件等)实现各自的读写逻辑。
总结:虚函数的核心意义
- 实现多态:通过基类指针或引用调用子类的方法,让代码更加灵活。
- 解耦和扩展性:调用代码无需关心具体的子类类型,新功能的扩展只需增加子类,而无需修改原有的调用逻辑。
- 统一接口:为一组相关类定义共同的接口,简化代码设计。
所以,虚函数的意义不仅仅是“用父类指针调用子类的方法”,而是提供了一种更灵活、可扩展的程序设计方式,特别是在需要处理多种对象、并且这些对象具有共同特性时,虚函数是非常有用的工具。
2.1.3 虚函数的重写/覆盖
虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。
注意:在重写基类虚函数时,派生类的虚函数在不加virtual
关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-打折" << endl; }
};
void Func(Person* ptr)
{
// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
// 但是跟ptr没关系,而是由ptr指向的对象决定的。
ptr->BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(&ps);
Func(&st);
return 0;
}
class Animal
{
public:
virtual void talk() const{}
};
class Dog : public Animal
{
public:
virtual void talk() const
{
std::cout << "汪汪" << std::endl;
}
};
class Cat : public Animal
{
public:
virtual void talk() const
{
std::cout << "(>^ω^<)喵" << std::endl;
}
};
void letsHear(const Animal& animal)
{
animal.talk();
}
int main()
{
Cat cat;
Dog dog;
letsHear(cat);
letsHear(dog);
return 0;
}
2.1.4 多态场景的一个选择题
以下程序输出结果是什么()
A: A->0
B: B->1
C: A->1
D: B->0
E: 编译出错
F: 以上都不正确
答案:
B
class A
{
public:
virtual void func(int val = 1){ std::cout<<"A->"<< val <<std::endl;}
virtual void test(){ func();}
};
class B : public A
{
public:
void func(int val = 0){ std::cout<<"B->"<< val <<std::endl; }
};
int main(int argc ,char* argv[])
{
B*p = new B;
p->test();
return 0;
}
首先创建了一个子类指针p
然后这个p
调用了父类的test()
父类的test()
要调用里面的func()
this->func()
这里的this
调用func()
是否构成多态呢?
这里的this
是A*
,是一个父类的指针。
因为继承,是先在子类找,子类找不到就去父类找。
注意:这里虽然子类B
没有写virtual
,但是父类写了,那么子类也算是虚函数。
然后虚函数的重写也满足了,多态也满足了。
调用 B::func(int val = 0)
但默认参数 val
的值是 基类 A
定义的默认值 1
。
最终,B::func(1)
被执行,输出结果为:B->1
2.1.5 虚函数重写的一些其他问题
2.1.5.1 协变(了解)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解一下即可。
class A {};
class B : public A {};
class Person {
public:
virtual A* BuyTicket()
{
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
virtual B* BuyTicket()
{
cout << "买票-打折" << endl;
return nullptr;
}
};
void Func(Person* ptr)
{
ptr->BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(&ps);//A*
Func(&st);//B*
return 0;
}
- 协变返回类型的基本概念
在面向对象编程中,协变(
Covariance
) 指的是允许在派生类中重写基类的方法时,将返回类型从基类类型更改为派生类类型。具体来说,如果基类中的虚函数返回一个基类类型的对象,那么在派生类中重写该函数时,可以返回派生类类型的对象。
- 为什么需要协变返回类型
协变返回类型的主要目的是增强多态性和代码的灵活性。通过允许派生类返回更具体的类型,可以使代码更加类型安全,并且减少类型转换的需要。
C++
中的协变返回类型在
C++
中,从C++11
标准开始支持协变返回类型。要实现协变返回类型,需要满足以下条件:
- 基类中的虚函数返回基类类型的指针或引用。
- 派生类中的重写函数返回派生类类型的指针或引用,且派生类类型是基类返回类型的派生类。
2.1.5.2 析构函数的重写
基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual
关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor
,所以基类的析构函数加了vialtual
修饰,派生类的析构函数就构成重写。
下面的代码我们可以看到,如果~A()
,不加virtual
,那么delete p2
时只调用的A
的析构函数,没有调用B
的析构函数,就会导致内存泄漏问题,因为~B()
中在释放资源。
注意:这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。
class A
{
public:
virtual ~A()
{
cout << "~A()" << endl;
}
};
class B : public A {
public:
~B()
{
cout << "~B()->delete:"<<
_p<< endl;
delete _p;
}
protected:
int* _p = new int[10];
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
A* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;
return 0;
}
(1) A* p1 = new A;
- 创建了一个基类
A
的对象。 - 指针
p1
指向该对象。
(2) A* p2 = new B;
- 创建了一个派生类
B
的对象。 - 指针
p2
指向该派生类对象,但类型是基类指针A*
。
(3) delete p1;
p1
指向的是一个基类A
的对象。- 调用
delete p1
时,执行 基类A
的析构函数:
~A()
- 没有其他资源需要释放,程序正常运行。
(4)delete p2;
-
p2
指向的是一个派生类B
的对象,但类型是基类指针A*
。 -
因为基类 A的析构函数是虚函数,所以会发生动态绑定:
- 首先调用派生类
B
的析构函数:
~B()->delete: <地址>
-
在派生类
B
的析构函数中,释放了动态分配的_p
。 -
然后调用基类
A
的析构函数:
~A()
总的来说就是:
-
虚函数表的机制:
- 在 C++ 中,一个类只有当它声明了虚函数时,才会有虚函数表(
vtable
)。虚函数表是用来支持动态绑定(多态)的。 - 如果基类的析构函数是虚函数,那么在删除派生类对象时,
C++
会通过虚函数表找到派生类的析构函数,并先调用派生类的析构函数,然后再调用基类的析构函数。
- 在 C++ 中,一个类只有当它声明了虚函数时,才会有虚函数表(
-
非虚析构函数的行为:
- 如果基类的析构函数不是虚函数,那么通过基类指针调用
delete
时,编译器只会调用基类的析构函数,而不会触发派生类的析构函数。 - 这是因为在没有虚函数表的情况下,编译器只知道基类的析构函数,并不会动态地绑定到派生类的析构函数。
- 如果基类的析构函数不是虚函数,那么通过基类指针调用
2.1.6 override 和 final关键字
从上面可以看出,C++
对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug
会得不偿失,因此C++11
提供了override
,可以帮助用户检测是否重写。如果我们不想让派生类重写这个虚函数,那么可以用final
去修饰。
// error C3668: “Benz::Drive”: 包含重写说明符“override”的方法没有重写任何基类方法
class Car {
public:
virtual void Dirve()
{}
};
class Benz :public Car {
public:
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
int main()
{
return 0;
}
// error C3248: “Car::Drive”: 声明为“final”的函数无法被“Benz::Drive”重写
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() { cout << "Benz-舒适" << endl; }
};
int main()
{
return 0;
}
2.1.7 重载/重写/隐藏的对比
注意:这个概念对比经常考,大家得理解记忆一下
3. 纯虚函数和抽象类
在虚函数的后面写上 =0
,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
定义:
- 纯虚函数是一个没有实现的虚函数,必须在基类中声明,并在派生类中提供具体实现。
- 用法:在基类中将虚函数设置为
= 0
。
特性:
- 包含纯虚函数的类称为抽象类,不能直接实例化。
- 用于定义接口或提供派生类必须实现的功能。
示例代码:
#include <iostream>
using namespace std;
class AbstractBase {
public:
virtual void show() = 0; // 纯虚函数
};
class Derived : public AbstractBase {
public:
void show() override { // 必须提供实现
cout << "Derived class implementing show()" << endl;
}
};
int main() {
// AbstractBase baseObj; // 错误!抽象类不能实例化
Derived derivedObj;
derivedObj.show();
AbstractBase* basePtr = &derivedObj;
basePtr->show(); // 调用派生类实现
return 0;
}
输出:
Derived class implementing show()
Derived class implementing show()
注意:
- 如果派生类没有实现纯虚函数,它本身也会成为抽象类,无法实例化。
- 纯虚函数常用于设计模式中,例如接口类。
4. 多态的原理
4.1 虚函数表指针
下面编译为
32
位程序的运行结果是什么()A. 编译报错
B. 运行报错
C. 8
D. 12
答案:
D
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
protected:
int _b = 1;
char _ch = 'x';
};
int main()
{
Base b;
cout << sizeof(b) << endl;
return 0;
}
上面题目运行结果12bytes
,除了_b
和_ch
成员,还多一个__vfptr
放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v
代表virtual
,f
代表function
)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
答案解析
问题分析
题目要求计算 sizeof(b)
的值,其中 b
是类 Base
的一个对象。程序编译为 32位,需要考虑以下几个因素:
- 类的成员变量对齐规则。
- 类中是否有虚函数表指针(
vptr
)。 - 数据成员的实际大小和对齐方式。
类的结构分析
1. 虚函数和虚函数表指针
Base
类中有一个虚函数 Func1()
,因此每个 Base
类对象中都会有一个 虚函数表指针(vptr)。在 32 位系统中,指针大小为 4 字节。
2. 成员变量
_b
是一个int
类型,占用 4 字节。_ch
是一个char
类型,占用 1 字节。
3. 内存对齐
为了提高内存访问效率,编译器会对数据进行对齐。通常,类的内存布局会以成员中最大类型的对齐要求为准。在 32 位系统中:
int
的对齐要求为 4 字节。char
的对齐要求为 1 字节。
因此,_ch
之后会填充 3 字节,使整个对象按 4 字节对齐。
内存布局计算
类 Base
的内存布局如下:
- 虚函数表指针(vptr):4 字节。
- 成员变量
_b
:4 字节。 - 成员变量
_ch
:1 字节。 - 填充(padding):3 字节(为了对齐到 4 字节边界)。
总大小为:
4(vptr) + 4(_b) + 1(_ch) + 3(填充) = 12 字节
运行结果
sizeof(b)
的值为 12
,因此正确答案是:D. 12
额外说明
如果程序编译为 64 位,则虚函数表指针(vptr
)的大小为 8 字节,整个对象的大小会变为 16 字节。
4.2 多态的原理
4.2.1 多态是如何实现的
从底层的角度Func
函数中ptr->BuyTicket()
,是如何作为ptr
指向Person
对象调用Person::BuyTicket
,ptr
指向Student
对象调用Student::BuyTicket
的呢?通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。第一张图,ptr
指向的Person
对象,调用的是Person
的虚函数;第二张图,ptr
指向的Student
对象,调用的是Student
的虚函数。
简单来说就是:
多态:指向谁就调用谁的虚函数
指向父类,运行时到指向父类对象的虚函数表中找到对应的虚函数进行调用
指向子类,运行时到指向子类对象的虚函数表中找到对应的虚函数进行调用
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
private:
string _name;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-打折" << endl; }
private:
string _id;
};
class Soldier: public Person {
public:
virtual void BuyTicket() { cout << "买票-优先" << endl; }
private:
string _codename;
};
void Func(Person* ptr)
{
// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
// 但是跟ptr没关系,而是由ptr指向的对象决定的。
ptr->BuyTicket();
}
int main()
{
// 其次多态不仅仅发生在派生类对象之间,多个派生类继承基类,重写虚函数后
// 多态也会发生在多个派生类之间。
Person ps;
Student st;
Soldier sr;
Func(&ps);
Func(&st);
Func(&sr);
return 0;
}
4.2.2 动态绑定与静态绑定
-
对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
-
满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。
// ptr是指针+BuyTicket是虚函数满足多态条件。
// 这里就是动态绑定,编译在运行时到ptr指向对象的虚函数表中确定调用函数地址
ptr->BuyTicket();
00EF2001 mov eax,dword ptr [ptr]
00EF2004 mov edx,dword ptr [eax]
00EF2006 mov esi,esp
00EF2008 mov ecx,dword ptr [ptr]
00EF200B mov eax,dword ptr [edx]
00EF200D call eax
// BuyTicket不是虚函数,不满足多态条件。
// 这里就是静态绑定,编译器直接确定调用函数地址
ptr->BuyTicket();
00EA2C91 mov ecx,dword ptr [ptr]
00EA2C94 call Student::Student (0EA153Ch)
4.2.3 虚函数表
-
基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
-
派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
-
派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
-
派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,派生类自己的虚函数地址三个部分。
-
虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)
-
虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
-
虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证一下。vs下是存在代码段(常量区)
这里Derive中没有看到func3函数,这个vs监视窗口看不到,可以通过内存窗口查看
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive : public Base
{
public:
// 重写基类的func1
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func1" << endl; }
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
int main()
{
int i = 0;
static int j = 1;
int* p1 = new int;
const char* p2 = "xxxxxxxx";
printf("栈:%p\n", &i);
printf("静态区:%p\n", &j);
printf("堆:%p\n", p1);
printf("常量区:%p\n", p2);
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
printf("Base虚表地址:%p\n", *(int*)p3);
printf("Derive虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
打印:
运行结果:
栈:010FF954
静态区:0071D000
堆:0126D740
常量区:0071ABA4
Base虚表地址:0071AB44
Derive虚表地址:0071AB84
虚函数地址:00711488
普通函数地址:007114BF