65.9K
CodeProject 正在变化。 阅读更多。
Home

在 C 中实现多态性

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.28/5 (16投票s)

2014年3月6日

CPOL

10分钟阅读

viewsIcon

32433

downloadIcon

376

手动在 C 语言中实现面向对象设计。

引言

本文旨在与社区分享对面向对象编程(OOP)的理解和知识,以及 OOP 诞生的“如何”和“为什么”。

在本文中,我们将尝试构建一个 Shape 指针数组,其中每个具体的 Shape 可以是 Circle、Square 或 Goat。

所有这些都将在纯 C 语言中完成。

背景

面向对象编程(OOP)无疑是人类智慧中最伟大的编程设计模式之一。

本文旨在努力模仿亲手实现 C 语言 OOP 的冒险过程。

尽管所有反对者可能会因为我试图“重新发明轮子”而诅咒我。但本文旨在达到以下目的。

  • 巩固你对 OOP 内部机制的理解。
  • 巩固你对 C 语言的理解。

使用代码

我使用了 Windows MinGW 编译器套装中的“gcc”编译器来编译所有代码。(注意 - 我不确定我利用的所有特性都是 C 标准,可能与编译器相关)。

文章

哦,多久没写纯 C 了。让我们重新熟悉一下基础知识。这才是“Hello World”应该有的样子。

#include <stdio.h>

int main(){
    printf("Hello World");
    return 0;
}

编译它,运行它,确保你的系统运行正常,然后开始加速。我们需要创建三个 Shape 类,它们将继承一个抽象的 Shape 类。 实际上 C 语言没有类或继承,但它有结构体(struct):),这是起点。

struct Square{
    int width;
    int height;
};

struct Circle{
    float radius;
};

struct Triangle{
    int base;
    int height;
}; 

这里没什么花哨的,让我们进一步研究。在 main() 中输入以下代码

    printf("size of square is %d\n", sizeof(struct Square));
    printf("size of Circle is %d\n", sizeof(struct Circle));
    printf("size of Triangle is %d\n", sizeof(struct Triangle)); 

输出应该是(不保证)

//size of cube is 8
//size of circle is 4
//size of triangle is 8 

sizeof() 是 C 语言中的一个特殊运算符,它告诉你某种类型在内存中占用多少字节。我们询问编译器“struct Cube”在内存中表示需要多少字节,它回答 8。这是有道理的,因为一个 Cube 由两个 int 组成。而如果你尝试 sizeof( int ),你应该得到 4,除非你有一个非常老的计算机。这很酷,因为显然 C 语言的结构体没有头开销,这就是我们的简单结构体在内存中的样子。

仔细注意,结构体中声明的成员的对齐方式与这些变量在内存中的对齐方式一致。这意味着。

struct Square{
    int width;
    int height;
}; 

在内存中,width 将占用 4 字节,然后 height 才占用 4 字节。我们不要假设任何事情,自己来断言。

struct Square square;
square.width = 1;
square.height = 2;
printf("the first 4 bytes of square are %d", (*(int*)(&square)));

基本上,这就是你可以监视自己的方式。我们创建一个名为 square 的 Square,给它赋值,然后打印 Square 前 4 个字节的 int。我的输出是 1,这意味着正如预期的那样,width 变量由 Square 的前 4 个字节表示,height 由 Square 的后 4 个字节表示。现在,不要被 printf() 函数的最后一个参数吓到,让我们慢慢来。

printf("the first 4 bytes of square are %d\n", square); //this works? 

这实际上也将 square 的前 4 个字节打印为一个 int!!令我惊讶的是,虽然效果相同,但这并不是一个非常通用的检查技术。

printf("the first 4 bytes of square are %d\n", &square); 

& 运算符给出 square 在内存中的起始地址。这也可以打印出来。但是,我们想要打印的是其地址也从那里开始的 int。所以我们将这个地址转换为 int 地址。

printf("the first 4 bytes of square are %d\n", (int*)&square); 

现在我们可以高兴地打印出我们转换后的 int 指针指向的“int”。这就是我在第一个例子中做的方式。但在打印之前,你可以选择将指针移动到任何你想去的地方。而这在第二个例子中是做不到的。所以如果我们想打印 square 的后 4 个字节,我们就需要将指针向前移动 4 个字节,或者一个 int 向前,因此。

printf("the second 4 bytes of square are %d\n", (*(int*)&square + 1)); 

需要注意的是,指针算术是相对于其引用的类型进行跳转的。这意味着一个 int 指针会向前和向后以 int 为单位移动。因此 +1 实际上是 +1 个 int 向前,或 +4 字节向前。

Gandalf:好了,C 语言就到这里,回到多态。既然我们知道了结构体是如何在内存中构建的,我们就给每个结构体两个函数。一个 print() 函数和一个 area() 函数。

Frodo Baggins:但是,但是,在 C 语言中不能直接给结构体添加函数!

Gandalf:好吧,一个隐形的霍比特人也不能随便吃东西,所以呢?

Frodo Baggins:那我们该怎么办,Gandalf 大师?

Gandalf:我们将使用函数指针,我亲爱的小霍比特人朋友!

Frodo Baggins:我不想再玩这个游戏了!

Gandalf:你不能通过!

没错,更多的指针,这次是函数指针,C 语言对弱者毫无怜悯。C 语言中的任何结构体都将函数指针视为其他任何成员一样,无论是 int、指向 int 的指针,还是指向自身的指针。因此,理论上我们可以通过函数指针成员为结构体添加函数。考虑这个函数。

void print_square( void ){
    printf("Hello Square\n");
} 

这是一个不接收任何参数也不返回任何参数的函数,名为 print_square。这意味着指向这个函数的指针的类型是“指向一个接收 void 并返回 void 的函数的指针”,这里是如何声明这样一个变量。

struct Square{
    int width;
    int height;
    
    //now for functions
    void (* print)( void );
    float (* area)( struct Square * this );
}; 

在 C 语言中读写类型的规则是,从变量名开始,尽可能向右移动,然后向左移动。

让我们一步一步来。

print 

1) print 是一个

(* print) 

2) print 是一个指针,指向

(* print)( 

3) print 是一个指向函数的指针,该函数

(* print)( void ) 

4) print 是一个指向函数的指针,该函数接收 void(无)并返回

void (* print)( void ); 

5) print 是一个指针,指向一个函数,该函数接收 void(无)并返回 void(无)

现在我们想给我们的 Square 添加一个 area 函数,它将接收一个 Square(它本身)并返回一个 float 来表示它的面积。阅读过程与 print 函数完全相同。

还没完,指针本身很棒,但它们需要指向某些东西才能有用。那么你怎么知道一个指针可能指向函数所在的内存位置?你怎么知道函数的位置?好消息是,函数名本身就是其内存地址。这是一个实际的例子。

struct Square square;
square.print = print_square; 

正如我之前提到的,函数指针就像其他任何成员一样。当我们创建一个 Square 并称之为 square 时,它的所有成员都包含垃圾值。我们需要手动为它们分配正确的值。这个任务需要一个构造函数。C 语言也没有这个。所以我们将创建自己的构造函数,如下所示。

void init_square( struct Square * square, int w, int h ){
    (*square).print = print_square;
    (*square).width = w;
    (*square).height = h;
    (*square).area = calc_square_area;
} 

这个构造函数只是另一个函数,它需要更改传递给它的 square 的值,因此它必须是指向 Square 的指针,按值传递在这里行不通。正如你在这里看到的,我们为两个函数指针分配了正确的函数。你可以自己实现 calc_square_area 函数,或者可以查看可下载的完整示例。

请原谅我没有提供所有三个 Shape 的所有 9 个函数(print、area、init)。因为时间紧迫。我们必须继续前进争取胜利。

让我们来测试一下我们迄今为止所构建的内容。

struct Square square;
struct Circle circle;
struct Triangle triangle;
    
init_square( &square, 2, 2 );
init_circle( &circle, 7.7 );
init_triangle( &triangle, 2, 3 );
    
square.print();
circle.print();
triangle.print();
    
printf("the area of the square is %f\n", square.area(&square));
printf("the area of the circle is %f\n", circle.area(&circle));
printf("the area of the triangle is %f\n", triangle.area(&triangle)); 

在 C 语言中一切都是反过来的,我们没有让所有三个形状继承一个 Shape 结构体 而是创建一个 Shape 结构体 它将(在某种程度上)成为所有三个形状的父类。由于我们需要从某个地方开始,让我们创建一个逻辑上的 Shape 结构体。

//abstract father class
struct Shape{
    void (* print)( void );
    float (* area)( struct Shape * this );    
}; 

既然我们知道从哪里开始,让我们开始思考我们希望发生什么。所以如果你再次创建一个 Square,初始化它等等。

struct Square square;
init_square(&square, 2, 4); 

如果我尝试打印它会发生什么?

square.print(); 

现在,如果某个不期望的 Shape 指针尝试打印我们的 square 会发生什么?

struct Shape * pointer = (struct Shape *)&square;
(*pointer).print();    //?? what is going to happen?? 

我得到了一个分段错误,你呢?好吧,结果目前是出乎意料的,正如预期的那样。我们希望发生的是,Shape 指针的 print() 调用将激活它指向的 Square 对象的 print() 函数。

我们已经有一段时间没有谈论内存了,让我们再来谈谈。与我们的 Square 结构体相比,我们的 Shape 结构体在内存中看起来是什么样的?函数指针是如何影响结构体的?正如我之前提到的,它们就像任何其他成员一样,因此最终,它只是结构体内存中的另一个指针。这是一张图片。

我的蜘蛛感应在刺痛!Shape 内存模型中的 print() 函数是前 4 个字节,而 Square 内存模型中的 print() 函数是第三个 4 个字节!啊哈,第一个不等于第三个。发生了什么?

当我们把指向 Square 的指针转换为指向 Shape 的指针时,内存没有被改变。请记住,通常情况下,强制类型转换通常不会改变内存中的任何东西。除非你是像我一样使用动态转换的怪人,内存永远不会在强制类型转换中改变,甚至在运行时也不会进行。所有的强制类型转换都是在编译时完成的。然而,内存本身可能没有改变,但我们将其视为改变了。

square.print(); 

这使用了 Shape 内存模型中的第三个 4 字节的指针来激活一个名为 print() 的函数。而这个

(*pointer).print(); 

它使用第一个 4 字节中的指针来激活一个名为 print() 的函数。但第一个 4 字节中没有指针,只有一个旧的 int,我们知道它等于 2。嗯,我们的指针并不真的在乎它是 2,它忠实于 2 是一个指向它必须激活的函数的指针的事实,因此它会去 2 的内存位置(这是一个 BIOS 驱动程序/操作系统内存位置)并激活那里的任何东西。这就是缺点。

现在我们大致了解了问题,让我们来解决它。要是 Shape 结构体的 print() 指针恰好是第三个 4 字节就好了。我多么希望 Shape 结构体能有一些东西来填充前 8 个字节,这样它的函数指针就能与 Square 的函数指针对齐。于是就有了,我们在 C 语言中称之为“填充技术”。

struct Shape{
    char padd[8];
    void (* print)( void );
    float (* area)( struct Shape * this );    
}; 

看看 Circle 结构体,我们需要什么样的填充技术才能使 Shape 结构体与 Circle 对齐?

最终测试。

struct Shape * shapes[3];
shapes[0] = (struct Shape *)&square;
shapes[1] = (struct Shape *)&circle;
shapes[2] = (struct Shape *)&triangle;
    
int i;
for(i=0; i<3; ++i){
    (*shapes[i]).print();
    printf("%f\n\n", (*shapes[i]).area(shapes[i]));
} 

如果以上都正常工作,那么你就已经成功地在 C 语言中实现了 OOP 的基础知识,包括多态。这才是成功的滋味。

结论

总的来说,这种设计相当容易实现。一个人必须始终根据自己的喜好对结构体中的成员进行对齐,以实现所需的兼容性和可用性。更敏锐的读者可能会注意到,首先声明函数指针会更节省内存。但再次强调,总有改进的空间。我再次非常享受用 C 语言编程的乐趣,并相信如果有一天幸运女神眷顾任何人,这种实践可能会变得有用。多态性并非只能通过这些手段实现。通过对内存如何对齐的正确理解,你可以快速实现继承和虚表,以及一些从未有人想过的事情。我相信即使你永远不会在 C 语言中使用 OOP 设计,本文仍然是一堂好课,因为它迫使你深入事物的本质,以理解现代语言(包括 C 语言,它无疑是一门现代语言)幕后是如何工作的。

© . All rights reserved.