多态(上)

多态(上)

文章目录

  • 多态(上)
    • [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 虚函数重写的⼀些其他问题](#2.1.5 虚函数重写的⼀些其他问题)
        • 1.协变(Covariance)
          • [1.1 结合协变代码讲解:](#1.1 结合协变代码讲解:)
          • [1.2 协变完整带注释代码](#1.2 协变完整带注释代码)
        • [2. 析构函数的虚函数重写(面试重点)](#2. 析构函数的虚函数重写(面试重点))
      • 最重要实际意义(面试核心)
          • [1. 析构代码逐行讲解](#1. 析构代码逐行讲解)
          • 反面场景(坑)
          • [2. 虚析构完整带注释代码](#2. 虚析构完整带注释代码)
          • [3. 考试 / 面试小结](#3. 考试 / 面试小结)
      • [2.1.6 override 和final关键字](#2.1.6 override 和final关键字)
        • 1、override(检查是否成功重写虚函数)
        • [2. final(两个用途)](#2. final(两个用途))
          • [用途 1:修饰虚函数 ------ 禁止派生类重写这个虚函数](#用途 1:修饰虚函数 —— 禁止派生类重写这个虚函数)
          • [用途 2:修饰类 ------ 禁止这个类被继承](#用途 2:修饰类 —— 禁止这个类被继承)
        • 总结
        • 补充小对比
      • [2.1.7 重载/重写/隐藏的对比](#2.1.7 重载/重写/隐藏的对比)
        • [1. 重载(同一作用域)](#1. 重载(同一作用域))
        • [2. 重写 / 覆盖(实现多态,继承体系 + virtual 虚函数)](#2. 重写 / 覆盖(实现多态,继承体系 + virtual 虚函数))
        • [3. 隐藏(又叫遮蔽,继承体系,不满足重写条件)](#3. 隐藏(又叫遮蔽,继承体系,不满足重写条件))
        • 总结对照表(方便记忆)

1. 多态的概念

同一个动作,交给不同对象去做,表现出不一样的效果,这就是多态(多种形态)

多态分成两大类:静态多态(编译时)、动态多态(运行时)

  1. 静态多态【编译阶段就确定好调用哪个函数】

代表:函数重载、模板

同一个函数名字,根据你传入不同参数,直接确定执行哪段代码

程序还没跑起来(编译的时候),编译器一看参数,就知道要用哪个方法。

举例子:

add(1,2) 两个数字相加

add("a","b") 两个字符串拼接

函数名都是 add,参数不一样,功能不一样。

代码编译的时候就分清调用哪个,所以叫静态 / 编译时多态

  1. 动态多态【程序跑起来之后,才确定执行什么代码】(面向对象重点)

规则:同一个行为,传入不同对象,产生不同结果,程序运行的时候才决定用哪段逻辑

原文两个例子通俗翻译:

例子 1:买票

动作:【买票】

  • 普通人对象执行买票 → 付全款
  • 学生对象执行买票 → 半价优惠
  • 军人对象执行买票 → 优先购票

动作名称不变都是 "买票",对象不一样,行为结果不一样。

例子 2:动物叫唤

统一动作:【叫】

  • 猫对象调用【叫】 → 喵喵喵
  • 狗对象调用【叫】 → 汪汪汪

静态多态 vs 动态多态最简单区分

✅ 静态多态:看参数区分逻辑(编译时定好)

✅ 动态多态:看对象区分逻辑(程序运行时才确定)

2. 多态的定义及实现

2.1 多态的构成条件

多态是⼀个继承关系的下的类对象,去调⽤同⼀函数,产⽣了不同的⾏为。⽐如Student继承了 Person。Person对象买票全价,Student对象优惠买票。

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

  1. 条件 1:要用基类指针 / 基类引用去调用函数

基类相当于通用模板(比如:人、动物),派生类是具体种类(学生、猫、军人、狗)。

普通指针只能固定指向一种对象;

基类指针 / 引用很灵活:既能指向父类对象,也能指向子类对象。

举例子:

基类 = 动物

派生类 = 猫、狗

复制代码
动物* p; // 基类指针
p = new 猫(); // 可以指向猫
p = new 狗(); // 也可以指向狗

如果不用基类指针,直接用猫指针只能指向猫,狗指针只能指向狗,没法统一用一套代码接收多种子类,自然谈不上多态。

简单理解:基类指针就是一个 "通用容器",能装下所有子类。

  1. 条件 2:调用的函数必须是虚函数 ,并且子类完成重写(覆盖)

  2. 基类里的函数前面加上 virtual,它就变成虚函数

  3. 派生类写一个一模一样的函数(函数名、参数、返回值完全匹配),这就叫重写 / 覆盖。

没有虚函数会发生什么?

就算基类指针指向猫、指向狗,调用方法时只会死板执行基类里面的代码,不会自动调用猫、狗自己实现的版本,出不来多态效果!

虚函数作用:相当于给程序开了一个开关。程序运行的时候,不去死板看指针是什么类型,而是看指针真正指向的是什么对象,调用这个对象自己重写的函数。

结合动物例子串起来看

基类【动物】,里面写虚函数 virtual 叫()

子类【猫】重写 叫():喵喵喵

子类【狗】重写 叫():汪汪汪

复制代码
动物* a = new 猫();
a->叫(); // 输出喵喵喵

a = new 狗();
a->叫(); // 输出汪汪汪

满足两条规则,多态生效!

以下是反面教材:

如果函数不加 virtual(不是虚函数)

基类指针哪怕指向猫,调用叫(),只会执行动物默认的叫声,不会调用猫的版本。

如果不用基类指针,直接用猫指针、狗指针各自调用

虽然也能正常叫唤,但是只是普通函数调用,不是多态,没法用统一的指针接收所有子类。

2.1.2 虚函数

类成员函数前⾯加virtual修饰,那么这个成员函数被称为虚函数。注意⾮成员函数不能加virtual修饰。

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

2.1.3 虚函数的重写/覆盖

(1)定义

基类写了一个虚函数

子类里面写一个函数:函数名一模一样、参数一模一样、返回值符合要求

这就叫:子类重写(覆盖)了基类虚函数。

(2) 重点注意点

  1. 子类可以不写virtual,依然能构成重写

    基类函数已经是虚函数,继承到子类后,这个函数天生自带虚函数身份。

    哪怕子类函数前面省略virtual,依旧算是重写,多态照样生效。

  2. ❗但是不推荐省略 virtual,代码可读性很差,规范开发都建议写上。

  3. 💡考试高频坑!

    选择题经常故意把子类的virtual删掉,迷惑你,很多人误以为没有 virtual 就不能重写、无法多态,这是易错点。

简单记住:虚属性是从基类继承下来的,子类可省略 virtual;但规范上一定要加上。

(3) 补充一个硬性前提

想要实现动态多态,必须满足重写;如果函数名字 / 参数不一样,那不叫重写,自然触发不了多态。

(4)结合两段代码讲解

案例 1:买票 Person & Student

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

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

基类PersonBuyTicket()带有virtual,是虚函数

子类Student:函数名BuyTicket、参数列表、返回值和基类完全一致 → 构成重写

复制代码
void Func(Person* ptr) // 条件1:基类指针
{
    ptr->BuyTicket();
}
int main()
{
    Person ps;
    Student st;
    Func(&ps); // 普通人类对象 → 全价买票
    Func(&st); // 学生对象 → 打折买票
    return 0;
}

✅ 两条动态多态条件全部满足:

① 使用基类指针调用函数

② 虚函数 + 子类完成重写

运行结果:

复制代码
买票-全价
买票-打折

👉 关键点:指针永远都是Person*类型,但是程序运行时,看指针指向什么对象,就调用哪个类的函数,这就是多态。

拓展测试:把 Student 里面的 virtual 删掉

复制代码
void BuyTicket() { cout << "买票-打折" << endl; }

依然构成重写,多态依旧生效!只是代码不规范,考试要认清这个情况。

案例 2:动物叫声 Animal Dog Cat

复制代码
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;
    }
};

基类Animal拥有虚函数talk()

Dog、Cat 中talk()函数名、参数、const 修饰、返回值完全匹配基类,构成重写。

复制代码
void letsHear(const Animal& animal) // 条件1:基类引用(指针/引用二选一都可以)
{
    animal.talk();
}

int main()
{
    Cat cat;
    Dog dog;
    letsHear(cat);
    letsHear(dog);
    return 0;
}

✅ 使用基类引用(也是实现多态合法方式)+ 虚函数重写,满足条件。

运行结果:

复制代码
(>^ω^<)喵
汪汪

再次区分:指针、引用都能实现多态;直接用父类对象接收子类(值传递切割)无法实现多态!

坑点总结

子类重写虚函数时,省略 virtual,依旧是重写,能够触发多态

很多考生误以为子类必须写 virtual 才算重写,这是出题人最爱设置的陷阱;

但是工程开发规范:建议子类重写虚函数时主动写上 virtual(C++11 推荐再加 override 关键字);

如果函数名 / 参数不一样,不叫重写,叫函数隐藏,无法实现多态!

2.1.4 多态场景的⼀个选择题

各位大佬,请亮出您的答案!

答案:B: B->1

解释:

  1. 先看能不能触发多态
  • 基类 A:virtual void func(int val = 1)

  • 派生类 B:

    复制代码
    void func(int val = 0)

    函数名、返回值、参数列表完全相同,

    构成虚函数重写

    (子类不写 virtual 依然有效)。

  1. 调用流程梳理

    B* p = new B;
    p->test();

  2. test() 这个函数定义在 A 类里面virtual void test(){ func(); }

  3. 虽然 test 写在 A 中,但是 test 内部调用func()。由于 func 是虚函数,会触发多态,执行 B 类重写的 func

重点:最终跑的是 B::func(),不是 A::func ()。

  1. 最容易踩坑:默认参数编译期确定,不参与多态!

✅ 记住铁律:

虚函数是运行时确定调用哪个函数;函数默认参数值,是编译阶段就确定好的,不会跟着子类改变!

test() 属于 A 类的成员函数,编译的时候,编译器看到 func();(无传参),直接拿A 类 func 的默认参数 val=1

运行时:执行的是 B 的 func 函数,但传入的参数值是编译期敲定的 1

  1. 最终执行

运行 B::func(1)

打印输出:B->1

总结:

调用哪个函数(A::func 还是 B::func)运行时看虚函数;

参数默认值在编译时就定死,由调用处所在的类版本决定,不会动态跟随子类!

易错辨析

很多人会误以为:调用 B 的 func,就应该使用 B 的默认参数 0。❌错误!

默认参数不参与动态多态,编译时在 A 的 test 函数内部,func 不传参,直接绑定 A 的默认值 1。

拓展验证

如果代码写成:

复制代码
A* p = new B();
p->func(); // 依然输出 B->1

同样原理!

对应选项: 选项 B: B->1

简易流程图

p->test() → 执行 A::test () → test 里面调用 func ()

→ 虚函数多态,运行找到 B::func

→ 参数编译阶段确定为 A 的默认值 1

→ 执行 B::func(1) → 输出 B->1

2.1.5 虚函数重写的⼀些其他问题

1.协变(Covariance)

正常虚函数重写要求:返回值必须一模一样。

协变是唯一的特例

基类虚函数返回【基类指针 / 基类引用】

派生类重写的虚函数返回【派生类指针 / 派生类引用】

这种返回值不一样,但依然合法构成重写,就叫做协变。

⚠️ 硬性限制:

  1. 只允许指针、引用;普通对象不能协变
  2. 必须是「基类 ← 派生类」的继承关系
  3. 日常开发极少使用,了解概念即可。
1.1 结合协变代码讲解:
复制代码
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;
    }
};

基类虚函数返回 A*

子类重写函数返回 B*

B 公有继承 A,满足协变条件 → 合法重写,可以触发多态

复制代码
void Func(Person* ptr)
{
    ptr->BuyTicket();
}

int main()
{
    Person ps;
    Student st;
    Func(&ps);  // 输出:买票-全价
    Func(&st);  // 输出:买票-打折
    return 0;
}

运行逻辑:

Func 接收基类指针,调用 BuyTicket。因为构成合法重写,多态生效。

ptr 指向 Student 对象,自动执行 Student::BuyTicket。

1.2 协变完整带注释代码
复制代码
#include <iostream>
using namespace std;

class A {};
// B公有继承A,满足协变的继承关系
class B : public A {};

class Person {
public:
    // 基类虚函数:返回基类A的指针
    virtual A* BuyTicket()
    {
        cout << "买票-全价" << endl;
        return nullptr;
    }
};

class Student : public Person {
public:
    // 协变:返回派生类B*,允许构成重写
    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;
}
2. 析构函数的虚函数重写(面试重点)

正常成员函数重写:函数名、参数必须完全相同。

析构函数是编译器特殊处理的例外!

  1. 源代码里基类析构~A(),派生类析构~B()名字看起来不同
  2. 编译阶段,编译器把所有析构函数统一改名 destructor
  3. 只要基类析构加 virtual,派生类析构自动构成重写,派生类析构写不写 virtual 都有效。

最重要实际意义(面试核心)

当用基类指针指向派生类对象 ,执行delete 指针;

  • 如果基类析构不是虚函数 :只会调用基类析构,派生类析构不执行。派生类里面申请的内存无法释放 → 内存泄漏
  • 如果基类析构是虚函数:触发多态,先调用派生类析构,再调用基类析构,资源完整释放。
1. 析构代码逐行讲解
复制代码
class A
{
public:
    // 基类析构设置为virtual
    virtual ~A()
    {
        cout << "~A()" << endl;
    }
};

class B : public A
{
public:
    // 自动重写基类虚析构,可省略virtual
    ~B()
    {
        cout << "~B()->delete:" << _p << endl;
        delete[] _p; // 释放子类申请的堆内存
    }
protected:
    int* _p = new int[10]; // 子类持有堆资源
};

int main()
{
    A* p1 = new A;
    A* p2 = new B;

    delete p1; // 释放A对象,调用 ~A()
    delete p2; // 基类析构是虚函数,触发多态
               // 先调用 ~B() 释放_p数组,再调用 ~A()
    return 0;
}

✅ 运行输出:

复制代码
~A()
~B()->delete:0x...
~A()
反面场景(坑)

如果删掉virtual,变成~A(){}

delete p2; 只会执行~A()不会执行~B ()

_p指向的堆数组没有释放,永久内存泄漏。

💡面试标准答案:

如果一个类可能被继承,最好把基类析构定义为虚析构函数,防止基类指针管理派生类对象时发生内存泄漏。

2. 虚析构完整带注释代码
复制代码
#include <iostream>
using namespace std;

class A
{
public:
    // 重点:基类析构加上virtual,成为虚析构函数
    virtual ~A()
    {
        cout << "~A()" << endl;
    }
};

class B : public A
{
public:
    // 自动重写基类虚析构,virtual可写可不写,推荐写上增强可读性
    ~B()
    {
        cout << "~B()->delete:" << _p << endl;
        delete[] _p; // 释放子类堆资源
    }
protected:
    // 子类在堆上开辟内存
    int* _p = new int[10];
};

int main()
{
    A* p1 = new A;
    A* p2 = new B;

    delete p1;
    delete p2;
    return 0;
}
3. 考试 / 面试小结
  1. 协变仅支持指针 / 引用,日常开发很少用,认识即可
  2. 析构函数名称源代码不同,编译器统一改名,满足虚函数重写条件
  3. 被继承的基类,析构函数建议加 virtual,避免基类指针 delete 派生类对象造成内存泄漏。

2.1.6 override 和final关键字

1、override(检查是否成功重写虚函数)
问题背景

虚函数重写要求很严:函数名、参数、返回值(协变除外)都要匹配。

一不小心写错函数名、参数,编译器不会报错 ,它只会认为:你在派生类新增了一个独立函数,没有完成重写

等到运行程序,多态失效,结果不对,你还要花大量时间查 bug。

override 的作用

在派生类的虚函数后面写上 override

编译器会主动检查:这个函数能不能成功重写基类的虚函数

  • 满足重写规则:正常编译
  • 写错名字 / 参数,无法构成重写:直接编译报错,提前发现错误。

示例:

复制代码
class A
{
public:
    virtual void func(){}
};

class B : public A
{
public:
    // 正确:成功重写
    void func() override{}

    // void func(int x) override{} // 报错!参数不一样,无法重写
};

小建议:写重写的虚函数时,习惯性加上 override。

2. final(两个用途)
用途 1:修饰虚函数 ------ 禁止派生类重写这个虚函数

基类虚函数后面加 final,后续所有子类都不能重写它。

子类强行重写 → 编译报错。

复制代码
class A
{
public:
    virtual void func() final {}
};

class B : public A
{
public:
    // void func(){} // 编译报错,func被final禁止重写
};
用途 2:修饰类 ------ 禁止这个类被继承

类名后面加 final,别的类不能继承它

复制代码
class A final
{};
// class B:public A{}; // 报错,A不能被继承
总结
  1. override:告诉编译器「我打算重写虚函数,帮我检查有没有写错」,防止隐性 bug。
  2. final:上锁。修饰函数 = 不让子类重写;修饰类 = 不让别人继承这个类。
补充小对比
  • override:只用在派生类的虚函数
  • final:可以写在基类虚函数后,也可以写在类名后

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

如果追女生是每个少年(男生)心中第一件事喵,那么搞清楚这三个对比就是现在的当务之急,重中之重喵。(其实我想说这是第0件事,哈哈哈哈哈,因为计算机的世界是二进制的花园喵)

先一句话分清场景:

重载:同一个班级里;重写:父子班级 + 虚函数;隐藏:父子班级、但是达不到重写条件。

1. 重载(同一作用域)

条件:同一个范围,函数名字一样,参数不一样。

🌰例子:

班里同一个教室(同一个类 / 同一个作用域)有两位老师,都叫 "讲课"

  • 讲课 (讲数学)

  • 讲课 (讲语文)

    名字都叫讲课,但是授课内容(参数)不同。

    调用的时候看你传什么参数,自动匹配对应函数。

关键点:

全都在同一个教室(同一作用域),和继承没关系。

返回值随便,可以相同也可以不同,只看参数

复制代码
// 同一个类内部,属于重载
class Person
{
public:
    void Talk(string subject);
    void Talk(int age);
};
2. 重写 / 覆盖(实现多态,继承体系 + virtual 虚函数)

条件:父类、子类两个不同作用域;父类函数是 virtual 虚函数;函数名、参数一致(协变除外)。

🌰例子:

父亲开小店:virtual 买票 () → 全价票

儿子继承店铺,重新改造规则:买票 () → 学生打折票

规则名称一模一样,入参一样。

有人通过店铺招牌(父类指针)买票时,自动区分是谁经营店铺,执行对应的买票逻辑,这就是多态。

重点区别:

必须加 virtual!不加就不叫重写。

只有重写才能触发动态多态。

复制代码
class Person
{
public:
    virtual void BuyTicket() { cout << "全价票"; }
};
class Student : public Person
{
public:
    void BuyTicket() override { cout << "打折票"; } // 重写
};
3. 隐藏(又叫遮蔽,继承体系,不满足重写条件)

规则:父子类,函数名相同,只要构不成重写,全部是隐藏

两种常见情况:

1)父类没有写 virtual;

2)有 virtual,但是参数列表不一样;

🌰例子:

父亲店铺功能:买票 ()(无参数)

儿子店铺:买票 (int num)(带参数)

名字都叫买票,但是参数不一样。

如果你拿着儿子店铺的招牌调用买票 (),只会看到儿子自己的版本,父亲同名函数直接被藏起来了 。想要调用父类版本,必须手动指定 Person::BuyTicket()

⚠️ 重要坑:

隐藏≠重写,不会触发多态!

额外补充:父子类同名成员变量,也属于隐藏!

父亲有个变量 int id;,子类也写 int id;,子类 id 会隐藏父类 id。

复制代码
class Person
{
public:
    virtual void BuyTicket() { cout << "全价票"; }
};
class Student : public Person
{
public:
    void BuyTicket(int x) { cout << "打折票"; }
    // 参数不一样!无法构成重写,属于【隐藏】
};
总结对照表(方便记忆)
  1. 重载:同一个房间,同名不同参
  2. 重写:父子房间、带 virtual、签名一致,实现多态
  3. 隐藏:父子房间、名字相同,但达不到重写标准;父类同名成员被遮蔽

又是美好的一天,各位观众姥爷们,若有什么不懂的地方,欢迎在评论区留言哦,我会给出相应解释的。如有什么疏忽的细节,请点出,我会及时纠正。感谢观看。喵喵

相关推荐
无敌秋1 小时前
python/c++/java上云
java·c++·python
三少爷的鞋1 小时前
Android 面试系列:Retrofit 的 suspend 是如何实现的?
android
code_pgf11 小时前
C++11 / C++14 / C++17 / C++20 新特性总结
c++·c++20
魔力女仆11 小时前
分享一个 JS 鼠标跟随贪吃蛇背景库
开发语言·javascript·计算机外设
2401_8949155312 小时前
GEO 搜索优化完整源码从零部署:环境配置、集群搭建全流程
开发语言·python·tcp/ip·算法·unity
麻瓜老宋12 小时前
AI开发C语言应用按步走,表达式计算器calc的第二十二步,分号赋值链式修复、TOKEN_ASSIGN
c语言·开发语言·atomcode
solo_9913 小时前
用 LSPosed 模拟录屏音频编码器卡顿:从思路到落地
android
zzq779714 小时前
别把大模型 API Key 写进 APK:移动 AI 应用接口防盗刷实践
android·人工智能·安全·app加固·御盾安全·安卓加固
颜x小14 小时前
[C#] C++与c#语法对比
开发语言·c++·c#