「遊戲引擎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是一種視角」

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