# 一、概述

C++ 中的多态性(Polymorphism)是面向对象编程的一个关键概念,它允许对象以不同的方式呈现相同的接口。C++ 中的多态性可以分为静态多态性(静态多态)和动态多态性(动态多态),它们有不同的实现方式和特点:

# 1、静态多态性(静态多态)

静态多态性是在编译时(编译期间)解析的多态性,也称为编译时多态性。它是通过函数的重载运算符重载来实现的。
在静态多态性中,编译器在编译时根据函数参数的类型和数量来确定调用哪个函数或运算符。
例如,函数重载允许你定义多个具有相同名称但不同参数列表的函数,编译器会根据调用时的参数类型来选择正确的函数。

1
2
3
4
5
6
7
void print(int num) {
std::cout << "Printing an integer: " << num << std::endl;
}

void print(double num) {
std::cout << "Printing a double: " << num << std::endl;
}

静态多态性的特点是效率高,因为在编译时已经确定了函数的调用,不需要在运行时进行额外的查找和判断。

# 2、动态多态性(动态多态)

动态多态性是在运行时解析的多态性,也称为运行时多态性。它是通过继承虚函数来实现的。
在动态多态性中,子类对象可以以基类的指针或引用来调用虚函数,而实际执行的函数取决于对象的类型。这种机制允许在运行时选择正确的函数,使得程序更灵活。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Shape {
public:
virtual void draw() {
std::cout << "Drawing a shape" << std::endl;
}
};

class Circle : public Shape {
public:
void draw() override {
std::cout << "Drawing a circle" << std::endl;
}
};

在动态多态性中,使用基类指针或引用来调用虚函数时,实际执行的函数取决于对象的类型。这种机制在面向对象编程中非常重要,因为它允许你编写通用的代码,以处理不同子类的对象。

# 3、动态多态示例

但我们常说的多态一般都是指的动态多态

C++ 的多态是面向对象编程的一个重要概念,通过多态性,可以实现基类指针或引用调用子类对象的方法,实现动态绑定,使程序可以根据实际对象的类型来选择正确的方法执行。

在 C++ 中,实现多态性主要依赖于两个机制:虚函数基类指针 / 引用

  1. 虚函数(Virtual Function):在基类中声明虚函数,允许子类对其进行重写。通过在函数声明前加上关键字 virtual ,可以将函数声明为虚函数。虚函数使得基类指针或引用在运行时可以根据实际对象的类型来调用相应的子类函数。基类的虚函数可以在子类中进行重写(override),即子类可以提供自己的实现。通过虚函数,可以实现动态绑定,使得程序在运行时根据对象的实际类型来确定调用的具体函数。
  2. 基类指针 / 引用:基类指针或引用可以指向子类对象,并且可以通过基类指针或引用调用虚函数。这样做的好处是,可以在编译时使用基类指针或引用,而在运行时确定实际调用的函数。当基类指针或引用指向子类对象时,如果该基类中存在虚函数并且被子类重写了,那么调用该函数时会根据对象的实际类型来选择具体的实现。

通过虚函数和基类指针 / 引用的组合使用,可以实现多态性。当程序通过基类指针或引用调用虚函数时,会根据实际对象的类型来选择具体的函数实现,从而实现了多态。这种多态性使得程序具有更高的灵活性和可扩展性,可以处理不同类型的对象,而不需要在编译时明确知道对象的具体类型。

下面是一个简单的示例代码,演示了 C++ 中的多态性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>

class Shape {
public:
virtual void draw() {
std::cout << "Drawing a shape." << std::endl;
}
};

class Circle : public Shape {
public:
void draw() override {
std::cout << "Drawing a circle." << std::endl;
}
};

class Rectangle : public Shape {
public:
void draw() override {
std::cout << "Drawing a rectangle." << std::endl;
}
};

int main() {
Shape* shape1 = new Circle();
Shape* shape2 = new Rectangle();

shape1->draw(); // 调用 Circle 的 draw 函数
shape2->draw(); // 调用 Rectangle 的 draw 函数

delete shape1;
delete shape2;

return 0;
}

在上述代码中,基类 Shape 声明了虚函数 draw() ,并在两个子类 CircleRectangle 中进行了重写。在 main 函数中,通过基类指针 shape1shape2 分别指向 CircleRectangle 对象,并通过调用虚函数 draw() 实现了多态性。在运行时,根据实际对象的类型来选择调用的具体函数实现。

提问:为什么要用基类指针指向子类对象呢,不能用子类指针去接住 new 出来的子类对象吗?

使用基类指针指向子类对象是为了实现多态性。多态性允许我们通过基类指针或引用来操作不同的子类对象,从而提供更大的灵活性和可扩展性。

当我们使用基类指针指向子类对象时,编译器会将对象的类型信息视为基类类型。这样做的好处是,我们可以在编译时使用基类指针,而在运行时根据实际对象的类型来选择调用相应的函数。这使得我们可以编写通用的代码,处理不同类型的对象,而不需要显式地指定对象的具体类型。

如果我们使用子类指针接收 new 出来的子类对象,那么我们只能访问子类中定义的成员和方法,无法访问基类中定义的成员和方法。 这将导致代码的局限性,不便于处理多个不同类型的对象。而通过使用基类指针,我们可以统一处理不同类型的对象,并且根据实际对象的类型来选择调用相应的函数,实现了多态性。

因此,使用基类指针指向子类对象是为了充分利用多态性的特性,实现代码的灵活性和可扩展性。

# 二、C++ 的动态绑定

当使用基类指针或引用指向派生类对象时,C++ 中的动态绑定(也称为运行时多态)允许根据实际对象的类型来确定要调用的函数,而不仅仅根据指针或引用的类型。

动态绑定通过虚函数虚函数表实现。
在基类中声明一个函数为虚函数时,可以使用 virtual 关键字,如下所示:

1
2
3
4
5
6
class Base {
public:
virtual void foo() {
// 实现基类的 foo 函数
}
};

在派生类中重写基类的虚函数时,可以使用 override 关键字,明确表示对基类虚函数的重写:

1
2
3
4
5
6
class Derived : public Base {
public:
void foo() override {
// 实现派生类的 foo 函数
}
};

当我们通过基类指针或引用调用虚函数时,编译器会根据指针或引用的类型来查找虚函数表,并根据实际对象的类型选择调用相应的函数。这样,在运行时,程序会动态地绑定(根据对象的类型)调用的函数,从而实现了多态性。

例如:

1
2
Base* ptr = new Derived();  // 使用基类指针指向派生类对象
ptr->foo(); // 根据实际对象的类型选择调用 Derived 类的 foo 函数

在上述示例中,虽然 ptr 是基类类型的指针,但由于其指向的是派生类对象,因此在运行时会调用派生类 Derivedfoo 函数。这就是动态绑定的作用。

动态绑定的优点是可以在运行时根据对象的实际类型来决定调用哪个函数,实现了多态性和灵活性。它使得基类指针或引用能够处理不同类型的对象,而不需要显式指定对象的具体类型。这为实现代码的可扩展性和维护性提供了便利。

# 虚函数指针、虚函数表

当一个类中包含虚函数时,编译器会为该类创建一个虚函数表(vtable),其中存储了各个虚函数的地址。虚函数表是一个数组,每个元素对应一个虚函数,它们按照声明顺序排列。

当类被实例化为对象时,该对象会存储一个指向虚函数表的指针,通常称为虚函数指针(vptr)。虚函数指针位于对象的内存布局的开头部分,它指向该对象所属类的虚函数表。

在运行时,当通过基类指针或引用调用虚函数时,实际发生的是通过虚函数指针找到对应的虚函数表,再根据虚函数的索引或偏移量来调用具体的函数。这就是动态绑定的过程。

具体步骤如下:

  1. 当基类对象被创建时,虚函数指针(vptr)被初始化为指向基类的虚函数表。
  2. 当派生类对象被创建时,首先会调用基类的构造函数,此时基类的虚函数指针被初始化为指向基类的虚函数表。然后再调用派生类的构造函数,此时派生类的虚函数指针被初始化为指向派生类的虚函数表。
  3. 当通过基类指针或引用调用虚函数时,首先会根据虚函数指针找到对应的虚函数表。
  4. 在虚函数表中,根据虚函数的索引或偏移量找到要调用的具体函数,并执行该函数。

通过虚函数表和虚函数指针的配合,实现了在运行时根据对象的实际类型动态绑定调用的函数,即动态多态(dynamic polymorphism)。这意味着我们可以通过基类指针或引用调用虚函数,而实际执行的是派生类中的重写函数。

动态绑定的机制使得程序能够根据对象的实际类型选择正确的函数实现,实现了多态性和灵活性,同时提高了代码的可扩展性和维护性。

每个类只有一个虚函数表,它是类的静态成员之一虚函数表存储在内存中的一个只读数据区,通常是在程序的可执行代码段中。

每个对象都包含一个虚函数指针(vptr),该指针指向所属类的虚函数表。虚函数指针位于对象的内存布局的开头部分,一般是对象的头部或前几个字节。当对象被创建时,虚函数指针被初始化为指向所属类的虚函数表。

虚函数表(vtable)存在于类的级别上,每个类只有一个虚函数表。虚函数指针(vptr)则存在于类的每个对象上。每个对象都包含一个虚函数指针,用于指向所属类的虚函数表。

虚函数指针在对象的内存布局中的开头部分,而虚函数表在内存的一个只读数据区,通常是在程序的可执行代码段中。通过虚函数指针,每个对象可以在运行时访问所属类的虚函数表,从而实现动态绑定和多态性的特性。

# 三、相关问题

# i、虚函数与构造函数

构造函数可以是虚函数吗?

在 C++ 中,构造函数不能被声明为虚函数。构造函数的调用是在对象创建时自动发生的,对象的动态类型在构造函数执行期间还没有确定,因此虚函数机制无法应用到构造函数上。因此,构造函数不能是虚函数。

于是乎,进一步地,要避免在构造函数中调用虚函数。如果在构造函数中调用了虚函数,并且在构造函数执行期间这些虚函数被调用,那么这些虚函数的调用将不会按照预期方式派发到子类的实现上,而是会直接调用基类的实现。这可能会导致一些问题和混乱。

原因在于,在对象构造过程中,对象的类型是由其静态类型确定的,而不是动态类型。在构造函数执行期间,对象的动态类型尚未被初始化,因此调用虚函数时只能根据对象的静态类型进行派发,即使对象最终可能会成为某个子类的实例。

由于这种行为可能导致不一致的结果,所以在构造函数中避免调用虚函数是一个良好的实践。如果需要在构造函数中执行某些特定的操作,最好将这些操作设计成非虚函数,并在子类中进行覆盖或重载,以确保正确的行为。

# i、纯虚函数的继承与覆写

提问:在 C++ 中,子类必须实现父类的纯虚函数吗?

在 C++ 中,如果一个类拥有纯虚函数(即在函数声明后面加上 = 0),那么任何派生类必须实现这个纯虚函数,否则派生类也会成为一个抽象类,无法被实例化。(从这个角度上来看,也不是必须,只不过派生类仍然是抽象类罢了,不排除故意设计如此。)

纯虚函数是一种在基类中声明但没有具体实现的虚函数。它的存在要求派生类必须提供自己的实现,使得每个派生类都能为这个函数赋予特定的行为。

以下是简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>

class Shape {
public:
virtual void draw() const = 0; // 纯虚函数
};

class Circle : public Shape {
public:
void draw() const override {
std::cout << "Drawing a circle." << std::endl;
}
};

class Rectangle : public Shape {
public:
void draw() const override {
std::cout << "Drawing a rectangle." << std::endl;
}
};

int main() {
// Shape shape; // 错误!抽象类不能被实例化

Circle circle;
Rectangle rectangle;

circle.draw(); // 输出:Drawing a circle.
rectangle.draw(); // 输出:Drawing a rectangle.

return 0;
}

在上述示例中, Shape 类是一个抽象类,它包含一个纯虚函数 draw() ,因此不能被实例化。 CircleRectangle 类是 Shape 类的派生类,并且它们必须实现 draw() 函数,否则它们也会成为抽象类。在 main() 函数中,我们实例化了 CircleRectangle 对象,并调用了它们的 draw() 函数,分别输出了相应的结果。

# i、纯虚函数的类外实现

提问:C++ 中,类中的某个函数被设为纯虚函数了,它还可以在该类中定义该函数的实现吗?

在 C++ 中,当一个类中的函数被声明为纯虚函数后,意味着该函数没有实际的实现,类被认为是抽象类,无法被实例化。抽象类的主要目的是提供一组接口或协议,规定了派生类必须实现的纯虚函数。

纯虚函数在类的声明中使用 = 0 来表示。例如:

1
2
3
4
class Shape {
public:
virtual void draw() const = 0; // 纯虚函数,没有实际实现
};

因为纯虚函数没有实现,所以抽象类不能创建对象,也不能直接在抽象类中定义该函数的实现。

== 但是!== 如果您希望在抽象类中为纯虚函数提供默认实现,可以在类外部定义纯虚函数的实现。这样做的目的是为了提供一个默认实现,以便在派生类没有实现该函数时,可以调用抽象类中的默认实现。

例如:

1
2
3
4
5
6
7
8
9
10
11
class Shape {
public:
virtual void draw() const = 0; // 纯虚函数,没有实际实现
virtual ~Shape() {}
};

// 在类外部提供纯虚函数的默认实现
void Shape::draw() const {
// 默认实现代码
// ...
}

注意,在派生类中如果实现了纯虚函数,则该函数不再是纯虚函数,派生类可以被实例化。如果派生类没有实现纯虚函数,它仍然是抽象类,无法被实例化。

总结:纯虚函数没有实际的实现,抽象类中不能定义纯虚函数的实现。但可以在类外部为纯虚函数提供默认实现,以便在派生类中提供默认行为。

更新于

请我喝杯咖啡吧~

Rick 微信支付

微信支付

Rick 支付宝

支付宝