C++ 多态上篇:从 virtual 到抽象类,彻底理解多态的使用

一、什么是多态

"多态"英文是:

复制代码
polymorphism

直译就是:

多种形态。

在程序中,可以理解成:

同一个行为,面对不同对象时,产生不同结果。

例如买票:

复制代码
普通人买票   -> 全价
学生买票     -> 打折
军人买票     -> 优先

它们执行的行为都叫:

复制代码
BuyTicket()

但是不同对象执行之后产生了不同结果。

再例如动物叫:

复制代码
Dog -> 汪汪
Cat -> 喵喵

我们希望程序能够写成:

复制代码
animal->Talk();

至于最终是"汪汪"还是"喵喵",由这个 animal 实际指向什么对象决定。

这就是运行时多态最核心的思想。


1.1 编译时多态

所谓编译时多态,就是:

编译器在程序运行之前,就已经确定调用哪个函数。

最常见的就是函数重载。

复制代码
#include <iostream>
using namespace std;

void Func(int x)
{
    cout << "Func(int)" << endl;
}

void Func(double x)
{
    cout << "Func(double)" << endl;
}

int main()
{
    Func(10);
    Func(3.14);

    return 0;
}

编译器看到:

复制代码
Func(10);

就知道应该调用:

复制代码
Func(int)

看到:

复制代码
Func(3.14);

就知道调用:

复制代码
Func(double)

因此函数调用关系在编译阶段已经确定。

函数模板也是类似的思想,所以一般将:

复制代码
函数重载
函数模板

归为:

静态多态 / 编译时多态。


1.2 运行时多态

运行时多态就不一样了。

来看一个例子:

复制代码
#include <iostream>
using namespace std;

class Person
{
public:
    virtual void BuyTicket()
    {
        cout << "普通人买票:全价" << endl;
    }
};

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

void Func(Person* ptr)
{
    ptr->BuyTicket();
}

int main()
{
    Person p;
    Student s;

    Func(&p);
    Func(&s);

    return 0;
}

运行结果:

复制代码
普通人买票:全价
学生买票:打折

注意:

复制代码
void Func(Person* ptr)

这里两次调用的参数类型都是:

复制代码
Person*

第一次:

复制代码
Func(&p);

ptr 实际指向 Person 对象,因此调用:

复制代码
Person::BuyTicket()

第二次:

复制代码
Func(&s);

虽然 ptr 仍然是:

复制代码
Person*

但它实际指向的是 Student 对象,因此调用:

复制代码
Student::BuyTicket()

虽然都是 Person* 调用 BuyTicket(),最终调用哪个函数由指针实际指向的对象决定。

这就是:

运行时多态。


二、运行时多态的构成条件

C++ 中要真正形成运行时多态,必须满足两个条件:

基类的指针或者引用调用虚函数,并且派生类对该虚函数完成了重写。

这是讲义明确给出的两个核心条件。

也就是说,仅仅有继承是不够的:

复制代码
class Student : public Person

这只能说明存在继承关系。

真正的多态还需要:

复制代码
基类指针 / 引用
        +
虚函数
        +
派生类重写

2.1 为什么一定要用基类指针或者引用

先看:

复制代码
Student s;

Person* ptr = &s;

为什么 Person* 可以指向 Student

因为派生类对象内部包含继承得到的基类部分。

可以粗略理解为:

复制代码
Student 对象

低地址
↓
┌────────────────────┐
│ Person 基类部分     │
├────────────────────┤
│ Student自己的成员   │
└────────────────────┘

因此:

复制代码
Person* ptr = &s;

本质上就是让:

复制代码
ptr

指向 Student 对象中的:

复制代码
Person 基类部分

这也是继承中:

派生类对象可以赋值给基类指针。

最重要的意义。


2.2 基类指针可以统一接收不同对象

假设:

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

class Student : public Person
{
public:
    void BuyTicket() override
    {
        cout << "学生:打折" << endl;
    }
};

class Soldier : public Person
{
public:
    void BuyTicket() override
    {
        cout << "军人:优先" << endl;
    }
};

我们可以统一写:

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

然后:

复制代码
int main()
{
    Person p;
    Student s;
    Soldier so;

    Func(&p);
    Func(&s);
    Func(&so);

    return 0;
}

结果分别是:

复制代码
普通人:全价
学生:打折
军人:优先

这才是基类指针真正有价值的地方:

它把多个不同的派生类统一成了一种接口。


2.3 基类引用也能实现多态

不仅指针可以,引用也可以。

例如:

复制代码
#include <iostream>
using namespace std;

class Animal
{
public:
    virtual void Talk() const
    {
        cout << "Animal" << endl;
    }
};

class Dog : public Animal
{
public:
    void Talk() const override
    {
        cout << "汪汪" << endl;
    }
};

class Cat : public Animal
{
public:
    void Talk() const override
    {
        cout << "喵喵" << endl;
    }
};

void LetAnimalTalk(const Animal& animal)
{
    animal.Talk();
}

int main()
{
    Dog dog;
    Cat cat;

    LetAnimalTalk(dog);
    LetAnimalTalk(cat);

    return 0;
}

结果:

复制代码
汪汪
喵喵

因此判断多态时可以直接记:

复制代码
基类指针
或者
基类引用

三、virtual 与虚函数重写

多态真正的关键字就是:

复制代码
virtual

类的成员函数前加上 virtual,这个函数就称为:

虚函数。

例如:

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

3.1 没有 virtual 会发生什么

来看:

复制代码
#include <iostream>
using namespace std;

class Base
{
public:
    void Func()
    {
        cout << "Base::Func()" << endl;
    }
};

class Derive : public Base
{
public:
    void Func()
    {
        cout << "Derive::Func()" << endl;
    }
};

int main()
{
    Derive d;

    Base* ptr = &d;

    ptr->Func();

    return 0;
}

输出:

复制代码
Base::Func()

很多初学者第一次看到这里会疑惑:

ptr 明明指向 Derive,为什么不调用 Derive::Func()

因为:

复制代码
Base::Func()

不是虚函数。

编译器看到:

复制代码
Base* ptr;

就按照 ptr 的类型直接确定调用:

复制代码
Base::Func()

此时不会发生运行时多态。


3.2 加上 virtual 后发生什么

把代码改成:

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

派生类:

复制代码
class Derive : public Base
{
public:
    void Func() override
    {
        cout << "Derive::Func()" << endl;
    }
};

仍然:

复制代码
Derive d;

Base* ptr = &d;

ptr->Func();

结果变成:

复制代码
Derive::Func()

区别就在:

复制代码
virtual

所以我们目前可以先把 virtual 理解为:

告诉编译器,这个函数以后允许根据对象实际类型,在运行阶段决定最终调用哪一个版本。

至于编译器到底是怎么做到的,涉及虚函数表和虚表指针,放到多态下篇再讲。


3.3 什么叫虚函数重写

假设基类:

复制代码
class Base
{
public:
    virtual void Func(int x)
    {
        cout << "Base" << endl;
    }
};

派生类:

复制代码
class Derive : public Base
{
public:
    void Func(int x)
    {
        cout << "Derive" << endl;
    }
};

此时派生类的:

复制代码
Func(int)

就重写了基类的:

复制代码
Func(int)

讲义给出的核心规则是:派生类中的虚函数与基类虚函数在函数名、参数列表和返回类型规则上对应,就构成虚函数重写,也称覆盖。


3.4 派生类不写 virtual 行不行

可以。

例如:

复制代码
class Base
{
public:
    virtual void Func()
    {
    }
};

class Derive : public Base
{
public:
    void Func()
    {
    }
};

仍然构成虚函数重写。

原因是:

基类中的虚函数被继承之后,在派生类中仍然保持虚函数属性。

讲义也专门提醒过这一点。

不过从现代 C++ 的代码风格来看,更推荐:

复制代码
class Derive : public Base
{
public:
    void Func() override
    {
    }
};

因为:

复制代码
override

比再次写一个 virtual 更能明确表达:

"这个函数就是用来重写基类虚函数的。"


3.5 override 有什么作用

考虑:

复制代码
class Car
{
public:
    virtual void Drive()
    {
    }
};

你本来想在 Benz 中重写:

复制代码
Drive()

结果不小心写错:

复制代码
class Benz : public Car
{
public:
    void Dirve()
    {
        cout << "Benz" << endl;
    }
};

注意:

复制代码
Drive
Dirve

拼写错了。

如果不加 override,你可能很长时间都发现不了。

但如果写:

复制代码
void Dirve() override
{
}

编译器就会报错:

这个函数并没有重写任何基类虚函数。

讲义同样使用 Drive/Dirve 的拼写错误说明了 C++11 override 的作用。

所以推荐:

复制代码
class Benz : public Car
{
public:
    void Drive() override
    {
        cout << "Benz" << endl;
    }
};

3.6 final 又是什么

override 是:

检查我有没有正确重写。

final 则是:

到我这里结束,不允许继续重写。

例如:

复制代码
class Base
{
public:
    virtual void Func() final
    {
    }
};

再写:

复制代码
class Derive : public Base
{
public:
    void Func()
    {
    }
};

编译器会报错。

因为:

复制代码
Base::Func()

已经被标记:

复制代码
final

virtual
   ↓
这是虚函数
   ↓
派生类重写
   ↓
override 检查重写是否正确
   ↓
final 禁止继续重写

四、重载、重写和隐藏

这三个名字非常像,也是 C++ 继承和多态中的高频考点。

最容易混淆的是:

复制代码
重载 overload
重写 override
隐藏 hide

4.1 重载

重载发生在:

同一个作用域。

例如:

复制代码
class A
{
public:
    void Func(int x)
    {
    }

    void Func(double x)
    {
    }

    void Func(int x, int y)
    {
    }
};

三个函数都叫:

复制代码
Func

但是参数列表不同。

这就是函数重载。

它属于:

编译时多态。


4.2 重写

重写发生在:

基类和派生类之间。

例如:

复制代码
class Base
{
public:
    virtual void Func(int x)
    {
    }
};

class Derive : public Base
{
public:
    void Func(int x) override
    {
    }
};

这里:

复制代码
Derive::Func

重写:

复制代码
Base::Func

重写是:

运行时多态的重要基础。


4.3 隐藏

来看:

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

    void Func(int x)
    {
        cout << "Base::Func(int)" << endl;
    }
};

class Derive : public Base
{
public:
    void Func(double x)
    {
        cout << "Derive::Func(double)" << endl;
    }
};

派生类中出现了一个同名的:

复制代码
Func

这会影响基类同名函数在派生类作用域中的名字查找。

如果你要显式调用基类版本,可以写:

复制代码
Derive d;

d.Base::Func();

所以隐藏和重写最大的区别就是:

重写必须围绕虚函数形成覆盖关系,而隐藏主要是父子作用域中的同名函数问题。


4.4 三者快速区分

特征 重载 重写 隐藏
位置 同一作用域 基类和派生类 基类和派生类
函数名 相同 相同 相同
参数列表 不同 对应相同 可以不同
基类是否要求 virtual 不涉及 要求 不要求
是否与运行时多态有关 通常否

五、虚函数重写中的特殊问题

这一部分有两个非常经典的点:

复制代码
默认参数
协变

尤其默认参数那道题,非常适合考试和面试。


5.1 经典问题:为什么输出 B->1

来看讲义中的代码:

复制代码
#include <iostream>
using namespace std;

class A
{
public:
    virtual void Func(int val = 1)
    {
        cout << "A->" << val << endl;
    }

    virtual void Test()
    {
        Func();
    }
};

class B : public A
{
public:
    void Func(int val = 0) override
    {
        cout << "B->" << val << endl;
    }
};

int main()
{
    B* p = new B;

    p->Test();

    delete p;

    return 0;
}

最终结果:

复制代码
B->1

为什么不是:

复制代码
B->0

需要把:

复制代码
调用哪个函数

和:

复制代码
默认参数是多少

分开看。


5.2 第一步:调用哪个函数

执行:

复制代码
p->Test();

虽然 p 是:

复制代码
B*

但是 B 没有重写:

复制代码
Test()

因此执行:

复制代码
A::Test()

函数内部:

复制代码
virtual void Test()
{
    Func();
}

现在调用的是虚函数:

复制代码
Func()

实际对象是:

复制代码
B

所以最终选择:

复制代码
B::Func()

因此输出一定是:

复制代码
B->

5.3 第二步:默认参数是多少

基类:

复制代码
virtual void Func(int val = 1)

派生类:

复制代码
void Func(int val = 0) override

问题是:

复制代码
Func();

到底使用 1 还是 0

这里要记住:

默认参数是在编译阶段根据静态环境确定的。

当前这句:

复制代码
Func();

写在:

复制代码
A::Test()

里面。

因此默认参数取:

复制代码
A::Func

中的:

复制代码
1

而函数体本身因为虚函数机制,最后调用:

复制代码
B::Func

所以效果相当于:

复制代码
B::Func(1);

因此最终输出:

复制代码
B->1

这道题可以浓缩成一句:

虚函数的函数体动态选择,默认参数静态确定。


六、协变返回类型

正常情况下,虚函数重写要求返回值类型保持对应。

不过有一个特殊情况:

基类虚函数返回基类对象的指针或引用,而派生类重写时返回派生类对象的指针或引用。

这种情况叫:

协变。

讲义将这一部分标记为了解内容。

例如:

复制代码
class A
{
};

class B : public A
{
};

然后:

复制代码
class Person
{
public:
    virtual A* BuyTicket()
    {
        cout << "Person" << endl;
        return nullptr;
    }
};

派生类:

复制代码
class Student : public Person
{
public:
    B* BuyTicket() override
    {
        cout << "Student" << endl;
        return nullptr;
    }
};

这里:

复制代码
A*

和:

复制代码
B*

并不相同。

但是由于:

复制代码
B

继承自:

复制代码
A

这种返回值形式允许构成重写。

这一部分知道概念即可,不需要投入太多精力。


七、为什么多态基类通常需要虚析构函数

这是多态里最重要的工程问题之一,也是很经典的面试题。

假设:

复制代码
Base* ptr = new Derive;

最后:

复制代码
delete ptr;

到底应该调用谁的析构函数?


7.1 先看一个存在风险的版本

复制代码
#include <iostream>
using namespace std;

class Base
{
public:
    ~Base()
    {
        cout << "~Base()" << endl;
    }
};

class Derive : public Base
{
public:
    Derive()
    {
        _ptr = new int[100];
    }

    ~Derive()
    {
        cout << "~Derive()" << endl;
        delete[] _ptr;
    }

private:
    int* _ptr;
};

然后:

复制代码
int main()
{
    Base* ptr = new Derive;

    delete ptr;

    return 0;
}

这里最大的风险是:

复制代码
ptr

的类型是:

复制代码
Base*

如果不能通过多态正确进入派生类析构过程:

复制代码
Derive::~Derive()

那么:

复制代码
delete[] _ptr;

就无法正确执行。

于是派生类负责管理的资源就可能得不到正确释放。

A* p2 = new B; delete p2; 这个例子说明:如果基类析构函数没有设计成虚函数,派生类负责释放的资源可能无法得到正确处理。


7.2 正确做法:基类析构函数写成虚函数

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

派生类:

复制代码
class Derive : public Base
{
public:
    Derive()
    {
        _ptr = new int[100];
    }

    ~Derive() override
    {
        cout << "~Derive()" << endl;
        delete[] _ptr;
    }

private:
    int* _ptr;
};

然后:

复制代码
Base* ptr = new Derive;

delete ptr;

销毁时就能够按照派生对象的实际类型正确进入析构链:

复制代码
~Derive()
    ↓
~Base()

这样派生类自身管理的资源也可以正确释放。


7.3 为什么析构函数名字不同还能构成重写

普通虚函数重写要求函数名相同。

但是:

复制代码
~Base()

和:

复制代码
~Derive()

显然名字不同。

析构函数属于特殊情况,编译器会对析构函数进行特殊处理,因此当基类析构函数是虚函数时,派生类析构函数能够参与虚函数覆盖机制。

所以工程中有一个非常重要的习惯:

复制代码
class Base
{
public:
    virtual ~Base() = default;
};

只要这个类明确要作为多态基类使用,这种写法就很常见。


八、纯虚函数与抽象类

前面我们写:

复制代码
class Animal
{
public:
    virtual void Talk()
    {
        cout << "Animal" << endl;
    }
};

但是思考一个问题:

"动物"这个概念本身到底应该怎么叫?

狗:

复制代码
汪汪

猫:

复制代码
喵喵

但:

复制代码
Animal

本身并没有一个统一具体的叫法。

这时候就可以使用:

纯虚函数。


8.1 什么是纯虚函数

写法:

复制代码
virtual void Talk() = 0;

例如:

复制代码
class Animal
{
public:
    virtual void Talk() = 0;
};

这里:

复制代码
= 0

表示:

复制代码
Talk 是纯虚函数

在虚函数后写 = 0 即成为纯虚函数,包含纯虚函数的类称为抽象类。02.多态.pdfPDF


8.2 什么是抽象类

只要类中包含纯虚函数,这个类就是:

抽象类。

例如:

复制代码
class Animal
{
public:
    virtual void Talk() = 0;
};

那么:

复制代码
Animal animal;

不能通过编译。

也就是说:

抽象类不能直接实例化对象。

Car 也是相同情况:

复制代码
class Car
{
public:
    virtual void Drive() = 0;
};

因此:

复制代码
Car car;

无法实例化。02.多态.pdfPDF


8.3 派生类必须重写纯虚函数

例如:

复制代码
class Animal
{
public:
    virtual void Talk() = 0;
};

派生类:

复制代码
class Dog : public Animal
{
public:
    void Talk() override
    {
        cout << "汪汪" << endl;
    }
};

此时:

复制代码
Dog dog;

可以正常实例化。

但如果:

复制代码
class Cat : public Animal
{
};

没有重写:

复制代码
Talk()

那么:

复制代码
Cat

自身仍然是抽象类。

也不能:

复制代码
Cat cat;

如果派生类没有重写继承下来的纯虚函数,那么派生类仍然是抽象类。


九、抽象类真正解决了什么问题

不要只记:

复制代码
纯虚函数 = 0
抽象类不能实例化

那只是语法现象。

真正重要的是:

抽象类可以规定统一接口。

例如:

复制代码
class Animal
{
public:
    virtual void Talk() const = 0;

    virtual ~Animal() = default;
};

这里相当于规定:

只要你想成为一种真正可以使用的 Animal,你就必须提供 Talk()

然后:

复制代码
class Dog : public Animal
{
public:
    void Talk() const override
    {
        cout << "汪汪" << endl;
    }
};

class Cat : public Animal
{
public:
    void Talk() const override
    {
        cout << "喵喵" << endl;
    }
};

统一接口:

复制代码
void LetAnimalTalk(const Animal& animal)
{
    animal.Talk();
}

调用:

复制代码
int main()
{
    Dog dog;
    Cat cat;

    LetAnimalTalk(dog);
    LetAnimalTalk(cat);

    return 0;
}

结果:

复制代码
汪汪
喵喵

调用方根本不需要知道:

复制代码
到底是Dog
还是Cat

它只知道:

复制代码
Animal

一定提供:

复制代码
Talk()

这就是抽象类非常重要的意义:

只规定"必须有什么能力",而把"具体怎么实现"交给派生类。

相关推荐
TheBestRucy9 分钟前
Python九阳神功之柒:数据分析三剑客·执剑问道
开发语言·python·数据分析
qq_3391911427 分钟前
go cpu占比高排查,cpu100%排查,go pprof cpu命令
开发语言·后端·golang
西西弗Sisyphus32 分钟前
Qt 配置文件图标和文件版本信息
开发语言·qt
秋田君39 分钟前
Qt_Qt(c++)开发中常见错误与解决方法
开发语言·c++·qt
夏霞1 小时前
c# 不支持的目标框架 解决方案
开发语言·c#
雄哥0071 小时前
java代码反编译CFR
java·开发语言·反编译·cfr
lsylalalala1 小时前
多线程(2)
java·开发语言·多线程
星恒随风1 小时前
C++11详解(一):统一初始化——列表初始化与 initializer_list
c++·笔记·学习·list·状态模式
jimy11 小时前
右值大类rvalue的xvalue,和 左值大类glvalue的xvalue的区别
开发语言·c++