13.3 Tic-Tac-Toe游戏

Tic-Tac-Toe游戏,俗称三子棋游戏、井字棋游戏……其规则是在3x3的格子里内由双方轮流落子,先连成直线或斜线者胜。伟大的程序教父Don Knuth先生据他自己说是编了这个游戏的程序之后才真正成为程序员。下面就让我们来追随一下牛人的足迹吧。

13.3.1 分割——思考、手段和意义

1.四顾茫然

可能你感到无从下手。其实我和你一样,写完main()框架之后就不知道干什么好了。

后来我想到,游戏软件一开始通常总是讲些欢迎之类的客气话以增加亲和力,所以我决定从这里着手。

程序代码13-1

13.3 Tic-Tac-Toe游戏 - 图1

这段代码写得看似无懈可击,可是一位小学语文老师看了之后的评价是“无厘头”,因为“致欢迎词”根本没有主语,从这个角度看这是一篇不及格的小学作文。

悲哀!头一次听说写程序还需要主语。但我还是决定试着按小学语文老师的意见修改。

2.主语问题

考虑到欢迎词一般都是领导来致的,所以我决定在代码中加个角色相当于“领导”的变量。“领导”总是念发言稿的,所以“领导”类型的特点就是带着发言稿。为此又定义了如下一种新的数据类型。

13.3 Tic-Tac-Toe游戏 - 图2

再考虑到领导都是从别人那里索取发言稿然后照着读。所以,我把代码改成了程序代码13-2所示。

程序代码13-2

13.3 Tic-Tac-Toe游戏 - 图3

13.3 Tic-Tac-Toe游戏 - 图4

这样,我的作文中的“宣读发言稿”终于有了主语,尽管在语句中是主谓倒置,然而C语言就是这样说话,我没办法改变。小学语文老师这回给的评价是及格:主谓语齐全,段落清晰,但总体上缺乏层次感。于是我决定继续修改,把这篇作文分成两章来写:第一章写“Tic-Tac-Toe”赛场的事情,另一章写领导干的事情。

3.分成两章

如图13-2所示,首先,通过Dev C++的“File\New\Source File”菜单或者“Cltr+N”快捷键可以建立另外一个源文件,建立时会弹出一个“消息框”询问是否把这个文件加入当前的工程中,选“是”。所谓“工程(Project)”,简单不太精确的理解就是构成一个程序的全部源文件的总和。C语言的源程序可以由若干源文件组成。

13.3 Tic-Tac-Toe游戏 - 图5

图13-2 为文件改名

然后把添加的新文件改名为“第二章领导干的事情.c”。此后的工作就是把原来第一章的部分内容移动或复制到第二章。这样最后得到了两个源文件。

程序代码13-3(第一章Tic-Tac-Toe赛场.c)

13.3 Tic-Tac-Toe游戏 - 图6

程序代码13-4(第二章领导干的事情.c)

13.3 Tic-Tac-Toe游戏 - 图7

这次小学语文老师的评价是良好:有层次感,但不足之处是两章的开头(header)重复。

4.添加引言

于是我又添加了一篇引言(在工程中添加文件“开头部分.h”),把两章相同的部分写到其中,在第一章和第二章相应的位置只写了“参见引言”(#include "开头部分.h")。下面是最后的代码。由于编译器并不支持汉字作为标识符,为了能让程序编译运行,伪代码中的汉字标识符都替换成了对应的汉语拼音。

程序代码13-5(开头部分.h)

13.3 Tic-Tac-Toe游戏 - 图8

程序代码13-6(第一章Tic-Tac-Toe赛场.c)

13.3 Tic-Tac-Toe游戏 - 图9

程序代码13-7(第二章领导干的事情.c)

13.3 Tic-Tac-Toe游戏 - 图10

程序的运行结果如图13-3所示。

13.3 Tic-Tac-Toe游戏 - 图11

图13-3 添加引言

这和最初程序的运行结果完全一致。所以结论就是一个源程序可以分成若干源文件。有读者或许会问:这样折腾的意义何在?下面就回答这个问题。

5.分割源程序的意义

首先,在有些情况下必须把源程序分割成若干源文件。比如几个人同时开发同一个源程序的时候,这几个人共同编辑同一个源文件是不可能的事情。

分成几个源文件的好处还有,形成的各个翻译单元(Translation Unit)可以分别编译成二进制文件(3),最后再连接(Link)成一个可执行文件(扩展名为“.EXE”)。这样如果只对某个源文件进行了修改,就不需要把所有源文件都重新再编译一次。须知,对于大型程序来说,编译非常耗时。当然,本书中代码的规模还不至于需要分别编译。

本质上来说,C语言编译(Compile)的基本单元是预处理之后由源文件形成的翻译单元,而不一定是整个源程序。每个翻译单元编译后都会形成一个所谓的目标文件,这个目标文件已经是机器语言代码。但是这种机器代码并不能直接运行,还需要通过链接程序把所有目标文件以及所用到的库函数中的代码链接(Link)装配成为一体,才能形成一个完整的可执行文件,如图13-4所示。顺便说一句,通常的IDE中可能用“Make”或“Build”指代编译和链接这两个过程的合称,而“Rebuildall”通常是指把所有的翻译单元重新编译链接一次。

13.3 Tic-Tac-Toe游戏 - 图12

图13-4 编译与链接

其次,即使是个人编写程序,若把源文件分成相对独立的若干模块,那么在各个模块中就只需要考虑本模块需要解决的问题,而不需要考虑程序其他部分的许多问题,这样一个大的问题就化简成了若干规模较小、相对简单的问题。这是“TOP-DOWN”(自顶向下)思想在源文件级别的体现,其道理就如同把main()中的代码写成若干函数。这可以使得开发过程本身以及代码的组织更具有条理。“条理”就是生产力,在软件业,“条理”是第一生产力。

总之,把源程序分割成若干源文件,由于可以减少代码各个部分之间的耦合,因而可以降低问题的难度,这无论对于源程序的编辑、编译,还是测试和调试都具有很重要的意义。

还需要指出的是,应该如何划分程序模块只有哲学意义上的抽象的指导原则,其核心要求就是,有利于代码的组织和开发过程的组织。但并没有具体的、通用的指导原则(至少我不知道有放之四海而皆准的原则),只能具体问题具体分析。不要忘记,软件设计也是一种艺术,而艺术则意味着为想象力和创造力留下了很大的发挥空间。本章中这个例题采用的办法是依据模块涉及的核心数据对象进行模块划分的。

“#include”预处理命令所包含的头文件是保证两个模块之间协调一致的纽带和关键。不难发现,只要“开头部分.h”文件的内容不变,当修改“第二章领导干的事情.c”中的内容时,对于“第一章Tic-Tac-Toe赛场.c”中的代码没有影响。

13.3.2 总结

1.OO式的思考

“OO”(Object-oriented)就是所谓的“面向对象”。面向对象至少有两个方面的含义:其一是思考方式,其二是程序设计语言在语法层面适当的支持。C语言在语法层面上不属于面向对象式的语言,但这并不妨碍我们用OO的方式思考问题,并在这种思想的指导下有条有理地组织程序。

从前面的思考过程来看,都是使用“主语”为主导,结合“主语”的行为(“谓语”)进行思考。这其实是我们日常生活中最频繁、最自然的思考方式。

在这种思考方式下,可以把main()函数(当然其他函数也可以)当作“剧本”或者当作对一特定场景中发生的“故事”来写。

一旦你确定了场景中的某个“人物”(主语),也就很容易找出相关的行为(“谓语”)。这样整个场景中的“故事”就很容易用自然语言描述出来。这种自然的语言才是我们最擅长的思考方式和描述解决问题的方式。用这种方式思考,由于其方式自然,所以难度也小。更主要的是,这种方式有利于进行概括性的思维。而离开了概括性的思维,根本不可能把握住复杂的问题。如果一开始就纠缠于琐碎的细节,只会把问题搞得更乱,更没有头绪。

所谓的“主语”,其实无非是某些特定数据的结合。你所需要的是建立这种数据类型并定义这种类型的变量。而“谓语”是主语参与的动作。“动作”这种东西在C语言中是用运算符描述的,“()”运算符是C语言中最复杂的也是功能最全面的运算符。所以一切“动作”也都可以映射为C语言中的函数。谓语总是和主语密切相关的,把它们归结在一个模块中,无疑是把原来的问题分解成了更小的问题,因为此时你只需要考虑这种特定类型数据的操作。“动作”的“主语”在C语言中可以用函数的参数表示,“动作”涉及(亦即用到)的其他数据(你可以把它们理解为“宾语”)也是如此。这就是把相对容易的自然语言思考结果转化为C代码的全部哲学基础。

当然,OO还有许多语法层面的技术细节,C语言并不很好地支持这些现代特性(OO理论是C语言发明之后发展的)。比如“封装”、“继承”、“多态”就是C语言中所没有的概念。但在开发程序的过程中,这些概念或其背后的理念并非绝对地不可能应用到C代码中去,只是实现方式不那么直截了当、不那么方便也无法做到十分严格而已。

OO最大的优点在于符合我们的自然思维,但在C代码中的实现需要一定的技术基础作为保障,同时也很难十分严格地满足OO理论的要求,有时还需要付出一些麻烦的代价。这种代价是否值得,要与所获得的回报比较过后才可能得到结论。在本书看来,能让思考简单化、条理化,让代码在程式化的工作过程中以逐步迭代的方式,得到有条不紊的增量式的不断推进,是程序开发过程中头等重要的事情。

2.PO式的思考与实现

PO(Procedure-oriented,面向过程,过程在C语言中就是函数)这种方式是以“动词”为中心进行思考的。C语言的函数是描述这种思考最有力的方式。每一个函数名其实都是一个“动词”。

必须承认,这种思考方式与我们平时多数情况下的思考方式是不同的。当然,对于训练有素的编程人员例外。事实上,多数人学习程序设计最初遇到的困难恰恰是这种思维方式的转变问题——代码是以动词为中心组织的。入门之后面对复杂的问题时,一般还会遇到类似的困窘。

任何OO式的思考,也都可以转用PO方式描述。事实上无论OO语言还是PO语言,最终的实现也总是归结到PO方式。OO只是人类自然思考方式与PO描述方式之间的一个思想跳板。在支持OO的语言中,这种跳板搭建得很好。而在C语言中,这种思想跳板要靠你自己搭建。由于C语言本身的特性,这种思想跳板不可能搭建得十分完美。但搭建的质量,与对C语言的理解深度和运用能力成正比。

3.工作流程与约定

在这种思想基础上,后面的程序编写主要基于下面这样的工作流程。

■ 以先后次序为据,为每个模块编号。同一功能模块的.h和对应的.c文件编号相同。

■ 当在m模块中抽象出某数据D之后,首先在m模块文件头部或对应的头文件中写上预处理命令#include n_XXX.h,其中n为需要新建立的模块的编号。

■ 在工程中添加文件“n_XXX.h”(也可能这个文件已经存在了),在其中定义数据对象D的数据类型,然后返回m模块。

■ 在模块m中定义D变量,写出关于D的函数调用语句,然后在文件“n_XXX.h”中写出该函数的函数原型,再返回模块m。

■ 根据模块m对函数的要求,总结出对函数功能的要求,然后建立“n_XXX行为.c”,在其中写入预处理命令#include n_XXX.h,写入根据函数功能要求得到的函数定义。

还需要说明的是,无论是函数原型还是函数定义,有时可能无法一次性完成,而是需要通过逐次补充才能完成(逐步增加参数的情况)。

这并不是一个放之四海而皆准的工作流程,而是笔者为自己提出的一个写代码的工作流程。这个流程的优点在于,可以很自然地思考,并能保证在一种有条不紊的前提下做到代码的逐步递增。之所以不避鄙陋地在这里提出来,是为了给读者一个参考和借鉴,并且也是为了后面描述程序编写过程的方便。也许你还能找到更科学合理或更适合你自己的工作流程。

在一种切实可行的、循规蹈矩的工作方式下作到代码的逐步递增,在笔者看来特别重要。因为谁也不能保证天天有灵感,况且无论你多么聪明,在面对复杂的问题时,你的聪明总是有限的。

13.3.3 回到起点

前面介绍了把代码分成若干模块的原则、过程和技术手段,但原来的问题依然如故,并没有得到进一步解决,我们只是手里又多了一件武器或工具而已。为了检验这种武器或工具的有效性,下面用这种武器或工具研究继续我们的问题。

首先把工程中的文件按照约定加上编号,然后开始考虑main()中构成游戏都需要哪些元素。

游戏需要参加者,即棋手,所以需要有棋手这样的数据类型。在“0第一章Tic-Tac-Toe赛场.c”的头部写上“#include "2棋手.h"”。

在工程中添加文件“2_棋手.h”,然后在该文件中定义该种数据类型。由于棋手可能由计算机充任,也可能是使用计算机运行程序的人。因而这种数据类型至少要有一个表示其种类的数据成员。

由于棋手“种类”只有计算机和人两种可能,所以用枚举类型描述恰如其分。

13.3 Tic-Tac-Toe游戏 - 图13

这样,棋手这种数据类型暂时可以描述为:

13.3 Tic-Tac-Toe游戏 - 图14

回到“0_第一章Tic-Tac-Toe赛场.c”文件。从程序要求看,需要两个“棋手类型”的变量,因此在main()中定义两个“棋手类型”的变量。

棋手类型甲,乙;

甲.棋手种类=人类;

乙.棋手种类=计算机;

又由于棋手都参与“走棋”,所以需要建立“走棋()”函数,这个函数至少需要“棋手”这样一个参数(应该注意到“人”走的方法和“计算机”走的方法有所不同);由于“走棋()”不会引起棋手本身有什么变化,所以这个参数可以描述为“棋手类型 棋手”。如果顾虑这样传递参数效率太低(比如“棋手”这种数据类型很大),也可以使用指针:“const棋手类型*p棋手”。其中的“const”表示的是“p棋手”所指向的数据对象在这个函数里不应该有所改变。

“走棋”这个函数是否应该具有返回值呢?这是个见仁见智的问题。你可以认为“走棋”的返回值是出现输赢、和局或者两者皆非,也可以认为没有返回值,然后在main()通过其他的办法判断是否出现输赢、和局或者两者皆非。就我个人而言,更喜欢前面的描述方法,因为那样代码紧凑、漂亮。但恐怕那样太复杂,不利于后面对程序的解说。所以这里选择“走棋”没有返回值。这样,相应的函数调用语句如下所示。

走棋(甲);

走棋(乙);

在文件“2棋手.h”,中写出“走棋()”的函数原型,“2棋手.h”的内容目前如下所示。

程序代码13-8(2_棋手.h)

13.3 Tic-Tac-Toe游戏 - 图15

13.3 Tic-Tac-Toe游戏 - 图16

回到“0第一章Tic-Tac-Toe赛场.c”中,给出“走棋()”的功能要求。由于这个函数调用目前尚未给出全部实参,这时可以考虑只写出一个空函数。建立一个“2棋手行为.c”文件,写入“走棋()”的函数定义。

程序代码13-9(2_棋手行为.c)

13.3 Tic-Tac-Toe游戏 - 图17

这些工作完成后,差不多就可以让main()中的棋手“走两步”了。

13.3.4 “走两步”

“走两步”的意义非常重大。每写完或修改完一个函数都立即进行测试,是程序员应该信守的一个准则。有条件要测试,没有条件创造条件也要测试。因为,无论你走得有多快,带着BUG是绝对走不远的,迟早你还要回头。

为了让棋手能够“走两步”,将“0_第一章Tic-Tac-Toe赛场.c”改写为如下所示。

程序代码13-10(0_第一章Tic-Tac-Toe赛场.c)

13.3 Tic-Tac-Toe游戏 - 图18

注意,原来“2棋手.h”,和“2棋手行为.c”中的各处伪代码中的汉字都已经改成了拼音。并且把“2_棋手行为.c”改写为如下所示。

程序代码13-11(2_棋手行为.c)

13.3 Tic-Tac-Toe游戏 - 图19

编译的结果产生了一个“Warnning”(警告):

13.3 Tic-Tac-Toe游戏 - 图20

原因显然是"2棋手.h"中没写“#include <stdio.h>”。在"2棋手.h"中添上“#include <stdio.h>”之后重新编译,警告消失。

运行结果如图13-5所示。

13.3 Tic-Tac-Toe游戏 - 图21

图13-5 “定两法”

显然,测试通过了。

但随之产生了两个问题,下面分别解答。

13.3.5 重复#include的问题

第一个问题是,在“2棋手.h”中添上“#include <stdio.h>”之后,在“0第一章Tic-Tac-Toe赛场.c”中的

13.3 Tic-Tac-Toe游戏 - 图22

实际上相当于写了两次“#include <stdio.h>”,因为包含的这两个“*.h”文件中都有“#include <stdio.h>”这条预处理命令。奇怪的是编译并没有出错。这是什么道理呢?

按照常理分析,写了两次“#include <stdio.h>”那么“stdio.h”中的函数原型、数据类型以及常量都被说明或定义了两次,这怎么可能没错呢?

如果打开“stdio.h”这个文件,多半会发现其开头会是这样两条预处理命令。

13.3 Tic-Tac-Toe游戏 - 图23

其结尾是这样一条预处理命令。

13.3 Tic-Tac-Toe游戏 - 图24

这三条预处理命令是即使写多次“#include <stdio.h>”也不会造成因为重复包含而出错的保障机制。

开头的“#ifndefSTDIO_H”预处理命令和结尾的“#endif”预处理命令是所谓的条件编译预处理命令,它们总是成对使用的,含义是,如果“STDIO_H”这个标识符没有被定义过(通过宏定义预处理命令#define),那么这两行预处理命令之间的内容参与编译,否则就不参与编译。

“#defineSTDIO_H”是一个宏定义预处理命令,这条命令已经使用过多次,现在我们会发现在宏名“STDIO_H”后面居然可以什么都不写。这也是宏定义预处理命令的一种写法,它并不关心“STDIO_H”被定义成什么,只关心它是否被定义过。

这样,由于“#include "1开头部分.h"”中的“#include <stdio.h>”导致的结果是“#define STDIO_H”,也就是“STDIO_H”已“被定义过”这样的结果,那么当预处理器“#include "2棋手.h"”第二次遇到“#include <stdio.h>”时,预处理器会发现“#ifndef_STDIO_H”已经不成立了,因而它与“#endif/*_STDIO_H*/”之间的内容就不会被编译了。这种办法是一种常见的保证不至于产生多重“#include”的基本技术。

见贤思齐,我们赶快把自己的几个头文件也添加上类似的预处理命令组。

下面是对“2_棋手.h”的修改,其余各处的修改在这里就不显示了。

程序代码13-12(2_棋手.h)

13.3 Tic-Tac-Toe游戏 - 图25

13.3 Tic-Tac-Toe游戏 - 图26

请注意“M_2_QS_H”这个宏名,它不是以下划线开头随后是一大写字母这样的风格。由于编译器用到的许多预先定义的宏都是下划线开头随后是一大写字母这样的风格,因此为避免重名应该主动回避编译器的命名风格。

13.3.6 如何处理测试用的代码

在源文件“0_第一章Tic-Tac-Toe赛场.c”中,有几行代码和某些变量纯粹是为了测试“走棋()”这个函数能否被很好地调用而写的,对于要解决的问题并没有直接的意义。在初步的测试通过之后,这些代码应该如何处理,是本小节要讨论的主题。

首先,可以考虑删除。理由是初步测试已经通过。

然而,“走棋()”这个函数并没有完全完成,随着对这个函数功能的补充和完善,后面可能还需要反复测试。这样看来,这些代码还有保留的必要。

那么,用注释把这些测试代码注释掉如何?必须承认,这个想法比鲁莽地删除要强多了。然而这也可能存在一些另外的问题,比如在添加注释和去除注释的过程中可能产生失误,并且这种杂乱无章地改来改去的过程多半是很混乱的。

所以在规模比较大的程序的开发过程中,还会遇到如何干净、简洁地“冬眠”某段代码或使其“复活”的问题。

这个问题用预处理命令解决更干净利索。比如,把“0_第一章Tic-Tac-Toe赛场.c”改写为如下所示。

程序代码13-13(0_第一章Tic-Tac-Toe赛场.c)

13.3 Tic-Tac-Toe游戏 - 图27

其中“#if 0”、“#if 1”和“#endif”都是条件编译预处理命令,其作用也是通知编译器哪段代码参加编译或哪段代码不参加编译。

这种条件编译命令的一般写法如下所示。

13.3 Tic-Tac-Toe游戏 - 图28

其中“[]”表示可选的含义。预处理器将会依次计算各个表达式(显然,这些都是常量表达式),如果发现某个表达式不为0,则编译其后相应的代码段,否则编译#else后面的代码段。整个结构和C语言中的if-else语句的执行类似,而且条件编译命令同样也允许嵌套使用。

需要特别指出的是,使用这种编译预处理命令比“if-else”语句还要注意配对关系,因为其结构更松散。为了改善这种结构的可读性,保持对编译过程有条不紊的管理,常见的办法是在对应的“#if”、“#endif”后面写上相同的注释。

全面地了解了这种编译预处理命令之后会发现,“0_第一章Tic-Tac-Toe赛场.c”更好的写法应该如下所示。

程序代码13-14(0_第一章Tic-Tac-Toe赛场.c)

13.3 Tic-Tac-Toe游戏 - 图29

这种写法的好处在于把运行部分的代码与测试部分的代码截然分成了两个部分,彼此之间互不干扰。而在测试部分中,又把各种情况下的测试分成了若干独立的部分,在测试时可以方便地通过改变常量的方式组合编译所需要的代码。就本段代码而言,不足之处是那个预处理命令行中的“0”和“1”这两个常量表达式,要是使用预先定义的有意义的符号常量就更好了。

有条理地测试并不比有条理地编写代码更容易,反而更为困难。限于本书的主旨,测试的组织大体也只能讲这么多了,更深入的内容请阅读专门的软件测试方面的文献。

此外还有一点需要补充,就是在预处理常量表达式中容许有“defined”这个预处理运算符。比如

13.3 Tic-Tac-Toe游戏 - 图30

1 也可以写做defined(TEST)。

13.3 Tic-Tac-Toe游戏 - 图31

其中“#if defined TEST”和“#ifdef TEST”的含义是一样的;同理,“#if!defined (TEST)”和“#ifndef TEST”的含义也是一样的。

除了用于测试,这种条件编译命令也常用于开发具有多个版本的程序,或者编写可适合多种不同类型机器环境的程序。

13.3.7 再次回到起点

到目前为止,我们似乎一直在进一步退两步的状态中反复徘徊。实际上这是软件开发的常态,没有人可以一蹴而就地拿出方案、设计,更不要说代码了。所以我一直奇怪为什么许多书籍像魔术师变兔子那样一下子就能给出完整的代码。

如果你对此感到不习惯,恐怕唯一的办法就是努力去适应。如果知道许多软件的维护成本是开发成本的几倍这样一个事实的话,我觉得你对这种反复带来的烦躁可能会减轻一些。

现在回到main(),继续分析这个问题。目前我们已经完成了其中所需要的主要的两种数据类型(棋手、领导)的定义和部分与它们有关的函数定义。

由于main()描述的是赛场内的事情,所以还需要有“棋盘”。不少初学者很难想到或者理解程序中实际上需要两张“棋盘”,一张是“抽象的棋盘”,它实际上是对真实的棋盘的一种数字化表示,是供程序用来记录或模拟真实的“棋盘”状态,它只需要能刻画出与真实棋盘一一对应的状态即可,无需形状、尺寸等这些外在的物理特性,也就是说只需要能描述一个3×3个格子的各种可能的状态即可。另一张棋盘则是“具象的棋盘”,它用于在程序内部描述在计算机屏幕上显示的那个棋盘。这两张“棋盘”尽管关系密切,然而在概念和功能上毕竟是两个独立的实体。它们之间的关系仿佛是围棋选手在对局室用的棋盘和转播大厅用于讲解演示的棋盘。如果用C语言来打比方,就好比语句“printf("%d",123——);”中的那个123与在屏幕上“画”出来的1、2、3那三个字符图案之间的关系。前者实际上是内存中的一个二进制数,而后者则只是在屏幕上画出的字符图案而已。如果用“%X”这种转换格式,那么画出的则是7、B这两个字符的图案。同样的道理,对于同一个抽象的棋盘来说,具象的棋盘可以被画得大些,也可以画得小些。

或问,把两者结合在一起,形成一个有里有表的棋盘如何?这样做也可以。但难度稍微大一点。只要你觉得在同一个模块里同时操作这两种数据没有什么难度,这也没什么不可以的。

一般来说,把相关的数据聚合为一体,更有助于概括性的抽象思维以及函数间的数据传递。在main()中,有两个不同种类的“棋盘”变量与只有一个“棋盘”变量相比,显然后者更有助于思考main()函数应该如何编写,这叫做“高内聚”。离开了这种抽象和概括,对复杂问题的思考几乎是不可能的。设想一下,不使用数组,代之以100个变量,你还会写代码吗?

但是在处理数据的时候,一定要善于把不相干的数据及一切不相干的内容撇开,集中处理你所直接关心的数据。这不但可以减轻写代码的工作强度,测试、调试以及维护的成本都更低。其道理其实很简单,汽车的轮子不和车轴固接在一起,汽车就无法作为一个整体跑起来;但是汽车的轮子和车轴是分别生产的,修理时,通常更是要把它们分开。

13.3.8 抽象的棋盘

到目前为止,“走棋”这个函数还是一个半成品,因为这个函数除了需要“棋手”这种数据以外,显然还需要一个棋盘,这个棋盘显然应该是“抽象的棋盘”。下面考虑建立这个模块。

首先还是像前面一样,在工程中添加一个“3_抽象的棋盘.h”,在其中给出“抽象棋盘”这种数据类型的定义。

在“3_抽象的棋盘.h”中需要给出“抽象棋盘”这种数据类型的定义,显然3x3的数组是最直观的选择。问题是每个元素的数据类型是什么?

由于每个元素描述的都是真实棋盘上的格子的状态,每个格子的状态一共有三种可能,一种为空,而另外两种状态,则不同的编程者可能有不同的想法和选择。比如黑和白,或者第一个棋手落的子和第二个棋手落的子。究竟采用哪种,要看代码怎么写比较容易,而代码怎么写容易又取决于你对问题的理解方式和描述方式。

比如说,如果你对游戏规则的理解和描述是:先走的棋手必须用黑子,那用黑和白来描述棋盘的状态可能更方便些。如果游戏规则是先走棋的棋手可以任选黑白一种颜色的棋子,那可能用先走的棋手落的子与后走的棋手落的子的描述更方便些。总之要顺其自然。

明确这些细节问题,应该是编程一开始就进行的工作,而不是开发中再进行的工作。然而尴尬的是这项工作还没开始。教训啊!

好在现在是在学习编程而不是在开发软件,好在前面真正进行了的工作尚不多,好在这个疏忽现在还可以补救,所以我们重新回头明确一下程序究竟要做什么,而且应该仔细地、明确地记录下来。

补充了不少编译预处理和程序组织方面的知识之后,现在让我们开始编程吧!