作者 青鸟

多态的原理

原理

虚函数是在面向对象编程中用于实现多态性的一种机制。它允许通过基类的指针或引用调用派生类中重写的函数,实现运行时的动态绑定。

虚函数的原理涉及以下几个关键概念:

  1. 虚函数表(Virtual Function Table):每个包含虚函数的类都会生成一个虚函数表,也称为 vtable。虚函数表是一个特殊的数据结构,它由编译器创建并存储在内存中。虚函数表中存储了指向每个虚函数的函数指针。

  2. 虚函数指针(Virtual Function Pointer):每个对象中都包含一个隐藏的指针,称为虚函数指针(vptr)。虚函数指针是一个指向虚函数表的指针。编译器在对象的内存布局中添加虚函数指针,它指向对象所属类的虚函数表。

  3. 虚函数的声明与重写:在基类中,将要在派生类中重写的函数声明为虚函数。这通过在函数声明前面加上 virtual 关键字来实现。派生类可以选择是否重写基类的虚函数,并使用 override 关键字进行标注。

  4. 动态绑定:当使用基类的指针或引用调用虚函数时,编译器会通过虚函数指针找到相应的虚函数表,并从中获取正确的函数指针来执行函数调用。这个过程称为动态绑定或后期绑定。编译器在运行时根据对象的实际类型来确定应该调用的函数,而不是根据指针或引用的类型。

  5. 调用虚函数:当使用基类的指针或引用调用虚函数时,编译器通过虚函数指针找到对象所属类的虚函数表,并从中获取正确的函数指针来执行函数调用。编译器在运行时根据对象的实际类型确定应该调用的函数,而不是根据指针或引用的类型。

通过虚函数的机制,可以实现基类的指针或引用以统一的方式处理不同派生类的对象,并根据对象的实际类型调用正确的重写函数。这实现了多态性和动态绑定的特性,使得程序能够在运行时根据实际情况进行适当的函数调用。

需要注意的是,虚函数机制只适用于通过指针或引用访问对象,而不适用于直接访问对象。直接访问对象时,编译器会根据对象的静态类型来确定调用的函数,而不会发生动态绑定。

虚函数表(Virtual Function Table)

虚函数表(Virtual Function Table)是用于实现虚函数机制的重要数据结构。每个包含虚函数的类都会生成一个虚函数表,也称为 vtable。

虚函数表是一个特殊的数据结构,由编译器在编译时创建并存储在内存中。它是一个指向虚函数的函数指针数组。虚函数表存储了类中所有虚函数的地址,按照它们在类中声明的顺序排列。

下面是关于虚函数表的几个关键点:

  1. 单独的虚函数表:每个类都有自己独立的虚函数表。虚函数表是静态的,与类的实例无关,只在类的定义时生成一次,并在整个程序的运行期间保持不变。

  2. 存储虚函数地址:虚函数表中的每个元素都是一个指向对应虚函数的函数指针。这些指针存储了虚函数的地址,使得在运行时能够动态绑定调用正确的函数。

  3. 虚函数表的位置:虚函数表通常存储在类的静态数据区,与类的实例分开存储。每个类的实例会在其内部存储一个隐藏的指针,称为虚函数指针(vptr),指向对应类的虚函数表。

  4. 继承和多级派生:派生类的虚函数表会包含基类的虚函数,并在其后添加自己的虚函数。这样,派生类的虚函数表会继承基类的虚函数,并根据需要添加或重写自己的虚函数。

  5. 动态绑定:通过虚函数表,编译器能够根据对象的实际类型,在运行时确定应该调用的虚函数。虚函数指针通过对象的内存布局找到对应的虚函数表,并从中获取正确的函数地址进行调用。

虚函数表是实现动态绑定和多态性的关键所在。它允许基类的指针或引用以统一的方式处理不同派生类的对象,并根据对象的实际类型调用正确的虚函数。这种机制在面向对象编程中非常重要,使得代码具有灵活性、可扩展性和可维护性。

虚函数指针(Virtual Function Pointer)

虚函数指针(Virtual Function Pointer)是一种特殊的指针,用于实现虚函数机制和动态绑定。每个对象中都包含一个隐藏的虚函数指针。

虚函数指针具有以下几个关键特点:

  1. 对象内存布局:编译器在每个对象的内存布局中添加了一个隐藏的虚函数指针(vptr)。这个指针通常是一个指向虚函数表(vtable)的指针。

  2. 指向虚函数表:虚函数指针指向对象所属类的虚函数表。虚函数表是一个函数指针数组,其中存储了类中所有虚函数的地址。

  3. 动态绑定:通过虚函数指针,编译器能够在运行时根据对象的实际类型确定应该调用的虚函数。当通过基类的指针或引用调用虚函数时,编译器会根据对象的虚函数指针找到对应的虚函数表,并从中获取正确的函数地址进行调用。这实现了动态绑定或后期绑定的特性。

  4. 继承关系:派生类的对象中的虚函数指针继承自基类。这意味着派生类的虚函数指针仍然指向派生类所属的虚函数表,而不是基类的虚函数表。这样可以保证在通过基类指针或引用调用虚函数时,调用的是正确的派生类重写的函数。

通过虚函数指针的机制,基类的指针或引用可以以统一的方式处理不同派生类的对象,并根据对象的实际类型调用正确的虚函数。这使得多态性和动态绑定成为可能,为面向对象编程提供了灵活性和可扩展性。

需要注意的是,虚函数指针的操作是由编译器在底层进行管理的,通常不需要手动操作虚函数指针。这个机制是由编译器自动生成和维护的,以支持虚函数的动态调用。

动态绑定(Dynamic Binding)

动态绑定(Dynamic Binding),也称为后期绑定或运行时绑定,是通过虚函数实现多态性的关键机制之一。

动态绑定的核心思想是,在编译时无法确定具体调用哪个函数,而是在运行时根据对象的实际类型来确定应该调用的函数。这使得基类的指针或引用可以在运行时表现出多态性,并调用派生类中重写的函数。

以下是动态绑定的工作原理:

  1. 虚函数的声明与重写:在基类中,将要在派生类中重写的函数声明为虚函数。这通过在函数声明前面加上 virtual 关键字来实现。派生类可以选择是否重写基类的虚函数,并使用 override 关键字进行标注。

  2. 虚函数表(Virtual Function Table):每个包含虚函数的类都会生成一个虚函数表,也称为 vtable。虚函数表是一个特殊的数据结构,由编译器创建并存储在内存中。虚函数表中存储了指向每个虚函数的函数指针。

  3. 虚函数指针(Virtual Function Pointer):每个对象中都包含一个隐藏的指针,称为虚函数指针(vptr)。虚函数指针是一个指向虚函数表的指针。编译器在对象的内存布局中添加虚函数指针,它指向对象所属类的虚函数表。

  4. 调用虚函数:当使用基类的指针或引用调用虚函数时,编译器通过虚函数指针找到对象所属类的虚函数表,并从中获取正确的函数指针来执行函数调用。编译器在运行时根据对象的实际类型确定应该调用的函数,而不是根据指针或引用的类型。

动态绑定使得程序能够以统一的方式处理不同派生类的对象,并根据对象的实际类型调用正确的重写函数。这种灵活性和多态性为面向对象编程提供了强大的功能,使得代码更具可扩展性和可维护性。

需要注意的是,动态绑定仅适用于通过指针或引用访问对象。直接访问对象时,编译器会根据对象的静态类型来确定调用的函数,而不会发生动态绑定。

调用虚函数的过程

调用虚函数的过程涉及动态绑定和虚函数表的使用。下面是调用虚函数的一般过程:

  1. 定义虚函数:在基类中,将要在派生类中重写的函数声明为虚函数。这通过在函数声明前面加上 virtual 关键字来实现。派生类可以选择是否重写基类的虚函数,并使用 override 关键字进行标注。

  2. 创建对象:创建基类或派生类的对象。对象可以通过栈上的自动变量、堆上的动态分配或静态变量的方式创建。

  3. 虚函数表和虚函数指针:每个对象的内存布局中都包含一个隐藏的虚函数指针(vptr)。虚函数指针指向对象所属类的虚函数表。

  4. 动态绑定:当通过基类的指针或引用调用虚函数时,编译器根据对象的虚函数指针找到对应类的虚函数表,并从中获取正确的函数地址。

  5. 调用函数:编译器使用函数地址调用虚函数,实现动态绑定。调用的是对象的实际类型中重写的函数,而不是指针或引用的静态类型中的函数。

总结起来,调用虚函数的过程可以概括为:通过虚函数指针找到对象所属类的虚函数表,根据函数在虚函数表中的位置获取函数地址,最终调用对应的虚函数。这个过程实现了在运行时根据对象的实际类型来决定调用的函数,实现了多态性和动态绑定的特性。

需要注意的是,虚函数的调用开销比普通函数的调用要稍高,因为它需要额外的查找虚函数表和函数指针的过程。但这种开销通常可以忽略不计,并且通过多态性带来的灵活性和可维护性往往是更为重要的考虑因素。

C++多态为什么只有指针或引用能实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include<iostream>
using namespace std;

class a {
public:
	virtual void print() { cout << 1; }
};

class b :public a{
public:
	void print() override { cout << 2; }
};

int main() {
	a x1 = b();
	x1.print();
	a* x2 = new b();
	x2->print();
}

输出结果:

1
2
1
2

在代码中,定义了两个类 abba 的子类,并重写了 print() 方法。

main() 函数中,你创建了一个 a 类对象 y,并将其初始化为 b 类的实例。然后调用 y.print() 方法。

由于 ya 类对象,即使它实际上引用的是 b 类的实例,由于对象切割(object slicing)的原因,它只会调用 a 类中的 print() 方法。因此,输出结果将是 1

接下来,你创建了一个 b 类的对象 x,并通过 a 类的指针来引用它,即 a* x = new b()。然后调用 x->print() 方法。

由于 print() 方法在 a 类中被声明为虚函数,并在 b 类中进行了重写,所以这里会发生多态性,动态绑定将根据对象的实际类型选择正确的方法。因此,输出结果将是 2

在代码中,普通对象和指针对象的效果不同是因为对象切割(object slicing)的原因。

当你创建一个对象时,例如 a y = b();,通过对象赋值的方式将 b 类的实例赋给 a 类的对象 y。这种情况下,会发生对象切割。对象切割会导致只有父类的成员被复制到子类对象中,子类特有的成员会被丢失。因此,虽然 y 实际上引用了 b 类的实例,但它只能调用父类 a 中的方法,而无法调用子类 b 中的重写方法。这就是为什么 y.print() 输出结果是 1

相反,当你使用指针来处理对象时,例如 a* x = new b();,通过使用基类指针 a* 来指向 b 类的实例。基类指针可以多态地引用派生类对象,并根据对象的实际类型调用相应的方法。因此,当你调用 x->print() 时,由于 print() 方法被声明为虚函数,并且在 b 类中进行了重写,所以动态绑定将会选择正确的方法,输出结果为 2

因此,普通对象和指针对象的效果不同是因为普通对象发生了对象切割,丢失了子类特有的成员和重写的方法,而指针对象可以通过动态绑定实现多态性,调用正确的重写方法。

可以在《深度探索C++对象模型》中找到答案:

  “一个pointer或一个reference之所以支持多态,是因为它们并不引发内存任何“与类型有关的内存委托操作; 会受到改变的。只有它们所指向内存的大小和解释方式 而已”

对这句话解释就是: 指针和引用类型只是要求了基地址和这种指针所指对象的内存大小,与对象的类型无关,相当于把指向的内存解释成指针或引用的类型。而把一个派生类对象直接赋值给基类对象,就牵扯到对象的类型问题,编译器就会回避之前的的虚机制。从而无法实现多态。

也就是说只有指针和引用可以实现动态绑定,所谓的动态绑定就是指静态类型和动态类型不同。(静态类型指的是在编译时就确定了的数据类型,动态类型指的是变量表示的对象在内存中实际的类型)指针或者引用只改变所指向对象的实际大小(事实上是内存分割),而将派生类赋值给基类发生了向上转型(也就是对象切割),x1的动态类型与静态类型一致都是a。

对象切割(Object Slicing)

这里也总结一下对象切割的芝士

对象切割(Object Slicing)是指在将派生类对象赋值给基类对象或传递派生类对象给基类对象时,会发生基类部分的成员和行为被复制而派生类特有的成员和行为被丢失的情况。

对象切割通常发生在以下情况下:

  1. 基类对象赋值为派生类对象:当你使用基类对象来接收派生类对象时,只有基类部分的成员和行为被复制到基类对象中,而派生类特有的成员和行为会被丢失。这是因为基类对象无法容纳派生类的额外成员。

  2. 基类引用或指针指向派生类对象:当你使用基类的引用或指针来引用派生类对象时,通过基类引用或指针只能访问到基类部分的成员和行为。这是因为基类引用或指针只能使用基类的成员和方法,而无法直接访问派生类的特有成员和方法。这会导致在使用基类引用或指针时,派生类特有的成员和行为无法被直接访问和调用。

对象切割的结果是,尽管派生类对象实际上包含了基类对象的部分,但在使用基类对象来引用派生类对象时,只能看到和操作基类部分的成员和行为,而派生类特有的成员和行为会被隐藏或丢失。

为避免对象切割的问题,可以使用基类指针或引用来处理派生类对象,以实现多态性,使基类引用或指针可以根据对象的实际类型调用正确的成员和方法。通过在基类中声明虚函数,并在派生类中进行重写,可以实现运行时的动态绑定,确保调用到派生类特有的方法。

多态

纯虚函数

在C++中,纯虚函数(Pure Virtual Function)是一种在抽象类中声明但没有提供具体实现的虚函数。它通过在函数声明的结尾处使用= 0来指定。

纯虚函数的存在使得包含它的类成为抽象类(Abstract Class),这意味着不能直接实例化该类。相反,它被用作其他类的基类,子类必须实现纯虚函数的具体定义才能被实例化。

纯虚函数在抽象类中起到了一种规范的作用,它定义了派生类必须实现的接口和行为。子类继承抽象类后,必须提供纯虚函数的具体实现,否则子类也会成为抽象类。

以下是一个使用纯虚函数的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class AbstractClass {
public:
    virtual void pureVirtualFunction() = 0;
    void nonVirtualFunction() {
        // 具体的实现
    }
};

class ConcreteClass : public AbstractClass {
public:
    void pureVirtualFunction() {
        // 子类必须提供纯虚函数的具体实现
    }
};

在这个例子中,AbstractClass是一个抽象类,其中包含了一个纯虚函数pureVirtualFunction()和一个非虚函数nonVirtualFunction()ConcreteClass继承自AbstractClass,必须提供pureVirtualFunction()的具体实现才能被实例化。

纯虚函数的存在使得C++支持了接口类(Interface)的概念,通过继承抽象类并实现纯虚函数,可以达到定义接口和多态行为的目的。

抽象类

抽象类是一种在面向对象编程中的概念。它是一种不能被直接实例化的类,而只能被其他类继承并实现其抽象方法的类。

抽象类用于定义一组相关类的通用行为和属性。它可以包含抽象方法、普通方法、成员变量和构造方法。抽象方法是在抽象类中声明但没有具体实现的方法,而普通方法是具有实现的方法。

通过定义抽象方法,抽象类可以强制子类必须实现这些方法。子类继承抽象类后,必须提供具体的实现来满足抽象方法的要求。这样可以确保子类具有一致的行为,并且可以避免忘记实现必要的方法。

抽象类本身不能被实例化,但可以作为类型来引用其子类的对象。这样可以实现多态性,即使用抽象类的引用来调用子类的方法。

在Java中,使用关键字abstract来定义抽象类,对应的方法前面使用abstract关键字定义为抽象方法。抽象类可以包含普通方法的具体实现,也可以没有抽象方法。如果一个类包含一个或多个抽象方法,那么这个类必须被声明为抽象类。

接口

在Java中,接口(Interface)是一种特殊的引用类型,它定义了一组方法的规范,但没有提供这些方法的具体实现。接口可以被类实现,实现类必须提供接口中定义的方法的具体实现。

接口在Java中用于实现类之间的协议,它定义了类应该具备的行为和功能。接口中的方法默认是公共的抽象方法(public abstract),并且可以包含常量(public static final)和默认方法(default methods)。

接口的定义使用interface关键字,方法声明没有方法体,只包含方法的签名。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public interface MyInterface {
    void doSomething(); // 抽象方法

    int getValue(); // 抽象方法

    String getName(); // 抽象方法

    // 默认方法
    default void printInfo() {
        System.out.println("This is MyInterface.");
    }
}

然后,其他类可以实现这个接口并提供方法的具体实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class MyClass implements MyInterface {
    public void doSomething() {
        // 具体的方法实现
    }

    public int getValue() {
        // 具体的方法实现
    }

    public String getName() {
        // 具体的方法实现
    }
}

在这个例子中,MyInterface定义了三个抽象方法doSomething()getValue()getName(),以及一个默认方法printInfo()MyClass实现了MyInterface接口,并提供了这些方法的具体实现。

通过接口,可以实现多态性,即使用接口的引用来引用实现了该接口的类的对象,并调用接口中定义的方法。这样可以实现代码的灵活性和可扩展性。

参考文章:

  1. C++多态为什么只有指针或引用能实现
  2. 百度百科