# 練習19:一個簡單的對象系統
> 原文:[Exercise 19: A Simple Object System](http://c.learncodethehardway.org/book/ex19.html)
> 譯者:[飛龍](https://github.com/wizardforcel)
我在學習面向對象編程之前學了C,所以它有助于我在C中構建面向對象系統,來理解OOP的基本含義。你可能在學習C之前就學了OOP語言,所以這章也可能會起到一種銜接作用。這個聯系中,你將會構建一個簡單的對象系統,但是也會了解更多關于C預處理器的事情。
這個練習會構建一個簡單的游戲,在游戲中你會在一個小型的城堡中殺死彌諾陶洛斯,并沒有任何神奇之處,只是四個房間和一個壞家伙。這個練習同時是一個多文件的項目,并且比起之前的一些程序看起來更像一個真正的C程序。我在這個賈少C預處理器的原因,是你需要它來在你自己的程序中創建多個文件。
## C預處理器如何工作
C預處理器是個模板處理系統,它主要的用途是讓C代碼的編程更加容易,但是它通過一個語法感知的模板機制來實現。以前人們主要使用C預處理器來儲存常量,以及創建“宏”來簡化復雜的代碼。在現代C語言中你會實際上使用它作為代碼生成器來創建模板化的代碼片段。
C預處理器的工作原理是,如果你給它一個文件,比如`.c`文件,它會處理以`#`(井號)字符開頭的各種文本。當它遇到一個這樣的文本時,它會對輸入文件中的文本做特定的替換。C預處理器的主要優點是他可以包含其他文件,并且基于該文件的內容對它的宏列表進行擴展。
一個快速查看預處理器所做事情的方法,是對上個練習中的代碼執行下列命令:
```sh
cpp ex18.c | less
```
這會產生大量輸出,但是如果你滾動它,會看到你使用`#include`包含的其他文件的內容。在原始的代碼中向下滾動,你可以看到`cpp`如何基于頭文件中不同的`#define`宏來轉換代碼。
C編譯器與`cpp`的集成十分緊密,這個例子只是向你展示它是如何在背后工作的。在現代C語言中,`cpp`系統也集成到C的函數中,你或許可以將它當做C語言的一部分。
在剩余的章節中,我們會使用更多預處理器的語法,并且像往常一樣解釋它們。
## 原型對象系統
我們所創建的OOP系統是一個簡單的“原型”風格的對象系統,很像JavaScript。你將以設置為字段的原型來開始,而不是類,接著將他們用作創建其它對象實例的基礎。這個“沒有類”的設計比起傳統的基于類的對象系統更加易于實現和使用。
## Object頭文件
我打算將數據類型和函數聲明放在一個單獨的頭文件中,叫做`object.h`。這個是一個標準的C技巧,可以讓你集成二進制庫,但其它程序員任然需要編譯。在這個文件中,我使用了多個高級的C預處理器技巧,我接下來準備簡略地描述它們,并且你會在后續的步驟中看到。
```c
#ifndef _object_h
#define _object_h
typedef enum {
NORTH, SOUTH, EAST, WEST
} Direction;
typedef struct {
char *description;
int (*init)(void *self);
void (*describe)(void *self);
void (*destroy)(void *self);
void *(*move)(void *self, Direction direction);
int (*attack)(void *self, int damage);
} Object;
int Object_init(void *self);
void Object_destroy(void *self);
void Object_describe(void *self);
void *Object_move(void *self, Direction direction);
int Object_attack(void *self, int damage);
void *Object_new(size_t size, Object proto, char *description);
#define NEW(T, N) Object_new(sizeof(T), T##Proto, N)
#define _(N) proto.N
#endif
```
看一看這個文件,你會發現我使用了幾個新的語法片段,你之前從來沒見過它們:
`#ifndef`
你已經見過了用于創建簡單常量的`#define`,但是C預處理器可以根據條件判斷來忽略一部分代碼。這里的`#ifndef`是“如果沒有被定義”的意思,它會檢查是否已經出現過`#define _object_h`,如果已出現,就跳過這段代碼。我之所以這樣寫,是因為我們可以將這個文件包含任意次,而無需擔心多次定義里面的東西。
`#define`
有了上面保護該文件的`#ifndef`,我們接著添加`_object_h`的定義,因此之后任何試圖包含此文件的行為,都會由于上面的語句而跳過這段代碼。
`#define NEW(T,N)`
這條語句創建了一個宏,就像模板函數一樣,無論你在哪里編寫左邊的代碼,都會展開成右邊的代碼。這條語句僅僅是對我們通常調用的`Object_new`制作了一個快捷方式,并且避免了潛在的調用錯誤。在宏這種工作方式下,`T`、`N`還有`New`都被“注入”進了右邊的代碼中。`T##Proto`語法表示“將Proto連接到T的末尾”,所以如果你寫下`NEW(Room, "Hello.")`,就會在這里變成`RoomProto`。
`#define _(N)`
這個宏是一種為對象系統設計的“語法糖”,將`obj->proto.blah`簡寫為`obj->_(blah)`。它不是必需的,但是它是一個接下來會用到的有趣的小技巧。
## Object源文件
`object.h`是聲明函數和數據類型的地方,它們在`object.c`中被定義(創建),所以接下來:
```c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "object.h"
#include <assert.h>
void Object_destroy(void *self)
{
Object *obj = self;
if(obj) {
if(obj->description) free(obj->description);
free(obj);
}
}
void Object_describe(void *self)
{
Object *obj = self;
printf("%s.\n", obj->description);
}
int Object_init(void *self)
{
// do nothing really
return 1;
}
void *Object_move(void *self, Direction direction)
{
printf("You can't go that direction.\n");
return NULL;
}
int Object_attack(void *self, int damage)
{
printf("You can't attack that.\n");
return 0;
}
void *Object_new(size_t size, Object proto, char *description)
{
// setup the default functions in case they aren't set
if(!proto.init) proto.init = Object_init;
if(!proto.describe) proto.describe = Object_describe;
if(!proto.destroy) proto.destroy = Object_destroy;
if(!proto.attack) proto.attack = Object_attack;
if(!proto.move) proto.move = Object_move;
// this seems weird, but we can make a struct of one size,
// then point a different pointer at it to "cast" it
Object *el = calloc(1, size);
*el = proto;
// copy the description over
el->description = strdup(description);
// initialize it with whatever init we were given
if(!el->init(el)) {
// looks like it didn't initialize properly
el->destroy(el);
return NULL;
} else {
// all done, we made an object of any type
return el;
}
}
```
這個文件中并沒有什么新東西,除了一個小技巧之外。`Object_new`函數通過把原型放到結構體的開頭,利用了`structs`工作機制的一個方面。當你在之后看到`ex19.h`頭文件時,你會明白為什么我將`Object`作為結構體的第一個字段。由于C按順序將字段放入結構體,并且由于指針可以指向一塊內存,我就可以將指針轉換為任何我想要的東西。在這種情況下,即使我通過`calloc`獲取了一大塊內存,我仍然可以使用`Object`指針來指向它。
當我開始編寫`ex19.h`文件時,我會把它解釋得更詳細一些,因為當你看到它怎么用的時候才能更容易去理解它。
上面的代碼創建了基本的對象系統,但是你需要編譯它和將它鏈接到`ex19.c`文件,來創建出完整的程序。`object.c`文件本身并沒有`main`函數,所以它不可能被編譯為完整的程序。下面是一個`Makefile`文件,它基于已經完成的事情來構建程序:
```makefile
CFLAGS=-Wall -g
all: ex19
ex19: object.o
clean:
rm -f ex19
```
這個`Makefile`所做的事情僅僅是讓`ex19`依賴于`object.o`。還記得`make`可以根據擴展名構建不同的文件嗎?這相當于告訴`make`執行下列事情:
+ 當我運行`make`時,默認的`all`會構建`ex19`。
+ 當它構建`ex19`時,也需要構建`object.o`,并且將它包含在其中。
+ `make`并不能找到`object.o`,但是它能發現`object.c`文件,并且知道如何把`.c`文件變成`.o`文件,所以它就這么做了。
+ 一旦`object.o`文件構建完成,它就會運行正確的編譯命令,從`ex19.c`和`object.o`中構建`ex19`。
## 游戲實現
一旦你編寫完成了那些文件,你需要使用對象系統來實現實際的游戲,第一步就是把所有數據類型和函數聲明放在`ex19.h`文件中:
```c
#ifndef _ex19_h
#define _ex19_h
#include "object.h"
struct Monster {
Object proto;
int hit_points;
};
typedef struct Monster Monster;
int Monster_attack(void *self, int damage);
int Monster_init(void *self);
struct Room {
Object proto;
Monster *bad_guy;
struct Room *north;
struct Room *south;
struct Room *east;
struct Room *west;
};
typedef struct Room Room;
void *Room_move(void *self, Direction direction);
int Room_attack(void *self, int damage);
int Room_init(void *self);
struct Map {
Object proto;
Room *start;
Room *location;
};
typedef struct Map Map;
void *Map_move(void *self, Direction direction);
int Map_attack(void *self, int damage);
int Map_init(void *self);
#endif
```
它創建了三個你將會用到的新對象:`Monster`,`Room`,和`Map`。
看一眼`object.c:52`,你可以看到這是我使用`Object *el = calloc(1, size)`的地方。回去看`object.h`的`NEW`宏,你可以發現它獲得了另一個結構體的`sizeof`,比如`Room`,并且分配了這么多的空間。然而,由于我像一個`Object`指針指向了這塊內存,并且我在`Room`的開頭放置了`Object proto`,所以就可以將`Room`當成`Object`來用。
詳細分解請見下面:
+ 我調用了`NEW(Room, "Hello.")`,C預處理器會將其展開為`Object_new(sizeof(Room), RoomProto, "Hello.")`。
+ 執行過程中,在`Object_new`的內部我分配了`Room`大小的一塊內存,但是用`Object *el`來指向它。
+ 由于C將`Room.proto`字段放在開頭,這意味著`el`指針實際上指向了能訪問到完整`Object`結構體的,足夠大小的一塊內存。它不知道這塊內存叫做`proto`。
+ 接下來它使用`Object *el`指針,通過`*el = proto`來設置這塊內存的內容。要記住你可以復制結構體,而且`*el`的意思是“`el`所指向對象的值”,所以整條語句意思是“將`el`所指向對象的值賦給`proto`”。
+ 由于這個謎之結構體被填充為來自`proto`的正確數據,這個函數接下來可以在`Object`上調用`init`,或者`destroy`。但是最神奇的一部分是無論誰調用這個函數都可以將它們改為想要的東西。
結合上面這些東西,我就就可以使用者一個函數來創建新的類型,并且向它們提供新的函數來修改它們的行為。這看起來像是“黑魔法”,但它是完全有效的C代碼。實際上,有少數標準的系統函數也以這種方式工作,我們將會用到一些這樣的函數在網絡程序中轉換地址。
編寫完函數定義和數據結構之后,我現在就可以實現帶有四個房間和一個牛頭人的游戲了。
```c
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "ex19.h"
int Monster_attack(void *self, int damage)
{
Monster *monster = self;
printf("You attack %s!\n", monster->_(description));
monster->hit_points -= damage;
if(monster->hit_points > 0) {
printf("It is still alive.\n");
return 0;
} else {
printf("It is dead!\n");
return 1;
}
}
int Monster_init(void *self)
{
Monster *monster = self;
monster->hit_points = 10;
return 1;
}
Object MonsterProto = {
.init = Monster_init,
.attack = Monster_attack
};
void *Room_move(void *self, Direction direction)
{
Room *room = self;
Room *next = NULL;
if(direction == NORTH && room->north) {
printf("You go north, into:\n");
next = room->north;
} else if(direction == SOUTH && room->south) {
printf("You go south, into:\n");
next = room->south;
} else if(direction == EAST && room->east) {
printf("You go east, into:\n");
next = room->east;
} else if(direction == WEST && room->west) {
printf("You go west, into:\n");
next = room->west;
} else {
printf("You can't go that direction.");
next = NULL;
}
if(next) {
next->_(describe)(next);
}
return next;
}
int Room_attack(void *self, int damage)
{
Room *room = self;
Monster *monster = room->bad_guy;
if(monster) {
monster->_(attack)(monster, damage);
return 1;
} else {
printf("You flail in the air at nothing. Idiot.\n");
return 0;
}
}
Object RoomProto = {
.move = Room_move,
.attack = Room_attack
};
void *Map_move(void *self, Direction direction)
{
Map *map = self;
Room *location = map->location;
Room *next = NULL;
next = location->_(move)(location, direction);
if(next) {
map->location = next;
}
return next;
}
int Map_attack(void *self, int damage)
{
Map* map = self;
Room *location = map->location;
return location->_(attack)(location, damage);
}
int Map_init(void *self)
{
Map *map = self;
// make some rooms for a small map
Room *hall = NEW(Room, "The great Hall");
Room *throne = NEW(Room, "The throne room");
Room *arena = NEW(Room, "The arena, with the minotaur");
Room *kitchen = NEW(Room, "Kitchen, you have the knife now");
// put the bad guy in the arena
arena->bad_guy = NEW(Monster, "The evil minotaur");
// setup the map rooms
hall->north = throne;
throne->west = arena;
throne->east = kitchen;
throne->south = hall;
arena->east = throne;
kitchen->west = throne;
// start the map and the character off in the hall
map->start = hall;
map->location = hall;
return 1;
}
Object MapProto = {
.init = Map_init,
.move = Map_move,
.attack = Map_attack
};
int process_input(Map *game)
{
printf("\n> ");
char ch = getchar();
getchar(); // eat ENTER
int damage = rand() % 4;
switch(ch) {
case -1:
printf("Giving up? You suck.\n");
return 0;
break;
case 'n':
game->_(move)(game, NORTH);
break;
case 's':
game->_(move)(game, SOUTH);
break;
case 'e':
game->_(move)(game, EAST);
break;
case 'w':
game->_(move)(game, WEST);
break;
case 'a':
game->_(attack)(game, damage);
break;
case 'l':
printf("You can go:\n");
if(game->location->north) printf("NORTH\n");
if(game->location->south) printf("SOUTH\n");
if(game->location->east) printf("EAST\n");
if(game->location->west) printf("WEST\n");
break;
default:
printf("What?: %d\n", ch);
}
return 1;
}
int main(int argc, char *argv[])
{
// simple way to setup the randomness
srand(time(NULL));
// make our map to work with
Map *game = NEW(Map, "The Hall of the Minotaur.");
printf("You enter the ");
game->location->_(describe)(game->location);
while(process_input(game)) {
}
return 0;
}
```
說實話這里面并沒有很多你沒有見過的東西,并且你只需要理解我使用頭文件中宏的方法。下面是需要學習和理解的一些重要的核心知識:
+ 實現一個原型涉及到創建它的函數版本,以及隨后創建一個以“Proto”結尾的單一結構體。請參照`MonsterProto`,`RoomProto`和`MapProto`。
+ 由于`Object_new`的實現方式,如果你沒有在你的原型中設置一個函數,它會獲得在`object.c`中創建的默認實現。
+ 在`Map_init`中我創建了一個微型世界,然而更重要的是我使用了`object.h`中的`NEW`宏來創建全部對象。要把這一概念記在腦子里,可以試著把使用`NEW`的地方替換成`Object_new`的直接調用,來觀察它如何被替換。
+ 使用這些對象涉及到在它們上面調用函數,`_(N)`為我做了這些事情。如果你觀察代碼`monster->_(attack)(monster, damage)`,你會看到我使用了宏將其替換成`monster->proto.attack(monster, damage)`。通過重新將這些調用寫成原始形式來再次學習這個轉換。另外,如果你被卡住了,手動運行`cpp`來查看究竟發生了什么。
+ 我使用了兩個新的函數`srand`和`rand`,它們可以設置一個簡單的隨機數生成器,對于游戲已經夠用了。我也使用了`time`來初始化隨機數生成器。試著研究它們。
+ 我使用了一個新的函數`getchar`來從標準輸入中讀取單個字符。試著研究它。
## 你會看到什么
下面是我自己的游戲的輸出:
```shell
$ make ex19
cc -Wall -g -c -o object.o object.c
cc -Wall -g ex19.c object.o -o ex19
$ ./ex19
You enter the The great Hall.
> l
You can go:
NORTH
> n
You go north, into:
The throne room.
> l
You can go:
SOUTH
EAST
WEST
> e
You go east, into:
Kitchen, you have the knife now.
> w
You go west, into:
The throne room.
> s
You go south, into:
The great Hall.
> n
You go north, into:
The throne room.
> w
You go west, into:
The arena, with the minotaur.
> a
You attack The evil minotaur!
It is still alive.
> a
You attack The evil minotaur!
It is dead!
> ^D
Giving up? You suck.
$
```
## 審計該游戲
我把所有`assert`檢查留給你作為練習,我通常把它們作為軟件的一部分。你已經看到了我如何使用`assert`來保證程序正確運行。然而現在我希望你返回去并完成下列事情:
+ 查看你定義的每個函數,一次一個文件。
+ 在每個函數的最上面,添加`assert`來保證參數正確。例如在`Object_new`中要添加`assert(description != NULL)`。
+ 瀏覽函數的每一行,找到所調用的任何函數。閱讀它們的文檔(或手冊頁),確認它們在錯誤下返回什么。添加另一個斷言來檢查錯誤是否發生。例如,`Object_new`在調用`calloc`之后應該進行`assert(el != NULL)`的檢查。
+ 如果函數應該返回一個值,也確保它返回了一個錯誤值(比如`NULL`),或者添加一個斷言來確保返回值是有效的。例如,`Object_new`中,你需要在最后的返回之前添加`assert(el != NULL)`,由于它不應該為`NULL`。
+ 對于每個你編寫的`if`語句,確保都有對應的`else`語句,除非它用于錯誤檢查并退出。
+ 對于每個你編寫的`switch`語句,確保都有一個`default`分支,來處理非預期的任何情況。
花費一些時間瀏覽函數的每一行,并且找到你犯下的任何錯誤。記住這個練習的要點是從“碼農”轉變為“黑客”。試著找到使它崩潰的辦法,然后盡可能編寫代碼來防止崩潰或者過早退出。
## 附加題
+ 修改`Makefile`文件,使之在執行`make clean`時能夠同時清理`object.o`。
+ 編寫一個測試腳本,能夠以多種方式來調用該游戲,并且擴展`Makefile`使之能夠通過運行`make test`來測試該游戲。
+ 在游戲中添加更多房間和怪物。
+ 把游戲的邏輯放在其它文件中,并把它編譯為`.o`。然后,使用它來編寫另一個小游戲。如果你正確編寫的話,你會在新游戲中創建新的`Map`和`main`函數。
- 笨辦法學C 中文版
- 前言
- 導言:C的笛卡爾之夢
- 練習0:準備
- 練習1:啟用編譯器
- 練習2:用Make來代替Python
- 練習3:格式化輸出
- 練習4:Valgrind 介紹
- 練習5:一個C程序的結構
- 練習6:變量類型
- 練習7:更多變量和一些算術
- 練習8:大小和數組
- 練習9:數組和字符串
- 練習10:字符串數組和循環
- 練習11:While循環和布爾表達式
- 練習12:If,Else If,Else
- 練習13:Switch語句
- 練習14:編寫并使用函數
- 練習15:指針,可怕的指針
- 練習16:結構體和指向它們的指針
- 練習17:堆和棧的內存分配
- 練習18:函數指針
- 練習19:一個簡單的對象系統
- 練習20:Zed的強大的調試宏
- 練習21:高級數據類型和控制結構
- 練習22:棧、作用域和全局
- 練習23:認識達夫設備
- 練習24:輸入輸出和文件
- 練習25:變參函數
- 練習26:編寫第一個真正的程序
- 練習27:創造性和防御性編程
- 練習28:Makefile 進階
- 練習29:庫和鏈接
- 練習30:自動化測試
- 練習31:代碼調試
- 練習32:雙向鏈表
- 練習33:鏈表算法
- 練習34:動態數組
- 練習35:排序和搜索
- 練習36:更安全的字符串
- 練習37:哈希表
- 練習38:哈希算法
- 練習39:字符串算法
- 練習40:二叉搜索樹
- 練習41:將 Cachegrind 和 Callgrind 用于性能調優
- 練習42:棧和隊列
- 練習43:一個簡單的統計引擎
- 練習44:環形緩沖區
- 練習45:一個簡單的TCP/IP客戶端
- 練習46:三叉搜索樹
- 練習47:一個快速的URL路由
- 后記:“解構 K&R C” 已死