C++多态的一些理解
多态的概念
什么是多态?多态是C++的三大特性之一。简单来说就是用一种接口(函数)来实现多种不同的功能。当我们调用同一个函数的时候,根据业务需要,会执行不同的功能,产生不同的效果。
为什么要用多态?多态性是一种强大的面向对象编程的特性,它提供了灵活性、可扩展性和可维护性。通过多态性,可以编写通用的代码,减少重复编码,并实现对象的替换和扩展。这样可以使代码更加灵活、可维护和可扩展,并提高程序的可读性和可靠性。试想这样一个场景,你需要实现一个加法器,但是参数类型是有整数和浮点数两种,函数的返回值也是有两种,如果不使用多态,那么就要分别考虑参数类型,针对不同的参数类型来写不同的函数,但是这样做就显得很繁琐,毕竟都是加法,直接用一个函数不好吗?
多态分为静态多态和动态多态,静态多态是在编译时确定要调用的函数版本,通过函数重载实现,适用于非虚函数和静态成员函数。动态多态是在运行时确定要调用的函数版本,通过虚函数和继承实现,适用于虚函数和纯虚函数,并且需要使用指针或引用来实现多态性。静态多态的绑定是在编译时完成的,而动态多态的绑定是在运行时完成的。
静态多态
(静态绑定,早绑定,编译时多态):
静态多态通过函数重载或者模板(包括模板函数和模板类)实现。编译器在编译时期决定调用哪个函数,这是通过参数的数量、类型或者两者来决定的。这种决定过程称为静态绑定或早期绑定。
静态多态的优点
- 性能:由于函数调用是在编译时解析的,静态多态避免了运行时的虚函数查找开销,提供了更好的性能。
- 类型安全:编译器在编译时检查类型,确保类型的正确性,减少运行时错误。
- 明确性:通过函数重载和模板的明确性,代码可以更清晰地表达其意图,提高了代码的可读性和可维护性。
但是,如果使用大量模板,会导致代码膨胀(编译后,程序体积显著增大)。
代码膨胀的影响
- 增加了可执行文件的大小:这可能影响程序的加载时间和内存使用,尤其是在内存受限的环境下。
- 可能降低运行时性能:更大的可执行文件可能导致更频繁的缓存失效和页换入换出,从而降低程序的运行效率。
- 编译时间增加:生成大量代码会延长编译时间,影响开发效率。
动态多态
(动态绑定,晚绑定,运行时多态):
- 动态多态是通过虚函数实现的,这允许在运行时决定调用哪个函数。这是通过派生类重写基类中声明为虚的函数来实现的。这种决定过程称为动态绑定或晚期绑定。
- 当通过基类指针或引用调用虚函数时,实际调用的是根据对象的动态类型确定的函数版本。
- 动态多态适用于虚函数和纯虚函数,并且需要使用指针或引用来实现多态性。
动态多态的优点
- 增强了软件的可维护性。动态多态使得代码更加模块化,通过基类接口可以操作所有派生类对象。这样,当需要修改或扩展程序功能时,只需添加或修改派生类,而无需修改使用基类引用或指针的代码。这大大减少了维护和升级软件所需的工作量。
- 提高了代码复用性。通过继承和多态,可以创建通用的代码来操作所有派生自同一基类的对象。这意味着相同的代码可以用于操作不同类型的对象,从而提高了代码的复用性。
- 支持动态绑定。动态多态允许程序在运行时动态地绑定对象和方法,而不是在编译时。这种能力使得程序可以更灵活地处理不同类型的对象,即使在编写代码时并不知道具体将操作哪些对象类型。
- 促进了接口与实现的分离。动态多态鼓励设计者将接口(通过基类定义)与实现(通过派生类完成)分离。这种分离不仅使得代码更加清晰,也更易于管理和扩展。
- 便于创建更复杂的行为。使用动态多态,可以在基类中定义接口和部分实现,在派生类中根据需要添加或修改特定行为。这种方法便于创建能表现出复杂行为的对象,同时保持代码的组织和简洁。
- 支持后期绑定。后期绑定(或运行时绑定)是动态多态的核心,它使得对象方法的调用可以在运行时确定,为创建可扩展和易于管理的大型系统提供了基础。
- 促进了设计模式的应用。许多设计模式,如策略模式、观察者模式和工厂模式,都依赖于动态多态来实现其灵活性和解耦。通过使用这些模式,可以提高代码的灵活性和可复用性。
如何实现多态
通过函数重载实现(静态)
函数重载是指在同一个作用域内(即全局函数或者在同一个类内)定义多个具有相同名称但参数列表不同的函数。
函数重载允许使用相同的函数名来执行不同的操作,根据传递给函数的参数来选择适当的函数进行调用,提供了代码简洁性、灵活性和函数命名一致性的好处,使得代码更加清晰、易于理解和可扩展。
#include <iostream>using namespace std;class FunctionOverloading
{
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;}
};int main()
{FunctionOverloading fol;cout << fol.add(1, 2) << endl;cout << fol.add(1.5, 2.5) << endl;cout << fol.add(1, 2, 3) << endl;return 0;
}
看下输出结果
可以看到不同的参数类型调用了不同的函数,这就是函数重载。
通过函数模板实现(静态)
什么是函数模板?函数模板是一种通用的函数定义,可以用于生成多个具有相似功能但类型不同的函数。它允许在代码中编写一次函数定义,然后可以根据需要使用不同的数据类型进行实例化和调用。函数模板的优势在于可以提供通用的代码,避免了重复编写相似功能的函数。它可以根据不同的类型参数自动生成不同的函数定义,提高代码的灵活性和重用性。函数模板常用于容器类、算法库和泛型编程等场景。
函数模板的定义使用关键字 template,后面跟着模板参数列表,其中包含一个或多个类型参数或非类型参数。类型参数用于指定函数中的参数类型,非类型参数用于指定函数中的常量值或枚举值。
利用函数模板也可以实现多态,我们修改刚才的代码,添加一个函数模板
#include <iostream>using namespace std;class FunctionOverloading
{
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;}
};template <typename T>
T add(T a, T b)
{return a + b;
}int main()
{FunctionOverloading fol;cout << fol.add(1, 2) << endl;cout << fol.add(1.5, 2.5) << endl;cout << fol.add(1, 2, 3) << endl;cout << add(1, 2) << endl;cout << add(3, 4) << endl;cout << add(1.5, 2.5) << endl;return 0;
}
运行结果如下
通过虚函数和纯虚函数实现(动态)
虚函数的特点包括:
- 基类中用virtual关键字声明的函数。派生类可以重写这个函数来提供特定的实现。如果有一个基类指针或引用指向派生类的对象,那么调用虚函数时,会根据对象的实际类型来调用相应的函数。
- 可被派生类重写:派生类可以通过在自己的类定义中使用相同的函数签名来重写(覆盖)基类中的虚函数。重写的函数必须具有相同的返回类型、参数列表和常量属性。
- 动态绑定:当通过基类指针或引用调用虚函数时,实际调用的是根据对象的动态类型来确定的函数版本。这种绑定是在运行时完成的,称为动态绑定。它允许在运行时根据对象的实际类型来调用适当的函数,实现多态性。
- 默认实现:虚函数可以在基类中提供默认的实现,如果派生类没有重写该函数,将使用基类中的默认实现。派生类可以选择性地重写虚函数,以便修改或扩展其行为。
纯虚函数的特点:
纯虚函数在基类中没有实现,必须在非抽象派生类中被重写。纯虚函数的声明方式是在函数原型的结尾加上 = 0。
举个例子
#include <iostream>
using namespace std;class Base {
public:virtual void show() { cout << "Base show" << endl; }virtual ~Base() {}
};class Derived : public Base {
public:void show() override { cout << "Derived show" << endl; }
};void display(Base* b) {b->show();
}int main() {Base* b = new Base();Base* d = new Derived();display(b); // 输出 Base showdisplay(d); // 输出 Derived showdelete b;delete d;return 0;
}
在上述示例中,display函数接受一个Base类的指针。虽然display函数调用的是Base类的show方法,但是由于show方法被声明为虚函数,并且在Derived类中被重写,所以当display函数传入Derived类的对象时,调用的是Derived类中的show方法。这就是动态多态的体现。
静态多态和动态多态的原理
静态多态(函数重载和模板)
静态多态主要通过函数重载和模板实现。编译器在编译时进行函数调用的解析(即所谓的静态绑定),决定调用哪个函数。因此,在生成的汇编代码中,你会看到对具体函数的直接调用。没有额外的运行时开销,因为所有的决策都在编译时完成了。
- 函数重载:编译器根据函数的参数类型和数量,在编译时决定调用哪个重载版本。在汇编代码中,每个重载函数都有其唯一的标签,调用时会直接跳转到相应的标签。
- 模板:模板实例化也发生在编译时。每个具体化的模板实例会生成一套独立的函数或类代码。在汇编代码中,你会看到每个模板实例化生成的函数都有其独立的地址,调用时直接跳转到这个地址。
对于这段代码
#include <iostream>
class Print {
public:void show(int i) {std::cout << "Integer: " << i << std::endl;}void show(double f) {std::cout << "Float: " << f << std::endl;}
};int main() {Print obj;obj.show(5); // Calls the show(int) functionobj.show(3.14); // Calls the show(double) functionreturn 0;
}
在编译后的汇编代码中,会看到对Print::show(int)和Print::show(double)的直接调用。编译器在编译时已经解析了哪个函数应该被调用,因此生成的代码中会有两个分别对应这两个函数的直接跳转或函数调用指令。
也就是说,在静态多态的情况下,编译器在编译时就已经决定了将调用哪个函数。因此,生成的汇编代码中会直接包含对特定函数版本的调用指令。这些调用是直接的,意味着没有运行时查找或决策过程。
动态多态(虚函数)
当通过基类的指针或引用调用虚函数时,具体调用哪个函数(基类还是某个派生类中的重写版本)是在运行时通过虚表(vtable)来决定的。因此,在生成的汇编代码中,调用虚函数的过程包括了查找虚表的步骤,这是一个运行时的开销。
- 在汇编代码中,每个含有虚函数的类都会有一个虚表。类的对象中包含了一个指向这个虚表的指针,也叫虚指针(通常是对象内存布局的第一个成员);
- 调用虚函数时,汇编指令首先会通过对象的虚表指针访问虚表,然后根据函数在虚表中的偏移找到具体的函数地址,最后跳转到这个地址执行函数;
- 这意味着,与静态多态相比,动态多态的函数调用包含了额外的间接寻址步骤,这是实现运行时多态性的开销。
- 这个过程涉及到间接跳转,因为实际的函数地址在程序运行前是不确定的,只有在运行时,通过对象的实际类型确定。
举个例子
#include <iostream>
class Base {
public:virtual void show() {std::cout << "Base show" << std::endl;}
};class Derived : public Base {
public:void show() override {std::cout << "Derived show" << std::endl;}
};void display(Base* b) {b->show(); // Dynamic polymorphism
}int main() {Base b;Derived d;display(&b);display(&d);return 0;
}
对于含虚函数的类,会生成一个虚表(vtable)。在汇编代码中,通过基类指针调用虚函数show时,会首先从对象的内存布局中取出虚表指针,然后通过这个虚表指针加上show函数的偏移量找到实际应该调用的函数地址,最后通过这个地址跳转到函数实现。这个过程涉及几个间接访问步骤,体现了运行时多态性的实现。
虚函数表
什么是虚函数表
虚函数表(Virtual Function Table,通常缩写为vtable)是C++用来支持动态多态性的一种机制。每个含有虚函数的类都会有一个虚函数表,这个表在编译时被创建,用于运行时解析虚函数的调用。
虚函数表用于存储指向类的虚函数实现的指针。当一个类的对象需要调用虚函数时,程序会通过这个对象的虚函数表来确定具体应该调用哪个函数实现。这个机制允许在运行时根据对象的实际类型来动态绑定函数调用,而不是在编译时静态确定。
当类中声明了虚函数时,编译器会为该类生成一个虚函数表。如果派生类覆盖了基类中的虚函数,派生类的虚函数表中会放置指向派生类函数实现的指针。
当创建一个对象时,编译器会在对象内存布局中设置一个指针(vptr),指向该对象类的虚函数表。这样,每个对象都能通过vptr来访问其虚函数表。
当通过基类的指针或引用调用虚函数时,程序会使用指针/引用所指对象的vptr来访问虚函数表,然后通过表中的地址找到并调用正确的函数实现。
在下面这个例子中,Base类有一个虚函数func(),而Derived类重写了这个函数。编译器为Base和Derived各自生成一个虚函数表。Derived对象的vptr指向Derived的虚函数表,因此当调用func()时,即使通过Base类型的指针进行调用,也会执行Derived中的实现。
class Base {
public:virtual void func() { std::cout << "Base::func()" << std::endl; }
};class Derived : public Base {
public:void func() override { std::cout << "Derived::func()" << std::endl; }
};Base* obj = new Derived();
obj->func(); // Calls Derived::func() at runtime
虚函数表的数量
- 在C++中,每个含有虚函数的类(包含虚函数的类以及其派生类)都会有一个虚函数表。这个表是类级别的,不是对象级别的,意味着同一个类的所有对象共享同一个虚函数表;
- 如果一个类没有虚函数,那么它就没有虚函数表;
- 虽然虚函数表是类级别的,但是每个含有虚函数的类的对象都会存储一个指向其类虚函数表的指针(通常称为vptr)。这个指针是对象级别的,是对象内存布局的一部分,通常位于对象数据的开始位置;
- 并不是每个对象都有一个完整的虚函数表,而是每个对象有一个指向其类虚函数表的指针;
- 派生类对象同样包含一个指向其虚函数表的指针。如果派生类没有新增虚函数并且没有覆盖基类的虚函数,它可能会使用与基类相同的虚函数表。但通常情况下,派生类至少会覆盖一些基类的虚函数,因此它会有自己的虚函数表,其中包含指向派生类特定实现的指针;
- 如果派生类新增了虚函数,这些函数也会被加入到派生类的虚函数表中;
- 当派生类覆盖了基类中的虚函数,派生类的虚函数表会更新为指向派生类中新的函数实现;
- 当派生类继承自一个含有虚函数的基类时,基类的虚函数(不论是否被派生类重写)在派生类中仍然是虚函数。如果派生类没有显式重写这些虚函数,它们会保留基类的行为;
- 即便派生类没有新增或重写任何虚函数,派生类的对象仍然会包含一个虚表(vtable),这个虚表中会包含指向基类虚函数实现的指针,除非派生类提供了自己的实现来覆盖这些函数;
- 派生类的虚表(如果派生类提供了至少一个新的虚函数或重写了基类的虚函数)可能会与基类的虚表不同。如果派生类没有提供任何新的虚函数实现,它的虚表会与基类相同或非常类似,但仍然会因为继承了虚函数而被视为含有虚函数的类;
- 当通过基类的指针或引用调用虚函数时,如果操作的对象实际上是派生类的实例,如果派生类没有重写那个虚函数,程序运行时也会调用基类中定义的虚函数实现;
- 如果未来派生类被修改以重写这些虚函数,不需要改动使用基类指针或引用的代码,就可以实现不同的行为,这正体现了多态性的强大之处。
综上所述,即使派生类没有重写基类的任何虚函数,它仍然是含有虚函数的类,并且通过继承维持了与基类相同的多态性。这种设计允许派生类在不修改基类接口的前提下,增加或改变功能,提供了极大的灵活性和扩展性。