C的笛卡尔之梦

 主页   资讯   文章   代码   电子书 

练习19:一个简单的对象系统

原文:Exercise 19: A Simple Object System

我在学习面向对象编程之前学了C,所以它有助于我在C中构建面向对象系统,来理解OOP的基本含义。你可能在学习C之前就学了OOP语言,所以这章也可能会起到一种衔接作用。这个联系中,你将会构建一个简单的对象系统,但是也会了解更多关于C预处理器的事情。

这个练习会构建一个简单的游戏,在游戏中你会在一个小型的城堡中杀死弥诺陶洛斯,并没有任何神奇之处,只是四个房间和一个坏家伙。这个练习同时是一个多文件的项目,并且比起之前的一些程序看起来更像一个真正的C程序。我在这里介绍C预处理器的原因,是你需要它来在你自己的程序中创建多个文件。

C预处理器如何工作

C预处理器是个模板处理系统,它主要的用途是让C代码的编程更加容易,但是它通过一个语法感知的模板机制来实现。以前人们主要使用C预处理器来储存常量,以及创建“宏”来简化复杂的代码。在现代C语言中你会实际上使用它作为代码生成器来创建模板化的代码片段。

C预处理器的工作原理是,如果你给它一个文件,比如.c文件,它会处理以#(井号)字符开头的各种文本。当它遇到一个这样的文本时,它会对输入文件中的文本做特定的替换。C预处理器的主要优点是他可以包含其他文件,并且基于该文件的内容对它的宏列表进行扩展。

一个快速查看预处理器所做事情的方法,是对上个练习中的代码执行下列命令:

cpp ex18.c | less

这会产生大量输出,但是如果你滚动它,会看到你使用#include包含的其他文件的内容。在原始的代码中向下滚动,你可以看到cpp如何基于头文件中不同的#define宏来转换代码。

C编译器与cpp的集成十分紧密,这个例子只是向你展示它是如何在背后工作的。在现代C语言中,cpp系统也集成到C的函数中,你或许可以将它当做C语言的一部分。

在剩余的章节中,我们会使用更多预处理器的语法,并且像往常一样解释它们。

原型对象系统

我们所创建的OOP系统是一个简单的“原型”风格的对象系统,很像JavaScript。你将以设置为字段的原型来开始,而不是类,接着将他们用作创建其它对象实例的基础。这个“没有类”的设计比起传统的基于类的对象系统更加易于实现和使用。

Object头文件

我打算将数据类型和函数声明放在一个单独的头文件中,叫做object.h。这个是一个标准的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制作了一个快捷方式,并且避免了潜在的调用错误。在宏这种工作方式下,TN还有New都被“注入”进了右边的代码中。T##Proto语法表示“将Proto连接到T的末尾”,所以如果你写下NEW(Room, "Hello."),就会在这里变成RoomProto

#define _(N)

这个宏是一种为对象系统设计的“语法糖”,将obj->proto.blah简写为obj->_(blah)。它不是必需的,但是它是一个接下来会用到的有趣的小技巧。

Object源文件

object.h是声明函数和数据类型的地方,它们在object.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文件,它基于已经完成的事情来构建程序:

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.cobject.o中构建ex19

游戏实现

一旦你编写完成了那些文件,你需要使用对象系统来实现实际的游戏,第一步就是把所有数据类型和函数声明放在ex19.h文件中:

#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

它创建了三个你将会用到的新对象:MonsterRoom,和Map

看一眼object.c:52,你可以看到这是我使用Object *el = calloc(1, size)的地方。回去看object.hNEW宏,你可以发现它获得了另一个结构体的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代码。实际上,有少数标准的系统函数也以这种方式工作,我们将会用到一些这样的函数在网络程序中转换地址。

编写完函数定义和数据结构之后,我现在就可以实现带有四个房间和一个牛头人的游戏了。

#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”结尾的单一结构体。请参照MonsterProtoRoomProtoMapProto
  • 由于Object_new的实现方式,如果你没有在你的原型中设置一个函数,它会获得在object.c中创建的默认实现。
  • Map_init中我创建了一个微型世界,然而更重要的是我使用了object.h中的NEW宏来创建全部对象。要把这一概念记在脑子里,可以试着把使用NEW的地方替换成Object_new的直接调用,来观察它如何被替换。
  • 使用这些对象涉及到在它们上面调用函数,_(N)为我做了这些事情。如果你观察代码monster->_(attack)(monster, damage),你会看到我使用了宏将其替换成monster->proto.attack(monster, damage)。通过重新将这些调用写成原始形式来再次学习这个转换。另外,如果你被卡住了,手动运行cpp来查看究竟发生了什么。
  • 我使用了两个新的函数srandrand,它们可以设置一个简单的随机数生成器,对于游戏已经够用了。我也使用了time来初始化随机数生成器。试着研究它们。
  • 我使用了一个新的函数getchar来从标准输入中读取单个字符。试着研究它。

你会看到什么

下面是我自己的游戏的输出:

$ 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。然后,使用它来编写另一个小游戏。如果你正确编写的话,你会在新游戏中创建新的Mapmain函数。