我们在编写程序时,通常采用以下步骤:
- 将问题的解法分解成若干步骤
- 使用函数分别实现这些步骤
- 依次调用这些函数
这种编程风格的被称作面向过程。除了面向过程之外,还有一种被称作面向对象的编程风格被广泛使用。面向对象采用基于对象的概念建立模型,对现实世界进行模拟,从而完成对问题的解决。
C语言的语法并不直接支持面向对象风格的编程。但是,我们可以通过额外的代码,让C语言实现一些面向对象特性。在这一节当中,我们将探究什么是面向对象,以及怎样用C语言来实现它。
单纯理论上的讨论可能比较难以理解,为了能够让我们的讨论能够落地到实际中,我们选取学校为场景,展开对面向对象风格编程的讨论。
一般而言面向对象风格的编程具有以下3大特性:
- 封装
- 继承
- 多态
我们将以这3个特性为线索,讨论C语言如何面向对象编程。
封装
我们来看看学校里面最重要的主体是什么?是学生。学生肯定拥有很多属性,比如学生的学号、姓名、性别、考试分数等等。自然地,我们会声明一个结构体用于表示学生。
struct student { int id; // 学号 char name[20]; // 姓名 int gender; // 性别 int mark; // 成绩 };
学生的学号由 入学年份 、 班级 、 序号 拼接构成。
例如,某一个同学的是 2022 年入学的 123 班的 26 号学生。那么,它的学号为 202212326 。
为了方便设置学号,我们有一个 makeStudentId 函数,参数为 入学年份 、 班级 、 序号 ,它将这些数据拼接成字符串,再将字符串转换为整型数据,最后将这个整型数据作为学生的 id 并返回。
int makeStudentId(int year, int classNum, int serialNum) { char buffer[20]; sprintf(buffer, "%d%d%d", year, classNum, serialNum); int id = atoi(buffer); return id; }
sprintf
和printf
函数类似,printf
函数会将占位符"%d%d%d"
替换为其后的参数,将结果打印到控制台上。而sprintf
不会将结果打印在控制台上,而是将结果存放在第一个参数buffer
所指示的字符数组当中。
函数atoi
能将buffer
指示的字符串转换为整型并返回结果。
性别在结构体中存储为整型数值,0
代表女生、1
代表男生。而显示时,我们希望0
显示为女
,1
显示为男
。因此,还需要有一对用于操作性别的函数。在函数命名中,使用numGender
代表使用整型表示的性别。strGender
代表使用字符串表示的性别。
我们将定义两个函数:
numGenderToStrGender
表示,将整型表示的性别转换为字符串表示的性别。
strGenderToNumGender
表示,将字符串表示的性别转换为整型表示的性别。
const char* numGenderToStrGender(int numGender) { if (numGender == 0) { return "女"; } else if (numGender == 1) { return "男"; } return "未知"; } int strGenderToNumGender(const char* strGender) { int numGender; if (strcmp("男", strGender) == 0) { numGender = 1; } else if (strcmp("女", strGender) == 0) { numGender = 0; } else { numGender = -1; } return numGender; }
我们将使用以下方式,调用这个结构体和这3个函数。
int main() { struct student stu; // 设置数值 // 学号:202212326 // 姓名:小明 // 性别: 男 // 成绩:98 stu.id = makeStudentId(2022, 123, 26); strcpy(stu.name, "小明"); stu.gender = strGenderToNumGender("男"); stu.mark = 98; // 打印这些数值 printf("学号:%d\n", stu.id); printf("姓名:%s\n", stu.name); const char* gender = numGenderToStrGender(stu.gender); printf("性别:%s\n", gender); printf("分数:%d\n", stu.mark); return 0; }
现在,我们使用面向过程风格写了3个函数和一个结构体,并且调用了这些函数,将函数返回的结果赋值给了结构体。接下来,让我们以面向对象风格来重新审视这段代码。
在面向对象风格中,结构体被看做数据(data),而操作数据的函数称作方法(method)。目前函数和数据是分离的,函数并不直接操作数据,我们需要拿到函数返回的结果,再将其赋值给数据。面向对象风格编程的第一大特性——封装,它希望方法直接操作数据,并且将数据和方法结合在一起,它们构成一个整体。而这个整体被称作对象。
此外,还有一个方法命名上的规则。一般来说,获取数据的方法会被命名为getXXX
,设置数据的方法会被命名为setXXX
。
我们对这3个函数做如下修改:
- 将函数的第一个参数设置为
struct student *
,让函数直接操作student
结构体。 - 修改函数名,获取数据的方法命名为
getXXX
,设置数据的方法命名为setXXX
。
void setStudentId(struct student* s, int year, int classNum, int serialNum) { char buffer[20]; sprintf(buffer, "%d%d%d", year, classNum, serialNum); int id = atoi(buffer); s->id = id; } const char* getGender(struct student* s) { if (s->gender == 0) { return "女"; } else if (s->gender == 1) { return "男"; } return "未知"; } void setGender(struct student* s, const char* strGender) { int numGender; if (strcmp("男", strGender) == 0) { numGender = 1; } else if (strcmp("女", strGender) == 0) { numGender = 0; } else { numGender = -1; } s->gender = numGender; }
现在,我们用修改后的函数,直接操作student
结构。
int main() { struct student stu; // 学号:202212326 // 姓名:小明 // 性别: 男 // 分数:98 setStudentId(&stu, 2022, 123, 26); strcpy(stu.name, "小明"); setGender(&stu, "男"); stu.mark = 98; // 打印这些数值 printf("学号:%d\n", stu.id); printf("姓名:%s\n", stu.name); const char* gender = getGender(&stu); printf("性别:%s\n", gender); printf("分数:%d\n", stu.mark); }
目前,函数可以直接操作数据了。但是,函数和数据依然是两个独立的部分。我们要将函数和数据结合到一起,这样,这个整体就能被称作对象,函数可以称作属于这个对象的方法。
大多数面向对象语言都提供了以下的格式调用一个对象的方法。
对象.方法(对象指针,参数1,参数2, 参数3...)
接下来,我们举几个这种格式的例子:
stu.setGender(&stu, "男");
以上代码中,对象为stu
,方法为setGender
。通过对象 + 点 + 方法的形式,可以调用属于对象stu
的setGender
方法。在方法的参数中传入性别男
。这样,方法会把性别男
转换为整形,并设置到对象stu
的数据当中。
const char* gender = stu.getGender(&stu);
以上代码中,对象为stu
,方法为getGender
。通过对象 + 点 + 方法的形式,可以调用属于对象stu
的getGender
方法。getGender
方法从对象数据中获取整形表示的性别,并返回性别对应的字符串。
在C语言中,若要实现对象 + 点 + 方法的形式,我们可以借助于函数指针。
在结构中,声明这3个函数的函数指针。
struct student { void (*setStudentId)(struct student* s, int year, int classNum, int serialNum); const char* (*getGender)(struct student* s); void (*setGender)(struct student* s, const char* strGender); int id; // 学号 char name[20]; // 姓名 int gender; // 性别 int mark; // 分数 };
为了让函数指针有正确的指向,我们需要通过一个initStudent
函数,为结构体初始化。
void initStudent(struct student* s) { s->setStudentId = setStudentId; s->getGender = getGender; s->setGender = setGender; }
现在,我们可以使用对象 + 点 + 方法的形式,调用对象的方法了。
struct student stu; // 初始化student initStudent(&stu); // 学号:202212326 // 姓名:小明 // 性别: 男 // 分数:98 stu.setStudentId(&stu, 2022, 123, 26); strcpy(stu.name, "小明"); stu.setGender(&stu, "男"); stu.mark = 98; // 打印这些数值 printf("学号:%d\n", stu.id); printf("姓名:%s\n", stu.name); const char* gender = stu.getGender(&stu); printf("性别:%s\n", gender); printf("分数:%d\n", stu.mark);
这里有一个需要注意的地方,结构体声明后,结构体内的函数指针是无效的。必须先调用initStudent
函数,将其设置正确的指向,才能使用这些函数指针。否则,将有可能导致程序崩溃。
为了让方法修改或访问对象,方法的参数中必须要有对象的指针。实现的形式中,第一个参数就是被操作对象指针。其它语言中,被操作对象指针是隐式传递的。不需要你在传参时写明参数,它会自动传入函数。例如,C++中会自动将一个名为this
的对象指针作为方法的参数。而C语言中,无法做到自动将对象的指针传入方法,所以我们需要手动写上需要操作的对象的指针。
// C++的写法 stu.setGender("男"); // C语言的写法 stu.setGender(&stu, "男");
继承
除了学生之外,学校里面还需要有老师,老师也具有很多属性。例如:
- 工号
- 姓名
- 性别
- 任课科目
声明一个结构体用于表示老师。
struct teacher { int id; // 工号 char name[20]; // 姓名 int gender; // 性别 char subject[20]; // 任课科目 };
比较一下学生和老师的结构体,看看它们之间有什么共同之处与不同之处。
struct teacher { int id; // 工号 char name[20]; // 姓名 int gender; // 性别 char subject[20]; // 任课科目 }; struct student { int id; // 学号 char name[20]; // 姓名 int gender; // 性别 int mark; // 分数 };
共同之处如下:
- 编号
- 姓名
- 性别
不同之处:
- 学生有考试分数
- 老师有任课科目
我们可以把两个结构体中的共同之处抽象出来,让它共同之处成为一个新的结构。这个结构体具有老师和学生的共性,而老师与学生它们都是人,可以把这个结构体命名为person
。
struct person { int id; // 编号 char name[20]; // 姓名 int gender; // 性别 };
接下来,我们可以让老师和学生结构包含这个person
对象。
struct teacher { struct person super; char subject[20]; // 任课科目 }; struct student { struct person super; int mark; // 分数 };
让我们比较一下原有代码与现有代码。
原有代码:
// 原有代码 struct teacher { int id; // 工号 char name[20]; // 姓名 int gender; // 性别 char subject[20]; // 任课科目 }; struct student { int id; // 学号 char name[20]; // 姓名 int gender; // 性别 int mark; // 分数 };
现有代码
// 现有代码 struct person { int id; // 编号 char name[20]; // 姓名 int gender; // 性别 }; struct teacher { struct person super; char subject[20]; // 任课科目 }; struct student { struct person super; int mark; // 分数 };
原有代码中,老师和学生结构体中,均有id
、name
、gender
三个变量。现有代码中,将这3个变量抽象成结构体person
。这样一来,有两个好处:
- 减少重复代码
- 代码层次更清晰
由于student
和teacher
拥有person
的一切,因此,我们可以说,student
与teacher
均继承于person
。person
是student
与teacher
的父对象。student
与teacher
是person
的子对象。
刚刚我们只讨论了数据,现在我们结合上方法一起讨论。
struct person { int id; // 编号 char name[20]; // 姓名 int gender; // 性别 }; struct teacher { struct person super; char subject[20]; // 任课科目 }; struct student { struct person super; int mark; // 分数 void (*setStudentId)(struct student* s, int year, int classNum, int serialNum); const char* (*getGender)(struct student* s); void (*setGender)(struct student* s, const char* strGender); };
之前我们为student
写了3个方法
- 设置性别
- 获取性别
- 设置学号
其中,性别相关的方法也属于共性的方法。可以把这两个函数指针移动到person
对象里面去,注意,要把方法的第一个参数struct student *
修改为struct person *
。移动后,子对象student
与teacher
均可以使用这一对性别相关的方法。而设置学号的方法,为student
独有的方法,因此保持不变,依然将其放置在student
对象内。
struct person { int id; // 编号 char name[20]; // 姓名 int gender; // 性别 // 设置性别 void (*setGender)(struct person* s, const char* strGender); // 获取性别 const char* (*getGender)(struct person* s); }; struct teacher { struct person super; char subject[20]; // 任课科目 }; struct student { struct person super; int mark; // 分数 // 设置学号 void (*setStudentId)(struct student* s, int year, int classNum, int serialNum); };
对应上面的更改,函数getGender
与setGender
的第一个参数也要由struct student *
修改为struct person *
。
const char* getGender(struct person* p) { if (p->gender == 0) { return "女"; } else if (p->gender == 1) { return "男"; } return "未知"; } void setGender(struct person* p, const char* strGender) { int numGender; if (strcmp("男", strGender) == 0) { numGender = 1; } else if (strcmp("女", strGender) == 0) { numGender = 0; } else { numGender = -1; } p->gender = numGender; }
此外,setStudentId
函数中,id
成员,不在student
中,而是在student
中的person
中。这里也要对应的修改一下。
void setStudentId(struct student* s, int year, int classNum, int serialNum) { char buffer[20]; sprintf(buffer, "%d%d%d", year, classNum, serialNum); int id = atoi(buffer); s->super.id = id; // 由s->id = id 修改为 s->super.id = id }
还有,别忘了给结构初始化函数指针。
void initPerson(struct person* p) { p->getGender = getGender; p->setGender = setGender; } void initStudent(struct student* s) { initPerson(&(s->super)); s->setStudentId = setStudentId; } void initTeacher(struct teacher* t) { initPerson(&(t->super)); }
下面我们即可使用这些对象了。
struct student stu; // 初始化student initStudent(&stu); // 学号:202212326 // 姓名:小明 // 性别: 男 // 分数:98 stu.setStudentId(&stu, 2022, 123, 26); strcpy(stu.super.name, "小明"); stu.super.setGender(&stu.super, "男"); stu.mark = 98; // 打印这些数值 printf("学号:%d\n", stu.super.id); printf("姓名:%s\n", stu.super.name); const char* gender = stu.super.getGender(&stu.super); printf("性别:%s\n", gender); printf("分数:%d\n", stu.mark); putchar('\n'); struct teacher t; // 初始化teacher initTeacher(&t); // 工号:12345 // 姓名:林老师 // 性别: 男 // 科目:C语言 t.super.id = 12345; strcpy(t.super.name, "林老师"); t.super.setGender(&t.super, "男"); strcpy(t.subject, "C语言"); // 打印这些数值 printf("学号:%d\n", t.super.id); printf("姓名:%s\n", t.super.name); gender = t.super.getGender(&t.super); printf("性别:%s\n", gender); printf("科目:%s\n", t.subject);
多态
我们以绘制各种图形为背景,展开对多态这一特性的讨论。
绘制图形
现在,我们有3种图形,它们分别为:
- 矩形
- 圆形
- 三角形
我们把这3种图形均看做对象,这些图形对象,分别需要有哪些属性呢?
- 矩形:左上角坐标、右下角坐标
- 圆形:圆心x坐标、圆心y坐标、半径
- 三角形:三个顶点坐标
现在,我们用代码分别实现这几个对象。
struct Rect { int left; int top; int right; int bottom; }; struct Circle { int x; int y; int r; }; struct Triangle { POINT p1; POINT p2; POINT p3; };
为了能够在屏幕上绘制这些图形,每个图形都设置一个名为draw
的方法。
struct Rect { void (*draw)(struct Rect*); int left; int top; int right; int bottom; }; struct Circle { void (*draw)(struct Circle*); int x; int y; int r; }; struct Triangle { void (*draw)(struct Triangle*); POINT p1; POINT p2; POINT p3; };
分别实现3个不同的绘制函数。
绘制矩形:
调用 easyx 中的 rectangle 函数,传入左上角坐标与右下角坐标。
void drawRect(struct Rect* r) { rectangle(r->left, r->top, r->right, r->bottom); }
绘制圆形:
调用 easyx 中的 circle 函数,传入圆心坐标与半径。
void drawCircle(struct Circle* c) { circle(c->x, c->y, c->r); }
绘制三角形:
调用 easyx 中的 line 函数,分别绘制点 p1 到 p2 的线段, p2 到 p3 的线段,以及 p3 到 p1 的线段。
void drawTriangle(struct Triangle* t) { line(t->p1.x, t->p1.y, t->p2.x, t->p2.y); line(t->p2.x, t->p2.y, t->p3.x, t->p3.y); line(t->p3.x, t->p3.y, t->p1.x, t->p1.y); }
下面,分别写3个初始化函数,用于给对象中的函数指针draw
进行赋值。
void initRect(struct Rect* r) { r->draw = drawRect; } void initCircle(struct Circle* r) { r->draw = drawCircle; } void initTriangle(struct Triangle* r) { r->draw = drawTriangle; }
现在,准备工作都做好了,我们开始绘制这些图形吧。
int main() { initgraph(800, 600); setaspectratio(1, -1); setorigin(400, 300); setbkcolor(WHITE); setlinecolor(BLACK); cleardevice(); struct Rect r = { -200, 200, 200, 0 }; struct Circle c = { 0, 0, 100 }; struct Triangle t = { {0, 200}, {-200, 0}, {200, 0} }; initRect(&r); initCircle(&c); initTriangle(&t); r.draw(&r); c.draw(&c); t.draw(&t); getchar(); closegraph(); return 0; }
创建一个800 * 600
的绘图窗体,设置x
轴正方向为从左到右,y
轴正方向为从下到上。将原点坐标从窗体左上角更改为窗体中心。设置背景颜色为白色,描边颜色为黑色,并使用背景色刷新整个窗体。下面分别声明矩形、圆形、三角形三个对象,并将需要的属性初始化。之后,三个对象分别调用各自的init
函数,为对象内的函数指针赋值。完成准备工作后,即可使用对象 + 点 + 方法的形式,调用各自的draw
方法绘制图形了。
多态
struct Rect { void (*draw)(struct Rect*); int left; int top; int right; int bottom; }; struct Circle { void (*draw)(struct Circle*); int x; int y; int r; }; struct Triangle { void (*draw)(struct Triangle*); POINT p1; POINT p2; POINT p3; };
我们仔细观察这3个对象,看看它们分别有什么共性?可以发现,这3个对象,它们都有一个draw
方法。那么,我们可以将draw
这个方法抽象出来,单独放置到一个对象当中。由于这三个对象都是形状。我们可以把单独抽象出来的对象,命名为shape
。shape
对象中的draw
方法,应当是一个共性的方法,所以,它的参数应当设置为struct Shape *
。
struct Shape { void (*draw)(struct Shape*); };
接下来,让Rect
、Circle
、Triangle
三个对象分别都包含Shape
对象。这样,它们就都能使用draw
这个方法了。
struct Rect { struct Shape super; int left; int top; int right; int bottom; }; struct Circle { struct Shape super; int x; int y; int r; }; struct Triangle { struct Shape super; POINT p1; POINT p2; POINT p3; };
这里有一个需要注意的地方,父对象与子对象的内存排布必须重合。
例如:下图中,上面的两个对象内存排布可以重合。而下面的两个对象的内存排布无法重合。
像下面一样的声明Rect
是正确的。
// 正确 struct Rect { struct Shape super; int left; int top; int right; int bottom; };
而下面一样的声明Rect
是错误的。
// 错误 struct Rect { int left; int top; int right; int bottom; struct Shape super; };
接着,我们需要修改各对象的初始化函数。将原有的r->draw
改为r->super.draw
。
void initRect(struct Rect* r) { r->super.draw = drawRect; } void initCircle(struct Circle* c) { c->super.draw = drawCircle; } void initTriangle(struct Triangle* t) { t->super.draw = drawTriangle; }
注意,这里还有一个问题,函数内赋值运算符左边的函数指针r->super.draw
的类型为void (*)(struct Shape*)
,参数为struct Shape*
。而赋值运算符右边的函数指针类型分别为:
void (*)(struct Rect*) void (*)(struct Circle*) void (*)(struct Triangle*)
函数指针参数类型不一致,无法进行赋值。我们可以把右边的函数指针强制类型转换为void (*)(struct Shape*)
。
void initRect(struct Rect* r) { r->super.draw = (void (*)(struct Shape*))drawRect; } void initCircle(struct Circle* c) { c->super.draw = (void (*)(struct Shape*))drawCircle; } void initTriangle(struct Triangle* t) { t->super.draw = (void (*)(struct Shape*))drawTriangle; }
我们考虑一下怎样来使用这些对象。
struct Rect r = { {}, - 200, 200, 200, 0 }; struct Circle c = { {}, 0, 0, 100 }; struct Triangle t = { {}, {0, 200}, {-200, 0}, {200, 0} };
首先,声明Rect
、Circle
、Triangle
这3个对象,并使用初始化列表将其初始化。注意,由于它们的第一个成员为super
,所以,这里使用空列表{}
,将super
成员初始化为零。
initRect(&r); initCircle(&c); initTriangle(&t);
让三个对象分别调用各自的初始化函数,给各自对象super
成员中的draw
设置为各自对应的绘图函数。
r.super.draw
设置为drawRect
c.super.draw
设置为drawCircle
t.super.draw
设置为drawRTriangle
struct Shape* arrShape[3] = { (struct Shape*)&r, (struct Shape*)&c, (struct Shape*)&t };
声明一个元素类型为struct Shape *
的数组,元素个数为3。分别用r
的指针,c
的指针,t
的指针初始化。注意,这里也需要进行强制类型转换,否则初始化列表里面的指针类型和数组元素的指针类型不一致。
for (int i = 0; i < 3; i++) { arrShape[i]->draw(arrShape[i]); }
到了关键的一步,使用循环,依次调用draw
函数。由于3次循环中的draw
函数分别为各个图形各自的绘图函数。所以,虽然统一调用的是draw
,但是,却可以执行它们各自的绘图函数。至此,不同实现的方法,在此得到统一。
总结实现多态的步骤
- 抽离出各个对象中共有的方法
draw
,将其单独放置在一个对象Shape
内。 - 各个对象均继承于
Shape
对象。 - 将各个子对象中的
draw
方法,设置为各自的实现方法。 - 声明一个
Shape
对象的指针,并将其赋值为一个子对象的指针。 - 通过上述对象指针,调用方法共有方法
draw
,执行的是第三步中设置的方法。