【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)的不可操作性
虚函数的核心目的是允许派生类重写基类行为,而非成员函数没有所属类,无法在派生类中通过继承机制重写。
相关文章:

【C++】17. 多态
上一章节中我们讲了C三大特性的继承,这一章节我们接着来讲另一个特性——多态 1. 多态的概念 多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态…...
Excel的详细使用指南
### **一、Excel基础操作** #### **1. 界面与基本概念** - **工作簿(Workbook)**:一个Excel文件(扩展名.xlsx)。 - **工作表(Worksheet)**:工作簿中的单个表格(默认名…...
没经过我同意,flink window就把数据存到state里的了?
欢迎关注我 不知道大家在初次使用Flink的时候,是否对Flink中定义本地变量和状态比较好奇,这俩有啥区别? 而且在使用Window API时明明没有显式地创建状态,也没调用getState(),却依然把每个窗口里的所有元素都自动缓存…...
Python+OpenCV打造AR/VR基础框架:从原理到实战的全链路解析
引言:重新定义数字与现实的边界 在元宇宙概念持续升温的当下,AR(增强现实)与VR(虚拟现实)技术正成为连接物理世界与数字世界的桥梁。Python凭借其丰富的计算机视觉生态(尤其是OpenCV库…...

家用或办公 Windows 电脑玩人工智能开源项目配备核显的必要性(含 NPU 及显卡类型补充)
一、GPU 与显卡的概念澄清 首先需要明确一个容易误解的概念:GPU 不等同于显卡。 显卡和GPU是两个不同的概念。 【概念区分】 在讨论图形计算领域时,需首先澄清一个常见误区:GPU(图形处理单元)与显卡(视…...

实现一个简单的 TCP 客户端/服务器
注意: TCP 三次握手建立连接建立连接后,TCP 提供全双工的通信服务,也就是在同一个连接中,通信双方 可以在同一时刻同时写数据,相对的概念叫做半双工,同一个连接的同一时刻,只能由一方来写数据T…...

对抗帕金森:在疾病阴影下,如何重掌生活主动权?
帕金森病,一种影响全球超 1000 万人的神经退行性疾病,正无声地改变着患者的生活轨迹。随着大脑中多巴胺分泌减少,患者逐渐出现肢体震颤、肌肉僵硬、步态迟缓等症状,甚至连扣纽扣、端水杯这类日常动作都变得艰难。更棘手的是&#…...

鸿蒙 UIAbility组件与UI的数据同步和窗口关闭
使用 EventHub 进行数据通信 Stage模型概念图 根据 Stage 模型概念图 UIAbility 先于 ArkUI Page 创建 所以,事件要先 .on 订阅 再 emit 发布 假如现在有页面 Page1 和他的 UIAbility // src/main/ets/page1ability/Page1Ability.ets onCreate(want: Want, laun…...
DeepSeek 赋能汽车全生态:从产品到服务的智能化跃迁
目录 一、引言二、DeepSeek 助力汽车产品介绍与推广2.1 新车性能参数与技术亮点宣传文案2.2 汽车品牌故事与文化内涵挖掘2.3 汽车广告创意与宣传方案设计 三、DeepSeek 赋能汽车售后服务支持3.1 汽车维修保养知识科普文章创作3.2 常见故障诊断与解决方案生成3.3 汽车用户个性化…...
MQTT 在Spring Boot 中的使用
在 Spring Boot 中使用 MQTT 通常会借助 Spring Integration 项目提供的 MQTT 支持。这使得 MQTT 的集成可以很好地融入 Spring 的消息驱动和企业集成模式。 以下是如何在 Spring Boot 中集成和使用 MQTT 的详细步骤: 前提条件: MQTT Brokerÿ…...

Vue3学习(组合式API——计算属性computed详解)
目录 一、计算属性computed。 Vue官方提供的案例。(普通写法与计算属性写法) 使用计算属性computed重构——>简化描述响应式状态的复杂逻辑。 (1)计算属性computed小案例。 <1>需求说明。(筛选原数组——>得新数组) &…...
Spring 中的 @ComponentScan注解详解
在 Spring 框架中,@ComponentScan 是一个非常重要的注解,它用于自动扫描和注册 Bean。通过该注解,Spring 能够自动发现并管理标注了特定注解的类(如 @Component, @Service, @Repository 等),从而实现依赖注入和容器管理。 本文将详细介绍 @ComponentScan 的作用、常见搭…...
MySQL 数据库故障排查指南
MySQL 数据库故障排查指南 本指南旨在帮助您识别和解决常见的 MySQL 数据库故障。我们将从问题识别开始,逐步深入到具体的故障类型和排查步骤。 1. 问题识别与信息收集 在开始排查之前,首先需要清晰地了解问题的现象和范围。 故障现象: 数…...

Android Studio 模拟器配置方案
Android Studio 模拟器配置方案 1.引言2.使用Android Studio中的模拟器3.使用国产模拟器1.引言 前面介绍【React Native基础环境配置】的时候需要配置模拟器,当时直接使用了USB调试方案,但是有些时候可能不太方便连接手机调试,比如没有iPhone调不了ios。接下来说明另外两种可…...

k8s中ingress-nginx介绍
1. 介绍 Ingress是一种Kubernetes资源,用于将外部流量路由到Kubernetes集群内的服务。与NodePort相比,它提供了更高级别的路由功能和负载平衡,可以根据HTTP请求的路径、主机名、HTTP方法等来路由流量。可以说Ingress是为了弥补NodePort在流量…...
键盘输出希腊字符方法
在不同操作系统中,输出希腊字母的方法有所不同。以下是针对 Windows 和 macOS 系统的详细方法,以及一些通用技巧: 1.Windows 系统 1.1 使用字符映射表 字符映射表是一个内置工具,可以方便地找到并插入希腊字母。 • 步骤…...

字节DeerFlow开源框架:多智能体深度研究框架,实现端到端自动化研究流程
🦌 DeerFlow DeerFlow(Deep Exploration and Efficient Research Flow)是一个社区驱动的深度研究框架,它建立在开源社区的杰出工作基础之上。目标是将语言模型与专业工具(如网络搜索、爬虫和Python代码执行࿰…...
MySQL 存储函数[特殊字符] VS 存储过程[特殊字符]
1、存储函数😸 一、存储函数概述 存储函数是MySQL中一种特殊的存储程序,具有以下特点: 返回单个值:必须通过RETURN语句返回明确的结果SQL表达式使用:可以直接在SQL语句中调用输入参数:只接受输入参数(隐…...
reactor实现TCP遇到的问题和探究
struct conn{ int fd; char rbuffer[1024]; char wbuffer[1024]; int wlength; int rlength; int (*recv_cb)(int); int (*send_cb)(int); }; int (*recv_cb)(int); recv_cb:函数指针的名称*recv_cb:星号 * 表示 recv_cb 是一个指针。(*recv_cb)&#…...
ElasticSearch重启之后shard未分配问题的解决
以下是Elasticsearch重启后分片未分配问题的完整解决方案,结合典型故障场景与最新实践: 一、快速诊断定位 检查集群状态 GET /_cluster/health?pretty # status为red/yellow时需关注unassigned_shards字段值 2.查看未分配分片详情 …...

算法第十八天|530. 二叉搜索树的最小绝对差、501.二叉搜索树中的众数、236. 二叉树的最近公共祖先
530. 二叉搜索树的最小绝对差 题目 思路与解法 第一想法: 一个二叉搜索树的最小绝对差,从根结点看,它的结点与它的最小差值一定出现在 左子树的最右结点(左子树最大值)和右子树的最左结点(右子树的最小值…...
QMK键盘编码器(Encoder)(理论部分)
QMK键盘编码器(Encoder)(理论部分) 前言 作为一名深耕机械键盘DIY多年的老司机,我发现很多键盘爱好者对QMK编码器的配置总是一知半解。今天我就把多年积累的经验毫无保留地分享给大家,从硬件接线到软件配置,从基础应用到高阶玩法,一文全搞定!保证看完就能让你的编码…...

微服务调试问题总结
本地环境调试。 启动本地微服务,使用公共nacos配置。利用如apifox进行本地代码调试解决调试问题。除必要的业务微服务依赖包需要下载到本地。使用mvn clean install -DskipTests进行安装启动前选择好profile环境进行启动,启动前记得mvn clean清理项目。…...
C++(2)
二、面向对象基础 1. 类与对象 1.1 核心概念 类(Class) 定义:抽象描述具有共同特征和行为的对象模板本质:代码复用的蓝图,定义数据(属性)与操作(行为࿰…...

美SEC主席:探索比特币上市证券交易所
作者/演讲者:美SEC主席Paul S. Atkins 编译:Liam 5月12日,由美国SEC加密货币特别工作组发起的主题为《资产上链:TradFi与DeFi的交汇点》系列圆桌会议如期举行。 会议期间,现任美SEC主席Paul S. Atkins发表了主旨演讲。…...
@Controller 与 @RestController-笔记
1.Controller与RestController对比 Spring MVC 中 Controller 与 RestController 的功能对比: Controller是Spring MVC中用于标识一个类作为控制器的标准注解。它允许处理HTTP请求,并返回视图名称,通常和视图解析器一起使用来渲染页面。而R…...
JavaScript篇:揭秘函数式与命令式编程的思维碰撞
大家好,我是江城开朗的豌豆,一名拥有6年以上前端开发经验的工程师。我精通HTML、CSS、JavaScript等基础前端技术,并深入掌握Vue、React、Uniapp、Flutter等主流框架,能够高效解决各类前端开发问题。在我的技术栈中,除了…...
c++和c的不同
c:面向对象(封装,继承,多态),STL,模板 一、基础定义与背景 C语言 诞生年代:20世纪70年代,Dennis Ritchie在贝尔实验室开发。主要特点: 过程式、结构化编程面向系统底层…...

MySQL Join连接算法深入解析
引言 在关系型数据库中,Join操作是实现多表数据关联查询的关键手段,直接影响查询性能和资源消耗。MySQL支持多种Join算法,包括经典的索引嵌套循环连接(Index Nested-Loop Join)、块嵌套循环连接(Block Nes…...
从构想到交付:专业级软开发流程详解
目录 一、软件开发生命周期(SDLC)标准化流程 1. 需求工程阶段(Requirement Engineering) 2. 系统设计阶段(System Design) 3. 开发阶段(Implementation) 4. 测试阶段&a…...