C++ 多态详解

文章目录

  • 1.多态的概念
    • [1.1 动、静态类型(多态时函数的默认参数的绑定)](#1.1 动、静态类型(多态时函数的默认参数的绑定))
  • [2. 多态的定义及实现](#2. 多态的定义及实现)
    • [2.1 多态的构成条件](#2.1 多态的构成条件)
      • [2.1.1 实现多态还有两个必须重要条件](#2.1.1 实现多态还有两个必须重要条件)
      • [2.1.2 虚函数](#2.1.2 虚函数)
      • [2.1.3 虚函数的重写 / 覆盖](#2.1.3 虚函数的重写 / 覆盖)
      • [2.1.4 多态和直接调用的对比](#2.1.4 多态和直接调用的对比)
      • [2.1.5 多态场景的一个题(非常建议结合1.1板块仔细阅读,能加深对多态难点的理解)](#2.1.5 多态场景的一个题(非常建议结合1.1板块仔细阅读,能加深对多态难点的理解))
    • [2.2 协变(了解)](#2.2 协变(了解))
    • [2.3 析构函数的重写](#2.3 析构函数的重写)
    • [2.4 override(推翻)和final关键字](#2.4 override(推翻)和final关键字)
    • [2.5 重载/重写/隐藏的对比](#2.5 重载/重写/隐藏的对比)
  • [3. 纯虚函数和抽象类](#3. 纯虚函数和抽象类)
  • [4. 多态的原理](#4. 多态的原理)
    • [4.0 重载原理(静态多态原理)](#4.0 重载原理(静态多态原理))
    • [4.1 虚函数表指针](#4.1 虚函数表指针)
    • [4.2 动态多态原理](#4.2 动态多态原理)
      • [4.2.1 动态多态是如何实现的](#4.2.1 动态多态是如何实现的)
      • [4.2.2 动态绑定与静态绑定](#4.2.2 动态绑定与静态绑定)
      • [4.2.3 虚函数表](#4.2.3 虚函数表)

1.多态的概念

cpp 复制代码
int main()
{
    double d = 1.1;
    int i = 2;

    cout << d;
    cout << i;

    return 0;
}

如上述代码 cout可以输出整形,也能输出浮点数,看起来像是在调用同一个函数,但其实调用的是不同的函数,同一个函数名,展示了多种形态,这就算是一种多态

多态(polymorphism)的概念 : 通俗来说,就是多种形态。多态分为编译时多态(静态多态 )和运行时多态(动态多态 ),这里我们重点讲运行时多态

  • 编译时多态(静态多态 )主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。
  • 运行时多态 ,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态 。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是" (>ω<)喵 ",传狗对象过去,就是"汪汪"。

1.1 动、静态类型(多态时函数的默认参数的绑定)

在C++中,静态类型(Static Type) 也叫「编译期类型」,指的是变量/指针/引用在代码中声明时的类型------这个类型在编译阶段就已确定,不会随程序运行而改变。

与之对应的是动态类型(Dynamic Type) (也叫运行时类型),指的是指针/引用实际指向的对象的类型------这个类型只有在程序运行时才能确定(比如继承的时候)。

类型 确定时机 是否可变 示例(结合之前的代码)
静态类型 编译期 不可变 B* p = new B;p的静态类型是B*A* p = new B;p的静态类型是A*
动态类型 运行时 可变 A* p = new B;p的动态类型是B*p = new A; → 动态类型变为A*
cpp 复制代码
// 基类
class Animal { virtual void cry() {} };
// 派生类
class Cat : public Animal { void cry() { cout << "喵" << endl; } };
class Dog : public Animal { void cry() { cout << "汪" << endl; } };

int main() {
    Animal* ptr; // 静态类型:Animal*(编译期确定,永远不变)
    
    ptr = new Cat(); // 动态类型:Cat*(运行时才确定,他实际指向的类是cat)
    ptr->cry();      // 多态:调用Cat::cry()
    
    ptr = new Dog(); // 动态类型:Dog*(运行时改变)
    ptr->cry();      // 多态:调用Dog::cry()
    
    return 0;
}
  • ptr静态类型 始终是 Animal*(写代码时声明的类型,编译器一眼就能确定);
  • ptr动态类型 是它"实际指向的对象类型":指向Cat对象时是Cat*,指向Dog对象时是Dog*(只有运行时才知道)。

运行时多态的本质

当用「基类静态类型」的指针/引用 ,指向「派生类动态类型」的对象时,调用虚函数会根据动态类型 绑定函数版本(多态)(运行时决定);同时默认参数则根据静态类型 绑定(编译期决定)。(可以参考下面2.1.5的例题 点击转跳

特性 绑定时机 决定因素
虚函数的调用版本 运行时 对象的实际类型(动态类型)
虚函数的默认参数 编译时 指针/引用的静态类型(声明类型)

2. 多态的定义及实现

2.1 多态的构成条件

多态是一个继承关系下的类对象,去调用同一函数,产生了不同的行为。比如Student继承了PersonPerson对象买票全价,Student对象优惠买票。

2.1.1 实现多态还有两个必须重要条件

  • 必须是基类的指针或者引用调用虚函数

引用调用:

指针调用:

但是对象调用不可以,无法构成多态

  • 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。

如果基类函数不是虚函数,一样无法构成多态:

说明:要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象 ;第二派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到

2.1.2 虚函数

类成员函数前面加 virtual 修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加 virtual 修饰。(就是非类里面函数不能virtual修饰)

2.1.3 虚函数的重写 / 覆盖

虚函数的重写 / 覆盖:派生类中有一个跟基类完全相同的虚函数 (即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。

注意 :在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,也可以构成重写 (因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

同时如果 派生类在多态函数部分加上了private 派生类函数仍然能调用 因为我本质使用的还是基类的壳,派生类只是重写的实现。

2.1.4 多态和直接调用的对比

还是拿这串代码:

cpp 复制代码
class Person {
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

class Student : public Person {
public:
//private:
    void BuyTicket() { cout << "买票-打折" << endl; }
};

void Func(Person& ptr)
{
        ptr.BuyTicket();
}

int main()
{
    Person ps;
    Student st;
    Func(ps);
    Func(st);
    
    st.BuyTicket();
    return 0;
}

(1)Func(ps):调用基类虚函数

  • Func 接收 Person& 类型的引用 ptr,此时 ptr 绑定的是 Person 对象 ps

  • 满足「基类引用调用虚函数」的多态条件,但实际对象是基类,因此调用 Person::BuyTicket(),输出 买票-全价
    (2)Func(st):多态生效,调用派生类重写的虚函数

  • ptr 绑定的是 Student 对象 st(基类引用可以指向派生类对象);

  • Student 重写了 Personvirtual BuyTicket()(函数签名完全一致),运行时多态触发,调用 Student::BuyTicket(),输出 买票-打折
    (3)st.BuyTicket():直接调用派生类成员函数

  • stStudent 对象,直接调用其 public 修饰的 BuyTicket(),不走多态逻辑,直接执行派生类版本,输出 买票-打折,就是说我一旦给派生类函数加个private它就无法访问了,但是虚函数 的「访问权限」和「重写 / 多态」是两个独立的维度:权限不影响重写,仅影响直接调用

总结:

调用场景 绑定时机 调用的函数版本 核心依据
Func(ps) 运行时 Person::BuyTicket() 基类引用绑定基类对象,多态指向基类版本
Func(st) 运行时 Student::BuyTicket() 基类引用绑定派生类对象,多态指向派生类版本
st.BuyTicket() 编译时 Student::BuyTicket() 派生类对象直接调用自身成员,编译期确定
  • "运行时多态(动态多态)"的本质:函数调用的绑定行为延迟到运行时,而非编译时。
  • 基类虚函数 + 派生类重写 + 基类指针/引用调用 = 运行时根据实际对象类型执行对应函数;
  • 派生类对象直接调用自身函数,与多态无关,仅看自身成员的访问权限和定义。

2.1.5 多态场景的一个题(非常建议结合1.1板块仔细阅读,能加深对多态难点的理解)

以下程序输出结果是什么?

cpp 复制代码
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();
    p->func();

    return 0;
}

写题前必须了解的概念 :
继承 并不是把基类成员拷贝一份到派生类中 派生类调用函数的时候的搜索过程是先派生类再基类 (派生类没有,调用基类)。

构成多态时,本质是重写虚函数的实现(就是说函数是由 基类的声明 +派生类的实现 构成的,这涉及到静态类型绑定的知识1.1板块
B::func 重写了 A::func(函数名、参数列表、返回值完全一致),因此虚函数的调用版本由对象的实际类型决定(运行时绑定) ;但默认参数是编译期绑定(由指针/引用的静态类型决定)。
p->test() 的执行过程:

  1. pB* 类型,调用 test()
  • B 未重写 test(),因此继承 A::test();(不构成多态 ,那就调用A的函数test,所以test()中的隐藏参数就是A* this 但是this本身是指向B的 可以理解成A* 一个指向B的指针 因为A* B*他们首地址相同 参考继承板块 这么写没问题 这个概念本人认为是其中一个难点 )
  • A::test() 中调用 this-> func() → 这里的 func() 是虚函数,test() 属于 A 的成员函数,且是通过基类的指针(A* this)调用的func函数,构成多态 ;因为执行时 this 指针指向 B 对象(实际类型是 B),因此调用 B::func();(难点2)
  • 默认参数的关键func() 的默认参数由「调用点的静态类型」决定------A::test() 中调用 func() 时,this 的静态类型是 A*,因此默认参数用 A::funcval=1;(派生类函数的重写实现 =基类的声明 +派生类的实现
  • 最终输出:B->1
    p->func() 的执行过程
  1. pB* 类型,不构成多态 ,直接调用 func()
  • 调用 func() 时,编译器在编译期就能确定:p的静态类型是 B*,且 B 重写了 func(),(不存在继承的二次搜索),因此直接绑定到 B::func()
  • 默认参数由「指针的静态类型(B*)」决定,因此用 B::funcval=0
  • 最终输出:B->0

程序输出为:

复制代码
B->1
B->0

扩展:

如果修改代码为 A* p = new B; p->test();,结果仍为 B->1

  • p 的静态类型是 A*,但 func() 调用版本仍为 B::func()(运行时多态);
  • 默认参数仍用 A::funcval=1(编译期绑定静态类型)。

2.2 协变(了解)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变

返回值不同一个返回A*(基类)一个返回B*(派生类)但是依然构成多态

这么写也对,依然构成多态:

所以C++中多态中重写必须要求返回值相同严格来说是不对的,但是协变的实际意义并不大,所以我们了解一下即可。

2.3 析构函数的重写

cpp 复制代码
class A
{
public:
     ~A()
    {
        cout << "~A()" << endl;
    }
};

class B : public A {
public:
     ~B()     {
        cout << "~B()->delete:" << _p << endl;
        delete _p;
    }
protected:
    int* _p = new int[10];
};

在继承章节中,我们是先析构派生类对象,再析构基类对象:

下面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用的A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题:

为什么会有内存泄漏问题?
delete原理是:调用析构后再调用operator delete(p1)释放空间。

但在这个情节下 A* 指针 有可能指向对象A 也可能指向对象B,这里根据A*直接静态类型调用了A的析构(不走多态);所以我们期望这里的析构调用是多态调用 (多态调用,指向谁,调用谁的析构),所以我们需要达成虚函数重写
基类的析构函数虚函数 ,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor(构成隐藏关系,底层相同),所以基类的析构函数加了virtual修饰,派生类的析构函数就构成重写

2.4 override(推翻)和final关键字

从上面可以看出,C++ 对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此 C++11 提供了 override,可以帮助用户检测是否重写 。如果我们不想让派生类重写这个虚函数 ,那么可以用 final 去修饰。

  1. override 核心价值
  • 把"运行时才发现的重写错误"提前到"编译期"暴露(比如函数名拼写错误、参数类型不一致);
  • 增强代码可读性:一眼识别该函数是重写基类的虚函数。
  1. final 核心价值
  • 修饰虚函数:保护基类核心逻辑不被派生类篡改,修饰的函数不能被重写;
  • 修饰类:避免类被继承带来的耦合和风险,修饰的类不能被继承。

2.5 重载/重写/隐藏的对比

3. 纯虚函数和抽象类

在虚函数的后面写上 = 0,则这个函数为纯虚函数,纯虚函数不需要定义实现 (实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。

cpp 复制代码
class Car
{
public:
    // 纯虚函数
    // 1、抽象类不能实例化对象
    // 2、强制子类重写
    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;
    }
};

不能实例化出对象:

派生类不重写也不能实例化对象:

4. 多态的原理

4.0 重载原理(静态多态原理)

cpp 复制代码
int main()
{
    int i;
    double d;
    cin >> i;  // operator>>i
    cin >> d;  // operator>>d
//本质上 >> 就是一个静态多态(重载) 他是由operater>>i 或者 operator>>d(不同编译器下名字不同,利用函数名修饰规则)

    return 0;
}

为了重载函数编译链接时方便查找,重载函数名进行修饰处理,各编译器规则不同,但是都会把形参类型带入函数名。

Linux下 我们可以用objdump指令 查看编译好程序的命令 一般用---S去看源码(汇编):

4.1 虚函数表指针

铺垫:

下面编译为32位程序的运行结果是什么?

cpp 复制代码
class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }

    virtual void Func2()
    {
        cout << "Func1()" << endl;
    }

    void Func3()
    {
        cout << "Func1()" << endl;
    }
protected:
    int _b = 1;
    char _ch = 'x';
};

int main()
{
    Base b;
    cout << sizeof(int*) << endl;

    return 0;
}

不打哑语,根据最大对齐数和虚函数表指针 答案是12(64位是16 因为此时虚表指针,8字节)。

除了_b_ch成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtualf代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中 ,本质是个函数指针数 ,组虚函数表也简称虚表。

对该内容有疑问可以结合4.2.2内容。

4.2 动态多态原理

4.2板块样例代码

cpp 复制代码
class Person {
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
    virtual void Func1() {}
    void Func2() {}
private:
    string _name;
};

class Student : public Person {
public:
    virtual void BuyTicket() { cout << "买票-打折" << endl; }
    virtual void Func1() {}
    virtual void Func3() {}
private:
    string _id;
};

class Soldier : public Person {
public:
    virtual void BuyTicket() { cout << "买票-优先" << endl; }
private:
    string _codename;
};

4.2.1 动态多态是如何实现的

从底层的角度 Func 函数中 ptr->BuyTicket (),是如何作为 ptr 指向 Person 对象调用 Person::BuyTicketptr 指向 Student 对象调用 Student::BuyTicket 的呢?

通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。(本质就是运行时候,指向哪个对象就去这个对象的虚函数表中找到对应的虚函数地址进行调用。)

第一张图,ptr 指向的 Person 对象,调用的是 Person 的虚函数:

第二张图,ptr 指向的 Student 对象,调用的是 Student 的虚函数:

汇编语言下,多态地址是运行的时候才确定,而函数普通调用确是编译的时候就已经找到函数地址了。

4.2.2 动态绑定与静态绑定

  • 对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
  • 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。
维度 静态绑定(编译期绑定) 动态绑定(运行期绑定)
别名 前期绑定、早绑定 后期绑定、晚绑定
绑定时机 编译阶段确定函数调用地址 运行阶段确定函数调用地址
触发条件 1. 调用非虚函数; 2. 用对象(非指针/引用)调用虚函数 1. 基类指针/引用调用被派生类重写 的虚函数; 2. 满足"虚函数+重写+基类指针/引用"三要素
底层逻辑 编译器直接将函数地址写入汇编指令,无额外寻址 通过对象的 __vfptr 读取虚表 → 按索引找虚函数地址 → 跳转执行

4.2.3 虚函数表

  • 基类对象的虚函数表中存放基类所有虚函数的地址 。同一个类的对象共用同一张虚表,不同类的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表
  • 派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
  • 先完整拷贝基类虚表的所有内容,再将其中被派生类重写的虚函数地址替换为派生类自身的函数地址,最后追加派生类新增的虚函数地址。

底层逻辑拆解:

  • 第一步:编译期生成基类虚表(存储基类所有虚函数地址);
  • 第二步:派生类编译时,先 "复制" 基类虚表的全部条目;
  • 第三步:遍历派生类重写的虚函数,将拷贝后的虚表中对应索引位置的地址,替换为派生类重写后的函数地址;
  • 第四步:若派生类有新增虚函数,将其地址追加到虚表末尾。
  • 派生类虚表的构建逻辑是派生类的虚函数表中包含:
    • (1)基类的虚函数地址
    • (2)派生类重写的虚函数地址完成覆盖
    • (3) 派生类自己的虚函数地址三个部分
  • 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)
  • 虚函数存在哪的?

虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。

  • 虚函数表存在哪的?

这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证一下:

相关推荐
Mr_WangAndy1 小时前
C++23新特性_多维下标运算符
c++·c++23·c++40周年·多维下标运算符
李日灐1 小时前
C++STL: vector 简单使用,讲解
开发语言·c++
明洞日记1 小时前
【VTK手册017】 深入详解 vtkImageMathematics:医学图像的基本算术运算
c++·图像处理·算法·vtk·图形渲染
晚风(●•σ )1 小时前
C++语言程序设计——【算法竞赛常用知识点】
开发语言·c++·算法
程序猿本员1 小时前
8. 定制new和delete
c++
..过云雨1 小时前
14.【Linux系统编程】进程间通信详解(管道通信、System V共享内存、消息队列、信号量)
linux·c语言·c++·后端
Mr_WangAndy2 小时前
C++23新特性_#warning 预处理指令
c++·c++23·c++40周年·c++23新特性·warning预处理命令
ULTRA??2 小时前
C++拷贝构造函数的发生时机,深拷贝实现
开发语言·c++
曦樂~2 小时前
【C++11】引用折叠原理
开发语言·c++