13.4 重新开始

13.4.1 明确任务

在着手写程序的最初,明确程序的要求和游戏的规则非常重要。尽管题目中给出了游戏的规则,然而认真地思考一下,就会发现,其实对于究竟要做什么以及不少细节性的东西,目前在我们的脑海里还是空白。

这似乎是一件简单的事情,然而却是程序开发中最重要的事情。你无法在不完全清楚程序任务的情况下正确地写出程序。现实中更坏的可能是,很多情况下对程序的任务要求几乎一无所知。

初学者往往容易有一个误区,就是轻率地以为他们了解问题也清楚程序要做什么,然而实际上却并非如此。所以解决问题的第一步是了解问题本身究竟是什么,并在此基础上设想、归纳、总结程序应该具有什么样的行为,越具体越好。

就本问题而言,应该想到的是,要开发的程序究竟是两个人之间通过软件进行游戏,还是一个人与计算机之间的游戏,还是两种功能都要具有。须知,这些几乎是完全不同的任务要求。开发这些程序采用的可能是完全不同的思路,经历完全不同的过程。

假设最终确认的任务是开发一个人与计算机之间进行的Tic-Tac-Toe游戏。作为一项思想的成果(不要笑),应该认真地把它准确地记录下来,要么记录在纸上,要么记录在源文件中——作为注释或程序的使用说明书。对于本问题来说,这个步骤并不很难(后面可能还会有反复或补充),工作量也不大。但是必须看到,这是不可或缺的一个工作步骤,在真正的软件开发过程中,这可能是一项相当耗神的工作。

13.4.2 程序功能的初步定义

接下来需要设想程序的行为。思考的原则仍然是所谓的“Top-Down”,先从整体、大处考虑,不要急着考虑细节。

按照一般的套路(就像前面提过的那样),程序运行开始之后总要显示一些欢迎之类的客气话,以示程序的友好和亲和力,有点像柔道比赛之前对手之间互相鞠躬表示尊重。之后可能需要向程序使用者介绍游戏的具体规则。

为了体现游戏的公平性,还应该有个抽签仪式,以决定谁先走棋。再然后就开始下棋(怎么下还是个问题,不过先不忙着细想)。棋局结束后宣布结果,然后程序结束。

这些就是对程序功能的概要性的描述。为了在总体上把握程序的正确性,这种概要性的思考一般宜粗不宜细,重点在于从全局保证程序功能上的完整性和逻辑上的正确性。须知,这种总体上的任何考虑不周,都可能导致整个开发的失败,有时候还不得不从头再来。所以通常是在保证了程序总体上的正确性之后,再对程序的各个部分进行逐步的细化工作,直到最后给出代码。

如果反复考虑后觉得思路可行,同样应该认真记录下这个总体思路,可以记录在纸上,也可以用伪代码的形式写在源文件中——事实上现在完全可以写main()的框架了。

下面我们重新开始,把原来的半截子烂尾楼工程放在一边(4)(有些用的着的内容是可以直接复制过来的)。

打开Dev C++,选择“Console Application”(控制台应用程序)类型工程,并为工程命名为“Tic-Tac-Toe”。然后开始写main(),并把main()所在的源文件取名为0_Tic-Tac-Toe.c。

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

13.4 重新开始 - 图1

13.4 重新开始 - 图2

必须要说明的是,这段伪代码并不是一次性得到的,而是经历过若干次反复的思考才得到的。反复的次数与个人运用程序设计语言的能力及编程经验成反比,与问题的难度成正比。但无论如何,在这里多花些时间是值得的,不要急着写代码。因为这里出现的错误到后期都会被放大几倍甚至几十倍,而且修改的代价极大,甚至没有修复的可能。

尽管没有写详细的代码,然而用伪代码描述的程序总体框架已经完成。我个人喜欢把这种伪代码写成合法的C语言代码的形式(while语句中的“0”就是这个目的),这可以保证随时能进行测试(注意,现在这个代码完全可以通过编译并运行)。

13.4.3 输出

代码中的“显示欢迎语”、“介绍游戏规则”等动作显然都属于程序的输出。在C语言中,输出的对象只有一种,就是所谓的“流”(stream)。C代码实现向流输出的唯一手段是使用“FILE*”指针。

由于这两个动作都与输出流有关,因此把它们的定义和原型归在同一模块中。这两个动作可以分别抽象为两个函数。一般来讲,这两个函数是输出流和所输出数据的函数。

假如这两个输出都是流向“stdout”的,由于“stdout”本身就是已经定义了的变量(编译器在“库”中定义的外部变量(5)),所以不必再在main()中定义相应的变量。这样这两个函数也就不需要这个“FILE*”类型的参数,因为面向“stdout”输出的库函数通常并不需要这个参数。两个函数所涉及的输出内容可在函数定义的函数体内确定,所以这两个函数同样也不需要所要输出数据的参数。在这个前提下,两个函数都是无参函数。

这样就把组织数据和输出的具体方法的任务从main()中分离了出来,并由“输出”模块单独考虑,而在main()中只需要单纯地考虑调用就可以了。

按照前面约定的工作流程,首先在main()前面添加“#include "1输出.h"”预处理命令,然后在工程中建立“1输出.h”文件;返回main()中写出函数调用语句,再在“1_输出.h”中写出两个函数的原型。

程序代码13-16(1_输出.h)

13.4 重新开始 - 图3

其中的“#include <stdio.h>”是因为这两个函数都必然涉及标准输出函数的调用。

然后,在工程中添加文件“1_输出行为.c”,在这个文件中添加这两个函数的定义及所需的预处理命令。

程序代码13-17(1_输出行为.c)

13.4 重新开始 - 图4

这时可以也应该进行一下测试。测试的方法前面已经演示过,这里不再重复。如果你是在一步一步地模仿着写这段代码,请一定完成测试之后再继续阅读。另外请注意,代码中有一部分是起注释说明作用的伪代码。

这两个函数都不难完成,这里无须再多做考虑。现在转回到main()中考虑其他部分的任务。

13.4.4 抽签

“抽签”部分要解决的问题是两位棋手(计算机和人)谁执黑先行的问题。这个问题的解决方法可以等价地描述为:假设有甲、乙两位棋手,甲执黑先行,乙执白后行,随机地确定甲、乙当中一位是计算机,另一位是人。

显然,“棋手”数据类型应该有一个“所执棋子颜色”的数据成员。为此,首先考虑“棋子”这种数据类型。

1.棋子

不难想到,“棋子”这种数据类型也应该有两种:抽象的和具象的。

作为“棋手”数据类型的数据成员的“棋子”,显然是那种抽象的“棋子”。这种数据只有“黑”、“白”两种值,用一个一位的位段就可以存储。但是由于没有其他可以组合在一起的数据,一位也至少需要一个字节。

显然,由于棋子只有两种值,用enum类型描述更为自然。

考虑到在代码中,由于棋子本身没有什么动作,只是被作为数据传来传去。所以暂时看来,不需要为它单独写一个“棋子行为.c”那样的模块。

然而即便如此,为这种数据类型建立“2_棋子.h”这样一个用来描述其数据类型定义的头文件仍然是必要的,这可以使得各个使用这种数据类型的模块获得一个统一的参照点。建立统一的参照点的意义对于有条理地进行编程具有特别重要的意义。不要偷懒,否则以后可能会遇到的麻烦要多几倍甚至几十倍。

首先,在main()的头部写上“#include "2_棋子.h"”,然后建立这个头文件,给出新的数据类型的定义。

程序代码13-18(2_棋子.h)

13.4 重新开始 - 图5

即使对于如此简单的数据类型,郑重地为它取个新名字也是值得的。因为在问题世界里,用“0”、“1”、“int”这样的概念直接去思考如何解决问题是难以想象的,而且也很不自然,对于比较复杂的问题,甚至是不可能的。况且,我们的思考正在进行中,无法预料后面会有什么样的改变。这里的处理为日后可能的改动留下充分的空间。写代码不可以小家子气,应该堂堂正正。

定义完“棋子”这种数据类型之后,回到main()中,继续考虑其他的问题。

现在可以进一步完善“棋手”这种数据类型了。

2.棋手

“棋手”这种数据类型前面已经初步讨论过了,这里需要进一步补充完善。

由于棋手总是持某种确定颜色的棋子,所以应该具有“所执棋子”这样的数据成员。更多的考虑也许包括“姓名”之类的数据成员。限于篇幅,就不予以考虑了。对于本问题来说,那不是重要的也不是必需的。作为一种编程方法的讲解,本书只关注那些核心的、重要的和必需的数据成员,锦上添花的事情读者可以自己完成。

在main()中(模块0)抽象出来“棋手”这个概念后,首先在main()开头写上“#include "3_棋手.h"”。

然后建立“3_棋手.h”文件(注意先解决文件重复包含问题),在其中定义“棋手”这种数据类型。

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

13.4 重新开始 - 图6

13.4 重新开始 - 图7

注意,由于这里也要用到“棋子”这种数据类型,所以加上了相应的文件包含命令。

在main()的“抽签”中并没有涉及“棋手”的动作,所以暂时这里没有函数原型可写,暂时也不需要建立“3_棋手行为.c”文件。

完成后返回main(),完成目前已经可以完成的部分代码。

3.完成main()中的部分代码

回到main()后,因为已经定义了“棋手”类型,现在完全可以考虑“抽签”这个问题了。

由于问题要求有两位“棋手”,因此这里需要首先定义两个“棋手”类型的变量:“甲”和“乙”。并设定“甲”执黑,“乙”执白。

抽签的办法是根据当时系统时间对2求余是否为0确定谁先走棋(即指定计算机是“甲”棋手还是“乙”棋手)。

由于定义了棋手数据类型,代码中“走棋者变为另一方”的功能也可以很容易地实现:甲、乙两个变量的值交换一下即可。程序代码13-20同样完成了交换这两个变量值的功能。

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

13.4 重新开始 - 图8

13.4 重新开始 - 图9

13.4 重新开始 - 图10

13.4 重新开始 - 图11

不难看出,代码中“抽签”部分的功能是通过函数调用完成的,但是交换“棋手甲”、“棋手乙”两个变量值的代码却是在main()中完成的。

大多数情况下,在本地完成功能代码并不是一种好的编程风格,然而这种做法也并非一无是处,毕竟这可以减少函数调用的开销。在有些对运行速度要求很高的场合,这样完成代码也可以理解。

但是无法否认的是,本质上这与结构化程序设计思想是相悖的,而且把一段功能代码写在本地会使得代码变得凌乱不堪。

在需要这样写(把代码写在原地,而不是通过函数调用实现)的情况下,如何可以让代码在形式上显得更为简洁呢?答案是,可以通过预处理的宏定义命令。所以,让我们稍微再次偏离主题,先深入了解一下预处理中的宏定义命令。