原文: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宏来转换代码。
#include
cpp
#define
C编译器与cpp的集成十分紧密,这个例子只是向你展示它是如何在背后工作的。在现代C语言中,cpp系统也集成到C的函数中,你或许可以将它当做C语言的一部分。
在剩余的章节中,我们会使用更多预处理器的语法,并且像往常一样解释它们。
我们所创建的OOP系统是一个简单的“原型”风格的对象系统,很像JavaScript。你将以设置为字段的原型来开始,而不是类,接着将他们用作创建其它对象实例的基础。这个“没有类”的设计比起传统的基于类的对象系统更加易于实现和使用。
我打算将数据类型和函数声明放在一个单独的头文件中,叫做object.h。这个是一个标准的C技巧,可以让你集成二进制库,但其它程序员任然需要编译。在这个文件中,我使用了多个高级的C预处理器技巧,我接下来准备简略地描述它们,并且你会在后续的步骤中看到。
object.h
#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 _object_h
有了上面保护该文件的#ifndef,我们接着添加_object_h的定义,因此之后任何试图包含此文件的行为,都会由于上面的语句而跳过这段代码。
_object_h
#define NEW(T,N)
这条语句创建了一个宏,就像模板函数一样,无论你在哪里编写左边的代码,都会展开成右边的代码。这条语句仅仅是对我们通常调用的Object_new制作了一个快捷方式,并且避免了潜在的调用错误。在宏这种工作方式下,T、N还有New都被“注入”进了右边的代码中。T##Proto语法表示“将Proto连接到T的末尾”,所以如果你写下NEW(Room, "Hello."),就会在这里变成RoomProto。
Object_new
T
N
New
T##Proto
NEW(Room, "Hello.")
RoomProto
#define _(N)
这个宏是一种为对象系统设计的“语法糖”,将obj->proto.blah简写为obj->_(blah)。它不是必需的,但是它是一个接下来会用到的有趣的小技巧。
obj->proto.blah
obj->_(blah)
object.h是声明函数和数据类型的地方,它们在object.c中被定义(创建),所以接下来:
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指针来指向它。
structs
ex19.h
Object
calloc
当我开始编写ex19.h文件时,我会把它解释得更详细一些,因为当你看到它怎么用的时候才能更容易去理解它。
上面的代码创建了基本的对象系统,但是你需要编译它和将它链接到ex19.c文件,来创建出完整的程序。object.c文件本身并没有main函数,所以它不可能被编译为完整的程序。下面是一个Makefile文件,它基于已经完成的事情来构建程序:
ex19.c
main
Makefile
CFLAGS=-Wall -g all: ex19 ex19: object.o clean: rm -f ex19
这个Makefile所做的事情仅仅是让ex19依赖于object.o。还记得make可以根据扩展名构建不同的文件吗?这相当于告诉make执行下列事情:
ex19
object.o
make
all
.o
一旦你编写完成了那些文件,你需要使用对象系统来实现实际的游戏,第一步就是把所有数据类型和函数声明放在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
它创建了三个你将会用到的新对象:Monster,Room,和Map。
Monster
Room
Map
看一眼object.c:52,你可以看到这是我使用Object *el = calloc(1, size)的地方。回去看object.h的NEW宏,你可以发现它获得了另一个结构体的sizeof,比如Room,并且分配了这么多的空间。然而,由于我像一个Object指针指向了这块内存,并且我在Room的开头放置了Object proto,所以就可以将Room当成Object来用。
object.c:52
Object *el = calloc(1, size)
NEW
sizeof
Object proto
详细分解请见下面:
Object_new(sizeof(Room), RoomProto, "Hello.")
Object *el
Room.proto
el
proto
*el = proto
*el
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; }
说实话这里面并没有很多你没有见过的东西,并且你只需要理解我使用头文件中宏的方法。下面是需要学习和理解的一些重要的核心知识:
MonsterProto
MapProto
Map_init
_(N)
monster->_(attack)(monster, damage)
monster->proto.attack(monster, damage)
srand
rand
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
assert(description != NULL)
assert(el != NULL)
NULL
if
else
switch
default
花费一些时间浏览函数的每一行,并且找到你犯下的任何错误。记住这个练习的要点是从“码农”转变为“黑客”。试着找到使它崩溃的办法,然后尽可能编写代码来防止崩溃或者过早退出。
make clean
make test
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8