C++面向对象编程核心特性:多态详解
C++多态是面向对象编程的核心特性之一,分为编译时多态和运行时多态。重点讲解运行时多态的实现条件,包括基类指针或引用调用虚函数、虚函数重写等。内容涵盖虚函数定义、override与final关键字用法、析构函数虚化防止内存泄漏、协变规则以及纯虚函数与抽象类概念。同时深入剖析多态底层原理,涉及虚函数表指针、虚函数表结构及动态绑定机制,帮助理解对象内存布局与函数调用过程。

C++多态是面向对象编程的核心特性之一,分为编译时多态和运行时多态。重点讲解运行时多态的实现条件,包括基类指针或引用调用虚函数、虚函数重写等。内容涵盖虚函数定义、override与final关键字用法、析构函数虚化防止内存泄漏、协变规则以及纯虚函数与抽象类概念。同时深入剖析多态底层原理,涉及虚函数表指针、虚函数表结构及动态绑定机制,帮助理解对象内存布局与函数调用过程。


微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
多态顾名思义就是有多种形态。多态是C++面向对象编程的最重要的特性之一,多态分为:编译时多态 (静态多态) 和运行时多态 (动态多态),这里我们重点讲运行时多态。编译时多态 (静态多态) 主要就是我们前面讲的函数重载和函数模板,它们传不同类型的参数就可以调用不同的函数,通过不同的参数类型来达到多种形态。而之所以叫编译时多态,是因为它们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。
运行时多态,具体点讲就是去完成某个行为 (函数),可以传不同的对象来完成不同的行为,就达到了多种形态。比如买票这个行为,当对象是普通人时,是全价买票;对象是学生买时,是优惠买票 (5折或75折);当对象是军人时,是优先买票。再比如,同样是动物叫的一个行为 (函数),传猫对象过去,就是喵,传狗对象过去,就是汪汪。
类成员函数前面加 virtual 修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加 virtual 修饰。
class Person {
public:
// 这里在成员函数 BuyTicket 前面加了 virtual,则 BuyTicket 就是虚函数
virtual void BuyTicket() {
cout << "买票 - 全价" << endl;
}
};
虚函数的重写 (Override) - 发生在派生类,在基类中一定要有一个被 virtual 修饰的虚函数。
重写是实现多态的关键。它的定义是:在派生类中提供一个与基类中某个虚函数具有完全相同函数名、参数列表、返回值类型的函数。
虚函数重写的目的就是为了在运行时,当通过基类指针或引用指向派生类对象时,能够调用派生类的版本,而不是基类的版本。这就是'动态绑定'。
// Person 类与 Student 类中的 buyticket 构成重写
class Person {
public:
virtual void buyticket() {
cout << "全价票" << endl;
}
};
class Student : public Person {
public:
virtual void buyticket() {
cout << "半价票" << endl;
}
};
还需要注意一点:在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,也可以构成重写 (因为子类继承基类后,基类的虚函数被继承下来了,在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态。
// A 类与 B 类中的 func 构成重写
class A {
public:
virtual void func() {}
};
class B : public A {
public:
void func() {}
};
从上面可以看出,C++ 对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此 C++11 提供了 override,可以帮助用户检测是否重写。
class Person {
public:
virtual void buyticket() {}
};
class Student : public Person {
public:
virtual void buyticket() override {}
};
如果我们不想让派生类重写这个虚函数,那么可以用 final 去修饰。
必须是基类的指针或者引用调用虚函数 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。
说明:第一,只有基类的指针或者引用才能同时指向基类和派生类的对象;第二,派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖之后,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。
//-------------------------------------基类----------------------------------------------
class Person {
public:
virtual void buyticket() {
cout << "全价票" << endl;
}
};
//-------------------------------------派生类----------------------------------------------
class Student : public Person {
public:
virtual void buyticket() override {
cout << "半价票" << endl;
}
};
void Func(Person* ptr) // 基类的指针来接收参数
{
ptr->buyticket(); // 用基类的指针调用虚函数
}
int main() {
Person p; // 基类对象
Student s; // 派生类对象
Func(&p); // 将基类对象作为实参,则调用基类的虚函数
Func(&s); // 将派生类对象作为实参,则调用派生类的虚函数
return 0;
}
以下程序输出结果是什么() A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
答案:B
我们也许都会对答案感到不可思议,这不就应该从 A 或者 D 中选一个吗?为什么最后的答案是 B 呢,下面我们来分析:
首先题目中创建了一个指向 B 类类型对象的指针 p,通过指针 p 来调用 test 函数,由于 test 函数是基类 A 的成员函数,所以隐含的参数 this 指针是 A 类型的(即基类的指针),同时基类与子类中 func 函数构成虚函数的重写*,虽然,派生类的 func 函数并没有加 virtual 关键字,但是由于继承的原因,仍然构成重写,所以满足多态的条件,此时,就会根据真正的实参的类型来决定调用哪个虚函数(动态绑定),而指针 p 是派生类类型的,所以,就会调用派生类 B 中的 func 函数,这时候我们肯定就会毫不犹豫的选择 D 选项,相信大多数人都能分析到这一步,
但是,最终的答案确选 B,这就是这个题经典的原因之一,也是最坑的地方之一。因为还有一点就是:
• 默认参数是静态绑定的(编译时根据调用者的类决定):test() 属于基类 A,因此 func() 的默认参数 val 会使用 A 中声明的 val = 1(而非 B 中声明的 val = 0)。
也可以理解为(形象),虚函数重写后,派生类中的虚函数其实是由基类虚函数的函数名和参数列表与派生类的虚函数的实现部分组成的。
所以,选择 B:B -> 1
基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加
virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor,所以基类的析构函数加了virtual修饰,派生类的析构函数就构成重写。
💬那么问题就来了,为什么要这样设计呢?这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。
当一个基类的指针 ptr 指向派生类对象时,如果基类的析构函数不实现为虚函数,那么当 delete ptr 释放该对象时,由于静态绑定(ptr 是什么类类型的指针就调用哪个类的析构函数),就只调用基类的析构函数,如果派生类对象中有额外的资源,这时候就会导致内存泄漏。所以,C++ 中只要基类的析构函数实现为虚函数,无论派生类析构函数加不加
virtual关键字,都会与基类的析构函数构成重写,即此时满足多态,编译器会根据 ptr 指针真正指向的类类型对象来调用相应的析构函数(动态绑定),而在前面的继承中已经讲到:子类的析构函数会自动调用基类的析构函数,所以,此时即使基类中有资源,也会被释放。
//-------------------------------------基类---------------------------------------------
class A {
public:
virtual ~A() // 虚函数 {}
protected:
int _a;
};
//-------------------------------------子类---------------------------------------------
class B : public A {
public:
~B() { delete[]_b; _b = nullptr; }
protected:
int* _b = new int[10]; // 派生类中有额外的资源
};
// 用一个基类的指针指向派生类对象
int main() {
A* a1 = new B;
delete a1;
return 0;
}
这个问题面试中也有可能会考到:
(此处省略图片对比,概念如下)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
class Person {
public:
virtual Person* BuyTicket() // 返回值为基类的指针
{
cout << "买票 - 全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
virtual Student* BuyTicket() // 返回值为派生类的指针
{
cout << "买票 - 打折" << endl;
return nullptr;
}
};
void Func(Person* ptr) {
ptr->BuyTicket();
}
int main() {
Person ps;
Student st;
Func(&ps);
Func(&st);
return 0;
}
在虚函数的后面写上 =0,则这个函数为纯虚函数,纯虚函数不需要定义实现,只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
//-------------------------------------基类(抽象类)-------------------------------------
class Car {
public:
virtual void Drive() = 0; // 纯虚函数
};
//-------------------------------------派生类---------------------------------------------
class Benz : public Car {
public:
virtual void Drive() {
cout << "Benz-舒适" << endl;
}
};
class BMW : public Car {
public:
virtual void Drive() {
cout << "BMW-操控" << endl;
}
};
int main() {
//-----------------错误示范:编译报错:error C2259: 'Car': 无法实例化抽象类-------------
// Car car;
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
return 0;
}
下面编译为 32 位程序的运行结果是什么() A. 编译报错 B. 运行报错 C. 8 D. 12
答案:D
看到这个题,我们肯定就会选 C,因为一个 int 占 4 个字节,一个 char 占 1 个字节,然后对齐到 4 的整数倍,不就是 8 吗。但是,在 b 对象中不仅仅放了 _b 和 _ch 成员,还有一个 _vfptr 的指针在这两个成员的前面,我们知道在 32 位机器下一个指针占 4 个字节,所以最后 b 对象的大小就是 12 个字节。
一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
• 基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
class A {
public:
virtual void func() {}
};
class B : public A {
public:
virtual void func() {}
};
class C : public A {
public:
virtual void func() {}
};
int main() {
A a1; // 基类
A a2; // 基类
B b1; // 派生类
B b2; // 派生类
C c; // 派生类
return 0;
}
• 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,因为基类对象的成员和派生类对象中的基类对象成员是独立的。
• 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。这也是为什么,当满足多态时编译器会根据真正的对象调用相应的虚函数的原因。就是因为,派生类与基类的虚函数表中放着各自独立的虚函数的指针。
• 派生类的虚函数表中包含,(1) 基类的虚函数地址,(2) 派生类重写的虚函数地址完成覆盖,派生类自己的虚函数地址三个部分。
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() {
Base b;
Derive d;
return 0;
}
• 虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
虚函数表存在哪的?这个问题严格说并没有标准答案 C++ 标准并没有规定,我们写下面的代码可以对比验证一下。vs 下是存在代码段 (常量区)。
可以看到,Person 虚表地址与 Student 虚表地址更加接近常量区。
满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。
class Person {
public:
virtual void buyticket() { cout << "全价票" << endl; }
protected:
string _name;
};
class Student : public Person {
public:
virtual void buyticket() { cout << "半价票" << endl; }
protected:
int _id;
};
class Soldier : public Person {
public:
virtual void buyticket() { cout << "优先买票" << endl; }
protected:
int _codename;
};
• 对不满足多态条件 (指针或者引用 + 调用虚函数) 的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
• 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。
多态是 C++ 面向对象编程非常重要的一个特性,多态在我们处理一些具有相似特性的问题时,有着非常重要的作用,同时,在面试中也有许多的考点和细节。