C++ 多态:同一个基类接口,调用不同的函数

文章目录

    • [1. 都能作为 Person 使用,为什么还是买了全价票?](#1. 都能作为 Person 使用,为什么还是买了全价票?)
    • [2. 加上 virtual,改变的是哪一步?](#2. 加上 virtual,改变的是哪一步?)
      • [2.1 指针没有变成 Student*,引用也没有换一种类型](#2.1 指针没有变成 Student*,引用也没有换一种类型)
      • [2.2 怎样判断当前例子是否会表现出不同的行为?](#2.2 怎样判断当前例子是否会表现出不同的行为?)
    • [3. 名字相同,就算重写了吗?](#3. 名字相同,就算重写了吗?)
      • [3.1 先匹配声明,再谈重写](#3.1 先匹配声明,再谈重写)
      • [3.2 重载、隐藏、重写,关注的是不同关系](#3.2 重载、隐藏、重写,关注的是不同关系)
      • [3.3 final 限制后续重写](#3.3 final 限制后续重写)
    • [4. 看起来已经写对了,为什么结果还是不一样?](#4. 看起来已经写对了,为什么结果还是不一样?)
      • [4.1 按值传参,会把动态类型一起"带过去"吗?](#4.1 按值传参,会把动态类型一起“带过去”吗?)
      • [4.2 函数由动态类型选,默认实参却由静态上下文选](#4.2 函数由动态类型选,默认实参却由静态上下文选)
    • [5. 析构函数的重写](#5. 析构函数的重写)
      • [5.1 普通虚函数和虚析构,各自解决一个问题](#5.1 普通虚函数和虚析构,各自解决一个问题)
      • [5.2 不加 virtual,不只是"漏调用一次"](#5.2 不加 virtual,不只是“漏调用一次”)
    • [6. 没有合理的默认行为:抽象类](#6. 没有合理的默认行为:抽象类)
      • [6.1 "= 0" 表示接口要求,不是函数返回零](#6.1 “= 0” 表示接口要求,不是函数返回零)
      • [6.2 抽象类并不是"只有空函数名"](#6.2 抽象类并不是“只有空函数名”)
    • [7. 同一个调用位置,底层是如何找到不同的函数的](#7. 同一个调用位置,底层是如何找到不同的函数的)
      • [7.1 先理解一个典型实现模型](#7.1 先理解一个典型实现模型)
      • [7.2 常见说法理解](#7.2 常见说法理解)
      • [7.3 使用 sizeof 观察具有虚函数的类大小](#7.3 使用 sizeof 观察具有虚函数的类大小)
    • [8. 多继承多个接口](#8. 多继承多个接口)
      • [8.1 一个对象,可以经两种基类接口使用](#8.1 一个对象,可以经两种基类接口使用)
      • [8.2 虚继承和虚函数,virtual 的位置与目的都不同](#8.2 虚继承和虚函数,virtual 的位置与目的都不同)
    • [9. 多态的使用限制与范围](#9. 多态的使用限制与范围)
    • [10. 总结](#10. 总结)

1. 都能作为 Person 使用,为什么还是买了全价票?


上一篇继承文章已经说明:公开继承可以让 Student 对象通过 Person 的指针或引用使用。不过,与"能交给同一个函数"与"调用时会执行各自的行为"之间,还差一步。

假设一个买票场景,普通人买全价票,学生买半价票。我们让学生继承作为普通人的共同身份,再分别为各自提供不同的买票函数。

cpp 复制代码
#include <iostream>

class Person
{
public:
    void BuyTicket() const
    {
        std::cout << "Person: full price" << std::endl;
    }
};

class Student : public Person
{
public:
    void BuyTicket() const
    {
        std::cout << "Student: half price" << std::endl;
    }
};

void BuyFor(const Person& person)
{
    person.BuyTicket();
}

int main()
{
    Person person;
    Student student;
    student.BuyTicket();
    BuyFor(person);
    BuyFor(student);
    return 0;
}

运行示例:

直接写 student.BuyTicket()时,编译器能在 Student 类中找到学生自己的买票函数,所以第一行输出是半价。可是 BuyFor 的参数表达式 person 的类型是 const Person,它的普通成员函数调用选择的是 Person::BuyTicket。这样即便引用绑定到了学生对象,最后一行依然是全价。

如果是在 BuyFor 中先判断对象是什么身份,再写一连串分支用于实现不同身份的对应功能,这样的话,每增加一种身份就得修改这个函数。而我们真正希望的是:BuyFor 只表达"请这个人买票",至于买票这件事,各个身份自己决定怎样买。

这就是本篇关注的运行时多态:通过共同接口发出同一种请求,实际执行的实现随对象而变化。

2. 加上 virtual,改变的是哪一步?


cpp 复制代码
#include <iostream>

class Person
{
public:
    virtual void BuyTicket() const
    {
        std::cout << "Person: full price" << std::endl;
    }

    virtual ~Person() = default;
};

class Student : public Person
{
public:
    void BuyTicket() const 
    {
        std::cout << "Student: half price" << std::endl;
    }
};

class Soldier : public Person
{
public:
    void BuyTicket() const
    {
        std::cout << "Soldier: priority" << std::endl;
    }
};

void BuyFor(const Person& person)
{
    person.BuyTicket();
}

void BuyThroughPointer(const Person* person)
{
    if (person != nullptr)
    {
        person->BuyTicket();
    }
}

int main()
{
    Person person;
    Student student;
    Soldier soldier;

    BuyFor(person);
    BuyFor(student);
    BuyFor(soldier);
    BuyThroughPointer(&student);
    return 0;
}

运行示例:

2.1 指针没有变成 Student*,引用也没有换一种类型

调用 BuyFor(student) 时,形参仍然是 const Person&;调用 BuyThroughPointer(&student) 时,形参仍然是 const Person*。

为了把"代码中是什么类型"和"实际对象是什么类型"区分开,引入两个词:

  • 静态类型 :从表达式和声明中确定的类型。例如 person 表达式的静态类型是 const Person,而指针参数的声明类型是 const Person*。
  • 动态类型 :这里指被访问的完整对象的实际类型。引用或指针关联的是学生对象时,动态类型是 Student。

表达式 person.BuyTicket() 先根据静态类型检查:Person 有没有这个成员?它是否能通过 const 对象调用?访问是否合法?匹配的是哪个重载?

这些检查通过之后,因为选中的是虚函数,调用才会根据对象选择对应的最终重写函数 。在当前例子中,学生对象对应 Student::BuyTicket,军人对象则对应 Soldier::BuyTicket。

2.2 怎样判断当前例子是否会表现出不同的行为?

最常用的模式是:公开继承,基类声明虚函数,派生类正确重写,通过基类指针或引用使用不同对象。

这里有几个边界需要一起理解:

  1. 指针或引用必须关联到仍在生命周期内的有效对象。
  2. 基类转换要满足可访问、无歧义等要求。
  3. 派生类没有重写某个虚函数时,可以继续使用继承来的实现,并不会因此失去所有虚函数特性。

3. 名字相同,就算重写了吗?


3.1 先匹配声明,再谈重写

重写,也称覆盖,是派生类为基类的某个虚函数提供对应实现。

先看基本情形:基类函数必须是虚函数,派生类函数的名字、参数类型列表,以及成员函数末尾的 const 等限定要匹配;返回类型通常也要相同。

override 是 C++11 提供的重写检查说明符

参数名不需要相同,缺省值不参与是否重写的判断。

基类声明 派生类声明 结果
virtual void Print() const; void Print() const override; 正确重写
virtual void Print() const; void Print() override; 没有匹配末尾的 const,override 让编译器报错
virtual void Print(int); void Print(double) override; 参数类型不匹配,报错
virtual void Print(int val = 1); void Print(int val = 2) override; 可以重写,但默认实参不同容易造成误解
void Print(); void Print() override; 基类函数不是虚函数,报错

派生类中正确匹配的函数,即便不再次写 virtual,也仍然是虚函数(但最好还是规范两边都写) 。在基类写 virtual,在派生类写 override,可以同时表达接口性质与重写意图。

override 不会把一个错误匹配的函数"强行变成重写"。它的作用是要求编译器核实:"这个函数必须确实重写了基类的虚函数。"

3.2 重载、隐藏、重写,关注的是不同关系

名称 需要观察什么 常见情形
重载 同一作用域内同名函数能否通过参数区分 Print(int) 与 Print(double);仅返回类型不同不能重载
隐藏 派生类名字查找是否遮住基类同名声明 派生类声明 Print(double),基类的 Print(int) 不自动参与派生类中的普通查找
重写 派生类声明是否对应某个基类虚函数 基类 virtual Print(int),派生类 Print(int) override

3.3 final 限制后续重写

上一篇中的类级别的 final 禁止进一步继承。同时函数级别的 final 也会禁止派生类继续重写这个虚函数。

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

class Benz : public Car
{
public:
    void Drive() override {} // Car::Drive 已禁止继续重写。
};

int main() {}

4. 看起来已经写对了,为什么结果还是不一样?


4.1 按值传参,会把动态类型一起"带过去"吗?

继承中的对象切片,在多态场景里特别容易造成误判。把 Student 复制成一个独立 Person,得到的是基类对象,不再是学生对象。

cpp 复制代码
#include <iostream>

class Person
{
public:
    virtual void BuyTicket() const { std::cout << "Person: full price" << std::endl; }
    virtual ~Person() = default;
};

class Student : public Person
{
public:
    void BuyTicket() const override { std::cout << "Student: half price" << std::endl; }
};

void ByValue(Person person) { person.BuyTicket(); }
void ByReference(const Person& person) { person.BuyTicket(); }

int main()
{
    Student student;
    ByValue(student);
    ByReference(student);

    const Person& person = student;
    person.Person::BuyTicket();
    student.BuyTicket();
    return 0;
}

运行示例:

第一行:ByValue 的参数是一个独立 Person 对象,学生的基类部分被复制进去,动态类型已经是 Person。

第二行:引用仍绑定原学生对象,执行学生实现。

第三行:person.Person::BuyTicket() 明确指定基类版本。

第四行:student.BuyTicket() 通过对象表达式直接调用自己内部实现。

4.2 函数由动态类型选,默认实参却由静态上下文选

这一步需要把"给函数传什么值"和"执行哪个函数"分开看。

cpp 复制代码
#include <iostream>

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

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

    virtual ~A() = default;
};

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

int main()
{
    B object;
    const A& base = object;
    base.Func();
    object.Func();
    object.Test();
    base.Func(7);
    return 0;
}

运行示例:

  1. base.Func() 从 A 声明取得默认实参 1,但对象是 B,所以执行 B::Func(1)。
  2. object.Func() 从 B 声明取得默认实参 0,执行 B::Func(0)。
  3. object.Test() 使用继承来的 A::Test。进入这个函数后,Func() 的默认实参按 A::Test 所在上下文取 1;它相当于对当前对象执行 this->Func(1)。当前完整对象仍是 B,因此执行 B::Func(1)。
  4. 明确传入 7 后,不再使用默认实参,执行 B::Func(7)。

进入基类成员函数,不会自动把正常使用中的派生对象变成基类对象。基类函数内部的普通、未限定虚调用仍能调用派生实现。

5. 析构函数的重写


5.1 普通虚函数和虚析构,各自解决一个问题

cpp 复制代码
#include <iostream>

//测试析构函数的重写

//如果~A(),不加virtual,那么deletep2时只调用的A的析构函数,没有调用
//B的析构函数,就会导致内存泄漏问题,因为资源在~B()中释放。
class A
{
public:
    virtual ~A()
    {
        std::cout << "~A()" << std::endl;
    }
};

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

// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能
// 构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
    A* p1 = new A;
    A* p2 = new B;

    delete p1;
    delete p2;

    return 0;
}

运行示例:

先构造 A 和 B,随后delete p1 删除基类对象,执行 A 析构。delete p2 虽然使用 A*,但基类析构是虚函数,因此先执行 B 析构,释放数组,再自动析构基类部分。

5.2 不加 virtual,不只是"漏调用一次"

在上个例子中,若 A 的析构不是虚函数,却通过 A* 来删除 new B 创建的对象,就会导致内存泄漏的问题。

如果一个基类准备通过公开接口供调用者删除派生对象,通常设计公开虚析构。

析构函数的重写是语言专门规定的规则:基类析构为虚函数时,派生析构也是虚函数,包括编译器隐式生成的派生析构。

基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了virtual修饰,派生类的析构函数就构成重写。

6. 没有合理的默认行为:抽象类


6.1 "= 0" 表示接口要求,不是函数返回零

含有尚未提供非纯最终重写实现的纯虚函数的类是抽象类,不能直接创建它的对象。派生类如果仍有未实现的纯虚接口,也仍是抽象类。

cpp 复制代码
#include <iostream>

class Car
{
public:
    virtual void Drive() const = 0;
    virtual ~Car() = default;
};

class Benz : public Car
{
public:
    void Drive() const override
    {
        std::cout << "Benz: 舒适" << std::endl;
    }
};

class BMW : public Car
{
public:
    void Drive() const override
    {
        std::cout << "BMW: 操控" << std::endl;
    }
};

void TestDrive(const Car& car)
{
    car.Drive();
}

int main()
{
    Benz benz;
    BMW bmw;
    TestDrive(benz);
    TestDrive(bmw);
    return 0;
}

运行示例:

6.2 抽象类并不是"只有空函数名"

抽象类也可以有数据成员、构造函数、普通成员函数和已经实现的虚函数。所以派生对象仍包含它的基类部分,因此仍要构造和析构这个部分。

纯虚函数通常只需要声明;但纯虚函数也可以在类外提供定义,类仍然是抽象类。

其中有一个点:如果把析构声明为纯虚函数,它仍然需要一个定义,因为派生对象销毁时必须析构基类部分。

cpp 复制代码
#include <iostream>

class Interface
{
public:
    virtual ~Interface() = 0;
};

Interface::~Interface()
{
    std::cout << "Interface destroyed" << std::endl;
}

class Concrete : public Interface
{
public:
    ~Concrete() override
    {
        std::cout << "Concrete destroyed" << std::endl;
    }
};

int main()
{
    Concrete object;
    return 0;
}

示例中的Interface 仍是抽象类;Concrete 的析构提供了相应的非纯重写,所以它可以创建对象。

7. 同一个调用位置,底层是如何找到不同的函数的


7.1 先理解一个典型实现模型

C++ 实现多态通常使用虚函数表,简称虚表 ,以及对象中的虚表指针实现虚调用。在当前简单单继承模型中,可以这样理解:

  1. 类的虚函数实现信息被组织到表中,不需要每个对象复制一份函数代码。
  2. 对象中保存关联到适当表的指针。
  3. 虚调用沿这个关联找到对应条目,进入当前对象适用的函数实现。

虚函数代码与普通函数代码一样,通常都是编译后的指令;表记录的是调用所需的信息。

cpp 复制代码
#include <iostream>

class Base
{
public:
    virtual void First() const { std::cout << "Base::First" << std::endl; }
    virtual void Second() const { std::cout << "Base::Second" << std::endl; }
    virtual ~Base() = default;
};

class Derived : public Base
{
public:
    void First() const override { std::cout << "Derived::First" << std::endl; }
    virtual void Third() const { std::cout << "Derived::Third" << std::endl; }
};

int main()
{
    Base base;
    Derived first;
    Derived second;
    const Base* objects[] = {&base, &first, &second};
    for (int i = 0; i < 3; ++i)
    {
        objects[i]->First();
        objects[i]->Second();
    }
    first.Third();
    // objects[1]->Third(); // Base 接口没有 Third,不能据此调用。
    return 0;
}

运行示例:

在示例中,Derived 的表把 First 对应的实现关联到 Derived::First,保留 Second 对应的 Base::Second,再包含新增加的虚函数相关信息。两个正常使用中的同类型对象,通常共享相应虚表,而各自拥有自己的数据。

7.2 常见说法理解

问题 应怎样理解
虚表指针一定在对象第一个位置吗? 常见简单布局中可能如此,语言标准不要求固定位置
一个对象一定只有一个虚表指针吗? 简单单继承模型常如此,多继承等布局可能有多个
每个类一定只有一张表吗? 不宜概括,多继承及构造阶段可能有更复杂的表组织
表一定是只有函数地址的数组吗? 刚学习时可以这么认为,实际 ABI 还可能包含类型信息、偏移、调整入口等
表末尾一定有零吗? 没有这种可移植保证,不能用"读到零"为止遍历虚表
表一定存在某个固定内存区域吗? 没有标准指定的位置,调试器观察只能描述当前环境
非虚成员函数也放进虚表吗? 普通非虚成员调用不依赖虚表分派

7.3 使用 sizeof 观察具有虚函数的类大小

以下程序在32位环境运行(指针变量为 4 字节大小):

cpp 复制代码
#include <iostream>

class Plain
{
protected:
    int _number = 1;
    char _letter = 'x';
};

class WithVirtual
{
public:
    virtual void Func() {}
protected:
    int _number = 1;
    char _letter = 'x';
};

int main()
{
    std::cout << "Plain: " << sizeof(Plain) << std::endl;
    std::cout << "WithVirtual: " << sizeof(WithVirtual) << std::endl;
    return 0;
}

运行示例:

在32位环境中,Plain 的 int 和 char 加上尾部填充占 8 字节;WithVirtual 还包含一个 4 字节的虚表指针,整体占 12 字节。

8. 多继承多个接口


8.1 一个对象,可以经两种基类接口使用

由多继承可知,一个派生对象可以继承多个基类,也就可以提供多个不同的接口视角。

cpp 复制代码
#include <iostream>

class Printable
{
public:
    virtual void Print() const = 0;
    virtual ~Printable() = default;
};

class Speakable
{
public:
    virtual void Speak() const = 0;
    virtual ~Speakable() = default;
};

class Robot : public Printable, public Speakable
{
public:
    void Print() const override { std::cout << "Robot information" << std::endl; }
    void Speak() const override { std::cout << "Robot says hello" << std::endl; }
};

int main()
{
    Robot robot;
    const Printable* printable = &robot;
    const Speakable* speakable = &robot;
    printable->Print();
    speakable->Speak();
    return 0;
}

运行示例:

两个指针分别关联到两个基类子对象,语言规则保证各自调用正确的最终实现。

8.2 虚继承和虚函数,virtual 的位置与目的都不同

class Student : virtual public Person 中的 virtual 用于虚继承,处理共同基类子对象的共享问题;virtual void Run() 中的 virtual 用于虚函数,处理实现选择问题。

二者可以同时出现,但不能相互替代。虚继承不会自动让普通成员函数成为虚函数,虚函数也不会自动合并普通菱形继承中的两份基类子对象。

9. 多态的使用限制与范围


在基类中,有些函数并不适合写 virtual,不要为了多态而多态。

函数类别 规则或原因
构造函数 不能声明为虚函数;构造时需要先确定要创建的类型,不能靠尚未完成的对象再选择构造实现
静态成员函数 不能是虚函数,没有关联某个对象的 this 来选择实现
非成员函数 不能声明为虚函数,虚函数属于类的非静态成员机制

10. 总结


  • 多态的核心条件: 存在继承关系,基类函数使用 virtual 修饰,派生类完成重写,通过基类指针或引用调用函数。值传递会发生对象切片,无法触发多态。
  • virtual 的两种用途: 写在函数前代表虚函数,依靠虚表实现运行时动态绑定;写在继承列表前代表虚继承,用来解决菱形继承里公共基类重复存储的问题。二者作用相互独立,不能互相替换。
  • 虚函数底层依靠虚表与虚表指针实现: 每个含有虚函数的类会生成一张虚函数表,类对象头部存放虚表指针,运行时通过虚表指针查找对应函数地址。
  • 函数重写要求: 函数名、参数列表、返回值符合规则,const 属性也要匹配。派生类重写的虚函数可以不加 virtual,override 关键字可以显式校验派生类中是否完成重写。
  • 不要滥用 virtual: 虚函数会带来运行时开销,虚继承会增大对象体积。只有需要多态或者需要解决菱形继承时,才使用对应的 virtual 机制。
相关推荐
笔墨登场说说1 小时前
flink bin/start-cluster.sh 帮我做成开机启动
开发语言·python
Sarvartha1 小时前
Lambda 表达式和函数式接口
java·开发语言
小白学大数据1 小时前
拼多多反爬对抗实战:Scrapy 中间件化采集架构解析
开发语言·scrapy·中间件·架构
colinu2 小时前
C++ 异步任务与线程生命周期:`std::async`、`future`、`thread`、`jthread` 与协作式退出
java·jvm·c++
FGJCJS2 小时前
ASTM D4169-23e1跌落测试详解:保证等级、跌落高度与判定标准
经验分享
学逆向的2 小时前
win32注入代码
开发语言·网络安全·api·win32
wuminyu2 小时前
ForkJoinPool窃取线程在扫描其他WorkQueue时的随机采样算法与Phase线程挂起唤醒机制解析
java·linux·c语言·jvm·c++
longlongzihan2 小时前
链表找环:Floyd 判圈算法(LeetCode 141 & 142 )
c++·算法·leetcode·链表
Sarvartha2 小时前
内部类知识
java·开发语言