【C++】17. 多态
上一章节中我们讲了C++三大特性的继承,这一章节我们接着来讲另一个特性——多态
1. 多态的概念
多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态。编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。
运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”(>^ω^<)喵“,传狗对象过去,就是"汪汪"。
2. 多态的定义及实现
2.1 多态的构成条件
多态是一个继承关系下的类对象,去调用同一函数,产生了不同的行为。比如Student继承了
Person。Person对象买票全价,Student对象优惠买票。
2.1.1 实现多态还有两个必须重要条件:
• 必须基类指针或者引用调用虚函数
• 被调用的函数必须是虚函数。
说明:要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向派生类对象;第二派生类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派生类才能有不同的函数,多态的不同形态效果才能达到。
2.1.2 虚函数
类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修 饰,至于为什么?我们下面再给出解释
class Person
{
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
2.1.3 虚函数的重写/覆盖
重写(Override) 是实现运行时多态(Runtime Polymorphism)的核心机制之一。它允许子类(派生类)重新定义父类(基类)中已有的虚函数(virtual
函数),从而在调用时根据对象的实际类型动态选择执行哪个函数。
重写的条件
-
派生类中的函数必须与基类虚函数具有相同的函数名、参数列表和返回类型(协变返回类型除外,见下文)。
-
基类函数必须是
virtual
的(除非是接口中的纯虚函数)。 -
派生类的函数访问权限可以不同(如基类为
protected
,派生类可为public
)。
总结一下就是:
虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态。
重写的本质:子类通过重新定义基类虚函数,实现多态行为。
示例1-买票:
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;
}
示例2-动物叫:
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: 以上都不正确
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;
}
解析:
-
虚函数调用机制
-
test()
是基类A
的虚函数,虽然被B
继承,但未被 B 重写,因此p->test()
调用的是A::test()
。 -
在
A::test()
中,func()
的调用触发动态绑定。由于p
指向B
对象,实际调用的是B::func(int val)
。
-
-
默认参数的静态绑定
-
默认参数的值在编译时根据调用者的静态类型确定。
-
A::test()
中调用func()
时,静态类型是A*(A类中this指针,即是A*)
,因此默认参数使用A::func(int val = 1)
中的1
。 -
动态调用的是
B::func(int val)
,但参数值已确定为1
。
-
-
最终输出逻辑
-
B::func(int val)
接收参数1
,输出B->1
。
-
需要注意的是我们重写是派生类重写基类虚函数的定义,也就是重写基类虚函数的实现
误解:默认参数随虚函数动态绑定。
正解:默认参数是静态绑定的,仅依赖调用表达式的静态类型,与虚函数的动态分派无关。
运行结果:
2.1.5 虚函数重写的一些其他问题
• 协变(了解)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解一下即可。
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);Func(&st);return 0;
}
• 析构函数的重写
基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加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.为什么允许名称不同仍能重写?
(1)语言设计的必要性
-
资源释放的正确性:
C++需要确保派生类对象销毁时,先调用派生类析构函数释放派生类资源,再调用基类析构函数释放基类资源。若不允许名称不同重写,多态销毁将无法实现,导致资源泄漏。 -
统一销毁接口:
所有析构函数的调用最终通过delete
运算符触发,而delete
的语法是统一的(如delete ptr;
)。虚析构函数机制使得无论对象的实际类型如何,都能正确调用完整的析构链。
(2)底层实现的支持
-
析构函数的特殊标识:
编译器在内部将析构函数视为一种特殊的虚函数,其名称差异在编译阶段被隐藏。虚函数表中会为析构函数保留专用槽位,确保动态绑定。 -
析构函数链的自动调用:
即使派生类析构函数名称不同,编译器也会在派生类析构函数末尾自动插入基类析构函数的调用代码(类似于Base::~Base();
)。
2. 与普通虚函数重写的区别
特性 | 普通虚函数重写 | 析构函数重写 |
---|---|---|
函数名 | 必须相同 | 允许不同(如 ~Base() 和 ~Derived() ) |
返回类型 | 必须相同(协变返回类型例外) | 固定为 void ,无需关心返回类型 |
参数列表 | 必须相同 | 无参数,无需匹配 |
调用顺序 | 由代码显式调用决定 | 由继承链隐式控制(派生类→基类) |
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 重载/重写/隐藏的对比
1. 核心定义
概念 | 描述 |
---|---|
重载 | 在同一作用域内,定义多个同名函数,但参数列表不同(参数类型、顺序、数量)。 |
重写 | 派生类中重新定义基类的虚函数,函数签名(名称、参数、返回类型)必须一致。 |
隐藏 | 派生类中定义与基类同名的函数(无论参数是否相同),导致基类同名函数被隐藏。 |
2. 关键特性对比
特性 | 重载 | 重写 | 隐藏 |
---|---|---|---|
作用域 | 同一作用域(如类内或同一命名空间) | 基类和派生类之间(跨作用域) | 基类和派生类之间(跨作用域) |
函数签名 | 必须不同(参数列表不同) | 必须相同(协变返回类型除外) | 可以相同或不同 |
多态性 | 静态多态(编译时决定调用哪个函数) | 动态多态(运行时根据对象实际类型决定) | 静态多态(根据调用者的静态类型决定) |
virtual | 不要求 | 基类函数必须声明为 virtual | 不要求 |
访问权限 | 不影响重载 | 派生类函数的访问权限可以不同于基类 | 不影响隐藏 |
典型场景 | 提供同一功能的多种实现方式 | 实现多态,扩展基类行为 | 派生类无意中屏蔽基类函数 |
3. 代码示例
(1) 重载(Overload)
class Calculator {
public:int add(int a, int b) { return a + b; }double add(double a, double b) { return a + b; } // 重载:参数类型不同
};
(2) 重写(Override)
class Base {
public:virtual void print() { cout << "Base" << endl; }
};class Derived : public Base {
public:void print() override { cout << "Derived" << endl; } // 重写:函数签名一致
};
(3) 隐藏(Hiding)
class Base {
public:void func(int x) { cout << "Base::func(int)" << endl; }
};class Derived : public Base {
public:void func(double x) { cout << "Derived::func(double)" << endl; } // 隐藏Base::func(int)
};// 使用示例
Derived d;
d.func(5); // 调用 Derived::func(double),而非 Base::func(int)
4. 常见问题与陷阱
陷阱1:隐藏导致意外行为
class Base {
public:void show() { cout << "Base" << endl; }
};class Derived : public Base {
public:void show(int x) { cout << "Derived" << endl; } // 隐藏 Base::show()
};Derived d;
d.show(); // 编译错误!Base::show() 被隐藏,需通过 d.Base::show() 调用
陷阱2:重写与隐藏的混淆
class Base {
public:virtual void foo(int x) { /* ... */ }
};class Derived : public Base {
public:void foo(double x) { /* ... */ } // 隐藏 Base::foo(int),非重写!
};
5. 总结对比表
行为 | 重载 | 重写 | 隐藏 |
---|---|---|---|
作用域 | 同一作用域 | 基类与派生类 | 基类与派生类 |
函数签名 | 不同 | 相同 | 可同可异 |
多态性 | 静态绑定(编译时) | 动态绑定(运行时) | 静态绑定(编译时) |
virtual | 无关 | 必须 | 无关 |
目的 | 扩展功能接口 | 实现多态行为 | 无意或有意屏蔽基类函数 |
6. 最佳实践
-
优先使用重载:同一功能的不同参数形式。
-
明确使用重写:基类虚函数标记
virtual
,派生类使用override
关键字(C++11+)。 -
避免意外隐藏:在派生类中使用
using Base::func;
显式引入基类函数:class Derived : public Base { public:using Base::func; // 引入 Base::func(int)void func(double x) { /* ... */ } };
结论
-
重载:同一作用域,参数不同,静态绑定。
-
重写:跨作用域,虚函数签名一致,动态绑定。
-
隐藏:跨作用域,同名函数屏蔽基类函数,静态绑定。
正确理解三者差异,是编写高效、安全C++代码的基础。
3. 纯虚函数和抽象类
在虚函数的后面写上 =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;return 0;
}
int main()
{// 编译报错:error C2259: “Car”: 无法实例化抽象类//Car car;Car* pBenz = new Benz;pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive();return 0;
}
纯虚函数的默认实现
纯虚函数不需要定义实现,但是可以定义默认实现,需在类外定义,但派生类仍需显式重写:
class Logger {
public:virtual void log(const std::string& msg) = 0;
};// 类外提供默认实现
void Logger::log(const std::string& msg) {std::cout << "[Default] " << msg << std::endl;
}class FileLogger : public Logger {
public:void log(const std::string& msg) override {Logger::log(msg); // 调用默认实现// 添加文件写入逻辑}
};
抽象类的设计价值
场景 | 作用 |
---|---|
定义接口规范 | 强制派生类实现特定功能(如 area() 对所有几何形状是必需的)。 |
多态的基础 | 通过基类指针或引用操作派生类对象,实现运行时多态。 |
代码复用与扩展 | 抽象类可提供公共实现(如通用算法),派生类只需实现差异化逻辑。 |
解耦接口与实现 | 用户代码依赖抽象接口,而非具体类,提高系统灵活性。 |
抽象类 vs 普通类
特性 | 抽象类 | 普通类 |
---|---|---|
实例化 | 不能直接创建对象 | 可以直接实例化 |
纯虚函数 | 必须包含至少一个纯虚函数 | 无需纯虚函数 |
用途 | 定义接口,强制派生类实现多态行为 | 提供具体实现,可直接使用 |
4. 多态的原理
4.1 虚函数表指针
下面编译为32位程序的运行结果是什么()
A. 编译报错 B. 运行报错 C. 8 D. 12
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;
}
解析:
首先我们来看成员变量,一个int,4个字节,一个char,1个字节,然后补齐到8个字节,所以答案是C吗?
我们运行来看一下(注意是32位程序)
为什么是12呢?
除了_b和_ch成员,还多一个_vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中至少都有一个虚函数表指针,因为一个类中所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
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; }
};
class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-打折" << endl; }
};
class Soldier : 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;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 虚函数表
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:// 重写基类的func1virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func1" << endl; }void func4() { cout << "Derive::func4" << endl; }
protected:int b = 2;
};
• 基类对象的虚函数表中存放基类所有虚函数的地址。
同类型的对象虚表共用,如果每个对象都各自一份,那就会冗余。但是不同类型虚表各自独立
• 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
• 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
基类和派生类的虚表不是同一个,就像基类的成员a,虽然被派生类继承下来,但是两者只是值一样,都有自己的空间。
可以看到派生类中重写的虚函数func1的地址和基类的虚函数func1的地址不一样,说明派生类重写基类的虚函数之后,在派生类的虚表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。也就是说重写之后,派生类自己的虚表存着他自己的那份虚函数
• 派生类的虚函数表中包含,基类的虚函数地址,派生类重写的虚函数地址,派生类自己的虚函数地址三个部分。
• 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)
可以看到派生类的虚表中存放着基类的虚函数func2的地址,派生类重写的虚函数func1的地址,这里Derive中没有看到func3函数,这个vs监视窗口看不到,可以通过内存窗口查看
再结合内存窗口,可以看到派生类自己的虚函数func3的地址,以及vs系列编译器会在虚函数表后面放个0x00000000标记
• 虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
• 虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证一下。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:// 重写基类的func1virtual 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("Person虚表地址:%p\n", *(int*)p3);printf("Student虚表地址:%p\n", *(int*)p4);printf("虚函数地址:%p\n", &Base::func1);printf("普通函数地址:%p\n", &Base::func5);return 0;
}
为什么要把Base*的p3和Derive*的p4强转成int*呢?
1. 强制转换 int*
的原理
(1) 对象内存布局
-
对于包含虚函数的类,每个对象的内存起始位置存储一个指向虚表的指针
vptr
,随后是类的成员变量。 -
内存布局示意图:
| vptr (4/8字节) | a (4字节) | ... |
(2) 强制转换的意义
-
Base* p3 = &b;
的指针类型是Base*
,但我们需要访问vptr
(即内存起始的地址)。 -
通过
*(int*)p3
:-
先将
Base*
转换为int*
,此时指针指向对象内存的首地址(即vptr
的位置)。 -
解引用
int*
得到vptr
的值(即虚表的地址)。
-
(3) 代码示例分析
Base* p3 = &b;
printf("Person虚表地址:%p\n", *(int*)p3);
-
步骤分解:
-
p3
指向Base
对象b
的起始地址(即vptr
的位置)。 -
将
Base*
强转为int*
,此时int*
指向vptr
。 -
解引用
int*
得到vptr
的值(即虚表的地址)。
-
2. 为什么必须是 int*
?
-
指针类型与内存对齐:
虚表指针vptr
的大小与int*
相同(在32位系统为4字节,64位系统为8字节)。
将指针转换为int*
可以正确解析vptr
的二进制内容。 -
通用性:
int*
是一种通用的指针类型,可以兼容不同平台下指针的存储方式。
运行结果:
可以看到虚表地址和常量区的地址是非常接近的,虚函数和普通函数的地址也是非常接近的状态
不过可能有人会有这样的问题:为什么多态要使用基类的指针或引用而不能使用对象呢?
1. 对象切片(Object Slicing)问题
当派生类对象直接赋值给基类对象时,派生类特有的成员会被“切掉”,仅保留基类部分。此时,基类对象无法访问派生类的虚函数,导致多态失效。
示例
class Base {
public:virtual void print() { cout << "Base" << endl; }
};class Derived : public Base {
public:void print() override { cout << "Derived" << endl; }
};int main() {Derived d;Base b = d; // 对象切片:b 是 Base 类型,无法保留 Derived 的信息b.print(); // 输出 "Base"(多态失效)return 0;
}
关键问题
-
值传递的拷贝行为:直接操作对象会触发拷贝构造函数或赋值运算符,生成一个基类对象副本。
-
虚函数表丢失:基类对象副本的虚表指针(
vptr
)指向基类的虚表,而非派生类。
2. 动态绑定的依赖条件
动态绑定(运行时多态)要求通过指针或引用访问对象,以保留对象的完整类型信息。
指针/引用的行为
Base* ptr = new Derived(); // 指针保留对象的动态类型
Base& ref = *ptr; // 引用同理
ptr->print(); // 输出 "Derived"(动态绑定成功)
-
内存布局保留:指针或引用直接操作原始对象的内存,包括派生类的虚表指针(
vptr
)。 -
虚表查找机制:调用虚函数时,通过
vptr
找到派生类的虚表,执行正确的函数。
直接使用对象的局限性
void func(Base obj) { obj.print(); } // 值传递导致对象切片Derived d;
func(d); // 输出 "Base"(派生类信息丢失)
-
静态类型绑定:对象类型在编译时确定,无法根据实际类型动态分派。
3. C++语言规则的限制
根据C++标准,动态绑定仅适用于通过指针或引用调用虚函数,直接使用对象时虚函数调用是静态绑定的。
规则总结
调用方式 | 绑定类型 | 多态行为 |
---|---|---|
Base obj = derived; obj.func(); | 静态绑定(编译时) | 无 |
Base* ptr = &derived; ptr->func(); | 动态绑定(运行时) | 有 |
Base& ref = derived; ref.func(); | 动态绑定(运行时) | 有 |
4. 内存模型的本质差异
对象的内存布局
-
直接对象:内存中仅包含基类成员和基类虚表指针(
vptr
)。 -
指针/引用:指向完整的派生类对象内存(包括派生类成员和派生类虚表指针)。
示例分析
Derived d;
Base b1 = d; // 对象切片:b1 是 Base 类型,vptr 指向 Base 的虚表
Base* b2 = &d; // b2 指向 Derived 对象,vptr 指向 Derived 的虚表
5. 解决方案与最佳实践
避免对象切片
-
使用指针或引用传递对象:
void func(Base& obj) { obj.print(); } // 通过引用传递 Derived d; func(d); // 输出 "Derived"
-
使用智能指针:
std::unique_ptr<Base> ptr = std::make_unique<Derived>(); ptr->print(); // 输出 "Derived"
后面的章节会做详细的讲解
明确多态的适用范围
-
基类析构函数必须为虚函数:确保通过基类指针删除派生类对象时,正确调用派生类析构函数。
-
优先使用
override
关键字:明确派生类函数的重写意图,避免隐藏或错误重写。
总结
-
对象切片的根本原因:值传递导致派生类信息丢失,虚表指针无法指向派生类。
-
动态绑定的必要条件:必须通过指针或引用保留对象的完整内存布局(包括虚表指针)。
-
语言规则限制:C++标准规定动态绑定仅支持指针或引用调用虚函数。
上面我们还提到了一个问题:为什么非成员函数不能加virtual修饰
我们下面给出回答
1. 语言设计的本质约束
-
虚函数依赖对象上下文
虚函数的调用依赖于对象的动态类型(通过虚函数表vtable实现)。而非成员函数没有隐含的this
指针,无法绑定到具体对象的上下文,因此无法实现动态分派。 -
作用域归属问题
虚函数必须是类的成员函数,因为它需要属于某个类的接口。非成员函数不属于任何类,自然无法参与类的继承体系。
2. 技术实现的不可行性
-
虚函数表(vtable)的绑定机制
每个含有虚函数的类会生成一个虚函数表,其中存储了指向虚函数的指针。非成员函数无法被添加到类的虚函数表中,因为它们没有与类实例关联的this
指针。 -
对象内存布局的缺失
非成员函数无法访问对象的成员变量,而虚函数通常需要操作对象内部状态。若允许非成员函数为虚,其实现将无法与对象的内存布局兼容。
3. 语义矛盾
-
多态与封装的关系
虚函数是面向对象中封装和多态的核心机制,而非成员函数通常是过程式编程的产物。若允许非成员函数为虚,会破坏C++对“对象行为”的封装性设计。 -
重写(override)的不可操作性
虚函数的核心目的是允许派生类重写基类行为,而非成员函数没有所属类,无法在派生类中通过继承机制重写。