13.7 走棋

现在回到main(),继续考虑main()中的其他部分。

“走棋”这个动作明显是由棋手完成的,因此“棋手”应该是这个函数的参数,并且应该把这个函数写在“棋手”模块中。但是,这个函数还涉及到另外一个参数“抽象的棋盘”。

一种思路是,在main()中建立一个“抽象的棋盘”类型的变量,把这个变量作为参数传递给函数。但是为了演示另一种思考问题和组织程序的方式,这里不准备采用这种思路。

13.7.1 多态

首先在main()中写出“走棋”的函数调用语句,如下所示。

程序代码13-28(0_Tic-Tac-Toe.c)

13.7 走棋 - 图1

13.7 走棋 - 图2

然后在“3_棋手.h”中写出其函数原型,如下所示。

程序代码13-29(3_棋手.h)

13.7 走棋 - 图3

回到main()后,着手考虑函数的定义。首先在工程中添加文件“3_棋手行为.c”,然后根据main()对该函数提出的功能要求,写出函数定义。

应该注意到,这个函数完成的动作与棋手是人还是计算机相关。这样可以根据棋手的类别把这个函数的功能分别交由两个函数完成。

这两个函数的功能都是选择一个落子点将所执棋子落入“抽象棋盘”,不同之处在于旗手种类不同,选择落子点的方法不同。相同之处是都以“所执棋子”作为参数,完成选择落子点的功能。之后的事情就是“抽象棋盘”“被”在某处落某种颜色棋子的问题了。

程序代码13-30(3_棋手行为.c)

13.7 走棋 - 图4

13.7 走棋 - 图5

13.7 走棋 - 图6

zq()函数中的两句if语句的代码非常相似,显得非常烦琐。可以用指向函数的指针所构成的数组来改进,如下所示。

13.7 走棋 - 图7

这样的代码显得非常优雅简洁,但这种方法不是总能如愿。比如当“该棋手种类”无法用连续的从0开始的枚举类型值描述时,这种办法就难以奏效。另外一种改进的办法是用宏实现,但其中的一个难点是“计算机走棋”和“人类走棋”这两个单词的名称不同,下面介绍这个问题的解决办法。

13.7.2 拼接单词

1.“##”运算符

在宏定义中,可以使用运算符“##”构造特定的“预处理单词”(Preprocessing Token)。例如

13.7 走棋 - 图8

代码中的标识符“XY”将被展开为“xy”。再如

13.7 走棋 - 图9

代码中的标识符“HASH_HASH”,将被展开为“##”。这个宏定义的替换列表中,中间的两个“##”是运算符,两边的空格是必须的,否则“####”可能被预处理器解释为“##”“##”。无疑,这是不合法的。但在展开时,运算符“##”两边的空格将和“##”一同被删除。

“##”是个二元运算符,在其前面和后面都需要有一个运算对象,因此C语言要求这个运算符不可以出现在替换列表的开头和结尾。这个运算符出现在类似函数的宏中更为多见。下面是一个简单的示例。

13.7 走棋 - 图10

那么在代码中的S(1,2)在宏替换结束后将变成“12”,S(x,y)将成为xy。如果你高兴,也可以把“if”这个关键字写成S(i,f)(估计你还不至于这么无聊吧)。

如果在调用宏的时候没有提供相应的参数,那么这个缺少的参数会被视为"",比如S(x,),展开之后得到的是“x”。

如果展开后的“预处理单词”中还有宏名,预处理器还会继续进行替换。

例如

13.7 走棋 - 图11

那么代码中的“S(AB)”最终将被替换为“36”。

这样,如果定义宏

13.7 走棋 - 图12

那么“计算机走棋”和“人类走棋”这两个单词就都可以用宏展开的方法得到。这样就把函数名这样的单词参数化了。而一旦实现参数化,把两条if语句都写成同一个宏的调用就不那么困难了。同时,这也为日后程序可能的扩展留下了空间(很容易添加函数和修改代码)。

2.#undef

这样“3_棋手行为.c”中的“走棋()”函数就可以写成如下所示。

程序代码13-31(3_棋手行为.c(片段))

13.7 走棋 - 图13

13.7 走棋 - 图14

其中的“#undef行棋”预处理命令的作用是取消一个预处理宏定义,也可以理解为规定了一个宏的有效区间的结束位置,一个宏只在这个区间之内有效。例如

13.7 走棋 - 图15

那么,“AB”这个宏在从“#defineAB”到“#undefAB”的区间之外是无效的。

由于“走棋_(×)”这个宏只用于局部,因此应该尽量限制其作用范围。

在文件中的两个函数定义及函数原型前面出现了“static”关键字,其含义在稍后解释。

13.7.3 static函数

程序代码13-31(3_棋手行为.c)中函数定义前面出现的关键字“static”的含义是表明该函数只可以在模块内部也就是翻译单元(Translation Unit)内部调用。每个模块向外部其他模块暴露的东西应该越少越好。如果一个函数只在函数内部被使用,就应该被定义成“static”存储类别。这个“static”可以看作是C语言对面向对象编程思想中“封装”的一种初级的理解和简陋的实现手段。“封装”的理由不难被理解:事实上,大多数工业产品都是“封装”好的。比如电视机只留出电源、信号和几个开关等有限几个外部接口。作为软件,也应该如此。

函数原型前面的“static”的含义是这个函数是static存储类别,其定义在文件后面。由于这些函数并不需要暴露给其他模块,所以,这些函数原型也没必要写在相应的头文件中。

按照这种“封装”的观点来看,多数函数都应该尽可能是static存储类别的。然而C语言中函数的默认(不做任何说明时)存储类别却是“extern”存储类别—也就是说默认为其他模块可以调用。这非常令人遗憾,甚至这被许多人认为是C语言的一大缺憾。没有任何语言能够十全十美,对于这种缺憾,应该努力回避——把尽量写static函数作为自己的编程习惯。就像Brian Kernighan说的那样:"Use the good features of a language; avoid the bad ones"(使用语言中好的特性而避免那些坏的)。但是,难点和前提是知道好歹。

这样看来,"0_Tic-Tac-Toe-.c"中的“抽签()”函数也应该被改为static函数。

13.7.4 完成“棋手”模块

下面首先考察一下“3棋手行为.c”中“计算机走棋()”和“人类_走棋();”这两个函数对抽象棋盘模块的要求。

1.计算机_走棋()

“计算机_走棋()”这个函数准备以一种简单的方式实现:即要求抽象棋盘模块提供棋盘上空格的个数(比如为N),然后随机选择一个O~N-1中的数作为选择空格的序号,其大致的实现过程如下所示。

13.7 走棋 - 图16

由于“空格数目”的计算与“抽象棋盘”的数据密切相关,而“落子_由计算机”可以理解为“抽象棋盘”“被”“落子”,所以这两个函数被考虑安排在“抽象棋盘”模块中。

为此,首先在“3棋手.h”中添加“#include"5抽象棋盘.h"”命令。

然后在工程中新建一个“5抽象棋盘.h”文件,在其中写出“空格数目();”和“落子由计算机(M,某种棋子);”的函数原型。注意,由于用到了“棋子颜色类型”,所以在“5抽象棋盘.h”这个文件中还需要写上“#include"2棋子.h"”。

然后再在工程中添加“5抽象棋盘行为.c”文件,在其中写出空的“空格数目()”和“落子由计算机(M,某种棋子);”的函数定义。

2.人类_走棋()

再来详细分析一下“人类_走棋()”这个函数对抽象棋盘模块的要求。这个函数的功能大致可以用下面的方法实现。

13.7 走棋 - 图17

这个函数只要求“抽象棋盘”模块提供“落子由人类()”这样一个函数。为此,分别在“5抽象棋盘.h”和“5_抽象棋盘行为.c”中写出这个函数的原型和定义。

“printf("请输入格子的x, y坐标");和scanf("%d%d", &x, &y);”这两条语句也可以用一个宏展开实现。在"请输入格子的x, y坐标"字符串字面量中的x, y可以通过预处理运算符“#”给出。

3.“#”运算符

“#”运算符出现在宏展开部分某个参数前面时,表示把这个参数替换为相应的字符串字面量,比如对于宏定义

13.7 走棋 - 图18

代码中的S(123)会被替换为“"123"”。

这个运算符在写调用printf()输出的宏时非常有用,比如

13.7 走棋 - 图19

这样的函数调用,可以写成

13.7 走棋 - 图20

那么

13.7 走棋 - 图21

展开之后得到的是

13.7 走棋 - 图22

由于预处理阶段相邻的字符串字面量会被合并为一个,所以最终得到的就是

13.7 走棋 - 图23

此外,如果参数中存在“\”、“"”这样的字符,它们将被保留成为字符串字面量中的“\”、“\"”;如果参数中存在若干连续的空白字符,这些空白字符将被合并成一个空格。

在程序调试阶段,可能需要在很多地方写类似的函数调用语句,这时利用这种运算会使得输出十分清晰。

这样,“printf("请输入格子的x, y坐标");scanf("%d%d", &x, &y);”就可以由

13.7 走棋 - 图24

13.7 走棋 - 图25

通过SHURU(x, y)这样的宏展开得到了。

当然,这里只是介绍一种预处理的知识,代码中做这样的处理是否非常恰当则是另一个问题。

13.7.5 抽象棋盘——对封装的模拟

如果程序中的某种数据对象只有一个,那么很容易把它按照面向对象的编程思想“伪装”成一个“对象”。

事实上,面向对象编程思想的核心理念之一就是把某种数据及与其相关的操作组织为一体。对于使用这种数据的代码只暴露必要的接口,这些接口无非就是函数调用而已。

OO式的编程语言通常是首先定义一个对象变量(主语),然后通过这个主语调用函数(谓语),所调用的函数针对的就是“主语”这个特定的对象变量的操作(OO式的语言通常把这种操作叫做对象的“方法”),一般的语句句型如下所示。

13.7 走棋 - 图26

C语言没有类似的句型,把特定的数据对象与特定的函数绑定在一起很需要技术和技巧。然而对于本例题来说,由于“抽象的棋盘”在程序中是唯一的,那么这个主语显然是可以省略的,但是需要把这个作为主语的数据对象定义为外部变量,而且最好与和它相关的函数定义定义在一个模块中。如果将之定义为static类别,这在事实上就实现了封装。而对这个模块中的extrern存储类别函数的调用,效果就相当于“主语.行为(参数)”省略主语的情况。这样就很容易用OO的思想去思考问题、组织代码了。

“抽象棋盘”首先需要能够描述3×3个格子的各种可能的状态。格子一共有3种可能的状态(黑子、白子、空),这个状态可以用枚举数据类型描述。这样目前可将“5_抽象棋盘.h”写为如下所示。

程序代码13-32(5_抽象棋盘.h)

13.7 走棋 - 图27

13.7 走棋 - 图28

此外,由于“棋手”模块要求这个模块提供空格的个数,在模块中定义一个static存储类别的外部变量将省去每次都通过函数数一遍空格个数的麻烦(空间换时间)。或问,将之定义为extern存储类别的外部变量,“棋手”模块获得这个值不是更方便吗?为什么要定义成static存储类别呢?答案是:封装。面向对象的程序设计思想通常喜欢把数据隐藏在模块内部而不提供直接让模块外部读写的自由。这样外部读写这个数据就只有通过extern函数,模块可以在这些函数内对读写这些函数实现一定的限制以保证模块内部的数据不被滥用。

程序代码13-33(5_抽象棋盘行为.C)

13.7 走棋 - 图29

“落子由计算机()”和“落子由人类()”这两个函数的前半部分(改写抽象棋盘的数据并使空格数减1)不难写出。其后,都应该把所走的这一“着数”(落子位置和棋子颜色)传递给改写“具象棋盘”的函数。显然,由于“具象棋盘”本身是static外部变量,因此把这个函数定义为在“具象棋盘”模块中的一个extern函数比较恰当。另一方面,为了减少数据传输参数的个数,也为了明确传递参数的意义,应定义“着数”这种数据类型。这个数据类型可以定义在“抽象棋盘”模块,也可以定义在“具象棋盘”模块。如果选择把这种类型定义在“抽象棋盘”模块中,那么在“具象棋盘”模块中写改写“具象棋盘”的函数原型和定义时,应注意到要加入“#include"5抽象棋盘.h"”。同时,由于“抽象棋盘”模块需要调用“具象棋盘”模块中的函数,在“5抽象棋盘.h”也需要加入“#include"4_具象棋盘.h"”命令。

这样分析后得到的“5_抽象棋盘.h”如下所示。

程序代码13-34(5抽象棋盘.h)

13.7 走棋 - 图30

由于“具象棋盘”模块和“抽象棋盘”模块互相引用对方的头文件,这时很容易产生函数说明写在类型定义之前的问题。通常在各个头文件中应当把类型定义写在前面,在遇到相互引用的情况时把“#include”命令写在类型定义与函数声明之间的位置。这是我的一点经验之谈。

之后就可以完成“5_抽象棋盘行为.c”文件,这个文件目前的样子如下所示。

程序代码13-35(5_抽象棋盘行为.C)

13.7 走棋 - 图31

13.7 走棋 - 图32

13.7 走棋 - 图33

13.7 走棋 - 图34

13.7.6 具象棋盘的处理

现在由“抽象棋盘”模块中的“lz_y_jsj()”和“lz_y_rl()”函数中的“xg_jxqp(zs);”函数调用转到了“具象棋盘”模块。Xg_jxqp()这个函数的定义并不难完成,如下所示。

程序代码1336(4具象棋盘行为.C(片段))

13.7 走棋 - 图35

完成了这个函数后,就可以返回main()了。这时main()还有两个功能没有完成。“出现方获胜或和棋”这个函数显然写在“抽象棋盘”中较为恰当。这样就需要在“0Tic-Tac-Toe.h”加入“#include"5抽象棋盘.h"”,同时应该注意到这个函数应该返回一个值,由于这个值只有4种可能:黑胜、白胜、平局、尚无法判断,因此可以在“抽象棋盘”中定义一个表示棋局状况的枚举数据类型来作为函数返回值的数据类型。在main()中可以用一个变量记录下这个返回值,这样“宣布比赛结果()”的问题也就解决了。

13.7.7 反省与检讨

至此,整个程序的框架全部建立完毕。但是需要特别指出的是,这个代码非常粗糙,尚有许多缺陷。

首先,“人类走棋()”这个函数并没有考虑到游戏者输入错误这一可能,而是建立在游戏者输入一定正确的前提下完成的。我们知道,这几乎是不可能的。一旦输入有错误,程序将发生无法预知的错误。这说明程序缺乏“强健性”。真正的程序对可能发生的错误必须事先有所防备,给出必要的处理。也就是说,不能假设程序用户永远不出错误,程序必须要有一定的“容错”能力。就本问题来说,至少应该考虑到输入的是非法数据(不合输入格式或超出棋盘边界)以及输入了合法数据但棋盘上该处不为空的情况。

其次,对于两种棋盘数据对象,程序中均没有很好地考虑其初始化问题。对于真正的程序来说,数据对象的初始化问题往往是一个非常重要的问题,一般需要一个专门的函数完成初始化这个任务。如果将本章的这个代码改成可以反复游戏的程序,就会发现这个问题非常严重。

最后,这个例子的目的是为了演示程序的组织和预处理命令的应用,但是个别地方的预处理命令应用得是否特别恰当则值得商榷,比如“#”运算符的那个例子。

Don Knuth先生曾经说过,他编写的那个“Tic-Tac-Toe游戏”程序带有自动学习的功能。所以这里的程序代码离Don Knuth先生当年的境界显然还差得很远。限于篇幅,本书无法把这个代码应该具备的功能写得更加完善。值得一提的是,这个代码可以很容易地被修改成两个人之间的游戏——把cq()函数中的两处“JSJ”改为“RL”就可以了。

把代码改成计算机与计算机之间的游戏也不难,不过修改的地方可能稍微多些(主要需要考虑到计算机走棋很快这个特点)。