C 语言中的多态






4.92/5 (40投票s)
本文演示了如何使用 C 语言实现多态性。
引言
多态性是面向对象编程中迄今为止最重要和广泛使用的概念。COM、MFC 等广泛使用的技术和库都以多态性为基础。如果你查看所有原始设计模式,几乎每一种模式在其结构中都使用了多态性。
在这篇文章中,我希望揭示 C++ 编译器在实现多态性方面所做的工作。在我们将用 C 语言实现多态性时,还会涉及 C++ 的一些内部机制,如虚表、虚表指针等。
问题
我采用了一个简单的三类层次结构来实现多态性。
问题的类图如下:
类 X
是基类,它有三个虚函数 —— One
、Two
和 Three
。类 Y
公有继承自 X
并重写了函数 One
。类 Z
公有继承自 Y
并重写了函数 Two
。所有类都有构造函数和析构函数,并且析构函数都被声明为 virtual
。类 X
包含一个字符数组来存储类的名称,该名称被其他类继承。这三个类都有自己的整数成员变量。
一些 C++ 概念
在这里我们将看一些 C 语言中没有的 C++ 概念,并讨论如何在 C 语言中实现它们。
构造函数和析构函数
C++ 实现
构造函数和析构函数是 C++ 中特殊的函数,它们分别在对象创建和销毁时自动调用。new
运算符首先为类分配内存,然后调用类的构造函数。类似地,delete
运算符首先调用类的析构函数,然后为类释放内存。
我们的实现
在我们的 C 语言实现中,我们将为每个类创建两个函数 —— 一个用于构造函数,另一个用于析构函数。构造函数名将是类名后附加 _Ctor,析构函数名将是类名后附加 _Dtor。例如,类 X
的构造函数和析构函数将分别是 X_Ctor
和 X_Dtor
。我们将在使用 malloc
函数为类分配内存之后立即调用构造函数,并在使用 free
函数为类释放内存之前立即调用析构函数。
对象的内存布局
C++ 实现
当一个对象被实例化时,只为其数据成员分配内存。成员函数只存在一份副本,并由类的所有实例共享。
我们的实现
为了实现这种行为,我们将所有类的所有函数都创建为全局函数。这包括前面部分提到的构造函数和析构函数。函数名将以类名和下划线为前缀。例如,属于类 X
的函数 Two
将命名为 X_Two
。类的数据成员将放在一个结构体中,因为 C 语言中没有 class
关键字。
C++ 实现
由于成员函数在内存中只存在一份副本,为了区分不同的类实例,这些成员函数可以访问一个 this
指针,该指针保存了实例化对象的地址。函数使用 this
指针来访问数据成员。在内部,this
指针中的地址通过微处理器的全局 ecx
寄存器传递给成员函数。
我们的实现
为了实现 this
指针,我们声明了一个名为 ECX
的全局整数变量。在任何函数被调用之前,这个 ECX
变量将被设置为为结构体分配的内存的地址。所有函数都期望 ECX
变量正确指向分配的内存。函数使用这个 ECX
变量来访问结构体的成员。
虚表和虚表指针
C++ 实现
每个至少有一个虚函数的类,无论是虚析构函数,都将有一个与之关联的虚表。它不过是一个函数指针数组。类的虚表填充了在该类中声明的虚函数以及该类继承的虚函数的地址。对于继承的虚函数,只有那些未在该类中重写的虚函数才会被考虑。
填充虚表的顺序如下:
- 添加虚析构函数的地址。
- 按声明顺序添加从基类继承的虚函数的地址。
- 重写函数的地址将替换继承函数的地址。
- 按声明顺序添加在该类中声明的新虚函数的地址。
我们的实现
我们将虚函数实现为 void
指针的全局数组,并用类虚函数的地址填充该数组。在我们的问题中,有三个带有虚函数的类,因此将有三个虚表。
类 X
的虚表将包含其析构函数的地址和其三个虚函数的地址。
类 Y
的虚表将包含其析构函数的地址和从类 X
继承的虚函数的地址。重写函数 Y_One
的地址将替换继承函数 X_One
的地址。上述部分解释的第四步未完成,因为类 Y
中没有声明新的虚函数。如果有,它将被附加到虚表的末尾。
类似地,类 Z
的虚表将包含其析构函数的地址和从类 Y
继承的虚函数的地址。重写函数 Z_Two
的地址将替换继承函数 X_Two
的地址。这里同样,前面部分解释的第四步未完成。
C++ 实现
虚表通过虚表指针或 vptr
与类关联。vptr
是一个指针,它是类中的第一个数据成员。这个 vptr
将保存类的虚表的地址,从而将类与其虚表关联起来。这种 vptr
的关联或初始化是在类的构造函数中完成的。
我们的实现
在我们即将实现的每个结构体中,第一个成员将是一个整数指针,它将充当虚表指针或 vptr
。在每个类的构造函数中,vptr
将用其相应虚表的地址进行初始化。
继承
C++ 实现
当通过继承一个基类派生一个新类时,派生类继承基类的所有数据成员。尽管从概念上讲,私有数据成员不会被继承,但在内部,派生类甚至会包含私有数据成员。但是编译器不允许访问这些成员。由于我们的问题中没有私有数据成员,所以这对我们来说不是问题。
我们的实现
为了实现继承,我们将创建三个独立的结构体。结构体 X
将包含一个 vptr
作为第一个成员,后跟一个字符数组来存储类名,再后跟一个整数变量 'x
'。结构体 Y
将包含结构体 X
的所有成员,顺序与结构体 X
中相同,后跟一个整数变量 'y
'。类似地,结构体 Z
将包含结构体 Y
的所有成员,顺序与结构体 Y
中相同,后跟一个整数变量 'z
'。
C++ 实现
与继承相关的另一个概念是构造函数和析构函数。派生类的构造函数在进行任何初始化之前应该调用基类的构造函数,而派生类的析构函数在完成清理活动之后必须调用基类的析构函数。对于需要传递参数的参数化构造函数,用户必须显式地调用基类构造函数。否则,它由编译器隐式完成。
我们的实现
由于类 Z
派生自类 Y
,而类 Y
又派生自类 X
,因此类 Z
的构造函数和析构函数必须分别调用类 Y
的构造函数和析构函数,而类 Y
的构造函数和析构函数必须分别调用类 X
的构造函数和析构函数。Z_Ctor
在函数的最开始调用 Y_Ctor
,Y_Ctor
在函数的最开始调用 X_Ctor
。同样,Z_Dtor
在函数的最末尾调用 Y_Dtor
,Y_Dtor
在函数的最末尾调用 X_Dtor
。
以下是我们为了实现多态性而需要自己实现的所有 C++ 概念的总结列表:
- 构造函数和析构函数
- 类成员函数
- 成员函数中对
this
指针的访问 - 虚表和虚表指针
- 继承
站在编译器的角度
现在,我们必须完成 C++ 编译器为实现多态性所做的所有“脏活累活”。为此,我们将根据编译器从我们编写的 C++ 代码中获得的所有信息,构建我们需要实现的东西。
构造函数
C++ 实现
代码片段显示了使用 new
运算符为类 Z
的对象分配内存。内存分配后,类 Z
的构造函数会自动调用。
X* pClass = NULL;
pClass = new Z; // Constructor automatically called
// after allocating memory.
我们的实现
在上面的 C++ 实现中,编译器知道要实例化的类是类 Z
。因此,它使用 sizeof
运算符获取类 Z
的大小,并在内部调用 malloc
函数或 HeapAlloc
API 来分配所需的内存量。这也是 C++ 中不允许重载 sizeof
运算符的一个原因。内存分配后,会调用构造函数。为此,编译器知道构造函数的名称与类名相同。因此,编译器会调用与类名相同的函数。
在我们的实现中,类 Z
的构造函数名为 Z_Ctor
,因此我们需要在为类 Z
分配所需的内存后调用此函数。代码片段显示了我们的 C 实现。
struct X* pClass = NULL;
pClass = malloc(sizeof(struct Z)); // Allocate memory
Z_Ctor(); // Call constructor
C++ 实现
在 C++ 中,派生类构造函数在进行任何初始化之前会调用基类构造函数。如前所述,对于带参数的构造函数,基类构造函数必须从派生类构造函数中显式调用。由于我们的代码中有默认构造函数,此调用由编译器自动完成。
在这里,编译器知道类 Z
继承自类 Y
,而类 Y
继承自类 X
。因此,它会在派生类的构造函数中添加代码,以调用其直接基类的构造函数。
我们的实现
在我们的实现中,派生类构造函数中的第一条可执行行必须是调用基类构造函数。所以 Z_Ctor
必须调用 Y_Ctor
,而 Y_Ctor
必须调用 X_Ctor
。下面是我们实现中的代码片段。
void Z_Ctor()
{
.
.
.
Y_Ctor(); // First executable statement
.
.
.
}
void Y_Ctor()
{
.
.
.
X_Ctor(); // First executable statement
.
.
.
}
析构函数
C++ 实现
当对象的内存被释放时,析构函数会自动调用,下面是析构函数被调用的代码片段。
delete pClass;
这里编译器知道 pClass
所属的类,也知道析构函数的名称,该名称与类名相同,并带有波浪号 (~) 前缀。因此,编译器首先在内部调用析构函数,然后使用 free
函数或 HeapFree
API 释放对象的内存。
在某些情况下,析构函数也可以是 virtual
的,就像我们的例子一样。在这种情况下,析构函数的地址将从虚表中获取并调用。这是一个很好的特性,因为编译器只知道使用 delete
运算符的对象类型。它不知道它实际指向的类型。
例如,如果内存被分配为 X* pClass = new Z
,并被释放为 delete pClass
,这里的 pClass
是 X
类型的指针,但其 vptr
指向类 Z
的虚表。因此,如果析构函数不是 virtual
,则在释放内存时将调用 X_Dtor
,但如果析构函数是 virtual
,则其地址将从类 Z
的虚表中获取,该虚表包含 Z_Dtor
的地址,这是正确的要调用的析构函数。由于派生类析构函数会调用其直接基类析构函数,因此内存释放将完成。
在这里,C++ 编译器知道析构函数是 virtual
的,因此析构函数的地址是从虚表中获取的。编译器还知道虚指针或 vptr
是类的第一个数据成员。编译器还知道析构函数的地址是虚表中的第一个元素。
我们的实现
结合 C++ 编译器所了解的上述三点,并假设 pClass
是一个指向为类 Z
分配的内存的类 X
的指针,我们实现代码如下所示:
int* vptr; // Pointer to hold virtual pointer
int* vtbl; // Pointer to hold the virtual table address
typedef void (*FPTR)(); // Function pointer to hold
// address of destructor
vptr = (int*)pClass; // Typecast to get the virtual pointer
vtbl = (int*)*vptr; // Get contents of vptr which is
// address of vtable
Dtor = (FPTR)*vtbl; // Get first element of vtable which
// is destructor address
Dtor(); // Call destructor
free(pClass); // Deallocate memory
pClass = NULL;
创建了两个整数指针,分别代表虚指针和虚表。还创建了一个函数指针来保存虚析构函数的地址。
pClass
存储着类 Z
对象的地址。为了获取对象的头 4 字节(即 vptr
),我们将 pClass
强制转换为 int*
。现在我们有了对象的 vptr
,我们可以通过使用 '*
' 运算符对 vptr
解引用来获取其内容,这正是虚表的地址。虚表的第一个元素包含要调用的析构函数的函数地址。为了获取存储在虚表第一个元素中的地址,我们再次使用 '*
' 运算符对 vtbl
解引用,并将其强制转换为函数指针。
然后使用函数指针调用析构函数,之后使用 free
函数释放内存。
C++ 实现
在 C++ 中,派生类析构函数在完成所有清理活动后会调用基类的析构函数。这由编译器自动完成。
这里再次,编译器知道类 Z
继承自类 Y
,而类 Y
继承自类 X
。因此,它在派生类的析构函数中添加代码,以调用其直接基类的析构函数。
我们的实现
在我们的实现中,派生类析构函数中的最后一行将是对基类析构函数的调用。所以 Z_Dtor
必须调用 Y_Dtor
,而 Y_Dtor
必须调用 X_Dtor
。下面是我们实现中的代码片段。
void Z_Dtor()
{
.
.
.
Y_Dtor(); // Last statement
}
void Y_Dtor()
{
.
.
.
X_Dtor(); // Last statement
}
类成员函数
C++ 实现
特定类的所有实例共享该类成员函数的一个副本。每次创建类的实例时,只为数据成员分配内存。成员函数使用特殊的 this
指针来访问类的每个独立实例的数据成员。this
指针保存着类的特定实例的地址。在内部,地址通过微处理器的 ecx
寄存器传递给函数。
我们的实现
C 语言中不允许在结构体内部定义函数。我们将把类的成员函数创建为全局函数。函数名称将以类名和下划线为前缀。例如,我们将类 X
的函数 One
命名为 X_One
,将类 Y
的函数 One
命名为 Y_One
。
为了创建实例,我们使用 malloc
为结构体分配内存。上述函数需要知道分配内存的地址,以便它可以访问结构体的数据成员。
我们将创建一个名为 ECX
的全局整数变量,用于传递结构体分配内存的地址。该变量将用分配内存的地址初始化,如下所示:
int ECX; // Global variable
struct X* pClass = NULL;
pClass = malloc(sizeof(struct Z)); // Allocate memory
ECX = (int)pClass; // Assign memory address
//to global variable
ECX
变量将用于从函数(包括构造函数和析构函数)访问结构体的数据成员。例如,我们来看一下结构体 Z
的内存布局,并看看如何使用 ECX
变量访问其成员。
|
|
ECX | int* vptr |
ECX + 4 | char classname[8] |
ECX + 12 | int x |
ECX + 16 | int y |
ECX + 20 | int z |
下面是函数 Z_Two
中的代码片段,展示了如何访问结构体 Z
的数据成员。在这里,ECX
将持有为结构体 Z
分配的内存的起始地址。
char* className = (char*)(ECX + 4);
int* x = (int*)(ECX + 4 + 8);
int* y = (int*)(ECX + 4 + 8 + 4);
int* z = (int*)(ECX + 4 + 8 + 4 + 4);
虚表
C++ 实现
如前所述,每个至少有一个虚函数(无论是在类中定义还是从其他类继承)的类都将有一个与之关联的虚表。在我们的示例中,所有三个类都拥有虚表。下表展示了所有三个类的虚表布局。
|
|
|
X::析构函数 | Y::析构函数 | Z::析构函数 |
X::One | Y::One | Y::One |
X::Two | X::Two | Z::Two |
X::Three | X::Three | X::Three |
我们的实现
为了创建上述虚表布局,我们将为每个类创建三个 void
指针数组。
对于类 X
,编译器知道它没有基类,因此没有函数被继承,析构函数被声明为虚函数,因此其地址必须是虚表的第一个元素,并且声明了三个虚函数。有了这些知识,我们将为类 X
创建虚表,如下所示:
void* X_vtable[] =
{
X_Dtor,
X_One,
X_Two,
X_Three
};
对于类 Y
,编译器知道它继承自类 X
,它的析构函数也是虚函数,因为基类析构函数是虚函数,它继承了类 X
的三个虚函数,并且它重写了函数 One
,因为它具有与基类中声明的相同签名。因此,我们为类 Y
创建虚表,如下所示:
void* Y_vtable[] =
{
Y_Dtor,
Y_One,
X_Two,
X_Three
};
对于类 Z
,编译器知道它继承自类 Y
,它的析构函数是虚函数,因为基类析构函数是虚函数,它继承了类 Y
的三个虚函数,并且它重写了函数 Two
,因为它具有与基类中声明的相同签名。类 Z
的虚表将如下所示:
void* Z_vtable[] =
{
Z_Dtor,
Y_One,
Z_Two,
X_Three
};
虚指针
C++ 实现
在 C++ 中,类的虚指针在其构造函数中初始化。编译器知道虚指针是类的第一个数据成员,并且已经如前所述创建了虚表。因此,初始化虚指针是一项简单的任务,即将指针赋值为虚表的地址。析构函数中也做了同样的操作。
我们的实现
在我们的实现中,我们已经创建了虚表,并且类中有一个整数指针来表示虚指针。所以我们所要做的就是将整数指针初始化为我们创建的虚表的地址。下面的代码片段显示了类 Z
的 vptr
的初始化。类 Z
的虚表显示在上一节中。
void Z_Ctor()
{
.
.
.
// Call the base class constructor
Y_Ctor();
// Initialize the virtual pointer
vptr = (int*)ECX;
.
.
.
}
在上面的代码片段中,ECX
包含类的地址。由于类的第一个数据成员是虚指针,ECX
将指向虚指针。
继承
C++ 实现
C++ 使用继承来重用现有类。我们案例中的内存布局如下图所示:
类 X
有三个数据成员,虚指针是第一个成员。类 Y
继承自类 X
。这意味着类 Y
包含类 X
的所有数据成员,后跟其自身的一个数据成员,总共四个数据成员。这里虚指针仍然是第一个数据成员。类似地,类 Z
继承自类 Y
,这意味着类 Z
将首先包含类 Y
的所有数据成员,后跟其自身的一个。因此它将总共有五个数据成员,其中虚指针是第一个成员。
我们的实现
C 语言不提供重用现有结构体的支持。我们将重新声明类 X
的变量在类 Y
的开头,以及类 Y
的变量在类 Z
的开头,以实现继承。下面的代码片段显示了我们创建的结构体。
struct X
{
int* vptr;
char className[8];
int x;
};
struct Y
{
int* vptr; // Re-declaration of
// members of class X
char className[8]; // Re-declaration of
// members of class X
int x; // Re-declaration of
// members of class X
int y;
};
struct Z
{
int* vptr; // Re-declaration of
// members of class Y
char className[8]; // Re-declaration of
// members of class Y
int x; // Re-declaration of
// members of class Y
int y; // Re-declaration of
// members of class Y
int z;
};
以下是代码
到目前为止,我们已经讨论了 C++ 如何实现多态性以及如何使用 C 语言实现它。现在让我们通过一个例子来看看我们所实现的控制流。
当运行程序时,用户会被要求选择要实例化的类。我们假设用户选择了类 Z
,并执行以下代码:
pClass = malloc(sizeof(struct Z));
ECX = (int)pClass;
Z_Ctor();
首先,使用 malloc
函数为结构体 Z
分配内存,并将其地址存储在 pClass
指针中。pClass
指针将用于获取虚指针,然后获取类的虚表,以便可以调用虚表中的函数。
相同的分配地址也存储在全局 ECX
变量中。该变量将用作将被调用以访问结构体各个数据成员的函数中的基地址。
最后一步是调用结构体 Z
的构造函数。在结构体 Z
的构造函数内部,我们首先调用结构体 Y
的构造函数,而在结构体 Y
的构造函数内部,我们首先调用结构体 X
的构造函数。所以首先执行 X_Ctor
,然后执行 Y_Ctor
,最后执行 Z_Ctor
。
void X_Ctor()
{
vptr = (int*)ECX;
className = (char*)(ECX + 4);
x = (int*)(ECX + 4 + 8);
*vptr = (int)X_vtable;
strcpy(className, "class X");
*x = 100;
}
首先通过偏移 ECX
变量来获取对虚指针和类 X
其他数据成员的引用。然后初始化包括虚指针在内的所有数据成员。
void Y_Ctor()
{
X_Ctor();
vptr = (int*)ECX;
className = (char*)(ECX + 4);
x = (int*)(ECX + 4 + 8);
y = (int*)(ECX + 4 + 8 + 4);
*vptr = (int)Y_vtable;
strcpy(className, "class Y");
*y = 200;
}
这里发生的事情与 X_Ctor
中相同,唯一的区别是,在获取引用和初始化成员之前,首先调用了 X_Ctor
。这里数据成员 'x
' 没有被初始化。'x
' 仅在 X_Ctor
中初始化。
void Z_Ctor()
{
Y_Ctor();
vptr = (int*)ECX;
className = (char*)(ECX + 4);
x = (int*)(ECX + 4 + 8);
y = (int*)(ECX + 4 + 8 + 4);
z = (int*)(ECX + 4 + 8 + 4 + 4);
*vptr = (int)Z_vtable;
strcpy(className, "class Z");
*z = 300;
}
这里发生的事情与 Y_Ctor
中完全相同。
vptr = (int*)pClass;
vtbl = (int*)*vptr;
调用构造函数后,将获取虚指针及其内容(即虚表的地址)的引用。
Dtor = (FPTR)*(vtbl + 0);
One = (FPTR)*(vtbl + 1);
Two = (FPTR)*(vtbl + 2);
Three = (FPTR)*(vtbl + 3);
通过偏移 vtbl
变量来获取虚表的内容,并将其存储到函数指针中。结构体 Z
的虚表内容,如前所述,依次是 Z_Dtor
、Y_One
、Z_Two
和 X_Three
的地址。
One();
Two();
Three();
使用函数指针依次调用函数 One
、Two
和 Three
,这些函数指针存储了从虚表获取的相应函数地址。
Dtor();
free(pClass);
在释放内存之前,使用函数指针调用析构函数,该函数指针使用从虚表获取的析构函数地址进行初始化。之后,使用 free
函数释放内存。
在结构体 Z
的析构函数内部,我们在末尾调用结构体 Y
的析构函数,而在结构体 Y
的析构函数内部,我们在末尾调用结构体 X
的析构函数。因此,首先执行 X_Dtor
,然后执行 Y_Dtor
,最后执行 Z_Dtor
。
void X_Dtor()
{
vptr = (int*)ECX;
*vptr = (int)X_vtable;
}
首先通过偏移 ECX
变量来获取对虚指针的引用。然后重新初始化虚指针。虚指针的重新初始化是在析构函数中完成的,这样即使在析构函数中调用虚函数,代码也不会中断,并且会调用正确的函数。
void Y_Dtor()
{
vptr = (int*)ECX;
*vptr = (int)Y_vtable;
X_Dtor();
}
这里发生的事情与 X_Dtor
中相同。唯一的区别是 X_Dtor
在最后被调用。
void Z_Dtor()
{
vptr = (int*)ECX;
*vptr = (int)Z_vtable;
Y_Dtor();
}
这里发生的事情与 Y_Dtor
中完全相同。
结论
C++ 编译器为实现多态性做了大量工作。我们所看到的只是 C++ 编译器必须支持的面向对象编程的一个特性。还有很多其他东西,比如访问修饰符、模板等,这些都需要编译器付出大量的努力。我们现在看到的是,以某种方式在不直接支持面向对象编程的语言中实现面向对象编程是可能的。这是实现多态性的一种方式,但是编译器可以采用完全不同的方法,并加入大量的优化。