用C语言进行面向对象编程,有一本非常古老的书,Object-Oriented Programming With ANSI-C。1994出版的,很多OOC的思想可能都是源于这本书。但我觉得,没人会把书里的模型用到实际项目里,因为过于复杂了。没有必要搞出一套OOP的语法,把C模拟的和C++一样,那还不如直接使用C++。
Mojoc使用了一套极度轻量级的OOC编程模型,在实践中很好的完成了OOP的抽象。有以下几个特点:
* 没有使用宏来扩展语法。
* 没有函数虚表的概念。
* 没有终极祖先Object。
* 没有刻意隐藏数据。
* 没有访问权限的控制。
宏可以做一些有意思的事情,但是会增加复杂性。有个C的开源项目利用宏,把C宏成了函数式语言,完全创造了新的高层次抽象语法,有兴趣的可以看看,orangeduck/Cello。所以,我的原则是能不用宏就不用,尽量使用C原生的语法就很纯粹 (当然在使用过程中会感到一些限制)。
面向对象是一种看待数据和行为的视角,其核心是简单而明确的。但OOP语言提供的语法糖和规则是复杂的,是为了最大限度的把错误消除在编译期,并减少编写抽象层的复杂度,也可以理解为不太信任程序员。而C的理念是相信程序员能做对事情。所以,我的初衷是用C去实现抽象视角,不提供抽象语法糖,而是保持C语法固有的简单。
Mojoc的OOC规则,设计思考了很久,在使用过程中反复调整了很多次,一直在边用边修改,尝试了很多种写法,最终形成了现在这个极简的形式。在实现Spine骨骼动画Runtime的时候,是对照着官方Java版本移植的,这套规则很好的实现了Java的OOP,Mojoc Spine 与 Java Spine。下面就介绍一下Mojoc的OOC规则,源代码中充满了这种写法。
单例
Mojoc中单例是非常重要的抽象结构。在C语言中,数据(struct)和行为(function)是独立的,并且没有命名空间。我利用单例充当命名空间,去打包一组行为,也可以理解为把行为像数据一样封装起来。这样就形成了平行的数据封装和行为封装,而一个类就是一组固定的行为和一组可以复制的数据模板。
抽象单例的形式有很多,这里使用了最简单的方式。
// 在.h文件中定义
struct ADrawable
{
Drawable* (*Create)();
void (*Init) (Drawable* outDrawable);
};
extern struct ADrawable ADrawable[1];
// 在.c文件中实现
static Drawable* Create()
{
return (Drawable*) malloc(sizeof(Drawable));
}
static void Init(Drawable* outDrawable)
{
// init outDrawable
}
struct ADrawable ADrawable[1] =
{
Create,
Init,
};
- ADrawable 就是全局单例对象。
- 利用了struct类型名称和变量名称,所属不同的命名空间,都命名为ADrawable。
- ADrawable[1]是为了把ADrawable定义为数组,这样ADrawable就是数组名,可以像指针一样使用。struct成员变量也大量使用了这样的形式。
- ADrawable 绑定了一组局部行为的实现,初始化的时候就已经确定了。
- 并没有限制struct ADrawable定义其它的对象,单例的形式依靠的是约定和对约定的理解。
封装
正如前面所说,利用struct对数据和行为来进行封装。
typedef struct Drawable Drawable;
struct Drawable
{
float positionX;
float positionY;
};
typedef struct
{
Drawable* (*Create)();
void (*Init) (Drawable* outDrawable);
}
ADrawable;
extern ADrawable ADrawable[1];
- Drawable 封装数据,非单例类型,都会使用typedef定义别名,去除定义时候的struct书写。
- ADrawable 封装行为。因为有了命名空间,所以函数不需要加上全名前缀,来避免冲突。
- Create 使用malloc在堆上复制Drawable模板数据,相当与new关键字。
- Init 初始化已有的Drawable模板数据,通常会在栈上定义Drawable,让Init初始化然后使用,最后自动销毁不需要free。也可以,在继承的时候初始化父类数据模板。
继承
父类struct变量嵌入子类struct类型,成为子类的成员变量,就是继承。这个情况下,一次malloc会创建继承链上所有的内存空间,一次free也可以释放继承链上所有的内存空间。
typedef struct Drawable Drawable;
struct Drawable
{
int a;
};
typedef struct
{
Drawable drawable[1];
}
Sprite;
struct ASprite
{
Sprite* (*Create)();
void (*Init) (Sprite* outSprite);
};
- Drawable 是父类,Sprite 是子类。
- drawable[1]可以作为指针使用,但内存空间全部属于Sprite。
- ASprite 的Create和Init中,需要间接调用ADrawable的Init来初始化父类数据。
- 这里继承并不需要把drawable放在第一个成员的位置,并且可以用这种形式,继承无限多个父类。
子类访问父类,直接简单的使用成员运算符就好了。那么,如何从父类访问子类 ?
/**
* Get struct pointer from member pointer
*/
#define AStruct_GetParent2(memberPtr, structType) \
((structType*) ((char*) memberPtr - offsetof(structType, memberPtr)))
Sprite* sprite = AStruct_GetParent2(drawable, Sprite);
- 这里使用了一个宏,来获取父类在子类结构中的,数据偏移。
- 然后使用父类指针与数据偏移,就可以获得子类数据的地址了。
- 这样父类也可以看成一个接口,子类去实现接口,利用父类接口可以调用子类不同的实现,从而体现了多态性。
组合
struct指针变量嵌入另一个struct类型,成为另一个struct的成员变量,就是组合。这时候组合的struct指针对应内存就需要单独管理,需要额外的malloc和free。组合的目的是为了共享数据和行为。
typedef struct Drawable Drawable;
struct Drawable
{
Drawable* parent;
};
- parent 被组合进了 Drawable,parent的内存有其自身的Create和Init管理。
- 同样一个struct可以组合任意多个struct。
多态
typedef struct Drawable Drawable;
struct Drawable
{
void (*Draw)(Drawable* drawable);
};
- 我们把行为Draw封装在了Drawable中,这意味着,不同的Drawable可以有相同或不同的Draw行为的实现。
typedef struct
{
Drawable drawable[1];
}
Hero;
typedef struct
{
Drawable drawable[1];
}
Enemy;
Drawable drawables[] =
{
hero->drawable,
enemy->drawable,
};
for (int i = 0; i < 2; i++)
{
Drawable* drawable = drawables[i];
drawable->Draw(drawable);
- Hero和Enemy都继承了Drawable,并分别实现了Draw行为。
- 而统一使用父类Drawable,在循环中调用Draw,会得到不同的行为调用。
重写父类行为
在继承链中,有时候需要重写父类的行为,有时候还需要调用父类的行为。
typedef struct
{
Drawable drawable[1];
}
Sprite;
struct ASprite
{
void (*Draw)(Drawable* drawable);
};
extern ASprite ASprite;
- 需要被重写的行为,就需要被提取到单例中来。比如这里Sprite所实现的Draw行为,被放到了ASprite中。
- 这样,Sprite的Draw被覆盖了,其本身的Draw还储存在ASprite中供子类使用。
typedef struct
{
Sprite sprite[1];
}
SpriteBatch;
// subclass implementation
static void SpriteBatchDraw(Drawable* drawable)
{
// call father
ASprite->Draw(drawable);
// do extra things...
}
// override
spriteBatch->sprite->drawable->Draw = SpriteBatchDraw;
- SpriteBatch 又继承了 Sprite,并且覆盖了Draw方法。
- 而在SpriteBatch的Draw实现中,首先调用了父类Sprite的Draw方法。
内存管理
就如前面所说,继承没有什么问题,但是组合就需要处理共享的内存空间。这里有两种情况。
第一,组合的struct没有共享,这样只需要在外层struct提供一个Release方法,用来释放其组合struct的内存空间即可。所以,凡是有组合的struct,都需要提供Release方法,删除的时候先调用Release,然后在free。
第二,组合的struct被多个其它struct共享,这时候就不知道在什么时候对组合的struct进行清理。一般会想到用计数器,或是独立的内存管理机制。但我觉得有些复杂,并没有去实现,但也没有更好的方法。目前,我的做法是,把共享的组合struct指针放到一个容器里,等到某一个确定的检查点统一处理,比如关卡切换。
总结
数据和行为,并没有本质的却别。行为其实也是一种数据,可以被传递,封装,替换。在C中行为的代理就是函数指针,其本身也就是一个地址数据。
组合与继承,其本质是数据结构的构造,因为C的语法还是把数据与行为分开的,所以继承多个父类数据,并不会把父类固定的行为一起打包,就不会感觉到违和感,也没有什么限制。
Mojoc的OOC规则就是简单的实现面向对象的抽象,没有模拟任何一个OOP语言的语法形式。原生的语法最大限度的降低了学习成本和心智负担,但需要配合详细的注释才能表达清楚设计意图,并且使用的时候有一些繁琐,没有简便的语法糖可用。
实例
Drawable.h
Drawable.c
Sprite.h
Sprite.c
Struct.h
「OOC是一种视角」