用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,
};
正如前面所说,利用struct对数据和行为来进行封装。
typedef struct Drawable Drawable;
struct Drawable
{
float positionX;
float positionY;
};
typedef struct
{
Drawable* (*Create)();
void (*Init) (Drawable* outDrawable);
}
ADrawable;
extern ADrawable ADrawable[1];
父类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);
};
/**
* 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;
};
typedef struct Drawable Drawable;
struct Drawable
{
void (*Draw)(Drawable* drawable);
};
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);
在继承链中,有时候需要重写父类的行为,有时候还需要调用父类的行为。
typedef struct
{
Drawable drawable[1];
}
Sprite;
struct ASprite
{
void (*Draw)(Drawable* drawable);
};
extern ASprite 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;
就如前面所说,继承没有什么问题,但是组合就需要处理共享的内存空间。这里有两种情况。
第一,组合的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是一种视角」