[C++] 剖析多态的原理及实现

2024-09-18 08:25:43 浏览数 (2)

多态的概念及定义

多态(Polymorphism)是面向对象编程中的一个重要概念,它使得同一个行为可以针对不同类型的对象表现出不同的形态。通俗来讲,多态就是“多种形态”的实现。

根据执行的时机,多态可以分为两种类型:

  1. 编译时多态(静态多态)
  2. 运行时多态(动态多态)

编译时多态(静态多态)

编译时多态,顾名思义,就是在编译期间决定函数调用的行为。它通过以下两种方式实现:

  • 函数重载:同名函数可以根据不同的参数类型或数量,做出不同的实现。
  • 模板:函数模板或类模板能够针对不同的类型参数生成不同的代码。

静态多态的特点是函数调用的解析过程在编译时就完成了。例如,函数重载通过传入不同的参数类型,编译器在编译时选择正确的函数版本。

函数重载示例:

代码语言:javascript复制
void print(int i) {
    std::cout << "Integer: " << i << std::endl;
}

void print(double d) {
    std::cout << "Double: " << d << std::endl;
}

int main() {
    print(10);    // 输出: Integer: 10
    print(3.14);  // 输出: Double: 3.14
    return 0;
}

编译时多态通常称为静态绑定,因为在编译阶段就已经确定了实际调用的函数。这使得编译时多态非常高效,但不具备灵活的运行时决策能力。

运行时多态(动态多态)

运行时多态是在程序运行时,根据实际传入的对象类型来决定函数的具体实现。这种形式的多态依赖于继承虚函数

动态多态的原理

动态多态的核心思想是基类定义了接口(虚函数),而派生类根据自己的需求对这些接口进行不同的实现。在运行时,调用具体派生类的实现,而不是基类的实现。

实现动态多态有两个**必要条件****:**

  1. 基类的函数必须是虚函数,即使用<font style="background-color:#FBDE28;">virtual</font>关键字声明。
  2. 必须通过基类的指针或引用来调用虚函数。
示例:运行时多态

假设有一个“买票”行为,不同类型的对象执行该行为时有不同的表现:

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

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

class Soldier : public Person {
public:
    void BuyTicket() override {
        std::cout << "买票-优先" << std::endl;
    }
};

void BuyTicketForPerson(Person* person) {
    person->BuyTicket();  // 调用具体对象的BuyTicket函数
}

int main() {
    Person p;
    Student s;
    Soldier so;

    BuyTicketForPerson(&p);  // 输出: 买票-全价
    BuyTicketForPerson(&s);  // 输出: 买票-打折
    BuyTicketForPerson(&so); // 输出: 买票-优先
    return 0;
}

在这个例子中,BuyTicketForPerson 函数接收一个基类Person的指针,但是在实际调用时,动态多态会根据具体传入的对象类型,调用派生类的BuyTicket函数。对应不同人群的买票价格不一样。

两种多态的区别

  • 编译时多态:通过函数重载和模板实现,函数调用在编译阶段确定,效率高,但灵活性较低。
  • 运行时多态:通过虚函数和继承实现,基类指针或引用根据实际对象类型调用对应的函数实现,具有更大的灵活性,但需要在运行时进行决策。

多态的实现

基本条件

  1. 通过基类的指针或引用调用虚函数:多态的前提是通过基类的指针或引用来访问派生类对象。只有基类的指针或引用才能够指向不同的派生类对象,并且根据派生类对象的实际类型,决定具体调用哪个函数。
  2. 函数必须是虚函数:要想在运行时根据对象的实际类型调用不同的函数实现,基类中的函数必须声明为虚函数(virtual)。虚函数机制使得调用操作在运行时决定,而不是在编译时。
  3. 派⽣类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派⽣类才能有不同的函数,多态的不同形态效果才能达到

虚函数

虚函数是实现多态的核心。在C 中,类的成员函数前加上virtual关键字,就将其声明为虚函数。虚函数允许派生类重写该函数,并在运行时根据实际对象类型调用具体实现

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

BuyTicket是一个虚函数,允许派生类重写它。

虚函数的重写与覆盖

重写(Override)是指派生类对基类的虚函数提供新的实现。派生类中的虚函数必须和基类虚函数的签名完全相同,即**返回类型、函数名、参数列表**必须一致。只有这样,才能保证派生类重写了基类的虚函数。

**注意:**尽管在派生类中可以省略virtual关键字,但不建议这样做,因为它可能会导致可读性和可维护性的下降。

现在可以分析开头引入多态概念的带代码:

  1. 虚函数的重写
代码语言:javascript复制
class Person {
public:
    virtual void BuyTicket() {
        std::cout << "买票-全价" << std::endl;
    }
};

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

Student类重写了Person类的BuyTicket函数。当通过基类指针或引用调用时,实际执行的是派生类的BuyTicket函数。

  1. 使用虚函数实现多态
代码语言:javascript复制
void Func(Person* ptr) {
    // person* 的ptr会对传入的派生类对象进行切片操作
    // 尽管ptr是Person类型的指针,实际调用的函数由ptr指向的对象决定
    ptr->BuyTicket();
}

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

通过Person类型的指针调用BuyTicket,具体执行的函数取决于指针实际指向的对象。对于Student对象,将调用其重写的BuyTicket函数。

虚函数重写的其他问题

协变

当派生类重写基类的虚函数时,如果基类虚函数返回基类类型的指针或引用,派生类虚函数可以返回派生类类型的指针或引用。这种情况称为协变

代码语言:javascript复制
class A {};
class B : public A {};

class Person {
public:
    virtual A* BuyTicket() {
        std::cout << "买票-全价" << std::endl;
        return nullptr;
    }
};

class Student : public Person {
public:
    B* BuyTicket() override {
        std::cout << "买票-打折" << std::endl;
        return nullptr;
    }
};
析构函数的重写

在使用多态时,基类的析构函数应该声明为虚函数,否则会出现内存泄漏问题。如果基类析构函数不是虚函数,那么通过基类指针删除派生类对象时,只会调用基类的析构函数,派生类的析构函数不会被调用,导致资源无法释放。

虽然基类与派⽣类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成<font style="color:rgb(31,35,41);">destructor</font>,所以基类的析构函数加了<font style="color:rgb(31,35,41);">vialtual</font>修饰,派⽣类的析构函数就构成重写

代码语言:javascript复制
class A {
public:
    virtual ~A() {
        std::cout << "~A()" << std::endl;
    }
};

class B : public A {
public:
    ~B() {
        std::cout << "~B()" << std::endl;
    }
};

int main() {
    A* p = new B;
    delete p;  // 正确调用B的析构函数
    return 0;
}

C 11 中的 overridefinal 关键字

为了防止虚函数重写时出现意外情况,C 11引入了overridefinal关键字。

  • override:确保派生类的函数确实是重写了基类的虚函数。如果函数签名不匹配,编译器会报错。
  • final:用于禁止派生类进一步重写某个虚函数。
代码语言:javascript复制
class Car {
public:
    virtual void Drive() final {
        std::cout << "Car driving" << std::endl;
    }
};

class Benz : public Car {
    // 编译错误,不能重写final函数
    // void Drive() override { std::cout << "Benz driving" << std::endl; }
};

重载、重写和隐藏的对比

重载(Overloading)

重载是指在同一个类中,存在多个同名函数,它们的参数列表不同(参数类型或数量)。重载函数在编译时通过传递给函数的参数类型或数量来确定调用哪个函数。

特点:

  • 发生在同一个作用域中(同一类或同一个函数)。
  • 函数名相同,但参数列表必须不同(类型或数量不同)。
  • 重载与返回值无关,返回值类型不能用于区分重载。
代码语言:javascript复制
class Example {
public:
    void print(int i) {
        std::cout << "Integer: " << i << std::endl;
    }

    void print(double d) {
        std::cout << "Double: " << d << std::endl;
    }

    void print(std::string s) {
        std::cout << "String: " << s << std::endl;
    }
};

print函数被重载了三次,分别接受intdoublestd::string类型的参数。调用时,根据参数类型选择相应的print函数。

重写(Overriding)

重写是指在继承关系中,派生类对基类的虚函数重新实现。当基类中有虚函数时,派生类可以重写该虚函数,从而在运行时根据实际对象的类型调用对应的函数实现。

特点

  • 发生在继承层次结构中。
  • 基类中的函数必须是虚函数virtual),且派生类的函数与基类虚函数具有相同的签名(即返回值、参数列表必须一致)。
  • 运行时根据对象的实际类型调用对应的派生类或基类函数,实现动态多态
  • 派生类函数可以使用override关键字明确表示重写。
代码语言:javascript复制
class Base {
public:
    virtual void show() {
        std::cout << "Base class" << std::endl;
    }
};

class Derived : public Base {
public:
    void show() override {
        std::cout << "Derived class" << std::endl;
    }
};

Derived类重写了Base类的show函数。当通过基类指针调用show函数时,具体调用哪个函数取决于实际的对象类型。

隐藏(Hiding)

隐藏是指在派生类中定义了一个与基类同名但非虚的函数,此时基类的同名函数会被隐藏。隐藏的函数在派生类中无法通过对象或指针访问,除非显式地使用作用域解析符调用基类版本的函数。

特点

  • 发生在继承层次结构中。
  • 隐藏的函数与重写不同,隐藏的函数不是虚函数,因此不会参与动态多态机制。
  • 派生类函数的签名可以与基类相同,也可以不同,但一旦存在同名函数,基类函数就会被隐藏。
  • 可以通过基类的作用域解析符调用基类函数。
代码语言:javascript复制
class Base {
public:
    void show() {
        std::cout << "Base class" << std::endl;
    }
};

class Derived : public Base {
public:
    void show(int i) {
        std::cout << "Derived class with int: " << i << std::endl;
    }
};

Derived类的show(int i)函数隐藏Base类的show()函数。通过Derived类的对象,无法调用Base类的show()函数。

如果需要访问基类函数,可以如下使用:

代码语言:javascript复制
Derived d;
d.show(10);          // 调用 Derived 类的 show(int)
d.Base::show();      // 调用 Base 类的 show()
画板画板

纯虚函数和抽象类

纯虚函数(Pure Virtual Function)

在C 中,虚函数后加= 0,就将该函数声明为纯虚函数。纯虚函数没有具体实现,只提供接口,要求派生类必须实现该函数。通过纯虚函数,C 允许程序设计者定义一个抽象的接口,并要求任何继承该接口的类必须实现这些接口方法。

  • 定义:虚函数在声明时,末尾加= 0,表明它是一个纯虚函数,无法在基类中实现。
  • 特点:纯虚函数只需要声明,不需要定义。
代码语言:javascript复制
class Car {
public:
    virtual void Drive() = 0;  // 纯虚函数
};

Car类不能直接实例化,因为它包含了纯虚函数,必须由派生类来实现。

抽象类(Abstract Class)

抽象类是指包含一个或多个纯虚函数的类。抽象类不能被实例化,必须通过派生类进行实例化。抽象类的作用是为派生类提供统一的接口,使得多个派生类可以通过相同的接口进行调用,从而实现多态。

  • 特点:抽象类不能被直接实例化,它只能作为基类存在。
  • 派生类要求:派生类必须实现抽象类中的所有纯虚函数,否则派生类也将成为抽象类,无法实例化。
代码语言:javascript复制
class Car {
public:
    virtual void Drive() = 0;  // 纯虚函数
};

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

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

int main() {
    // Car car; // 错误,Car是抽象类,不能实例化

    Car* pBenz = new Benz;
    pBenz->Drive();  // 输出: Benz-舒适

    Car* pBMW = new BMW;
    pBMW->Drive();   // 输出: BMW-操控

    delete pBenz;
    delete pBMW;
    return 0;
}

Car是一个抽象类,因为它包含了纯虚函数DriveBenzBMW继承自Car,并且实现了Drive函数。因此,BenzBMW对象可以通过Car类型的指针实现多态调用。

多态的原理

虚函数表指针(vptr)

每个包含虚函数的对象都有一个隐藏的指针,称为虚函数表指针(vptr)。该指针指向该类的虚函数表,虚函数表中存储了类中虚函数的地址。

  • vptr的作用:vptr用于指向对象的虚函数表,帮助程序在运行时通过虚函数表找到具体的函数实现。
  • vptr的存储位置:vptr通常位于对象内存布局的开头,但这取决于编译器的实现。在某些平台上,vptr可能会位于对象的最后。

虚函数表指针用来指向当前对象对应的虚函数表(虚表)

多态实现的原理

如何实现多态?
代码语言:javascript复制
class Person {
public:
    virtual void BuyTicket() {
        std::cout << "买票-全价" << std::endl;
    }
};

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

class Soldier : public Person {
public:
    void BuyTicket() override {
        std::cout << "买票-优先" << std::endl;
    }
};

void Func(Person* ptr) {
    ptr->BuyTicket();  // 通过指针调用虚函数
}

int main() {
    Person ps;
    Student st;
    Soldier sr;

    Func(&ps);  // 输出: 买票-全价
    Func(&st);  // 输出: 买票-打折
    Func(&sr);  // 输出: 买票-优先
    return 0;
}

Person*类型的ptr作为Func的形参,用来指向接受的对象并进行切片。当运行的时候,多态展现出来,并不是使用PersonBuyTicket。通过上述图片发现当每次将不同类型传入Funcptr调用的都是接受的那个对象的类的BuyTicket。这样就是实现了指针或引用指向基类或者派生类直接调用指向类的虚函数。实现了多态。

动态绑定和静态绑定
  • 静态绑定:编译器在编译时已经确定了函数调用的地址,通常用于普通函数(不满足多态条件)。由于函数地址在编译时已经确定,静态绑定非常高效。
  • 动态绑定:程序在运行时根据对象的实际类型确定函数的调用地址,通常用于虚函数。这种方式提供了极大的灵活性,但运行时效率相对静态绑定较低。
代码语言:javascript复制
// ptr是指针 BuyTicket是虚函数满⾜多态条件。

// 这⾥就是动态绑定,编译在运⾏时到ptr指向对象的虚函数表中确定调⽤函数地址
ptr->BuyTicket();

00EF2001 mov eax,dword ptr [ptr]
00EF2004 mov edx,dword ptr [eax]
00EF2006 mov esi,esp
00EF2008 mov ecx,dword ptr [ptr]
00EF200B mov eax,dword ptr [edx]
00EF200D call eax

// BuyTicket不是虚函数,不满⾜多态条件。
// 这⾥就是静态绑定,编译器直接确定调⽤函数地址
ptr->BuyTicket();

00EA2C91 mov ecx,dword ptr [ptr]
00EA2C94 call Student::Student (0EA153Ch)
虚函数表
  • 基类的虚函数表:基类的虚表中存放该类所有虚函数的地址。当基类中的虚函数未被派生类重写时,派生类的虚表会继承这些地址。
  • 派生类的虚函数表:当派生类重写了基类的虚函数,派生类的虚表中的相应条目会替换为派生类的虚函数地址。派生类的虚表包含三类地址:

  1. 基类的虚函数地址:未被派生类重写的基类虚函数。
  2. 重写的虚函数地址:派生类对基类虚函数的重写。
  3. 派生类特有的虚函数地址:派生类定义的独有虚函数。
    1. 派生类独有的虚函数地址被存放在虚函数表的最后
    2. 如果一个派生类继承了多个有虚函数的类,一个类对应一个虚函数表,派生类独有的虚函数地址存放在第一个虚函数表的后面。
  • 派生类的虚函数表与基类相独立
  • 派⽣类中重写的基类的虚函数,派⽣类的虚函数表中对应的虚函数就会被覆盖成派⽣类重写的虚函 数地址。
  • 虚函数表的底层工作原理
    • 当通过基类指针调用虚函数时,程序会:
  1. 通过对象的vptr访问该对象的虚函数表。
  2. 在虚表中找到对应函数的地址。
  3. 通过动态绑定机制)调用函数,函数的具体实现取决于虚表中存储的地址。

虚函数表是一个由虚函数指针构成的数组,虚表的最后可能会存储一个标记(如0x00000000),用来表示数组的结束(不同的编译器可能会有不同的实现)。

  • 虚函数存储在哪?

虚函数和普通函数一样,编译后会变成一段机器指令,并被存储在代码段(Code Segment)中。虚函数表本质上是指向这些指令的指针数组。

代码语言:javascript复制
 ------------------------- 
|       代码段            |   --> 存储所有的函数(包括虚函数)的指令代码
 ------------------------- 
|    只读数据段/常量区    |   --> 存储常量字符串、只读数据(如虚表)
 ------------------------- 
|       全局数据区        |   --> 存储全局和静态变量
 ------------------------- 
|       堆(Heap)        |   --> 动态分配的对象(如new分配的对象)
 ------------------------- 
|       栈(Stack)       |   --> 存储局部变量和函数调用的上下文
 ------------------------- 

练习题

多态场景的⼀个选择题

以下程序输出结果是什么( ) A:A->0B:B->1C:A->1D:B->0E:编译出错F:以上都不正确

代码语言:javascript复制
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();

    return 0;
}
  1. 虚函数与默认参数
    • 在C 中,默认参数的绑定是在编译时完成的,而虚函数调用的解析是在运行时完成的。尽管函数调用的解析在运行时根据对象的类型调用了B类的func,但是默认参数的值是在编译时绑定的,它依然使用了基类**A**的默认参数值
  2. 详细过程
    • 当调用p->test()时,程序首先执行test函数,这个函数是A类中的虚函数。
    • test()函数内部调用了func(),由于func是虚函数,调用的是B类重写的func
    • 虽然B类的func函数被调用了,但是默认参数val是在编译时绑定的,所以val的值仍然是基类**A**的默认值**1**,而不是B类中的0
    • 因此,func()的输出是B->1
执行流程:
  1. 调用p->test()
    • 调用的是A类中的test函数,执行test()中的func()调用。
  2. func()是虚函数,实际调用的是B类的func
  3. 虽然调用了B类的func,但是由于默认参数val在编译时已绑定为1(基类A的默认参数),所以输出B->1

或者说:重写的是函数体部分

0 人点赞