用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是一種視角」