【C++】多态

多态概述

多态 (Polymorphism)是面向对象程序设计(OOP)的三大核心特性之一(另两个是封装和继承)。

它的字面意思是"多种形态",在 C++ 中,多态允许同一段代码调用,根据对象的实际类型执行不同的操作,从而使程序更具灵活性和可扩展性。

C++ 中的多态主要分为两大类:

  • 编译时多态(静态多态) :在编译阶段就确定函数调用地址,主要通过函数重载运算符重载函数/类模板实现。
  • 运行时多态(动态多态) :在程序执行期间(运行时)才确定调用哪个函数,依赖于继承虚函数

编译时多态(静态多态)

静态多态之所以称为"静态",是因为所有决策都在编译期完成,不涉及运行时类型识别。它的优势是调用迅速(无间接寻址开销),并且可以完全内联优化。

函数重载

概念

在同一作用域内,可以定义多个同名函数 ,但要求它们的参数列表不同(参数的类型、个数或顺序不同)。编译器根据调用时传入的实参,在编译期选择最匹配的重载版本。

重要规则

  • 仅返回值类型不同不能构成重载。
  • 参数类型不同(包括 const / volatile 修饰)可以重载,但顶层 const(修饰参数本身)不影响签名;底层 const(修饰指针指向的对象)会影响重载。
  • 重载解析会进行隐式类型转换、默认参数匹配等操作。

示例:数学运算函数的多种重载

cpp 复制代码
#include <iostream>
#include <string>

class Calculator {
public:
    // 重载:处理整数
    int add(int a, int b) {
        std::cout << "int version" << std::endl;
        return a + b;
    }

    // 重载:处理浮点数
    double add(double a, double b) {
        std::cout << "double version" << std::endl;
        return a + b;
    }

    // 重载:处理字符串拼接
    std::string add(const std::string& a, const std::string& b) {
        std::cout << "string version" << std::endl;
        return a + b;
    }
};

int main() {
    Calculator calc;
    calc.add(1, 2);          // int version
    calc.add(1.5, 2.7);      // double version
    calc.add("Hello, ", "World!"); // string version
}

底层实现

C++ 编译器通过**名字修饰(name mangling)**技术,将函数名与参数类型信息编码成唯一的内部符号。例如 add(int, int) 可能被修饰为 _Z3addiiadd(double, double) 修饰为 _Z3adddd,这样链接器就能区分不同的重载版本。

运算符重载

概念

运算符重载允许为自定义类型(如类、结构体)定义运算符的行为,使其像内置类型一样使用运算符(+-*/<<[] 等)。

实现方式

  • 成员函数形式Complex operator+(const Complex& rhs) const;
  • 非成员函数形式 (通常声明为友元):friend Complex operator+(const Complex& lhs, const Complex& rhs);

限制

  • 不能创建新的运算符,只能重载 C++ 已有的运算符。
  • 不能改变运算符的优先级、结合性和操作数个数。
  • 某些运算符不能重载(如 ::..*?:)。
  • 重载后不能改变运算符用于内置类型时的原有含义。

示例:重载下标运算符 [] 实现安全的数组访问

cpp 复制代码
#include <iostream>
#include <stdexcept>

class SafeArray {
private:
    int data[10];
public:
    SafeArray() {
        for (int i = 0; i < 10; ++i) data[i] = i;
    }

    // 重载 [](读/写)
    int& operator[](int index) {
        if (index < 0 || index >= 10)
            throw std::out_of_range("Index out of range");
        return data[index];
    }

    // 重载 [](只读,用于 const 对象)
    const int& operator[](int index) const {
        if (index < 0 || index >= 10)
            throw std::out_of_range("Index out of range");
        return data[index];
    }
};

int main() {
    SafeArray arr;
    std::cout << arr[5] << std::endl; // 5
    arr[5] = 100;                     // 修改
    std::cout << arr[5] << std::endl; // 100
    // arr[20] = 0;                  // 抛出异常
}

运算符重载的本质

运算符重载实际上是函数调用的语法糖。c1 + c2 会被编译器解释为 c1.operator+(c2)operator+(c1, c2)。因此它也是静态多态的一种形式。

函数模板(泛型多态)

函数模板是一种泛型编程技术,通过参数化类型,使同一段代码能处理不同类型的数据。它也是一种编译时多态,编译器会根据实参类型生成具体的函数实例。

示例:通用的 max 函数

cpp 复制代码
template<typename T>
T myMax(T a, T b) {
    return (a > b) ? a : b;
}

int main() {
    std::cout << myMax(3, 5) << std::endl;        // T 推断为 int
    std::cout << myMax(3.14, 2.72) << std::endl;  // T 推断为 double
    std::cout << myMax('a', 'z') << std::endl;    // T 推断为 char
}

模板与重载的比较

  • 模板可实现类型无关的算法,一次编写,多次实例化。
  • 重载则针对不同参数类型分别编写,更适用于差异较大的处理逻辑。

运行时多态(动态多态)

运行时多态是面向对象编程的精髓。它允许你使用基类的指针或引用来操作派生类对象 ,并在运行时根据对象的实际类型调用对应的派生类函数。这一特性依赖于虚函数继承

继承与虚函数

核心要素

  1. 继承:派生类继承基类的成员(非私有)。
  2. 虚函数:在基类中使用 virtual 关键字声明的成员函数,允许在派生类中重写(override)。
  3. 基类指针/引用指向派生类对象。
  4. 通过该指针/引用调用虚函数。

示例:动物叫声的多态

cpp 复制代码
#include <iostream>
#include <vector>

class Animal {
public:
    virtual void speak() const {   // 虚函数
        std::cout << "Animal makes a sound." << std::endl;
    }
    virtual ~Animal() {}           // 虚析构函数,稍后解释
};

class Dog : public Animal {
public:
    void speak() const override {  // 重写虚函数(override 可选但推荐)
        std::cout << "Dog barks: Woof!" << std::endl;
    }
};

class Cat : public Animal {
public:
    void speak() const override {
        std::cout << "Cat meows: Meow!" << std::endl;
    }
};

void makeSound(const Animal& animal) {
    animal.speak();   // 运行时多态:实际调用 Dog::speak 或 Cat::speak
}

int main() {
    Dog dog;
    Cat cat;

    makeSound(dog);   // Dog barks: Woof!
    makeSound(cat);   // Cat meows: Meow!

    std::vector<Animal*> zoo;
    zoo.push_back(new Dog);
    zoo.push_back(new Cat);

    for (Animal* a : zoo) {
        a->speak();   // 输出: Woof!  Meow!
        delete a;
    }
}

重写虚函数的条件

  • 派生类中的函数与基类虚函数完全相同(函数名、参数列表、返回类型,协变返回类型除外)。
  • 基类函数必须是虚函数(不写 virtual 则隐藏而非重写)。
  • 使用 override 关键字可让编译器检查是否符合重写条件(C++11 起)。

协变返回类型

如果基类虚函数返回某个类(或指针/引用),而派生类重写函数返回该类的派生类(或指针/引用),则允许返回类型不同,称为协变。

cpp 复制代码
class Base { public: virtual Base* clone() { return new Base(*this); } };
class Derived : public Base { 
public: 
    virtual Derived* clone() override { return new Derived(*this); } 
};

纯虚函数与抽象类

纯虚函数 :在基类中用 = 0 声明的虚函数,如 virtual void draw() = 0;
抽象类 :含有纯虚函数的类,无法实例化,只能作为接口或基类使用。
作用:定义规范,强制派生类实现该函数。

示例:图形接口

cpp 复制代码
class Shape {
public:
    virtual double area() const = 0;   // 纯虚函数
    virtual ~Shape() {}
};

class Circle : public Shape {
    double r;
public:
    Circle(double radius) : r(radius) {}
    double area() const override {
        return 3.14159 * r * r;
    }
};

class Rectangle : public Shape {
    double w, h;
public:
    Rectangle(double w, double h) : w(w), h(h) {}
    double area() const override {
        return w * h;
    }
};

void printArea(const Shape& s) {
    std::cout << "Area: " << s.area() << std::endl;
}

虚析构函数

当通过基类指针删除派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,派生类的资源将无法释放,导致内存泄漏。因此,只要一个类有可能作为基类被继承,就应该将析构函数声明为虚函数

反例(未定义虚析构函数):

cpp 复制代码
class Base { ~Base() {} };
class Derived : public Base { 
    int* p;
public: 
    Derived() { p = new int[100]; }
    ~Derived() { delete[] p; }
};

Base* b = new Derived;
delete b;      // 仅调用 Base::~Base,Derived 的资源未被释放!

正确做法 :给基类添加 virtual ~Base() = default;

底层原理:虚函数表(vtable)与虚指针(vptr)

运行时多态的实现依赖于 C++ 编译器为每个包含虚函数的类建立的虚函数表 以及每个对象内部的虚指针

虚函数表(vtable)

  • 是一个存储函数指针的数组(通常放在只读数据段)。
  • 每个包含虚函数的类都有一个自己的虚函数表,表中按声明顺序存放该类所有虚函数的地址。
  • 派生类如果重写了某个虚函数,则虚函数表中对应条目会被替换为派生类的函数地址;如果没有重写,则保留基类的地址。
  • 通常虚函数表末尾带有一个标记(如 RTTI 相关信息)。

虚指针(vptr)

  • 每个对象实例在内存开头(通常是前8/4个字节)会有一个隐藏的指针,指向所属类的虚函数表。
  • 当通过基类指针调用虚函数时,程序会:
    1. 通过对象的 vptr 找到虚函数表。
    2. 从虚函数表中取出对应槽位的函数指针。
    3. 间接调用该函数。

单继承下的对象内存布局示例(32位系统):

复制代码
class Base {
public:
    virtual void f();
    virtual void g();
    int a;
};

class Derived : public Base {
public:
    virtual void f() override;   // 重写 f
    virtual void h();            // 新增虚函数
    int b;
};

内存布局(以 Visual C++ 风格为例,其他编译器类似但可能不同):

复制代码
Derived 对象:
+--------+
| vptr   |  --> 指向 Derived vtable
+--------+
| a      |  (Base::a)
+--------+
| b      |  (Derived::b)
+--------+

Derived vtable:
+-----------------------+
| &Derived::f           |  (覆盖 Base::f)
+-----------------------+
| &Base::g              |  (未重写,指向基类函数)
+-----------------------+
| &Derived::h           |  (新增虚函数,扩展表)
+-----------------------+
| ... (RTTI)           |
+-----------------------+

构造函数与析构函数中的虚函数

在构造和析构期间,对象的 vptr 会随着构造/析构的进行而改变,指向当前正在构造/析构的类的虚函数表。此时通过对象调用的虚函数不会表现多态,而是调用当前构造函数/析构函数所属类中的版本。这是需要特别注意的陷阱。

cpp 复制代码
class Base {
public:
    Base() { f(); }   // 构造时调用 Base::f,不会调用派生类重写版本
    virtual void f() { std::cout << "Base::f\n"; }
};

class Derived : public Base {
public:
    Derived() { f(); } // 此时 vptr 已指向 Derived 表,调用 Derived::f
    void f() override { std::cout << "Derived::f\n"; }
};

int main() {
    Derived d;
    // 输出:Base::f   Derived::f
}

多继承下的虚函数表

当类从多个基类继承,且这些基类都有虚函数时,情况变得复杂。派生类对象会包含多个虚指针 ,分别指向对应的基类子对象的虚函数表(或者指向一个整合的表,具体取决于编译器实现)。同时,可能还会产生调整 this 指针 的操作,以确保不同基类视角下能正确访问成员。这部分原理较为深奥,但我们可以记住一个简单结论:慎用多继承,尤其在涉及虚函数时,推荐优先使用单一继承加接口类

动态多态的性能与适用场景

优点

  • 代码可扩展性强,符合开闭原则(对扩展开放,对修改关闭)。
  • 非常适合框架设计、插件系统、以及需要通用接口的场景。

代价

  • 一次虚函数调用相当于:两次内存读取(vptr + 函数指针)+ 一次间接调用,相比普通函数调用有微小但可测量的开销。
  • vptr 占用对象额外内存(4/8 字节)。
  • 虚函数通常不能内联,因为调用地址在编译时未知。
  • 虚函数表占据静态存储区。

适用建议

  • 如果性能敏感且调用频繁,考虑静态多态(CRTP 模式)或模板。
  • 如果层次清晰、扩展性更重要,动态多态是合适的选择。

C++11 特性:override 与 final

C++11 引入了两个上下文关键字,极大地改善了虚函数重写的代码安全性和可读性。

override 说明符

作用 :显式标记派生类中的虚函数是对基类虚函数的重写。
好处

  1. 增加代码可读性,明确告知读者这是一个重写函数。
  2. 让编译器进行签名检查:若基类中没有相同签名的虚函数,或签名不匹配(如参数类型、const 限定、协变不符),编译器报错,避免因拼写错误导致意外隐藏而非重写。

错误示例(不加 override 可能隐藏错误):

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

class Derived : public Base {
public:
    void show(double x) {}   // 不同参数类型,并未重写,而是隐藏基类的 show
};

int main() {
    Derived d;
    Base* p = &d;
    p->show(5);   // 调用 Base::show(int),不是多态!
}

使用 override 暴露问题

cpp 复制代码
class Derived : public Base {
public:
    void show(double x) override {} // 编译错误:没有基类虚函数 void show(double)
};

final 说明符

作用

  • 用于虚函数:阻止派生类继续重写该虚函数。
  • 用于类:阻止该类被继承。

示例:禁止重写

cpp 复制代码
class Base {
public:
    virtual void keyFunction() final;   // 派生类不可重写 keyFunction
};

class Derived : public Base {
    void keyFunction() override;       // 错误:无法重写 final 函数
};

示例:禁止继承

cpp 复制代码
class Sealed final { /* ... */ };
class InheritFromSealed : public Sealed { }; // 错误:Sealed 是 final,不可继承

finaloverride 可以同时使用(void func() override final;),表示这是一个重写函数,并且禁止后续重写。

多态的设计思想与应用

多态并不仅仅是一种语法特性,更是一种程序设计方法论。它体现了以下核心设计原则:

  1. 针对接口编程,而非针对实现编程

    多态使得我们可以依赖抽象的基类(接口),而不是具体的派生类。客户代码只需操作抽象基类,增加新类型时无需修改原有代码。

  2. 开闭原则(Open-Closed Principle)

    对扩展开放,对修改关闭。通过多态,新增功能只需添加新的派生类,而不用改动已存在的稳定代码。

  3. 依赖倒置原则

    高层模块不依赖低层模块的具体实现,二者都依赖抽象。多态是实现依赖注入、控制反转的基础。

经典应用场景

  • 图形渲染系统Shape 抽象基类,派生 CircleRectangleTriangle,渲染器统一调用 draw()
  • 策略模式:定义一系列算法,每个算法封装在派生类中,通过多态使算法可相互替换。
  • 工厂方法模式:通过基类指针创建具体产品对象,实现创建逻辑与使用的解耦。
  • 访问者模式:借助双重分发(多态)处理不同元素类型的操作。

多态与继承的组合

在实际工程中,应优先使用组合而非继承。当确实需要"is-a"关系和接口统一时,才使用继承和多态。过度使用继承会导致脆弱的基类问题(Fragile Base Class Problem)。

相关推荐
hello 早上好1 小时前
07_JVM 双亲委派机制
开发语言·jvm
Maguyusi2 小时前
go 批量生成c++和lua proto文件
c++·golang·lua·protobuf
前端程序猿i2 小时前
第 8 篇:Markdown 渲染引擎 —— 从流式解析到安全输出
开发语言·前端·javascript·vue.js·安全
kronos.荒2 小时前
滑动窗口:寻找字符串中的字母异位词
开发语言·python
_codemonster2 小时前
java web修改了文件和新建了文件需要注意的问题
java·开发语言·前端
shentuyu木木木(森)2 小时前
单调队列 & 单调栈
数据结构·c++·算法·单调栈·单调队列
sTone873752 小时前
C++中的引用传参和指针传参
c++
甄心爱学习2 小时前
【python】list的底层实现
开发语言·python
独自破碎E2 小时前
BISHI41 【模板】整除分块
java·开发语言