个人原创文章,转载请注明出处,谢谢合作
1 前言
在面向对象编程中,类是一个最基础也是最核心的概念,不严格的说,一个类的组成就是数据加方法,数据可被隐藏起来,保证对外不可见,然后通过暴露一组精心设计的方法对这些数据进行各种操作,由此从微观到宏观,从局部到整体,精细的实现简单的接口耦合。面向对象带来的好处不言而喻,而在C语言中,我们通过一些简单的技巧,也可以将面向对象的一些基本特性进行落地。
首先,static 关键字,在C语言中,作用于全局变量和函数,则意味着该全局变量或者函数只能在其声明的文件中被使用,不能被其它文件使用,如果我们将一个 .c 源文件看作一个类的话,static 关键字就起到了 private 的作用。
例如下述代码,我们在 foo.c 中声明了用 static 修饰的一个变量和一个函数,分别是 n
和 foo
,然后声明了一个非 static 变量 c
,在 main.c 中引用了这三个变量,如下所示。
# renren @ ubuntu in /data/ooc_test [4:31:31]
$ cat foo.c
static int n = 0;
static int foo(int n) { return n; }
char c = 0;
# renren @ ubuntu in /data/ooc_test [4:31:33]
$ cat main.c
extern int n;
extern int foo(int);
extern char c;
int main()
{
foo(n + c);
return 0;
}
然后我们进行编译,编译结果如下所示,使用 nm
命令查看 foo.o 的符号,发现导出符号中只有 c
,没有 n
和 foo
,这正是 static 在此种场景下的效用,而最后链接阶段,自然也因找不到符号而报错。
# renren @ ubuntu in /data/ooc_test [4:31:36]
$ gcc -o foo.c
gcc: fatal error: no input files
compilation terminated.
# renren @ ubuntu in /data/ooc_test [4:33:45] C:1
$ gcc -c foo.c
# renren @ ubuntu in /data/ooc_test [4:33:51]
$ gcc -c main.c
# renren @ ubuntu in /data/ooc_test [4:33:55]
$ nm -g foo.o
0000000000000000 B c
# renren @ ubuntu in /data/ooc_test [4:34:00]
$ gcc -o test.out foo.o main.o
/usr/bin/ld: main.o: in function 'main':
main.c:(.text+0x14): undefined reference to 'n'
/usr/bin/ld: main.c:(.text+0x1d): undefined reference to 'foo'
collect2: error: ld returned 1 exit status
2 静态类
对于静态类而言,由于无需创建实例,因此,我们可以直接将成员变量以 static 修饰直接定义在 .c 文件中,私有方法同样以 static 修饰,公有方法需对外暴露,则不用 static 修饰,并且在对应的 .h 文件中进行声明。
通常,我们以 init 作为静态类的初始化函数,以 dispose 作为静态类的反初始化函数,在命名方面,成员变量以 m_
作为变量名前缀,以 {类名}_{方法名}
来作为对外接口的规范,而私有方法可以以 __
作为前缀。
例如编写一个 surface 的静态类,可以按照以下展示的范式来进行编写,可以通过定义 PRIVATE
和 PUBLIC
的宏来增加可读性。
/* surface.c */
#define PRIVATE static
#define PUBLIC
/* 类的成员变量的定义 */
PRIVATE int m_width = 0;
PRIVATE int m_height = 0;
PRIVATE char* m_panel = NULL;
PRIVATE int m_pos_x = 0;
PRIVATE int m_pos_y = 0;
/* 类的私有方法*/
PRIVATE int __init_pannel() { /*...*/ }
/* 静态类的初始化 */
PUBLIC int surface_init(int width, int height){ /*...*/ }
/* 静态类的反初始化 */
PUBLIC int surface_dispose() { /*...*/ }
PUBLIC int surface_get_width() { /*...*/ }
PUBLIC int surface_set_width(int width) { /*...*/ }
/*...*/
如此,.c 文件作为类的实现部分,而 .h 文件作为类的公有方法的声明部分,.h 文件作为一个类对外暴露的接口和界面,务必限定其仅包含需要暴露的结构和方法,需要对外隐藏的信息都应避免在 .h 中声明,如下所示。
/* surface.h */
#ifndef SURFACE_H
#define SURFACE_H
int surface_init(int width, int height);
int surface_dispose();
int surface_get_width();
int surface_set_width(int width);
#endif
3 非静态类
非静态类意味着可以通过 new
关键字创建实例,不严格的说,同一个类的多个实例就是该类的成员变量的集合在堆上的多个副本,类的方法作用于各个实例各自的副本,互不干扰。
为了实现多实例的创建,我们可以将类的成员变量以结构体的形式进行定义,实例的构造函数可以定义为 {类名}_create
,析构函数可以定义为 {类名}_destroy
,例如,第二章中的 surface
类,我们改造为非静态类如下。
头文件 surface.h 如下所示:
/* surface.h */
typedef struct
{
int m_width;
int m_height;
char* m_panel;
int m_pos_x;
int m_pos_y;
}surface_t;
int surface_create(surface_t* surf);
int surface_destroy(surface_t* surf);
int surface_get_width(surface_t* surf);
int surface_set_width(surface_t* surf, int width);
在上述的定义中,构造和析构均需要传入 surface_t
的有效指针,构造和析构函数并不负责 surface_t
这个结构体对象的内存分配和释放,而是将该部分工作交由用户来完成,用户可以自由的选择将 surface_t
定义为全局变量,或者通过动态内存分配来创建,或者如果仅限于一个函数内部使用,则可以直接定义为栈上的局部变量,而该类暴露的各种公有方法的第一个参数均是 surface_t
的指针,这就充当了 C++ 中隐含的 this
指针的能力,而 POSIX pthread 的 mutex、condition variable 正是这种风格。
进一步的,我们也可以将内存分配的能力合入构造和析构中,从而实现与 new
和 delete
类似的形式,如下所示,构造函数中可以通过 malloc
分配内存,直接通过返回 surface_t
的指针来实现构造,而析构函数中,则需要进行相应的 free
操作
/* surface.h */
surface_t* surface_create();
如果构造或者其它公有方法涉及多种带参形态,即函数重载,那我们可以通过添加适当的后缀来进行函数名的区分,当然,这也是在 C 语言中的无奈之举,如下所示,后缀 ex
为 extended
的缩写,是一种添加后缀的常用实践。
/* surface.h */
surface_t* surface_create();
surface_t* surface_create_ex(int width, int height);
基于上述,我们借助结构体对象实现了对类的实例化,而类的聚合或组合,即是简单的将一个类的结构体声明在另一个类的结构体之中的成员变量即可。
4 非静态类进阶
之前我们提到过我们定义类最重要的目的是隐藏无需暴露的信息,将接口和界面尽可能的简化,从而通过简单的接口隔离实现更好的内聚和更低的耦合。而上一章节的实践中将非静态类的成员定义为结构体直接暴露给用户,显然违背了我们的初衷,因此,我们可以有更好的办法,即将结构体定义下放到 .c 文件中,从而实现对调用者的隐藏,而调用者只需要持有一个抽象的实例指针即可。
针对之前的 surface
类的定义,我们改造如下所示,我们定义一个 handle
指针来指向被隐藏的 surface_t
结构体,从而实现对数据结构的隐藏,而用户持有的就是一个单纯的对象指针。
/* surface.h */
typedef void* handle;
handle surface_create();
int surface_destroy(handle surf);
int surface_get_width(handle surf);
int surface_set_width(handle surf, int width);
在 .c 文件中,我们定义 surface_t
结构体,在每一个成员函数中,第一步要做的就是将 handle
指针转换为 surface_t
结构体指针,而为了避免调用者传入错误的对象,我们可以通过一个简单的签名来对对象的合法性进行判别。如下所示,在此实践方法中,任何一个类的实例,均为 handle
指针,而每一个类的结构体中均定义一个 m_sig
变量,在构造时赋值为唯一标记该类的一个数值,而类的公有方法和析构函数,都首先默认检查该签名数值是否正确,以便确认调用者传入的是正确的对象。
/* surface.c */
#include "surface.h"
#define SURFACE_SIG 0xfacedead
typedef struct
{
int m_sig;
int m_width;
int m_height;
char* m_panel;
int m_pos_x;
int m_pos_y;
}surface_t;
handle surface_create() {
surface_t* ptr = (surface_t*)malloc(sizeof(surface_t));
if(NULL == ptr) {
return NULL;
}
ptr->m_sig = SURFACE_SIG;
/* ... */
return (handle)ptr;
}
int surface_destroy(handle surf) {
surface_t* ptr = (surface_t*)surf;
if(ptr->sig != SURFACE_SIG) {
return -1;
}
/* ... */
}
int surface_get_width(handle surf) {
surface_t* ptr = (surface_t*)surf;
if(ptr->sig != SURFACE_SIG) {
return -1;
}
/* ... */
}
int surface_set_width(handle surf, int width) {
surface_t* ptr = (surface_t*)surf;
if(ptr->sig != SURFACE_SIG) {
return -1;
}
/* ... */
}
在该实践中,将一个复杂的数据结构通过一个重定义为 handle
的 void*
指针暴露给用户,从而实现对内部数据结构的隐藏,此种形态,在操作系统层面非常类同的普遍实践,例如在 Windows
系统中,所有的内核对象在用户态都以 HANDLE
类型的变量(即void*
),称之为句柄的概念来表示,通过 CreateFile
、ReadFile
、WriteFile
、DeviceIoControl
、CloseHandle
等函数来进行对象的创建、读写、控制、销毁。而相应的 Linux
平台则通过简单的 int 类型的变量,称之为文件描述符的概念来标识内核对象, 通过open
、read
、write
、fcntl
、ioctl
、close
等函数来进行对象的创建、读写、控制、销毁。而在操作系统的内核态,无论是句柄还是文件描述符,都对应了一个由操作系统或者驱动程序所定义的跟设备相关的复杂数据结构。
5 多态
在 C 语言中要想实现多态是比较困难的,在此,我们就单继承场景来探讨一种典型的实践。
基于上述章节中的实践可以看出,定义类的成员函数时,第一个参数必须为 this
指针,以此才能将对象传入函数,以便函数中操作对象的成员变量。同样,在多态的场景下,我们有一个前提,即无论是父对象还是子对象,传入的 this
指针始终保持与当前对象变量一致,该前提在编程实操层面,也很合理,不容易出错。
为了简化表示,便于理解,下述例子中,我们就不再进行成员变量的隐藏和签名验证。
还是以 surface
为例,我们先声明一个基类,该基类中包含了一个名为 draw
的函数指针,我们定义它为虚函数,然后,surface_draw
是对基类对该虚函数的实现,通过构造函数 surface_create
完成对基类实例的初始化和函数的绑定。
#include <stdio.h>
#define VIRTUAL
typedef void* handle;
typedef struct
{
int m_width;
int m_height;
VIRTUAL int (*draw)(handle);
}surface_t;
int surface_draw(handle h)
{
surface_t* p = (surface_t*)h;
printf("base draw : w %d, h %d\n", p->m_width, p->m_height);
return 0;
}
int surface_create(surface_t* surf)
{
surf->m_width = 1024;
surf->m_height = 768;
surf->draw = surface_draw;
return 0;
}
紧接着,我们实现一个 surface
的派生类 mirror_surface
,该派生类的结构体中首先声明了基类的实体,然后派生了一个 m_color
的成员,再次声明了 draw
的方法,然后 mirror_surface_draw
是派生类对 draw
的实现,在派生类的构造函数 mirror_surface_create
中,首先调用基类的构造方法对 super
进行初始化,然后再对派生成员进行初始化,最后,将基类的 draw
指针和派生类的 draw
指针均指向了派生类的 mirror_surface_draw
方法,由此实现派生类实体对基类虚函数的覆盖。
typedef struct
{
surface_t super;
int m_color;
VIRTUAL int (*draw)(handle);
}mirror_surface_t;
int mirror_surface_draw(handle h)
{
mirror_surface_t* p = (mirror_surface_t*)h;
printf("mirror draw : w %d, h %d, c %d\n",
p->super.m_width, p->super.m_height, p->m_color);
return 0;
}
int mirror_surface_create(surface_t* surf)
{
surface_create(&surf->super);
surf->m_color = 0xff;
surf->draw = mirror_surface_draw;
surf->super.draw = surf->draw;
return 0;
}
基于上述首先,我们编写测试案例如下:
int main()
{
surface_t base;
mirror_surface_t derived;
surface_t* base_ptr;
surface_create(&base);
mirror_surface_create(&derived);
base_ptr = (surface_t*)&derived;
base.draw(&base);
derived.draw(&derived);
base_ptr->draw(base_ptr);
return 0;
}
编译后,输出结果如下所示:
# renren @ ubuntu in /data/ooc_test [7:25:10]
$ ./test.out
base draw : w 1024, h 768
mirror draw : w 1024, h 768, c 255
mirror draw : w 1024, h 768, c 255
在上述的测试案例中,我们可以看到,基类的实体调用基类的函数,派生类的实体调用派生类的函数,指向派生类实体的基类指针,同样能够引用到派生类的变量,调用的是派生类的函数。
当基类指针 base_ptr
指向派生类实体后,base_ptr
调用的 draw
函数所引用的仍然是基类的 draw
指针,而由于派生类的构造中已经将基类的 draw
指针改写为指向派生类的函数,因此,基类指针仍然能够实现对派生类函数的调用,从而实现了多态特性中,派生类对基类虚函数的覆盖。
另一方面,由于基类是以实体形式直接声明在派生类的结构体之中,并且,基类的实体是派生类结构体中的第一个成员变量,因此,派生类中的基类的地址和派生类结构体的地址,是同一个地址,不存在偏移量,基于此,派生类实体无论是赋值给派生类指针还是基类指针,都是等价的,将基类实体声明为派生类的第一个成员变量,这是能够实现多态的一个关键技巧。
在上述的例子中,我们展示了基类虚函数被派生类重写的实现方式,如果基类需要定义纯虚函数,与该场景的实现完全一致,只是基类不需要对定义的纯虚函数进行实现,基类构造函数中将函数指针赋值为 NULL
即可,派生类中类似,除了将自身的函数指针赋值为对应的函数外,还需要将基类的函数指针赋值即可。
6 总结
经过这些年的实践,无论早期编写 Windows 内核驱动、日志文件系统以及在 Linux 内核上进行一些功能扩展,抑或近些年来在用户态上进行一些基础软件的设计和开发,无论是基于纯 C 语言的环境或者是 C/C++ 混合使用,我们都一直坚持适当的运用 Object Oriented in C 的方法来进行底层代码的组织和构建,这已经成为了我们的一种标准的实践,基于以功能内聚为核心的分层结构的设计和模块划分,让 C 语言的代码变得简单易懂且充满美感。
后续我抽时间将持续输出一系列文章,讲述我们这些年在 C/C++ 方面的一些经典实践,以期抛砖引玉,共同进步。