【C++ 多态】—— 礼器九鼎,釉下乾坤,多态中的 “风水寻龙诀“
欢迎来到一整颗红豆的博客✨,一个关于探索技术的角落,记录学习的点滴📖,分享实用的技巧🛠️,偶尔还有一些奇思妙想💡
本文由一整颗红豆原创✍️,感谢支持❤️!请尊重原创📩!欢迎评论区留言交流🌟
个人主页 👉 一整颗红豆
本文专栏 ➡️C++ 进阶之路
礼器九鼎,釉下乾坤,多态中的 "风水寻龙诀"
- 多态的概念
- 编译时多态(静态多态)
- `编译时多态的实现方式有,函数重载,运算符重载和模板。`
- 函数重载 `Function Overloading`
- 运算符重载(`Operator Overloading`)
- 模板(`Templates`)
- 编译时多态的特点
- 静态绑定(`Static Binding`)
- 类型安全(`Type Safety`)
- 无运行时开销
- 代码膨胀(`Code bloat`)
- 运行时多态(动态多态)
- 认识虚函数(`Virtual function`)
- 虚函数的重写/覆盖(`override`和`final`)
- 纯虚函数(`Pure Virtual Function`)和抽象类
- 虚析构函数(`Virtual Destructor`)
- 协变(`Covariant Return Types`)
- 重载(`Overload`)、重写(`Override`)、隐藏(`Hide`)的对比
- 重载(`Overload`)
- 重写(`Override`)
- 隐藏(`Hide`)
- 多态的原理
- 虚函数表(`vtable`)
- 虚函数表的概念
- 虚函数表的生成规则
- 多态是如何实现的
- 动态绑定和静态绑定
- 虚函数表的位置
- 总结多态调用
- 写在最后
多态的概念
众所周知,面向对象有三大特性,封装、继承和多态!
多态(
Polymorphism
) 是面向对象编程的核心特性,允许用统一的接口操作不同类型的对象,并根据对象实际类型执行不同的行为。C++中的多态分为编译时多态和运行时多态。
编译时多态(静态多态)
编译时多态(
Compile-Time Polymorphism
) 又称为 静态多态,是一种在 代码编译阶段就能确定具体调用行为的机制。它的核心特点是 基于静态类型系统,通过代码结构直接决定调用哪个函数或操作,无需运行时动态查找。
编译时多态的实现方式有,函数重载,运算符重载和模板。
函数重载 Function Overloading
- 通过 参数列表不同(类型/数量/顺序)定义同名函数
示例:
void print(int x)
{std::cout << "int: " << x << std::endl;
}
void print(double x)
{std::cout << "double: " << x << std::endl;
}
void print(const char *s)
{std::cout << "const char *: " << s << std::endl;
}
调用策略:
int main()
{print(10); // 调用 print(int)print(3.14); // 调用 print(double)print("Hello"); // 调用 print(const char*)
}
输出:
int: 10
double: 3.14
const char *: Hello
有关函数重载的原理及应用不再过多说明,详见博文
👉 C++函数重载
运算符重载(Operator Overloading
)
- 原理:为自定义类型重定义运算符行为
示例:
class Complex
{
private:double real;double imag;
public:Complex(double r = 0, double i = 0) : real(r), imag(i) {}// 重载 + 运算符Complex operator+(const Complex &other){return Complex(real + other.real, imag + other.imag);}void print(){std::cout << real << " + " << imag << "i" << std::endl;}
};
调用策略:
int main()
{// 运算符重载调用Complex c1(1, 2);Complex c2(3, 4);Complex c3 = c1 + c2;std::cout << "Complex addition: ";c3.print();
}
输出:
Complex addition: 4 + 6i
同样有关运算符重载的知识这里也不再过多讲解,在类和对象中已经讲的很详细了,忘了就快去复习一下吧!!
👉 C++ 类和对象 进阶篇
模板(Templates
)
- 原理:通过泛型编程生成类型特化代码
函数模板示例:
template <typename T>
T Max(T a, T b)
{return (a > b) ? a : b;
}
调用策略:
int main()
{std::cout << Max(3, 5) << std::endl; // 生成 int 版本std::cout << Max(2.7, 3.14) << std::endl; // 生成 double 版本return 0;
}
输出:
5
3.14
有关函数模板更深入的讲解,请参考博文
👉 C++ 函数模板
类模板示例:
template <typename T>
class Stack
{
public:void push(const T &item) { _elements.push_back(item); }T pop() {}
private:std::vector<T> _elements;
};
调用策略:
int main()
{Stack<int> int_Stack; // 存储整数的栈Stack<string> str_Stack; // 存储字符串的栈return 0;
}
编译时多态的特点
静态绑定(Static Binding
)
- 定义:函数调用在编译阶段确定,编译器根据调用时的 静态类型 直接绑定到具体实现。
原理:
int add(int a, int b); // 函数A
double add(double a, double b); // 函数B
int main()
{add(3, 5); // 编译时直接绑定到函数Aadd(3.0, 5.0); // 编译时直接绑定到函数Breturn 0;
}
- 编译器通过函数签名(函数名 + 参数类型)匹配最佳候选
- 生成的目标代码中直接写入函数地址,无运行时决策
下面是静态绑定和动态绑定 (后面要讲的虚函数的原理) 的区别
特性 | 静态绑定 | 动态绑定(虚函数) |
---|---|---|
决策时机 | 编译时 | 运行时 |
性能开销 | 无额外开销 | 虚表查找(1~2 次指针跳转) |
灵活性 | 固定 | 可动态切换 |
类型安全(Type Safety
)
模板类型推导:
template <typename T>
T Max(T a, T b) { return (a > b) ? a : b; }int main()
{Max(3, 5.0); // 编译出错:T 同时推导为 int 和 double,类型不一致return 0;
}
- 编译出错:
double和int类型不一致,模板无法正确推导
- 编译器严格检查模板参数类型一致性
- 避免隐式类型转换导致的意外行为
错误检测时机:
std::vector<int> v;
v.push_back("Hello,World"); // 编译错误:参数类型不匹配
静态多态,又叫做 编译时多态,显而易见在编译器进行编译时,就会对多态的正确性进行检测,如果发现有错误,则无法编译通过,所以是类型安全的,这也是其优点之一
无运行时开销
代码直接生成
// 模板函数
template <typename T>
T square(T x) { return x * x; }int main()
{square(5); // 生成 int square(int x) { return x*x; }square(3.14); // 生成 double square(double x) { return x*x; }return 0;
}
- 每个类型特化生成独立的机器代码
- 调用时直接跳转到具体函数,无间接寻址,提高运行效率
以上都是编译时多态的优点,其实编译时多态也有缺点,就是会导致代码膨胀,二进制文件的体积过大!
代码膨胀(Code bloat
)
模板实例化机制:
template <typename T>
class Wrapper
{T data; /*...*/
};int main()
{Wrapper<int> w1; // 生成 int 特化版本Wrapper<double> w2; // 生成 double 特化版本return 0;
}
- 每个不同类型参数生成完全独立的二进制代码
- 可能导致可执行文件体积显著增大
凡事都有两面性,直接生成代码,避免了间接寻址带来的性能损耗,无运行时开销,提高了效率,但同时也生成多种不同版本的二进制代码,代码膨胀,会导致编译链接后生成的可执行文件体积增大!
运行时多态(动态多态)
要理解运行时多态,首先要知道虚函数的概念,因为C++多态的核心机制就是派生类对基类虚函数的重写
。
认识虚函数(Virtual function
)
虚函数(Virtual Function) 是实现 运行时多态(动态多态) 的核心机制。它允许通过基类指针或引用调用派生类的重写函数,是面向对象编程中实现“一个接口,多种实现”的关键工具。
虚函数的概念:
- 虚函数 是用
virtual
关键字声明的成员函数,用于实现 运行时多态。 - 类成员函数前面加
virtual
修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual
修饰。 - 它允许派生类 重写(
Override
) 基类的函数实现,并通过基类指针/引用调用派生类的版本。
虚函数的定义:
基类声明虚函数:
class Animal
{
public:virtual void speak(){ // 使用 virtual 关键字std::cout << "Animal sound\n";}
};
派生类重写虚函数:
class Dog : public Animal
{
public:void speak() override{ // 使用 override 明确重写(C++11)std::cout << "Woof!\n";}
};
通过基类调用:通过基类指针/引用调用虚函数时,实际调用的是 对象实际类型 的函数:
int main()
{Animal *animal = new Dog();animal->speak(); // 输出 "Woof!"(调用 Dog 的实现)delete animal;return 0;
}
输出:
Woof!
虚函数的重写/覆盖(override
和final
)
虚函数重写(
Override
) 是指派生类重新定义基类的虚函数,实现 同签名不同行为。它是实现运行时多态的关键机制。
虚函数重写的必要条件
条件 | 说明 |
---|---|
基类函数为虚函数 | 基类函数必须使用 virtual 声明 |
函数签名一致 | 派生类函数必须与基类的 函数名、参数类型/数量/顺序、const 限定符 完全一致 |
访问权限允许 | 派生类函数访问权限不能比基类更严格(如基类为 public,派生类不能为 private) |
注意:
- 派生类在重写基类虚函数时,在不加
virtual
关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了,在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。 - 不过要牢记,即使派生类继承下来的虚函数即便不加
virtual
关键字也可以重写基类的虚函数,构成多态!
虚函数重写时的常见错误:
函数签名不一致
:
- 错误原因:派生类中重写的函数与基类虚函数的参数列表、返回类型(非协变类型)或 const 修饰符不一致。
- 后果:编译器不会报错,但函数不会正确覆盖基类虚函数,而是形成隐藏(
hide
)。这个之前讲过,派生类会隐藏和基类中函数签名相同的函数。
示例:
class Base {
public:virtual void func(int) {}
};
class Derived : public Base {
public:void func(double) {} // 参数类型不一致,未覆盖基类 func(int)
};
没有使用 override 关键字
:
- 错误原因:未在派生类中使用 C++11 引入的
override
关键字明确标记重写。 - 后果:若函数签名错误,编译器可能不会提示错误,导致隐藏而非覆盖。
class Derived : public Base {
public:void func(int) override {} // 使用 override 强制编译器检查覆盖
};
基类虚函数未声明为 virtual
:
- 错误原因:基类函数未用
virtual
修饰,但派生类试图重写。 - 后果:派生类函数与基类函数是独立的,无法通过基类指针/引用调用多态。
示例:
class Base {
public:void func() {} // 非虚函数
};
class Derived : public Base {
public:void func() {} // 隐藏基类函数,无法多态调用
};
注意:
- -
override
是 C++11 引入的一个新特性,它并非强制要求。在没有override
关键字时,满足条件重写的条件同样可以构成虚函数的重写,只是若派生类函数使用override
声明,但未正确重写基类虚函数(如函数名、参数列表或常量性不匹配),编译器会报错,有助于在编译阶段发现错误。 - C++11还提供了一个关键字叫做
final
,如果我们不想让派生类重写这个虚函数,那么可以用final
去修饰。
class Car
{
public:virtual void Drive() final {}
};
class Benz : public Car
{
public:virtual void Drive(){std::cout << "Benz" << std::endl;// 编译出错,无法重写final修饰的虚函数}
};
趁热打铁,接下来我们看一道有关多态场景的选择题:
以下程序输出结果是什么()
- 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;
}
正确的答案是B哦!不知道你做对了没有,可以想想以下两个问题哦:
1. func是否满足虚函数的重写条件?
2. 默认参数的确定实在编译时还是运行时呢?
纯虚函数(Pure Virtual Function
)和抽象类
纯虚函数(Pure Virtual Function)
是一种没有具体实现的虚函数,其存在的目的是强制派生类必须实现该函数。它的声明方式是在虚函数声明末尾添加 = 0。
例如;
virtual 返回类型 函数名(参数) = 0; // 纯虚函数
纯虚函数的作用:
- 定义接口规范:纯虚函数为所有派生类定义一个必须实现的统一接口。
- 创建抽象类:包含纯虚函数的类称为抽象类,它不能被直接实例化(不能创建对象)。
- 强制派生类实现:所有直接继承自抽象类的派生类必须重写(override)纯虚函数,否则派生类也会成为抽象类。
简单来说就是,如果基类定义了纯虚函数,那么这个基类被称为抽象类,不能用来创建对象,同时继承了该类的派生类必须重写该纯虚函数,否则派生类也将成为抽象类。这样一来,基类可以提供一个统一的接口,具体实现交给不同的派生类实现。
例如:
// 抽象基类(包含纯虚函数)
class Animal
{
public:virtual void sound() const = 0; // 纯虚函数virtual ~Animal() {} // 虚析构函数(重要!)
};// 派生类必须实现 sound()
class Dog : public Animal
{
public:void sound() const override{ // 重写纯虚函数std::cout << "Woof!" << std::endl;}
};class Cat : public Animal
{
public:void sound() const override{ // 重写纯虚函数std::cout << "Meow!" << std::endl;}
};
纯虚函数可以有实现(但通常不需要)
- C++允许基类为纯虚函数提供默认实现,但派生类仍需显式重写,注意纯虚函数的实现必须在类外
!
class Animal
{
public:virtual void sound() const = 0;
};
// 纯虚函数的默认实现(罕见用法)必须实在类外实现的!
void Animal::sound() const
{std::cout << "Default animal sound" << std::endl;
}
class Dog : public Animal
{
public:void sound() const override{Animal::sound(); // 调用基类的默认实现std::cout << "Woof!" << std::endl;}
};
虚析构函数(Virtual Destructor
)
看下面一个例子:
class Base
{ // 基类(无虚析构函数)
public:~Base(){std::cout << "Base destructor\n";}
};class Derived : public Base
{ // 派生类(持有动态资源)
public:int *data;Derived(){data = new int[100]; // 动态分配内存}~Derived(){delete[] data; // 释放内存std::cout << "Derived destructor\n";}
};int main()
{Base *obj = new Derived(); // 基类指针指向派生类对象delete obj; // 仅调用了Base基类的析构函数return 0;
}
输出:
Base destructor
不难发现,当我们用基类的指针指向派生类,释放基类指针时,只调用了基类的析构函数,释放了基类中的资源,并没有调用派生类的析构函数,这会导致什么问题呢?
是不是会导致资源没有正确释放,派生类中的data指向的100个 int
内存未被释放,会导致内存泄漏。
如果基类中还有其他动态资源,比如文件句柄、数据库连接等资源,这些资源也会泄漏。会对整个程序造成重大影响!这时就需要使用虚析构函数来解决问题 !
虚析构函数(
Virtual Destructor
) 是 C++中用于解决多态对象资源释放问题的关键机制。它通过动态绑定确保通过基类指针删除派生类对象时,派生类和基类的析构函数都能被正确调用,避免资源泄漏。
- 虚析构函数是用
virtual
关键字声明的析构函数。 - 当基类指针指向派生类对象时,若基类的析构函数是虚函数,删除该指针会触发动态绑定,确保调用实际对象类型的析构函数。
class Base
{
public:virtual ~Base(){ // 声明为虚析构函数std::cout << "Base destroyed\n";}
};class Derived : public Base
{
public:~Derived() override{ // 重写虚析构函数std::cout << "Derived destroyed\n";}
};int main()
{Base *obj = new Derived();delete obj; // 正确调用Derived和Base的析构函数return 0;
}
输出:
Derived destroyed
Base destroyed
还记得之前讲到的,虚函数重写的要求吗?派生类的析构函数与基类的析构函数名称都不一样!怎么能构成重写呢?
实际上,基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加
virtual
关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor
,所以基类的析构函数加了virltual
修饰,派⽣类的析构函数就构成重写。
虚析构函数的原理:(TODO)
涉及到虚函数表,我们放在多态的原理中讲,相信大家看到那里自然就会明白!
协变(Covariant Return Types
)
C++虚函数的协变允许派生类在重写基类虚函数时,将返回类型替换为基类函数返回类型的派生类指针或引用。
简单来说就是: 派生类重写基类虚函数时,可以与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解⼀下即可。
协变条件:
- 基类和派生类的虚函数返回类型必须为指针或引用。
- 派生类返回类型必须是基类返回类型的直接或间接派生类。
- 函数参数列表必须完全相同。
举个例子:
// 基类
class Fruit {
public:virtual Fruit* clone() const { // 虚函数,返回Fruit*(基类指针)return new Fruit(*this);}virtual ~Fruit() {}
};// 派生类
class Apple : public Fruit {
public:Apple* clone() const override { // 协变:返回Apple*(派生类指针)return new Apple(*this);}void sayName() const {std::cout << "I am an Apple!" << std::endl;}
};int main() {Fruit* fruit = new Apple(); // 基类指针指向派生类对象Fruit* cloned = fruit->clone(); // 调用派生类的clone()// 验证协变特性if (Apple* apple = dynamic_cast<Apple*>(cloned)) {apple->sayName(); // 成功调用Apple特有方法} else {std::cout << "Cloning failed!" << std::endl;}delete fruit;delete cloned;return 0;
}
输出:
I am an Apple!
返回类型
- 基类虚函数返回 Fruit*
- 派生类重写时返回 Apple*(Apple 是 Fruit 的派生类)
- 符合协变要求:返回类型是基类返回类型的 派生类指针/引用
多态行为
- 通过基类指针 Fruit* 调用 clone(),实际调用 Apple::clone()
- 返回的 Apple* 可以隐式转换为 Fruit*,但保留了实际类型信息
动态类型验证
- 使用 dynamic_cast 将 Fruit* 转回 Apple*,验证协变正确性
- 调用 Apple 类的特有方法 sayName()
对于协变大家了解即可,其实底层原理也是多态的虚函数表指针。
重载(Overload
)、重写(Override
)、隐藏(Hide
)的对比
特性 | 重载(Overload) | 重写(Override) | 隐藏(Hide) |
---|---|---|---|
定义 | 同一作用域内,同名函数参数不同 | 派生类重写基类虚函数 | 派生类同名函数遮蔽基类同名函数 |
作用域 | 同一类或同一命名空间 | 基类与派生类之间 | 基类与派生类之间 |
函数签名要求 | 函数名相同,参数列表不同 | 函数名、参数、返回类型均相同 | 函数名相同,参数可同可不同 |
virtual关键字 | 不需要 | 基类函数必须为虚函数 | 不需要 |
多态性 | 无 | 支持动态多态(运行时绑定) | 无(静态绑定) |
示例场景 | 同一类中的多个构造函数 | 派生类重写基类的虚函数 | 派生类定义与基类同名的非虚函数 |
重载(Overload
)
- 重载规则:在同一作用域内,函数名相同,但参数列表不同(参数类型、数量、顺序不同)。
- 返回类型无关:仅返回类型不同不构成重载,会导致编译错误。
- 典型场景:同一类中的多个构造函数、工具函数。
class Calculator {
public:// 重载示例int add(int a, int b) { return a + b; }double add(double a, double b) { return a + b; } // 参数类型不同int add(int a, int b, int c) { return a + b + c; } // 参数数量不同
};
重写(Override
)
- 重写规则:派生类重新定义基类的 虚函数,要求函数名、参数列表、返回类型完全一致(协变返回类型例外)。
- 多态性:通过虚函数表(
vtable
)实现运行时多态。 - 必须使用
virtual
:基类函数声明为virtual
,派生类建议使用override
明确意图(C++11+)。
举例就省略,上面我们刚讲过。
隐藏(Hide
)
- 隐藏规则:派生类定义与基类同名的函数(无论参数是否相同),导致基类同名函数被隐藏。
两种形式:
- 同名同参非虚函数:派生类函数隐藏基类函数(即使基类函数非虚)。
- 同名不同参:派生类函数隐藏基类所有同名函数(包括重载版本)。
class Base {
public:void func() { std::cout << "Base::func()\n"; }void func(int) { std::cout << "Base::func(int)\n"; } // 重载版本
};class Derived : public Base {
public:// 隐藏基类的所有 func 函数(包括重载)void func() { std::cout << "Derived::func()\n"; }
};int main() {Derived d;d.func(); // 正确:调用 Derived::func()// d.func(1); // 错误!Base::func(int) 被隐藏d.Base::func(1); // 正确:显式调用基类函数return 0;
}
多态的原理
虚函数表(vtable
)
虚函数表的概念
- 虚函数表(
vtable
) 是在编译期间,编译器为每个包含虚函数的类生成的静态表,存储该类所有虚函数的地址,生成后不可修改。 - 虚函数指针(
vptr
) 是每个对象实例中隐含的指针,指向其所属类的虚函数表。
内存布局示例
看下面一道题:
下面程序 在32位程序的运行结果是什么()
(32位下指针大小为4个字节
)
- A. 编译报错
- B. 运行报错
- C. 8
- D. 12
class Base
{
public:virtual void Func1(){std::cout << "Func1()" << std::endl;}protected:int _b = 1;char _ch = 'x';
};
int main()
{Base b;std::cout << sizeof(b) << std::endl;return 0;
}
正确答案为D,12个字节,你回答对了吗?
成员变量内存布局:
- int _b:占4字节。
- char _ch:占1字节。
内存对齐:
- 结构体的总大小需对齐到4字节(int的对齐要求)。
- char _ch后需填充3字节以满足对齐。
C++中类和C语言中的结构体都满足内存对齐的规则,所以成员变量一共占了8个字节,那么还有另外4个字节是什么呢?
还多⼀个
__vfptr
放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v 代表virtual
,f代表function
)。⼀个含有虚函数的类中都至少都有⼀个虚函数表指针,因为⼀个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
-
基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同⼀张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
-
派生类由两部分构成,继承下来的基类和自己的成员,⼀般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
-
派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
派生类的虚函数表中包含:
-
基类的虚函数地址,
-
派生类重写的虚函数地址完成覆盖,
-
派生类自己的虚函数地址三个部分。
举个例子:这是一个基类
class Base {
public:virtual void func1() {}virtual void func2() {}int data;
};
对象内存布局:
+----------------+
| vptr | --> 指向Base的虚函数表
| int data |
+----------------+
虚函数表内容:
Base的vtable:
+----------------+
| &Base::func1 |
| &Base::func2 |
+----------------+
虚函数表的生成规则
虚表在编译期间由编译器生成,程序启动时加载到内存,生命周期与程序一致。
单继承场景
派生类的虚函数表基于基类的虚函数表扩展:
- 若派生类重写基类虚函数,替换表中对应的函数指针。
- 若派生类新增虚函数,追加到表末尾。
class Derived : public Base {
public:void func1() override {} // 重写基类的func1virtual void func3() {} // 新增虚函数
};
Derived的vtable:
+----------------+
| &Derived::func1| // 重写基类func1
| &Base::func2 | // 未重写,保留基类func2
| &Derived::func3| // 新增虚函数
+----------------+
多重继承场景
每个基类对应独立的虚函数表,派生类合并所有基类的表并调整vptr偏移。
class Base1 { virtual void f1(); };
class Base2 { virtual void f2(); };
class Derived : public Base1, public Base2 {void f1() override {}void f2() override {}
};
Derived对象内存布局:
+----------------+
| Base1的vptr | --> [&Derived::f1]
| Base1成员变量 |
| Base2的vptr | --> [&Derived::f2]
| Base2成员变量 |
| Derived成员变量 |
+----------------+
多态是如何实现的
多态(
Polymorphism
) 主要通过 虚函数(virtual functions
) 和 虚函数表(vtable
) 实现,核心是动态绑定(Dynamic Binding
)。
动态绑定和静态绑定
动态绑定(
Dynamic Binding
) 和 静态绑定(Static Binding
) 是函数调用的两种不同解析机制,直接影响程序的执行行为。
特性 | 静态绑定(早绑定) | 动态绑定(晚绑定) |
---|---|---|
解析时机 | 编译时确定调用的具体函数 | 运行时根据对象实际类型确定调用的函数 |
实现机制 | 函数地址直接硬编码到代码中 | 通过虚函数表(vtable)和虚指针(vptr)动态查找 |
性能 | 高(无运行时开销) | 较低(需要查表和间接调用) |
灵活性 | 低(固定行为) | 高(支持多态) |
应用场景 | 普通函数、非虚成员函数、模板函数 | 虚函数(多态调用) |
静态绑定(Static Binding
)
工作机制
- 编译时确定:编译器根据调用者的 静态类型(声明类型)直接绑定函数地址。
- 无运行时决策:无论实际对象是什么类型,调用的函数在编译时已固定。
典型场景
- 非虚成员函数调用:
class Base {
public:void nonVirtualFunc() { std::cout << "Base\n"; }
};class Derived : public Base {
public:void nonVirtualFunc() { std::cout << "Derived\n"; }
};int main() {Base* obj = new Derived();obj->nonVirtualFunc(); // 输出 "Base"(静态绑定)delete obj;return 0;
}
对于上面这个例子:
函数未声明为虚函数
- Base 类的 nonVirtualFunc() 没有使用 virtual 关键字,因此它是一个普通成员函数,不具备多态性。
指针类型决定调用
- obj 的类型是
Base*
,编译器在编译时根据指针的静态类型(即Base*
)确定调用Base::nonVirtualFunc()
,与指针实际指向的对象类型无关。
派生类函数是“隐藏”而非“重写”
- Derived 类中的 nonVirtualFunc() 并未覆盖基类函数,而是定义了一个同名函数。这种现象称为 函数隐藏(NameHiding)。
- 若通过 Derived 类对象或指针调用 nonVirtualFunc(),才会调用派生类版本。
- 通过基类指针调用时,只能访问基类的函数。不具备多态性。
- 全局函数调用:
void func() { /* ... */ }
func(); // 静态绑定
- 模板函数实例化:
template <typename T>
void templateFunc(T t) { /* ... */ }templateFunc(42); // 编译时生成针对int的版本
动态绑定(Dynamic Binding
)
工作机制
- 运行时确定:通过虚函数表(vtable)和虚指针(vptr)动态查找函数地址。
- 依赖虚函数:只有虚函数(标记为 virtual 的成员函数)支持动态绑定。
典型场景
- 虚函数调用:
class Base {
public:virtual void virtualFunc() { std::cout << "Base\n"; }
};class Derived : public Base {
public:void virtualFunc() override { std::cout << "Derived\n"; }
};int main() {Base* obj = new Derived();obj->virtualFunc(); // 输出 "Derived"(动态绑定)delete obj;return 0;
}
- 多态对象销毁:(虚析构函数)
class Base {
public:virtual ~Base() {} // 虚析构函数
};class Derived : public Base {
public:~Derived() override { /* 释放派生类资源 */ }
};Base* obj = new Derived();
delete obj; // 动态调用~Derived()
这里我们就可以讲解虚析构函数的原理了,虚析构函数通过动态绑定(运行时多态)确保调用实际对象类型的析构函数。delete
操作符通过 vptr
找到实际对象的析构函数,实现动态调用。
析构函数的调用顺序
当销毁一个派生类对象时,析构函数的调用顺序是 “从派生类到基类” 的逆向构造顺序:
- 先执行派生类的析构函数:释放派生类独有的资源(如动态内存、文件句柄等)。
- 自动调用基类的析构函数:释放基类的资源。
这种顺序由编译器自动管理,确保所有资源按正确顺序释放。
如果基类的析构函数没有被定义为虚函数,那么在析构时就不会触发动态绑定,实际上会通过静态绑定直接指向该指针的类型对象,即基类,从而只调用基类的析构函数,释放基类的资源,导致派生类的析构函数无法被调用,造成内存泄漏等问题。
看到这里,相信大家已经明白了为什么析构函数要定义为虚函数。也明白了多态的核心机制——动态绑定
还有一个值得注意的点是:虚函数的默认参数是静态绑定的
- 虚函数的默认参数在编译时确定,与动态绑定的函数体无关
再看我们之前的那道题,相信现在这道题对你来说已经是小菜一碟了
以下程序输出结果是什么()
- 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;
}
结合我们上面讲到的知识点,现在已经不难理解,B重写了A的 func()
虚函数,当执行 p->test()
,这句代码时,会调用B中继承下来的基类的 test()
函数,从而执行 func()
函数,由于类B重写了基类的 func()
函数,会根据指针p指向的实际类型,执行动态绑定,实际上执行的是B中重写的 func()
函数,但由于默认参数是静态绑定,默认参数的取值由调用方的静态类型决定,与动态绑定的函数实现无关。
test()
函数在 A 类中定义,其内部的 func()
调用根据 A 的静态类型确定默认参数为 1。在编译时就已经确定了,所以 val
的值是基类中的默认参数的值,为1。
完整的调用链:
p->test() → A::test() → func()(动态绑定到B::func(),但默认参数来自A的声明)
所以最终的输出结果就是:
B->1
动态绑定的实现依赖虚函数表
真实流程:
- 通过实际指向的对象找到 vptr。
- 通过 vptr 找到虚函数表。
- 根据函数在表中的位置调用实际函数。
虚函数表的位置
最后补充一个知识点,本质上虚函数也是函数,编译后也是一段指令,虚函数的地址放在了对象的虚函数表中,那么虚函数表存放在哪里呢?
关于虚函数表的存放位置,C++标准并没有明确规定,是交给编译器来实现的,不同的编译器实现可能不同,但是一般情况都存放在程序的只读数据段(
.rodata
), 虚函数表的内容在编译期就已确定,且在运行时不可修改,适合存放在只读内存中。
通过如下代码验证:
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;
}
Visual Studio 2022平台下输出
:
栈:010FF954
静态区:0071D000
堆:0126D740
常量区:0071ABA4
Person虚表地址:0071AB44
Student虚表地址:0071AB84
虚函数地址:00711488
普通函数地址:007114BF
在 Visual Studio 2022
平台下虚函数表是存储在常量区的。
总结多态调用
+----------------------------------------------------------------+
| 多态实现流程总结 |
+----------------------------------------------------------------+
| |
| 1. 定义基类虚函数 |
| - 基类中声明虚函数: virtual void func(); |
| |
| 2. 派生类重写虚函数 |
| - 派生类中 override: void func() override; |
| |
| 3. 编译器生成虚函数表(vtable) |
| +---------------------------+ |
| | 基类 vtable | |
| | - &Base::func | |
| +---------------------------+ |
| | 派生类 vtable | |
| | - &Derived::func | (重写后替换基类地址) |
| +---------------------------+ |
| |
| 4. 对象内存布局 |
| +---------------------------+ |
| | vptr | --> 指向 vtable |
| | 基类成员变量 | |
| | 派生类成员变量 | |
| +---------------------------+ |
| (vptr 在对象内存首部,占 4/8 字节) |
| |
| 5. 动态绑定过程(运行时) |
| +----------------------------------------------------------+|
| | 通过基类指针调用虚函数: obj->func(); ||
| | ||
| | a. 访问 obj 的 vptr ||
| | b. 通过 vptr 找到 vtable ||
| | c. 查表调用实际函数地址 &Derived::func ||
| +----------------------------------------------------------+|
| |
| 6. 虚析构函数保障 |
| - 基类声明虚析构函数: virtual ~Base() |
| - 派生类析构函数自动重写 |
| - delete 基类指针时,触发完整析构链 |
| |
+----------------------------------------------------------------+
C++多态通过虚函数表和动态绑定机制实现,允许基类指针或引用在运行时根据实际对象类型调用对应的派生类方法:编译器为每个含虚函数的类生成虚函数表(存储函数地址),对象内置虚表指针(vptr)指向所属类的虚表,当通过基类指针调用虚函数时,程序通过 vptr 查表定位实际函数地址,实现运行时决议,同时虚析构函数确保对象销毁时正确调用派生类析构逻辑,从而支持面向对象中"同一接口,多种实现"的核心特性。
写在最后
本文到这里就结束了,有关C++更深入的讲解,还有更多的文章为大家讲解,敬请期待!感谢您的观看!
如果你觉得这篇文章对你有所帮助,请为我的博客 点赞👍收藏⭐️ 评论💬或 分享🔗 支持一下!你的每一个支持都是我继续创作的动力✨!🙏
如果你有任何问题或想法,也欢迎 留言💬 交流,一起进步📚!❤️ 感谢你的阅读和支持🌟!🎉
祝各位大佬吃得饱🍖,睡得好🛌,日有所得📈,逐梦扬帆⛵!
相关文章:

【C++ 多态】—— 礼器九鼎,釉下乾坤,多态中的 “风水寻龙诀“
欢迎来到一整颗红豆的博客✨,一个关于探索技术的角落,记录学习的点滴📖,分享实用的技巧🛠️,偶尔还有一些奇思妙想💡 本文由一整颗红豆原创✍️,感谢支持❤️!请尊重原创…...

SCSAI平台面向对象建模技术的设计与实现
一、核心设计思想 SCSAI平台的核心目标是通过元建模(Meta-Modeling)技术实现面向对象建模的零编码化。其核心思想为: 自反性设计:定义ObjectClassInfo (OCI)为元类(Meta-Class),所有对象类均为…...

pikachu通关教程-CSRF
CSRF(get) 用bp进行抓包 选择action value值的修改 点击test in browser copy然后放在bp代理的浏览器上,会出现一个提交按钮,这时候点击之后信息就被修改了。 CSRF(post) 请求的方式不同,其他都是一样 CSRF Token 存在cookie 首先要先下载一…...

智能体觉醒:AI开始自己“动手”了-自主进化开启任务革命时代
1. 智能体:AI从“工具”到“伙伴”的关键跃迁 1.1 什么是智能体? 智能体(Agent)是AI的“进化版”——它不再局限于生成文字或图像,而是能像人类一样“规划任务”“调用工具”甚至“协同合作”。例如,一个…...
Python爬虫实战:研究Aiohttp库相关技术
1. 引言 1.1 研究背景与意义 随着互联网的快速发展,网络上的数据量呈爆炸式增长。爬虫作为一种自动获取网络信息的工具,在数据挖掘、信息检索、舆情分析等领域有着广泛的应用。传统的同步爬虫在面对大量 URL 时效率低下,无法充分利用现代计算机的多核资源和网络带宽。而异…...

【C++指南】C++ list容器完全解读(二):list模拟实现,底层架构揭秘
. 💓 博客主页:倔强的石头的CSDN主页 📝Gitee主页:倔强的石头的gitee主页 ⏩ 文章专栏:《C指南》 期待您的关注 文章目录 引言一、链表节点设计:双向链表的基石1.1 节点类的实现 二、list框架与核心成员函…...

[神经网络]使用olivettiface数据集进行训练并优化,观察对比loss结果
结合归一化和正则化来优化网络模型结构,观察对比loss结果 搭建的神经网络,使用olivettiface数据集进行训练,结合归一化和正则化来优化网络模型结构,观察对比loss结果 from sklearn.datasets import fetch_olivetti_faces #倒入数…...
小明的Java面试奇遇之智能家装平台架构设计与JVM调优实战
一、文章标题 小明的Java面试奇遇之智能家装平台架构设计与JVM调优实战 二、文章标签 Java面试, 智能家装, 微服务架构, 高并发设计, JVM调优, SpringCloud, 消息队列, 分布式缓存, 架构设计, 面试技巧 三、文章概述 本文模拟了程序员小明应聘智能家装平台后端架构师的5轮…...
n8n:技术团队的智能工作流自动化助手
在当前数字化时代,自动化已经成为提高效率和减轻人工工作负担的一大推动力。今天,我们要为大家介绍一款极具潜力的开源项目——n8n,它不仅拥有广泛的应用场景,还具备内置AI功能,能够完全满足技术团队的高效工作需求。n8n的出现,为技术团队提供了自由编程与快速自动化构建…...
Flink 核心机制与源码剖析系列
Flink 核心机制与源码剖析系列 目录 第一篇:Flink 状态管理原理与源码深度剖析第二篇:水位线、事件时间与定时器源码全流程第三篇:Flink CEP 模式建模与高效事件匹配机制 第一篇:Flink 状态管理原理与源码深度剖析 1. 背景与意…...

华院计算出席信创论坛,分享AI教育创新实践并与燧原科技共同推出教育一体机
5月21日,信创论坛于上海漕河泾会议中心举办。本次论坛以“聚力融合,繁荣生态”为主题,话题聚焦工业制造、交通运输、金融、教育、医疗等领域。华院计算技术(上海)股份有限公司(以下简称“华院计算”&#x…...

华为OD机试真题——会议接待 /代表团坐车(2025A卷:200分)Java/python/JavaScript/C++/C语言/GO六种最佳实现
2025 A卷 200分 题型 本文涵盖详细的问题分析、解题思路、代码实现、代码详解、测试用例以及综合分析; 并提供Java、python、JavaScript、C++、C语言、GO六种语言的最佳实现方式! 本文收录于专栏:《2025华为OD真题目录+全流程解析/备考攻略/经验分享》 华为OD机试真题《会议…...

LabVIEW Val (Sgnl) 属性
在 LabVIEW 事件驱动架构中,Val (Sgnl) 属性(Value (Signaling))是实现编程触发与用户交互行为一致性的关键技术。与普通 Value 属性不同,Val (Sgnl) 在修改控件值的同时强制生成值改变事件,确保程序逻辑与 UI 交互保持…...

STM32G4 电机外设篇(三) TIM1 发波 和 ADC COMP DAC级联
目录 一、STM32G4 电机外设篇(三) TIM1 发波 和 ADC COMP DAC级联1 TIM1 高级定时器发波1.1 stm32cubemx配置 2 TIM1 ADC COMP DAC级联2.1 stm32cubemx配置 附学习参考网址欢迎大家有问题评论交流 (* ^ ω ^) 一、STM32G4 电机外设篇(三&…...

DAY 35 超大力王爱学Python
知识点回顾: 三种不同的模型可视化方法:推荐torchinfo打印summary权重分布可视化进度条功能:手动和自动写法,让打印结果更加美观推理的写法:评估模式 作业:调整模型定义时的超参数,对比下效果。…...

【数据结构】图的存储(十字链表)
弧节点 tailvex数据域:存储弧尾一端顶点在顺序表中的位置下标;headvex 数据域:存储弧头一端顶点在顺序表中的位置下标;hlink 指针域:指向下一个以当前顶点作为弧头的弧;tlink 指针域:指向下一个…...
005 flutter基础,初始文件讲解(4)
书接上回,今天继续完成最后的讲解: class _MyHomePageState extends State<MyHomePage> {int _counter 0;void _incrementCounter() {setState(() {_counter;});}可以看到,这里的_MyHomePageState是一个类,继承于 State&l…...

Redis最佳实践——秒杀系统设计详解
基于Redis的高并发秒杀系统设计(十万级QPS) 一、秒杀系统核心挑战 瞬时流量洪峰:100万 QPS请求冲击库存超卖风险:精准扣减防止超卖系统高可用性:99.99%服务可用性要求数据强一致性:库存/订单/支付状态同步…...

STM32软件spi和硬件spi
核心观点 本文主要介绍了SPI通信的两种实现方式:软件SPI和硬件SPI。详细阐述了SPI通信协议的基本概念、硬件电路连接方式、移位示意图、时序基本单元以及四种工作模式。同时,对W25Q64模块进行了详细介绍,包括其硬件电路、框图以及操作注意事…...
MATLAB实战:人脸检测与识别实现方案
我们要用电脑识别照片或视频中的人脸,并知道是谁的脸。就像手机相册能自动识别照片里的人是谁一样。 🔍 人脸检测(找脸) 目标:在图片中找到人脸的位置 怎么做: 用MATLAB的"人脸扫描仪"ÿ…...

深度刨析树结构(从入门到入土讲解AVL树及红黑树的奥秘)
目录 树的表示 二叉树的概念及结构(重点学习) 概念 : 特点: 树与非树 特殊的二叉树 二叉树的性质(重点) 二叉树的存储结构 堆的概念及结构 建堆方式: 向下调整算法 向上调整算法 建堆第一步初始化 建…...

【Linux】shell的条件判断
目录 一.使用逻辑运算符判定命令执行结果 二.条件判断方法 三.判断表达式 3.1文件判断表达式 3.2字符串测试表达式 3.3整数测试表达式 3.4逻辑操作符 一.使用逻辑运算符判定命令执行结果 && 在命令执行后如果没有任何报错时会执行符号后面的动作|| 在命令执行后…...

第九天:java注解
注解 1 什么是注解(Annotation) public class Test01 extends Object{//Override重写的注解Overridepublic String toString() {return "Test01{}";} }2 内置注解 2.1 Override Override重写的注解 Override public String toString() {ret…...

十一、【核心功能篇】测试用例管理:设计用例新增编辑界面
【核心功能篇】测试用例管理:设计用例新增&编辑界面 前言准备工作第一步:创建测试用例相关的 API 服务 (src/api/testcase.ts)第二步:创建测试用例编辑页面组件 (src/views/testcase/TestCaseEditView.vue)第三步:配置测试用例…...
react-native的token认证流程
在 React Native 中实现 Token 认证是移动应用开发中的常见需求,它用于验证用户的身份并授权其访问受保护的 API 资源。 Token 认证的核心流程: 用户登录 (Login): 用户在前端输入用户名和密码。前端将这些凭据发送到后端 API。后端验证凭据。如果验证成…...
ERP系统中商品定价功能设计:支持渠道、会员与批发场景的灵活定价机制
在现代零售、批发与电商环境下,商品的定价策略日益复杂。一个优秀的ERP系统不仅需要管理商品基础信息、库存与订单,还必须提供一套灵活且可扩展的商品定价机制,以满足: 不同销售渠道(如线上平台、线下门店、分销商&…...

Spring是如何实现属性占位符解析
Spring属性占位符解析 核心实现思路1️⃣ 定义占位符处理器类2️⃣ 处理 BeanDefinition 中的属性3️⃣ 替换具体的占位符4️⃣ 加载配置文件5️⃣ Getter / Setter 方法 源码见:mini-spring 在使用 Spring 框架开发过程中,为了实现配置的灵活性…...
数据结构之ArrayList
系列文章目录 目录 系列文章目录 前言 一、数据结构的前置语法 1. 时空复杂度 2. 包装类 3. 泛型 二、ArrayList 和顺序表 1. 顺序表的模拟实现 2. 源码 3. ArrayList 的优缺点 前言 本文介绍数据结构的前置算法,以及 ArrayList 的模拟实现,部…...

DDR4读写压力测试
1.1测试环境 1.1.1整体环境介绍 板卡: pcie-403板卡 主控芯片: Xilinx xcvu13p-fhgb2104-2 调试软件: Vivado 2018.3 代码环境: Vscode utf-8 测试工程: pcie403_user_top 1.1.2硬件介绍 UD PCIe-403…...
uniapp 开发企业微信小程序时,如何在当前页面真正销毁前或者关闭小程序前调用一个api接口
在 UniApp 开发企业微信小程序时,若需在页面销毁或小程序关闭前调用 API 接口,需结合页面生命周期和应用生命周期实现。以下是具体实现方案及注意事项: 一、在页面销毁前调用 API(页面级) 通过页面生命周期钩子 onUnl…...