C++ 面向对象核心:多态详解
一、多态的概念及实现
1. 什么是多态?
多态的核心在于'多种形态'。它是 C++ 面向对象编程最重要的特性之一,分为编译时多态(静态多态)和运行时多态(动态多态)。这里我们重点讨论运行时多态。
编译时多态主要涉及函数重载和模板,通过参数类型匹配在编译期决定调用哪个函数。之所以叫编译时多态,是因为实参传给形参的匹配是在编译阶段完成的。
运行时多态则更灵活:同一个行为(函数),传入不同的对象,执行不同的逻辑。比如买票这个行为,普通人全价,学生优惠,军人优先;再比如动物叫,猫是喵,狗是汪。这就是通过不同对象实现了同一接口的不同表现。
2. 虚函数
类成员函数前加 virtual 修饰,该函数即为虚函数。注意非成员函数不能加 virtual。
class Person {
public:
// 加上 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() {} // 未加 virtual,但依然构成重写
};
override 和 final 关键字
C++ 对虚函数重写的要求比较严格,有时疏忽导致函数名或参数写错,无法构成重写,而编译器不会报错,只有运行后才发现问题。因此 C++11 提供了 override 关键字来辅助检测。
class Person {
public:
virtual void buyticket() {}
};
class Student : public Person {
public:
virtual void buyticket() override {} // 如果签名不匹配,编译会报错
};
如果我们不想让派生类继续重写这个虚函数,可以用 final 修饰。
3. 多态的实现
多态实现的条件
要实现运行时多态,必须满足两个条件:
- 必须是基类的指针或者引用调用虚函数。
- 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。
第一点是因为只有基类的指针或引用才能同时指向基类和派生类对象。第二点是因为派生类必须重写虚函数,否则基类和派生类之间没有区别,也就没有多态的效果。
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;
}
多态场景下的经典面试题
看下面这段代码,输出结果是什么?
class A {
public:
virtual void test(int val = 1) { cout << "A->" << val << endl; }
};
class B : public A {
public:
virtual void test(int val = 0) { cout << "B->" << val << endl; }
};
int main() {
B* p = new B;
p->test();
delete p;
return 0;
}
很多人直觉会选 D(B->0),因为指针指向 B 类对象。但正确答案是 B (B->1)。
原因在于:默认参数是静态绑定的。虽然 test 函数是虚函数,支持动态绑定调用函数体,但默认参数的值是在编译时根据调用者的指针类型决定的。这里 p 是 B* 类型,但 test() 属于基类 A 的接口声明,所以默认参数 val 会使用 A 中声明的值 1,而不是 B 中的 0。
形象理解:虚函数重写后,派生类中的虚函数其实是由基类虚函数的函数名和参数列表(包括默认值)与派生类的虚函数实现部分组成的。
4. 虚函数重写的特殊场景
析构函数的重写
基类的析构函数为虚函数时,派生类析构函数无论是否加 virtual,都与基类析构函数构成重写。这是因为编译器对析构函数名称做了特殊处理,统一成 destructor。
为什么要这样设计? 这关系到内存安全。
当一个基类指针 ptr 指向派生类对象时,如果基类析构函数不是虚函数,delete ptr 时只会调用基类析构函数(静态绑定)。如果派生类中有额外资源(如动态分配的内存),就会导致内存泄漏。
只要基类析构函数设为虚函数,delete 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;
}
};
5. 纯虚函数和抽象类
在虚函数后面写上 =0,该函数为纯虚函数。包含纯虚函数的类叫做抽象类,抽象类不能实例化对象。
如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数强制了派生类必须重写,否则无法实例化。
class Car {
public:
virtual void Drive() = 0; // 纯虚函数
};
class Benz : public Car {
public:
void Drive() {
cout << "Benz-舒适" << endl;
}
};
int main() {
// Car car; // 错误:无法实例化抽象类
Car* pBenz = new Benz;
pBenz->Drive();
delete pBenz;
return 0;
}
二、多态的原理
1. 虚函数表指针
含有虚函数的类中至少都有一个虚函数表指针(vptr)。在 32 位机器下,一个指针占 4 字节。如果一个类有虚函数,对象大小会增加一个指针的大小。
例如:
class B {
char _ch;
int _b;
// 隐含了一个 _vfptr 指针
};
对象大小不仅仅是成员变量之和,还要考虑对齐和 vptr。
2. 虚函数表
- 基类对象的虚函数表中存放基类所有虚函数的地址。 同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表。
- 派生类虚函数表:包含继承下来的基类虚函数地址(可能被覆盖)、派生类重写的虚函数地址、派生类自己的虚函数地址。
- 虚函数存储位置:虚函数本身存储在代码段,虚函数表通常也存储在代码段(常量区)。
3. 多态的底层实现
满足多态条件后,底层不再是编译时确定函数地址,而是运行时到指向对象的虚表中查找对应的虚函数地址。这就实现了指针或引用指向基类就调用基类虚函数,指向派生类就调用派生类对应虚函数。
4. 动态绑定和静态绑定
- 静态绑定:对不满足多态条件的函数调用(如普通函数、非虚函数),在编译时确定调用地址。
- 动态绑定:满足多态条件的函数调用,在运行时到虚函数表中找到调用地址。
三、总结
多态是 C++ 面向对象编程非常重要的特性,在处理相似特性的问题时作用巨大。面试中关于多态的考点很多,尤其是虚函数机制、析构函数虚化以及默认参数的静态绑定等细节,需要结合底层原理深入理解。

