「游戏引擎Mojoc」(3)C面向对象编程

用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是一种视角」

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章