第1章 第一个游戏

图像说明文字

一说到游戏,很多人脑海中浮现的可能是家用游戏机上的绚丽画面。不过遗憾的是,我们暂时还不能开发那样的游戏,实际上即使技术上没有问题,那种作品也不是一个人就可以完成的。为了让大家对自己目前的技术水平有个了解,我们先试着开发一个最简单的游戏吧。

补充内容是为那些对开发语言不够熟悉的、编程入门级别的读者准备的。同时,如果读者只具备 Java 或者 C# 等高级语言的使用经验,就可能不是特别熟悉位运算和指针。另外,虽然在 Java 等语言中也经常出现“引用”这个概念,但是如果要搞清楚它和指针之间的关系,就必须学习 C ++ 的一些特性。关于这些,书中大概有几十页相对枯燥的内容,读者也可以暂时略过,后面需要时再回头重读。

1.1 开发一个益智游戏

提到最简单的游戏,读者会想到什么呢?象棋、日本将棋、五子棋这类游戏虽然很简单,但是都有个缺点,就是不能一个人玩。如果要支持单人游戏模式,就必须加入 AI(Artificial Intelligence,人工智能)程序,但我们暂时不想搞得这么麻烦。游戏中的 AI 开发需要考虑的东西太多,不是一个简单的工作。

因此,作为本书的第一个游戏,我们决定去开发那个非常有名的“推箱子”的游戏。准备好场景数据后就可以进行单人游戏,游戏中也没有什么复杂的动作,因此无须涉及太多东西就能够完成。从结果来看,只需 200 行的代码就可以实现。专业的游戏开发程序员甚至可能用不了 2 个小时。不过即便是这样一个简单的程序,我们也可以从中学到不少东西。

1.1.1  示例

为了将示例程序运行起来,需要做一些准备工作。

  • 下载资源文件

首先从以下网址下载本书附带的资源文件。

http://www.ituring.com.cn/book/1742

打开以上网址,点击“随书下载”,下载资源文件。里面包括 GameLib2017.zip 这个文件,将其解压到合适的位置。这里我们把文件解压到 D 盘的根目录下(d:\)。可以看到解压完成后出现了“d:\ GameLib”目录 ①,里面包含了所有文件。

  • 执行示例程序

在解压后生成的文件夹中,示例程序在 src 目录下。这里存放了示例代码的 Visual Studio 解决方案文件。打开 01_FirstGame 解决方案,然后将其中的 NimotsuKun 项目设置为启动项目,按下 F5 执行。

图像说明文字

① Windows 的标准术语应该是“文件夹”,但本书统一使用“目录”(directory)一词。原因是在编程语境中,Windows 也使用“目录”一词,比如 GetCurrentDirectory()函数。

可以看到控制台画面上显示了一些单调的字符,一动不动。这就是我们将要开发的第一个游戏的画面。

用键盘进行操作,这里只支持移动操作。“p”代表玩家角色。上下左右移动分别通过 “w”“z”“a”“s”键控制,并且需要按下回车键(Enter)。例如,依次按下“a”键和回车键后,玩家角色将向左移动。

除了“p”外,“#”代表墙壁,“o”代表尚未到达目的地的箱子,“.”代表目的地。为了便于识别,玩家到达“.”处时将变成“P”,箱子将变成“O”。等到所有的箱子都被推到“.”处并变成 “O”时,就表示游戏成功了。游戏中的箱子只能一个一个地推,不能拉。以上就是游戏大概的玩法。

游戏的名字叫作《箱子搬运工》。为这样一个山寨游戏起名其实还挺纠结的,不过为了避免侵权,暂时就用这个名字吧。

为了避免让读者觉得这样的东西做出来也没什么意思,我们先来展示一下这个游戏最终的成品模样。运行 NonFree 解决方案中的 NimotsuKunFinal 项目(运行游戏前需要设置环境变量,具体步骤请参考 2.2 节),如下所示。

图像说明文字

如果有足够的美术资源,那么不久后我们就能够制作出这样的游戏了。

1.1.2  准备

下面我们就开始创建程序,不过这里先对一些基础事项进行说明。

  • 创建项目

首先在 Visual Studio 中创建一个 Win32 控制台应用程序项目。因为暂时只需要一个 main.cpp 代码文件就足够了,所以请选择创建“Visual C ++”中的“控制台应用”,其他代码文件以后再添加。本书的所有项目都是这样创建的,还请读者记住这一点。Visual Studio 2017 中的截图如下所示。

图像说明文字

  • 键盘和画面输出

画面上的字符输出,以及键盘输入的获取,都是通过 C ++ 标准库 iostream实现的。输入处理主要使用 istream(input stream),输出处理则依赖 ostream(output stream)。

下面这段代码展示了 iostream的使用方法,我们不妨将其粘贴到 main.cpp 中执行一下看看。

图像说明文字

cin和 cout分别是 istream类型和 ostream类型的全局变量 ①,包含 iostream头文件并声明 using namespace std后,就可以在任意位置使用。

cin通过 >>将输入值写入变量,cout通过 <<将变量值输出。endl是“end line”的缩写,也就是换行,程序执行到该处将会输出一行字符到画面上。

① 全局变量指的是不属于任何类的变量。在 C# 和 Java 中没有这个概念,它们使用类中的 static且 public的变量来替代。

上面虽然只是一个在键盘上输入字符并通过回车键显示字符的简单程序,但这就是该游戏输入输出处理的全部内容。

  • 结束运行

在某些早期的 Vistual Studio 版本中,按下 F5 执行上述代码后,窗口一闪就消失了,我们很难观察到发生了什么。为了能够看清输出结果,可以在 return之前添加下面的无限循环。

while( true ){
;
}

这样一来,除非按下 Shift + F5 终止程序,否则窗口将一直显示在屏幕上。当然,因为这是试验性的代码,所以我们能这么做,但是这种做法在商业产品中是不允许的。

为了便于观察,本章的所有示例程序都将加入这种处理。

1.1.3  主循环

所谓游戏程序,无非就是获取输入、将输入反映到游戏世界中、显示结果这三项处理的无限循环,这个过程称为游戏循环或者主循环,代码如下所示。

图像说明文字

代码十分简单,但据笔者所知,所有的游戏程序无一例外地都采用了这种结构。可能根据游戏的不同,在 getInput()中会有 cin或手柄输入的不同,或者在 draw()中会有 cout或 3D 图形输出的不同,但是最基本的结构都是一致的。

前面的例子中只执行了一次主循环,这是因为该游戏采用了命令行执行的方式。至于如何去掉这种限制,我们将在后续章节中讨论。

1.1.4  编写处理逻辑

接下来开始各个模块的开发。建议读者动起手来,首先试着创建出前面截图中出现的游戏场景。为方便描述,我们将像下面这样使用文本来表示游戏场景。

图像说明文字

请读者尽量将书本合上,试着独立完成。一开始可能会觉得这不过是小菜一碟,但实际动手后会就发现并非如此。读者如果能在一小时内完成那就相当了不起了,在半天之内完成也没有问题,但如果超过两天,今后就要加倍努力了 ①。

1.1.5  测试

代码编写完后必须进行测试,这里我们按照下列四个要点进行。

● 移动墙壁所在的格子会如何

● 移动已经到达目的地的箱子会如何

● 推动两个以上的箱子会如何

● 朝着墙壁推动箱子会如何

测试中是不是发现了很多问题?可能要么墙壁排乱了,要么推动位于目的地的箱子时目标地点不见了,要么箱子被挤到墙壁中了……如果一开始就能正确运行,那固然值得高兴,但从长远来看,现在经历一些挫折或许会更有利。

1.2 示例代码解说

自己开发的《箱子搬运工》能够正常运行吗?如果能,就没有必要再读这一节了,可以进入后面更高级的内容。

但是考虑到一些初学者,这里笔者还是打算以准备好的程序为例进行讲解。请打开 01_ FirstGame 解决方案中的 NimotsuKun 项目,它就是前面我们运行过的示例。

代码中只有 main.cpp 一个文件。为了尽可能地简洁,也没有使用类(class)。除了 cout以外,代码所涉及的基本都是 C 语言的知识。其实对于《箱子搬运工》这样简单的游戏来说,这样的代码已经完全足够了,而且很容易读懂。

1.2.1  包含头文件

代码一开始就包含了头文件,即前面提到的 iostream。接下来的 using namespace std暂时不理解也没关系(参考 1.4.1 节),但要记住必须写上这一句,否则将发生编译错误,不过也可以看一下都会发生哪些错误。

1.2.2  场景数据常量

刚开始我们使用全局变量来存储场景数据。

图像说明文字 图像说明文字

① 笔者用了大约两个小时。如果是新人的话,一般需要整整一天或者两天的时间。

首先用一个容易理解的字符串对该变量赋值,游戏开始后再将其转化为其他形式。相比一开始就使用 0 代表空间、1 代表玩家进行赋值,这样做更加简单。但需要注意的是,在 C ++ 中,如果字符串常量要中途换行显示,就必须在行末加上符号 \。

这里定义的常量都是全局变量,笔者习惯以 g开头命名。因为全局变量可以在程序中的任何地方被访问,所以加上这样一个标志以示区别会很方便。此外,按笔者的命名习惯,变量名都以小写字母开头,其后的每个单词则首字母大写,比如 gStageWidth。另外,因为后续没有修改该值的打算,所以使用了 const关键字,这个习惯可以避免很多 bug。

const int gStageWidth = 8; //第一次赋值 OK 
gStageWidth = 4; //编译错误!

因为添加了 const,所以如果代码试图在变量定义之外的地方对其赋值,则将导致编译错误,这可以防止某些错误操作。建议读者养成这个习惯,对第一次赋值后就不会再修改的变量添加 const关键字。

1.2.3  枚举类型

接下来是枚举类型。

图像说明文字

场景中的所有状态都被保存在容量等于“宽 × 高”的枚举类型数组中。数组元素的类型可以是int或者 char,只要保证元素值不会超出该类型所能表示的范围即可。不过,这样有可能会因为疏忽而代入无意义的值。使用枚举类型则不会有这个问题,并且调试时可以看见枚举类型的名字,很方便,所以应该尽量使用枚举类型。

注意“位于目的地的箱子”和“不在目的地的箱子”的枚举值是不同的。其实也可以采用另一种做法,将是否是目的地的信息单独存储在另外一个数组中,或者通过位运算将两种信息保存在同一个数组里。不过为了便于理解,这里我们采用了最直接的做法。

另外,按照笔者的习惯,枚举类型的名称一律采用大写。注意最后有一个 UNKNOWN,关于它的用法,后面会进行说明。

1.2.4  函数原型

下面我们来看函数原型。

图像说明文字

上述代码声明了几个函数:读取场景数据字符串并将其转换为 O bject数组的 initialize()、画面绘制函数 draw()、更新函数 update()、检测游戏是否通关的 checkClear()。至于前面讨论主循环时出现的 getInput()函数,因为此处我们直接在 main()函数中添加了输入处理的代码,所以用不上。注意开发时应当尽可能地将各个功能封装成函数。

1.2.5  main 函数

现在来看程序的入口 main函数。首先创建一个大小等于“宽 × 高”的 Object数组,并调用初始化函数 initialize(),使游戏处于就绪状态。

图像说明文字

读者可能不太熟悉这种把枚举类型当作类名处理的写法。实际上枚举类型是一种用于列举的类型,所以可以通过 new生成,也可以用作参数和返回值。记住这一点将大有裨益。虽然枚举类型内部本质上是一个 int,但是如果将 Object类型的变量赋值为 5 或者 10 等数值,则会导致编译错误,这就保证了只能通过枚举类型来赋值。

另外请注意,虽然逻辑上 Object应当是个二维数组,但是这里使用了一维数组的创建方式。初学者往往习惯通过二维数组的方式进行声明,即像下面这样。

Object state[ 5 ][ 8 ];

但遗憾的是,二维数组无法通过 new动态创建。如果不采用 new来动态创建,就必须在定义数组时就确定好数组的尺寸,比如横 8 纵 5,但是这样做会使数组的尺寸永远固定,程序将丧失一定的灵活性。后面我们会讨论如何将一维数组当作二维数组来使用。

主循环结束意味着顺利通关,这时会输出胜利时的提示信息,然后释放 state空间并结束程序。虽然在程序退出前不执行 delete也不会有什么问题,但还是应当养成及时释放空间的习惯。注意这里通过 new创建的数组在释放时必须使用 delete[]而非 delete。后面我们会解释这么做的原因。

最好将 delete后的指针赋值为 0,这样可以在很大程度上避免一些指针相关的 bug。笔者在任何时候都遵守这个习惯,哪怕在程序即将退出时,也会将无用的指针赋值为 0。如果不理解原因,读者也可以暂时先不用这样做,后面我们会详细解释(参考 1.6 节)。

接下来我们来看一下主循环处理。

  • 主循环

相关代码大致如下所示。

图像说明文字

为了将输入前的状态反映到画面上,程序一开始就执行了 draw(),但游戏整体仍然按照输入、更新、绘制的顺序执行。调用 draw()后立刻执行通关检测是为了应对满足通关条件时的突发情况。试想如果在某个时刻场景数据已经满足了通关条件,这时没有执行通关处理却继续响应输入,那么推动箱子后就又将变成不允许通关的状态了。正因为如此,才需要在响应输入前先判断通关条件。

输入处理只是简单地通过 cin读取输入的字符,在读取之前程序会提示操作说明。输入的内容会被传递给 update()以更新游戏的状态。

各个游戏的主循环处理基本上都遵循这个模式。

1.2.6  初始化场景

initialize() 的内容如下所示。

图像说明文字 图像说明文字

这段代码会逐个读取字符并将其转换为 Object类型。switch后的 if语句是为了忽略非法输入。虽然程序考虑了数据中存在注释等无关信息的情况,但是错误处理仍不够完善。比如当场景数据残缺不全,或者宽度、高度值和预想的不一致时,程序的行为都是未知的。最好的做法是通过场景数据自行计算出宽度和高度值,我们可以封装一个函数来实现这个功能。

另外,如前所述,state变量是按照一维数组的方式创建的。

state[ y*width + x ] = t; //写入

y*width + x表示从左上角向右 x 列,向下 y 行处的网格位置。

这就是将一维数组当作二维数组使用的方法。

图像说明文字

按照图中的顺序增加下标,不难发现下标值就是 y*width + x。

1.2.7  绘制

图像说明文字

与 initialize()的处理相反,绘制处理将 Object数组的内容转换为玩家能看懂的字符,再通过 cout输出。这部分处理在一些图形游戏中也是类似的,只不过它们是将程序内的数据(例如类的变量)转换为图像渲染出来。

  • 使用枚举类型作为下标

上面的代码中存在如下写法:

cout << font[ o ];

该语句使用枚举类型变量作为数组下标,可能不太好理解。枚举类型本质上是 int,因此可以作为下标使用。

如果写成下面这种形式,

图像说明文字

则结果 A将等于 0,B等于 1,C等于 2,每次都递增 1。可能有些读者不太习惯这种方式,但笔者个人还是很喜欢这种简短风格的。如果读者不愿意用这种方法,也可以像下面这样,使用 switch进行处理。

图像说明文字

1.2.8  更新

update()的内容是游戏的核心,代码也比较长。这里因为篇幅有限而没有将代码全部列出,不过笔者会尽量介绍得详细一些。读者如果觉得枯燥,也可以先阅读后面的内容。

  • 参数

函数中的参数如下所示。

图像说明文字

一般而言,用单个字母作为参数变量名并不是一种良好的编码风格,但这里因为使用非常频繁,为了保持简洁,所以暂且这样命名。当然这样就必须在注释中明确写出其含义。也有人认为变量名称写得长一些比较好。笔者的意见是,如果该变量所在的代码很短,使用频率又非常高,而且即使采用单个字母命名也不太容易和其他变量混淆,那么只要在一开始加上详细的注释,就完全可以用单个字母来命名变量。

  • 输入

下面是输入的部分。

图像说明文字

有时直接处理从键盘输入的“a”“s”等字符不太方便,可以先将它们转化为 x 轴和 y 轴的移动量。当然这里也可以使用两个枚举类型的常量来表示左和右,不过如果我们按照“向左 + 1”“向右 - 1”来计算,那么在执行“移动一步”的处理时,就可以很方便地通过“将位置加上偏移量”来实现,无须添加向左则“+ 1”,向右则“- 1”的 if语句了。

  • 检测玩家位置

接下来检测玩家位置。

图像说明文字

当然,如果提前将玩家位置保存在某个变量中,此处就不需要这种处理了,而且运行起来更快。之所以像上面那样每次都查找一遍,只是因为在 Object数组之外再维护一个状态变量太麻烦。如果创建一个 Stage类等,并在其中设置一个成员变量,用于在查找结束时保存玩家位置,那么就可以在保持代码简洁的同时保证处理效率,这也不失为一种好方法。

不过,把计算后得到的信息保存起来的做法可能会导致矛盾,即通过查找获取的玩家位置和保存在变量中的玩家位置有可能会不一致。为了省事,不妨采取每次都查找一遍的做法。只要不影响速度,在需要时才去生成相应的数据总是不会出错的。

顺便说一下,下面这行代码分别执行了“一般的玩家”和“位于目的地的玩家”的判断,这种写法显得不够简洁。如果能封装成 isPlayer()之类的函数会更好,或者单独将“是否是目的地” 的信息保存到另外的变量中,这样 if语句会更简洁。读者更喜欢哪种方式呢?

   if ( s[ i ] == OBJ_MAN || s[ i ] == OBJ_MAN_ON_GOAL ){
  • 移动

接下来的移动处理是游戏逻辑的主体。在这个游戏中不存在玩家和箱子分开移动的情况,所以移动处理就是判断玩家朝上下左右哪个方向移动。

不过,在这之前,首先判断要移动到的位置是否处于允许范围之内。

图像说明文字

读者看到变量名可能会感到奇怪:“tx中的 t是什么?”因为该变量会被频繁使用,而且只在这附近几行中用到,所以就这样命名了。当然这并不是强制的,读者完全可以按照自己的喜好命名。

顺便说一下,dx中的 d是“difference”(偏差)的缩写,tx中的 t则是“temporary”(临时)的缩写。这是笔者的缩写习惯。当然,笔者已经在适当的地方添加了注释。

移动处理部分的详细代码如下所示。

图像说明文字 图像说明文字

在开头第一个 if语句中,检测要移动到的位置是否为空,如果为空则移动到该处。这个所谓的“空”可能有“空白”和“目的地”两种情况,所以代码变得有些复杂。如果将“是否是目的地”的信息存储在别处,因为该数据固定而且不会被修改,所以代码写起来可能会更简单。而之所以采用这种写法,主要是因为不想维护两个数组,也不想引入位运算。读者可以选取任意一种喜欢的做法。

接下来的 else if部分负责处理要移动到的位置有箱子的情况。同样,这里也需要判断存在 “位于目的地的箱子”的情况。然后检测沿该方向的第二个网格的位置,如果该位置不在允许范围内,则推动无效,处理结束。只有当允许范围内存在空间时,才会进行推箱子的处理。

如前所述,这种方法没有额外存储目的地数据,所以连续使用了多个三目运算符来完成判断。

如果要移动到的位置既没有箱子也不为空,就说明该处是墙壁,则什么也不做。这种“什么也不做”的情况是否需要明确地在 else代码中体现出来呢?为了保持代码简洁,笔者省略了该内容,但一般来说,游戏的核心逻辑等重要部分应当尽可能地让代码容易理解,所以这里最好写上 else部分,并添加“因为是墙壁,所以不做任何处理”的注释。

  • 三目运算符

我们在前面已经陆续接触了三目运算符,

a = b ? c : d;

相当于

if ( b ){
a = c;
}else{
a = d;
}

的省略写法,两者的含义相同,但笔者偏好更简洁的写法,因此多用前一种形式。如果读者觉得看起来怪怪的也可以不用,不过在阅读他人的代码时,有必要适应这种写法。这就好比我们不一定会写“饕餮”这类复杂的汉字,但是最起码要能看懂。毕竟多学些知识是没有坏处的。

1.2.9  通关判断

如果所有箱子都已经到达目的地,就意味着通关了。

图像说明文字

需要注意的是,即使场景数据出错,导致目的地数量比箱子数量还多,也依然会被判定为通关。因此,在初始化时必须对场景数据进行验证。

1.2.10  小结

示例代码的讲解到这里就结束了。虽然只有短短 160 行左右,游戏逻辑也很简单,但是需要考虑的东西相当多。不知道读者现在是不是还觉得“这不过是小菜一碟”呢?

笔者在写这份代码时并没有想着一定要写出质量上乘的代码,因此代码中有很多地方可以进一步改善,例如数据的容量、处理的速度、代码的可读性和可拓展性等,以及错误处理的实现方法,甚至变量和函数的命名、括号的位置和空白的使用方法等所谓的代码风格都可以调整。

值得推敲的地方还有很多。

下面我们就来考察一下所谓“值得推敲的地方”吧。

1.3 添加读取场景数据的功能

读者自行开发的《箱子搬运工》是如何存储场景数据的呢?示例程序中使用的是字符串,当然也可以使用其他方法。但是无论使用何种方法,将数据写入到代码中都不是一种好的做法。

如果想让程序员之外的工作人员来制作场景数据,就必须将场景数据从程序代码中分离出来。就算由程序员来制作场景数据,每次改变场景数据时都重新编译程序也是一件很辛苦的事。现在的代码只有一个文件,编译起来还算快,一旦游戏容量变大,编译超过半小时是很常见的事。因此,最好能将数据放到单独的文件中。下面我们将讨论如何在运行时载入数据。

在 Visual Studio 2017 中按下 F5 启动程序后,系统会把 .vcxproj 文件所在的文件夹位置作为标准查找路径。假设项目文件路径是“d:\Foo\Foo.vcxproj”,那么代码中写的“stage.txt”在加载时将被转换为路径“d:\Foo\stage.txt”。注意在 debug 和 release 文件夹下都有一个 exe 文件,如果场景数据等文件在这两个目录下,则按下 F5 时不会被识别。

1.3.1  准备

文件的读取通过 ifstream实现,代码如下所示。

图像说明文字 图像说明文字

运行这段代码后,程序会读取 stageData.txt 文件的内容,并将其显示在画面上。

ifstream(Input File Stream)是 C++ 标准库提供的类,用于读取文件,使用前需要先在代码中包含 fstream头文件。

通过构造函数打开文件后,先移动(seekg)到文件的末尾处,并通过探测(tellg)该位置来获取文件的大小,然后再回到(seekg)文件起始位置,分配足够的空间,一次性读取(read)整个文件的内容。

如果将上面的处理封装成函数,就可以通过一行代码来完成文件的读取,在大量使用该功能的情况下会很方便。一般来说,在游戏中读取文件很少有只需要读取一半的情况。

注意,在 ifstream的构造函数中,传递的第二个参数值为 ifstream::binary。这里先不解释这个参数的意义,但是要记住如果没有它,将无法完整地读取文件。如果是完全无法读取可能还比较容易发现问题,但糟糕的是大多数时候只有一部分内容读取不正确,在这种情况下问题就不容易发现了。不过,如果将文件读取处理封装为函数,那么以后基本上就不需要每次都考虑这个问题了。这也再一次体现了函数封装的优点 ①。

1.3.2  数据格式

场景数据采用什么形式好呢?

无论何种形式,最终在程序处理时都会被转换为枚举类型的数组,即整数的形式,其中 0 表示玩家、1 表示墙壁,等等。但是这对于人类来说太不直观,所以很难按这种格式去配置数据。

00000000
01221310
01441110
01111110
00000000

读者能够想象出上面这些数字表示什么样的场景吗?即使能,也非常容易出错。为了使配置文件更容易理解,不妨直接把画面上显示的场景字符串存入文件。例如,像下面这样简单地将场景输出的样子记录下来就可以了。

① 如果想了解 binary的含义,可以查阅“换行符”相关的知识,或许从中还能了解到其发展历程的曲折。另外还有一点需要注意,向 ifstream或用于写入文件的 ofstream传入的文件路径中如果带有汉字,必须在调用前加上setlocale(LC_ALL, "")。但此次的环境只是恰巧出现了这个问题,而且它一般容易在处理桌面文件拖曳的代码中出现,所以此处没有涉及。

图像说明文字

虽然和上面表示的是同一个场景,但是这种写法明显要容易理解得多。这样一来,即使不是程序员,也可以配置该文件了。益智游戏的场景制作不一定要由程序员来完成,试想如果游戏需要制作一二百个场景,那么显然大部分场景制作人员不是程序员。因此,应当尽量让配置文件直观易懂,以便后续工作能顺利进行。

众所周知,游戏公司里的开发工作都是多人协作进行的,这种多人协作的做事方法在独立开发游戏时也具有借鉴意义。无论是个人开发还是团队开发,让最困难的任务变得直观和有趣,都是有效节约时间的方法。对游戏公司而言,时间等于金钱(薪水),要想控制开发成本,就必须考虑到这一点。

1.3.3  场景的大小

现在我们来考虑如何设置场景的大小。在示例程序中,场景大小是通过全局变量指定的。因为状态数组可以通过 new动态地按照所需要的大小创建出来,所以只要通过其他方法把数值传递过去,就可以动态改变场景大小了。

按照这种做法,只有在读取文件时才会生成场景数据。毕竟在载入文件之前宽和高都是未知的。只有读取场景数据后检测到宽和高的值,才能够分配出相应的空间。

当然,如果游戏一开始就设计为“场景大小永远是 12 × 8”,可能就没有这些问题。但是,谁也无法保证需求不会发生变化。就算自己不愿意变更需求,也无法保证策划换人之后不会修改设计。即使一开始就约定好不再修改设计,但如果有人提出修改后游戏会更好玩,可能也就不得不去修改,有时为了顾及合作关系,也会选择修改设计。其实只需几行代码就可以让它支持可变,所以这种工作一定要在初期完成。游戏中较少用到二维以上的多维数组,也是出于这个原因。

  • 被视为二维数组的一维数组

前面已经介绍过将一维数组当作二维数组使用的方法,如下所示。

//访问 (x,y)
a[ y * width + x ];

但是,每次都这样写未免太麻烦了些,同时也容易造成隐患。最好能像下面这样创建一个模拟多维数组的类。

图像说明文字 图像说明文字

operator()的写法看起来可能有些别扭,但这其实是一种特殊函数的名称,定义后就可以像下面这样使用。

IntArray2D array( 4, 3 ); //4×3 的数组
int a = array( 2, 1 ); // 相当于array[ 2 ][ 1 ]
array( 3, 0 ) = 3; // 还可以赋值

a.operator()( 4, 3 )

可以写成

a( 4, 3 )

这是 C++ 的运算符重载功能,类似的特殊函数还有好几种,以后我们会逐个介绍。注意这里有两个 operator(),后面的那个在返回值和函数名称后多加了一个 const。另外,构造函数的写法看起来也很奇怪,这些知识在后面都会有详细说明。

另外,依照笔者的习惯,成员变量都以 m开头命名,清楚这一点对于理解示例代码将会有所帮助。

1.3.4  错误处理

当记录场景数据的文本文件中发生错误时该如何处理呢?

举例来说,如果没有外墙,玩家就可以随便走到任何地方。这样一来,程序内部很可能会因数组索引越界而崩溃 ①。又比如,场景中存在多个玩家,箱子数量比目的地数量更多或者更少,等等,此类异常必须正确处理。在载入文件时就应当对其内容进行检测。还可以特意制作一些有错误的文件,来验证一下程序能否检测出问题。

可是,如果场景的数量超过 1000 个,那该怎么办呢?

场景数据是由多人制作完成的,各工作人员将文件写好后提交给程序员。因为最终向顾客发布游戏时这些文件会被打包,而这个工作一般由程序员来完成。如果每个人都能仔细测试自己制作的场景文件还好,可是如果有人觉得测试太麻烦而直接将未测试的文件提交到程序员那里,结果会发生什么呢?除非打包完成后再将这 1000 个场景都运行一遍,否则不可能保证它们都是好用的。比如可能有人在修改错误的文件时,不小心再次混入了错误的文件;或者认为只是简单地修改一个字符不会有什么问题,于是没有经过测试就直接替换文件,结果导致游戏出现卡顿。

① “崩溃”“闪退”一般表示程序因为不正确的处理而异常退出。此外,“卡顿”“假死”这类词汇有时也会用到,它们不是指处理异常,而是指陷入了无限循环,没有反应,也用于指处理迟缓,看起来好像停顿了的状态。注意,这些程序员之间常常使用的词汇在非程序员看来是很难理解的。不过因为它们能简短地表达出相应的意思,所以本书中会大量使用这类词汇。

那么,这种情况该怎么应对呢?

一种办法是在游戏程序启动后立刻载入所有的场景数据并检测,这样就不需要等到实际进入某个场景后才能知道该场景数据的有效性。但是这样做会拖慢游戏启动的速度,对于稍微复杂一些的游戏来说,这种做法是不可取的。

更好的做法是,从根本上确保数据无误。比如,可以另外编写一段用于生成场景文件的程序,令所有的场景数据都只能通过这个程序生成。大家也可以思考其他做法。

如果一直探讨下去,我们就无法继续其他知识的学习了,因此这方面的讨论暂时到此为止,不过这个问题确实会随着游戏规模的扩大而变得越来越重要。实际上,因为疏忽大意而损失数亿的案例并不罕见。

1.3.5  示例代码

NimotsuKun2 项目中包含了文件读取、二维数组以及一些错误处理的内容,项目代码中运用了C ++ 类。对《箱子搬运工》来说可能没必要这样编写代码,不过因为是示例程序,所以笔者还是这样写了。读者可以先运行一下看看 ①。

现在我们来解释一下代码。

不同于之前的例子,这份代码将目的地信息单独存放在别处。对比之下,可以看到 update()变得简洁多了。为了写出 C ++ 的风格,代码变得很长,不过也就是 270 行左右的样子,应该还能看得过来。

这里读者可以测试一下自己对 C++ 语言的理解程度。可以一边阅读代码一边对照着下面几点进行确认。

● 是否理解 using namespace std;的含义,是否能够向他人说明什么是名称空间

● 是否理解 template语法,是否能够使用创建好的模板类

● 是否理解构造函数、析构函数的概念

● 是否理解构造函数中初始化的写法,能否在代码中运用

● 是否理解成员函数的声明后面的 const的含义,能否说明何时该这样使用

● 是否理解 operator系列成员函数的使用方法,能否在代码中声明并使用

● 是否知道 enum的名称可以和类、结构体名称一样使用,能否在项目中灵活应用

● 能否说出 delete和 delete[]的区别,能否解释为何会存在这两种形式

● 能否理解为何 C++的字符串以 0(NULL)作为结束符号

面对这么多问题,读者可能会感到灰心,不过实际上并没有多少人能够将上述问题回答得很好,所以即使不会也不用不安。也就是说,即使无法回答这些问题,也可以编程,甚至可以开发游戏。不过,上述知识点还是有必要熟悉一下的。当今市面上有很多耗费了大量时间开发最后却 bug 频出的游戏作品,造成这种现象的一个原因就是很多游戏开发人员的技术基础不够扎实。

① 在解决方案内切换执行的项目时,可以在解决方案管理器内用鼠标右键单击相应的项目,选择“设为启动项目”。

本书会对相关的 C++ 知识进行一些介绍。如果读者觉得枯燥,也可以先跳过这部分内容,不过要注意的是,如果没有掌握这部分知识,在阅读后面的代码时可能就会感到十分吃力。

1.4 C++ 课堂

下面我们将对前面提到的 C++ 知识点进行讲解。

1.4.1  名称空间

假设一年级二班和一年级三班都有一位姓王的同学。很明显,如果只用姓来表示,将无法区分这两位同学,因此可以用“二班的王同学”和“三班的王同学”来区分。同样,一年级和二年级都有二班。如果二年级二班也有一位姓王的同学,就需要使用“一年级二班的王同学”“二年级二班的王同学”来区分。这种思路体现在编程上就是名称空间,例如 C ++ 标准库就被放入了 std名称空间中。这样一来,虽然 std中有 ifstream这个类,但如果我们想创建自己的 ifstream类,只需要把它放入其他名称空间中,就可以区分开了。

例如,用代码来表示“一年级二班的王同学”和“二年级二班的王同学”,大概就是如下形式。

图像说明文字

这样两位王同学就是独立的两个人,两个类的内部实现也完全不相干。

因为包含了 fstream和 iostream等功能类的 C++ 标准库全部位于 std名称空间中,所以理论上在使用时都必须添加 std::前缀,但是每次都写成下面这样未免太烦琐。

std::cout << "aho" << std::endl;

方便起见,可以使用 using namespace声明。using namespace std声明了“接下来将使用 std名称空间中的东西”。不过,如果在使用这个声明的同时仍旧在代码中创建了名为 cout的变量,就可能导致编译器无法区分 std::cout和 cout变量而出错。在这种情况下,可以将自己创建的 cout放入某个名称空间中,比如 Aho,这样就可以通过 std::cout和 Aho::cout来区分它们。如果不打算将它放入任何名称空间中,就写作 ::cout。::表示全局的名称空间,所有未放入任何名称空间的东西都会被放在这里。

通过划分恰当的名称空间,在多人协作开发的情况下,即使出现了类重名的现象,在代码合并时也不会出现编译错误。

不过,要注意在头文件中使用 using有时会带来一些麻烦。除了个别情况外,笔者几乎不会在头文件中使用 std之外的 using声明。因为 using无法取消,一旦引入,所有包含了该头文件的cpp都将受其影响,变为 using某名称空间的状态。

1.4.2  模板

使用模板功能,可以让代码写起来更轻松。例如,像下面这样创建了模板类后,

template< class SomeClass > class A{
public:
SomeClass mMember;
};

就可以很方便地将 int或者 float代入 SomeClass。

A< int > aInt;
int bInt = 5;
aInt.mMember = bInt;
A< float > aFloat;
float bFloat = 5.f;
aFloat.mMember = bFloat;

第一行

template< class SomeClass> class A{

中的 SomeClass可以起任意名字,它相当于函数的参数。如果这里写成 T,那么 mMember的类型就是 T。不管是什么类型,最后在实际使用时都将被替换为 int或者 float这样的具体类型。前面的示例代码中出现的 IntArray2D是用模板写成的,因此可以创建出任意类型的二维数组。

另外,使用模板类的函数时,必须能看见函数体的内容。所谓“能看见函数体”指的是“包含了该函数”。如果将代码写在 cpp 中,那么其他 cpp 文件将“看不见”函数实现,所以模板的声明和实现都必须写在头文件中。

1.4.3  构造函数和析构函数

构造函数和析构函数分别指类对象生成和销毁时自动调用的函数。

以类型 T为例,下列代码中的注释说明了调用构造函数和析构函数的时机。

void foo(){
T t; // 构造函数
T* tp = new T; // 构造函数
delete tp; // 析构函数
}

注意 foo函数结束前将调用 t的析构函数。

下面我们来自行定义构造函数和析构函数。

图像说明文字 图像说明文字

构造函数没有返回值,并且函数名和类名相同。析构函数没有返回值,函数名和类名相同,并且函数名前面有个“~”。在 cpp 中写出来,大概如下所示。

图像说明文字

构造函数可以有多个参数,没有参数的构造函数称为默认构造函数,记住这个概念会很方便。另外,因为析构函数无法传参,所以它没有参数。无论对象是通过哪个构造函数生成的,销毁时都会调用析构函数。

也就是说,在 C++ 中只要生成对象就一定会调用构造函数,销毁时也一定会调用析构函数。因此,不需要自己特意创建初始化函数和结束函数并逐个调用,这一切都将由系统自动完成。

比如下面这样的代码写起来比较烦琐,而且往往容易出错,在这种情况下就应当灵活应用构造函数和析构函数。

T* t = new T;
t->initialize(); // 初始化处理
( 其他逻辑代码)
t->terminate(); // 结束处理
delete t;

1.4.4  初始化

通过构造函数设置变量值时,可以简单地使用下列代码完成赋值。

Foo::Foo(){
mBar = 0;
}

当然还有其他做法,比如像下面这样。

Foo::Foo() : mBar( 0 ){
}

这种写法也称为初始化。“:”的后面跟着“变量名 (值 )”的形式,如果有多个变量,则使用逗号分隔开。虽然这个例子的两种写法是等价的,但是在某些情况下只能通过后面这种写法才能实现,比如当 mBar被声明为 const时就是如此。因为使用了 const关键字,所以进入构造函数后mBar就不能再修改。但是如果像下面的代码这样进行初始化,就可以设置 mBar的值。

图像说明文字

这就好比

const int a = 5;

const int a;
a = 5;

的区别。上面的是初始化,而下面的只能称为赋值。

实际上二者的内部处理完全不同。初始化调用的是构造函数,而赋值调用的是 operator=()函数。以 int变量为例,假设有以下两个函数:

int::int( int a );
int& int::operator=( int a );

其中,

int a = 5;

将调用构造函数 int a(5);,而

int a;
a = 5;

会调用 operator=()函数 a.operator=(5);,大概就类似这样 A。将 int换成自己定义的类也是一样的。对 int而言,构造函数和赋值两种处理的结果看不出有什么区别,但这只是碰巧遇到 int型是这样而已。在这方面 C++ 确实显得很奇怪,但还是请读者务必理解这个过程并逐步适应。

1.4.5  成员函数的 const

在成员函数声明末尾添加 const,意在告知编译器“调用该函数时不允许改变类中的成员”。此外,如果某变量被 const关键字修饰,则只允许调用它的 const函数。例如下列代码:

图像说明文字

类 A中有 func1()和 func2()成员函数。其中 func1()使用了 const关键字,func2()则没有。

在这种情况下,接收含有 const的 ①的函数 foo()只能调用 func1()。

图像说明文字

而且,const类成员函数内不允许调用其他非 const类成员函数。

图像说明文字

① 因为 int和 float等类型其实并没有这样曲折的处理过程,而结果又是一样的,所以说“大概就类似这样”。

这样规定是为了保证函数内 A的内容不会被修改。

这个小例子可能无法体现出添加 const的好处,但是当程序规模变得庞大以后,这种安全机制的重要性就会凸显出来。

即便如此,在尝到苦头之前往往很难有深刻体会,所以多做做大项目是很有好处的。笔者曾经吃过这方面的亏,所以现在在写函数时,只要能够添加 const,就都会尽量加上。

1.4.6  两种 delete

在介绍 delete之前,我们先看下面的代码。

图像说明文字

~T()表示析构函数,deallocate()是清空函数,用于将使用过的内存标记为“不再使用”,以便释放。也就是说,delete用于在调用析构函数后释放相应的内存。当然这里只是为了便于读者理解而写的伪代码,真正的 delete代码并非这样。而关于 delete[],我们先来看一下下列代码。

图像说明文字

delete[]会根据数组元素的个数依次调用各元素的析构函数,然后释放内存。很明显,如果在应当调用 delete[]的地方调用了 delete,就只会调用第一个元素的析构函数。

另外,在上面的伪代码中,arraySize的位置是由编译器决定的,一般隐藏在 p附近。因此,如果在应当调用 delete的地方调用了 delete[],编译器就可能会将不当的值误认为 arraySize,从而导致出错。毕竟编译器无从判断指针所指是否为数组,必须由人来告知。

后面我们会讨论如何从根本上克服这种麻烦,现在还是请读者先养成正确使用 delete和delete[]的习惯。

1.4.7  字符串常量

虽然这是 C++ 的基础知识,但是仍有相当多的人没有理解透彻。下面我们再来讲解一下这部分内容。

const char baka[] = "baka"; 
const char* aho = "aho";

可以看出这段代码是在定义字符串常量,但是在 C++ 的内置类型中并没有字符串这种类型。那么这里的“字符串常量”是什么呢?这个问题不容易回答,很多初学者恐怕也难以理解。

为了不引起误解,我们将其称为“定义 char数组的简便写法”。以上面的 baka为例,

cosnt char baka[ 5 ] = { 'b','a','k','a','\0'};

这种写法和前面的写法本质上是一样的。大概是 C 语言的作者考虑到这样写太过烦琐,所以发明了更加简便的写法,但是程序员一定要理解这才是字符串的本质。用 ""包围着的内容其实就是一个单纯的 char数组,只不过会自动在最后加上 '\0'A。因此,baka的容量是 5 而非 4。

同样地,

const char* aho = "aho";

大概相当于下列代码的省略形式 ②。

const char ahoINTERNAL[ 4 ] = { 'a', 'h', 'o', '\0'}; 
const char* aho = &ahoINTERNAL[ 0 ];

ahoINTERNAL只是为了便于说明而随便起的名字,实际上并没有这个变量,但也要注意,在系统中某个看不见的位置确实存放着一个作用相似的变量。

上面我们介绍了 C ++ 的一些用法,之后还会对更加基础的内容进行讲解,包括标志位和位运算、指针和内存,以及引用。由于介绍得比较基础,如果读者已经很熟悉了,完全可以跳过。这些对经历过 C 语言时代的程序员来说应该都没有问题,不过 C# 和 Java 程序员可能会不太熟悉,而且即使是学过 C++ 的读者,也未必都能很好地理解,所以如果不着急的话,可以浏览一下。

1.5 补充内容:标志位和位运算

1.5.1  标志位

标志位,英文中叫作“flag”,也就是“旗子”的意思。程序中将保存开或关、有效或无效等状态的变量比拟为旗子,称为标志位。标志位变量一般是 bool类型。在《箱子搬运工》中,如果要设置一个变量来表示是否到达目的地,那么很明显,这个变量就可以是标志位 ③。

不过游戏中往往通过一个 unsigned型 ④ 变量来管理多个标志位,而不是使用多个 bool变量。因为只需要表示 0 或 1 的 1 位(bit)就能表示有或没有的状态,所以 4 字节(byte),也就是 32 位的空间足以用来管理 32 个标志位。

其实也可以使用普通整型变量中未被使用的部分来存储标志位。以《箱子搬运工》为例, Object数组中的元素都是 unsigned,可以用前半部分作为标志位来记录“该网格是否为目的地”,用后半部分来记录网格的状态。32 位的整数能表示 40 亿种数字状态,而这个游戏最多只需要 5 种状态,完全绰绰有余。

① '\0'表示“char类型的 0”,也可以直接写作 0,不过把 0 作为字符表示时,普遍采用 '\0'的写法。顺便说一下,'0'是表示字符“0”的数字,其值为 48,而不是 0。“字符 0”和“数字 0”经常容易混淆,要多加注意。同理,'1'的值为 49,而不是 1。
② 之所以说“大概”,是因为严格来说两者并不完全等同。不过能够说清楚二者具体区别的读者估计也不需要本书了。 
③ 将标志位设为有效状态的行为一般称为“设置标志位”。
④ 本书中将 unsigned int写作 unsigned。可能有些读者会不习惯,不过笔者的习惯是简洁至上。

因为 32 位的结构看起来太长不利于理解,所以下面我们都将以 8 位的 unsigned char为例进行说明。

1.5.2  标志位的存储

下面是一个二进制数的例子。

00110101

用 8 个值为 0 或 1 的数字排成一排,从右往左各个位依次代表十进制的 1, 2, 4, 8, 16, 32, 64, 128。对二进制不熟悉的读者可以确认一下,后一个值依次是前一个值的 2 倍。若将上面用二进制形式表示的数字 00110101 换算成十进制,则等于 1 + 4 + 16 + 32 = 53①。这 8 个 0 或 1 的数字分别都可以被当作某种标志位使用。

接下来最好能封装一些功能函数,比如用于检测标志位状态的函数、设置标志位状态为有效的函数,以及设置标志位为无效的函数。

标志位相关类的代码大概如下所示。

图像说明文字

check函数用于检测标志位状态,set用于设置标志位为有效,reset用于设置标志位为无效。参数一般主要用于指定标志位的位置,具体使用方法暂时还没确定。

1.5.3  使用普通计算获取标志位状态

如果把各个位视为标志位,那么为了检测各个标志位的状态,就需要判断对应位的值是 0 还是 1。

在前面的例子中,8 个 0 或 1 排成一排,结果一目了然,而在现实中,例如 87 这个数,我们只能看到一个十进制数 87。当然在计算机中仍是通过二进制来处理的。在计算机中,数字的二进制形式就像一个值只能为 0 或者 1 的 int数组,类似于一堆 bool型变量的集合。

因此,对于 0 ~ 255 的数,我们需要找到一种能够检测出任意二进制位的值是 0 还是 1 的方法。

  • 最直接的方法

现在试着将“是否是墙壁”和“是否是目的地”两个信息存入标志位变量中。最左边的位用于保存“墙壁标志位”,第二位用于保存“目的地标志位”。因为最左边的位对应 128,所以可以通过数值是否大于 128 来判断该二进制位是否为 1,但是不能通过数值是否大于 64 来判断第二位是否为 1。例如,

10000000
① 按照二进制与十进制的换算关系,该值可根据 1*20 + 0*21 + 1*22 + 0*23 + 1*24 + 1*25 + 0*26 + 0*27 算出。——译者注

表示的二进制数中,只有128 对应的位为1,所以该数值等于128。虽然比64 大,但是第二位是0,因此不能通过数值是否大于64 来判断第二位的二进制值。当数值大于128 时,需要减去128 才能执行该判断。

unsigned char t = mFlags;
if ( t >= 128 ){
   t -= 128;
}
return ( t >= 64 ) ? true : false;

但是,这种做法无论怎么看都很麻烦。想象一下,在右边的二进制位都被用上之后,代码中就需要逐次减去128, 64, 32, 16, 8, 4, 2,非常不便。

  • 使用除法

前面我们看到了减法运算的弊端,现在我们来试试除法运算。

为了检测 64 对应的二进制位等于 0 还是 1,我们先将数值除以 64。如果该数小于 64,那么余数等于 0,如果大于 64,则余数大于等于 1。比如,

00111111

表示 63,将其除以 64 后,余数等于 0。

11111111

表示 255,除以 64 后余数为 3。因为是整数运算,所以小数部分被舍去。

接下来再将余数乘以 128。余数为 0 的话则结果依然为 0,余数为 3 的话则结果等于 384,但因为 8 位的整数值最大不能超过 255,所以溢出被截断,结果值等于 384 - 256 = 128。如果 128 对应的二进制位的值为 1,那么除以 64 的结果等于 2,再乘以 128 后变为 256,因为 256 超出了 8 位的存储范围,所以将发生溢出而变为 0。也就是说,无论 128 对应的二进制位的值是 0 还是 1,这样计算出的结果都是相同的。另外,32 对应的二进制位和后面的其他二进制位也将在除法过程中变为 0,所以 64 对应的二进制位如果为 1,则计算结果等于 128,如果为 0,则结果等于 0,这样就可以判断出某二进制位的值。

代码如下所示。

unsigned char t = mFlags; // 因为会修改变量值,所以复制一个副本出来
t /= 64;
t *= 128;
return ( t != 0 ) ? true : false;

结果要么是 128 要么是 0,所以判断条件可以根据个人喜好写成“是否等于 128”或者“是否不等于0”。笔者选择了不易写错的“是否不等于 0”。

64 以外的其他二进制位也使用同样的方法,如果是 32 对应的二进制位,则可以除以 32 后再乘以 128;如果是 16 对应的二进制位,则除以 16 后再乘以 128。像下面这样将除数放入枚举型中,

图像说明文字

就可以用下面的 check()函数来检测某个标志位的状态。

图像说明文字

1.5.4  乘除法运算和移位

通过上面的例子可以看出,使用乘法和除法运算可以高效地算出标志位的值,不过在代码中直接写 64 或者 128 这样的数字显得不太美观。而且例子中只有 8 位,所以问题不大,但如果是 32 位,则会出现 131072 或者 536870912 这类容易写错的数字。因此,应当避免在代码中直接写入这样的数字,最好根据二进制的原理将数字写成 2 的阶乘,也就是 2 的 n 次方,这样代码看起来会更清晰。现在就来介绍具体的方法。

下面用二进制表示数字3。

00000011

将其乘以2,变成6。

00000110

请注意观察数字的排列变化。可以看到,乘以 2 后 01 序列整体向左移动了 1 位。C ++ 中准备了移位(shift)运算用于 01 序列的移动,执行向左移动 n 位的代码如下所示。

t = ( t << n );

它还可以执行类似于+ = 和- = 的运算,非常方便。

t <<= n;

而二进制序列移动 n 位相当于循环 n 次乘以 2 的操作,这和乘以 2 的 n 次方是等价的。与此相反,右移则意味着除以 2。

t >>= n;

表示向右移动 n 位,这意味着可以循环 n 次除以 2,也就是除以 2 的 n 次方的操作。通过这个功能,可以用移位操作改写用于检测标志位状态的函数 checkFlag()。

  • 用移位操作改写

首先将枚举类型中的数字定义为 2 的 n 次方形式。例如,128 是 2 的 7 次方,64 是 2 的 6 次方,代码如下所示。

enum Flag{
   FLAG_WALL = 7,
   FLAG_GOAL = 6,
};

然后利用这一点,将函数改写为下列形式。

bool Flag::check( unsigned char f ){
   unsigned char t = mFlags;
   t >>= f;
   t <<= 7;
   return ( t != 0 ) ? true : false;
}

改写后,原有的乘法或除法运算都可以被视为 01 序列的移位操作。先向右移位,将所关注位右侧的位的值全部消除,然后再向左移位,将所关注位左侧的位的值全部消除。这样一来,乘除法操作就可以很方便地通过位运算来完成。

1.5.5  使用位运算

到现在为止,我们一直在使用普通的加减乘除运算来实现相关的功能。即使引入了移位,本质上它也只是乘除法运算的另一种写法而已。由于在计算机内部,数据是用二进制表示的,如果遇到 “第三个二进制位的值等于 1 吗?”这样的问题,该如何解决呢?现在我们来讨论一下解决办法。

首先要知道,C++ 提供了对各个位单独进行乘法运算的功能。用“×”表示乘法运算,那么 5 × 6,即:

00000101 × 00000110 = 00000100

也就是等于4。请注意每个位的值。最后两位通过0 × 1 得到0,右起第三位,也就是数字4 对应的二进制位,为1 × 1 = 1,最终的运算结果等于4。

这不是普通的乘法,它的运算规则是“只有对应的两个二进制位的值都等于1 时结果才为1”。有了这个方法后,一切都变得简单了。

例如,对于

11011110

如果想知道64 对应的二进制位的值是否为1,只需执行下列运算即可。

11011110 × 01000000 = 01000000

准备一个只有64 对应的位等于1 的二进制数,然后将该数的各个位分别与11011110 进行乘法运算,如果64 对应的位等于1,则结果等于64,否则等于0。按位进行乘法运算的运算符是&,所以如果定义了下列枚举类型,

enum Flag{
   FLAG_WALL = 128,
   FLAG_GOAL = 64,
};

就可以像下面这样实现check 函数。

bool Flag::check( unsigned char f ){
   return ( ( mFlags & f ) != 0 ) ? true : false;
}

为了便于初学者理解,我们还可以写得更简洁些。

bool Flag::check( unsigned char f ){
   return ( ( mFlags & f ) != 0 );
}

此外,还可以使用移位改写枚举类型中的64、128 这些数字,如下所示。

enum Flag{
   FLAG_WALL = ( 1 << 7 ),
   FLAG_GOAL = ( 1 << 6 ),
};

这是一开始提到的示例代码中枚举类型的标准写法。另外,这种按位进行的乘法运算叫作“逻辑与”,也称为“and”,也就是“A 并且B”的意思。

1.5.6  设置标志位为有效

现在我们将指定的标志位设置为有效状态,即值为 1。只需对各个二进制位执行一次加法运算即可实现。

例如,如果要把

10011110

中 64 对应的二进制位设置为 1,那么进行下列运算即可。

10011110 + 01000000 = 11011110

但是,如果该位原本就等于 1,那么执行同样的操作后值将变为 0。

实际上 C ++ 提供了两种加法操作:1 + 1 = 0 的普通加法和 1 + 1 = 1 的另外一种加法。前者的运算符是 ^,后者的运算符是 |。显然这里我们应当使用后者。

使用这个功能,就可以写出设置标志位为有效的 set()函数,如下所示。

void Flag::set( unsigned char f ){
   mFlags |= f;
}

1 + 1 = 1 的加法运算叫作“逻辑或”,也称为“or”,也就是“A 或者 B”的意思,如果其中任一方的值为 1,则计算结果为 1。而 1 + 1 = 0 的加法运算叫作“异或运算”,也称为“xor”,是 exclusive or 的缩写,表示“如果 A 或者 B 中只有一个为 1”的意思。这里我们不怎么会用到它。

1.5.7  将标志位设置为无效

下面来编写将标志位设置为无效的 resetFlag()函数。例如下面这个二进制数,如何把 64 对应的位设置为 0 呢?

11011110

虽然稍微有些麻烦,但是并不复杂。我们先创建一个除了 64 对应的位为 0 其他位都为 1 的数,再将它和上面的数按位进行乘法运算即可。

11011110 × 10111111 = 10011110

问题在于,特意准备这样一个“除了特定位以外其他位都为 1”的数并放到枚举类型中非常麻烦。如果能有一种方法可以快速实现下列转换,就不必修改原来的枚举类型了。

01000000 → 10111111

要完成这种转换,执行按位相加即可。注意这里用的不是1 + 1 = 1 的or 运算,而是1 + 1 = 0 的普通加法运算xor。计算过程如下所示。

01000000 xor 11111111 = 10111111

和1 进行xor 运算后,1 变为0,0 变为1。然后再配合and 运算,函数可以写成如下形式。

void Flag::reset( unsigned char f ){
   mFlags &= ( f ^ 255 );
}

8 个 1 排列而成的二进制数等于 255,先和它完成 xor 运算,然后再执行 and 运算。不过还有更简单的写法。因为 C ++ 提供了按位反转的功能,所以还可以像下面这样使用 ~运算符交换0 和 1。

void Flag::reset( unsigned char f ){
   mFlags &= ˜f;
}

这种交换 0 和 1 的操作称为“取反”,英文叫作 not。也就是说,要将标志位设置为无效,只需要执行 not 和 and 即可。

现在读者已经大致学习了位操作的相关功能。虽然例子中参与运算的数都是 8 位,但换成 16 位或 32 位也都是一样的。实际运用时可以根据自己的需要选择大小合适的类型变量,如果太麻烦也可以全部使用 unsigned型,其大小占 4 个字节。

int型变量有可能为负数,如果对 int型变量执行按位运算或者移位操作,很容易就会产生符号的问题,建议使用不可能为负数的 unsigned型变量。

1.5.8  一次性操作多个标志位

这里还要补充一点,就是一次性操作多个标志位的方法。

比如需要将墙壁和目的地的标志位都设置为 true,在这种情况下,如果调用两次 check()就太麻烦了,这时可以通过连接两个枚举类型来解决。

flag.check( FLAG_WALL | FLAG_GOAL );

像上面这样使用 or 运算将两个枚举值连接起来就可以了。这是为什么呢?

01011110 or 11000000 = 11011110

对参与 or 运算的各个数来说,相同的位上只要有一个等于 1,那么该位运算的结果就不会等于0。因为最后只需判断结果是否为 0,所以非 0 结果的具体数值并不重要。64 也好 128 也好,甚至两者相加得来的 192 也好,只要不为 0 就可以了。

不光是标志位的检测,将标志位设置为有效和无效时都可以使用类似的方法。

flag.set( FLAG_WALL | FLAG_GOAL );
flag.reset( FLAG_WALL | FLAG_GOAL );

众所周知,一个数依次加上 64 和 128 的结果跟一次性加上 192 的结果是相同的。同样,单独将64 对应的位设为 0 后再将 128 对应的位设置为 0,和一次性将 64 和 128 对应的位都设为 0 的结果也是一样的。不过,像这样一次性地将 or 运算的结果作为参数传递给函数时,不能再使用 0、1、2、3这种普通数字作为枚举类型值,应当使用移位后的数字。

如果一次只修改一个标志位,就可以像下面这样,从 0 开始按顺序定义枚举值,在函数中执行移位操作。

enum Flag{
   FLAG_WALL,
   FLAG_GOAL,
};

假设f 是传入的枚举型变量,则处理的代码如下所示。

return ( mFlags & ( 1 << f ) ); //check
mFlags |= ( 1 << f ); //set
mFlags &= ˜( 1 << f ); //reset

这种写法使enum 变得简洁明了,而且不容易出错。不过如果需要同时操作多个标志位,这种做法就不可取了。

1.5.9  十六进制数

C++ 不支持在代码中用二进制的形式来表示数字。

if(a & 10110110){

虽然像上面这样用二进制表示数字更容易理解,但遗憾的是,这是行不通的。

作为替代方案,C ++ 提供了十六进制数表示法。顾名思义,十六进制数中每一位都可以用 16种数字之一表示。0 到 9 有 10 种,再加上 a 到 f 这 6 种,一共 16 种。其中,a 表示 10,b 表示 11,c表示 12,d 表示 13,e 表示 14,f 表示 15。在变量值开头加上 0x,就表示这是一个十六进制数。这样一来,上面的代码就可以写成下面这样。

if(a & 0xb6){

虽然仍旧比不上直接采用二进制表示法显得直观,但是相较于十进制表示法已经有很大改善了。

if(a & 182){

十六进制数的 1 个字符占 4 位,二进制序列 1011 等于 8 + 2 + 1,也就是 b,0110 则等于 4 + 2,也就是 6。熟练以后,程序员在看到 7 或者 d 时,眼前应该很快就能浮现出 0111 或者 1101 这样的二进制序列。

关于标志位的讨论暂且就到这里,读者可以试着改造一下《箱子搬运工》的目的地标志位,以确认自己的理解程度。具体可以参考笔者提供的示例代码 NimotsuKunBitOperation。

1.6 补充内容:指针和内存

指针是 C 和 C ++ 的精髓,不过笔者经常会听到“指针太难了”的感慨。其实,所谓“指针太难”,不是指针这个概念本身有多复杂,恰恰相反,和 C ++ 中其他元素比起来,指针的概念是非常简单的。函数、变量等高级功能和指针具有的底层特性结合起来,造就了 C ++ 的灵活性。而大家之所以感觉指针太难,或许就是因为没能很好地将二者融合。

本节将从源头讲起,试着梳理一下指针的来龙去脉。读者了解了这些细节后,再遇到不理解的地方时,就可以从底层的角度进行考虑。

1.6.1  内存的数组结构

假设现在有一台内存只有 16 字节的计算机。

图像说明文字

上图表示的是所有内存,1 个格子代表 1 字节。

下面准备计算 2 + 2。首先必须将数字 2 放入内存中的某个位置,并确定在何处存放计算结果。这里我们将 2 放入 0 号格子。因为内存被看作一个数组结构,所以标记索引从 0 开始。

图像说明文字

经过 CPU 的计算后,再将结果存入 1 号格子,如下图所示。

图像说明文字

C++ 的伪代码如下所示。

char memory[ 16 ];
memory[ 0 ] = 2;
memory[ 1 ] = memory[ 0 ] + memory[ 0 ];

貌似和正规的 C++ 代码有些出入。这里除了 memory外没有其他变量。

下面,我们将刚才计算得到的 4 再加上 2,同样也必须将结果存入某个位置。下图是将结果存入2 号格子的情况。

图像说明文字

下面是对应的 C++ 伪代码。

memory[ 2 ] = memory[ 0 ] + memory[ 1 ];

实际上,在用 C++ 进行一般的计算时,完全不用关心这些变量存放的位置,只要像下面这样写代码就可以了。

char a = 2;
char b = a + a;
char c = a + b;

至于 a、b、c这些变量存放在哪里,我们无须关心,但是也必须知道,是编译器帮我们进行了类似于“将 a放在 0 号格子,b放在 1 号格子,c放在 2 号格子”的工作。也就是说,上面这段C++ 代码会被编译器转换为下列形式。

memory[ 0 ] = 2;
memory[ 1 ] = memory[ 0 ] + memory[ 0 ];
memory[ 2 ] = memory[ 0 ] + memory[ 1 ];

就像这样,各个变量都会被放入 memory这个巨大的内存数组中,然后通过相应的下标索引进行管理。

1.6.2  指针是什么

前面的伪代码中使用的“开发语言”除了 memory之外没有其他变量,太不方便了。可以想象,不使用变量名而全靠下标索引值来引用该有多么麻烦。假如用这种语言来编写《箱子搬运工》,结果会是什么样呢?

虽然很麻烦,但笔者还是进行了尝试。读者可以参考 NimotsuKunRawMemory 中的代码,看后应该会感到很绝望吧。

代码中准备了下面这样一个全局变量,除此之外再没有创建任何变量,一切都通过下标索引来管理。

char m[ 100 ];

另外,因为函数的参数和返回值也是一种变量,所以使用全局变量的 0 号元素来完成交换处理。调用函数前先将参数放入 0 号位置,如果函数有返回值,也将其记录在 0 号位置。这些操作都由编译器在暗地里帮我们完成。

计算机的内存其实可以看作一个 char数组,变量、数组、结构体、函数和类都被放置在其中。系统通过一张记录了“从哪里到哪里表示的是什么名字的变量”的表来管理它们,负责生成和维护这张表的就是编译器。这一点请牢记。

  • 内存地址

这里要介绍一个重要的概念。

内存数组的下标索引称为内存地址(memory address)或者地址(address),而存储了该下标的变量称为指针(pointer)。

现在来看一下 NimotsuKunRawMemory 的 checkClear()函数。

图像说明文字

m[0]用于存放返回值,m[1]相当于 for循环中的计数器 i,m[18]用于存放场景的宽度 8,m[19]用于存放场景的高度 5。另外,从 m[20]开始到 m[59]存放的是场景的状态数组。现在请大家看一下下面这行代码。

if( m[ m[ 1 ] ] == OBJ_BLOCK ){

在循环过程中,m[1]的值从 20 开始逐次加 1,一直到 59。此外,m的 20 号位置到 59 号位置存储的是场景的状态数组。

如前所述,用于存储内存数组的下标位置的变量称为指针,所以 m[1]就是一个指针。C ++ 代码中的写法如下所示。

图像说明文字

*p 就相当于前面的m[m[1]]。也就是说,C + + 中的指针* 运算符可以将指针变量作为下标来

访问内存数组。指针本质上就是个整数,下面的代码会在屏幕上打印出一个整数。

cout << reinterpret_cast< int >( p ) << endl;

关于 reinterpret_cast后面会具体说明,它可以强制转换变量的类型。指针转换后可能会返回一个特别大的数字,这是内存空间比较大的缘故。如果有 100 MB 的内存,该数字可以达到 1 亿。

反过来,所有的整数都可以作为内存数组的下标,也就是指针来使用。例如,下面的代码会向下标 20 指向的位置写入数值 4。

char* p = reinterpret_cast< char* >( 20 );
*p = 4;

实际上在代码中这样做很可能导致程序中断退出,因为操作系统会检测出“没有权限向该位置写入数据”,只有在该段范围的内存恰好可用时才能够顺利写入。平常我们在使用变量时可能容易忽略一个事实,就是所有的变量其实都存放在内存这样一个 char数组中的某个位置。这个特性一定要牢记。

举例来说,类中的 private变量是禁止外部访问的,但是如果知道该变量的地址,也就是其对应的内存数组的下标位置,就可以创建一个指针向该位置写入数据。比如针对下面这个类:

图像说明文字

通过下面的代码就可以强行向b 写入值。

A a;
char* p = reinterpret_cast< char* >( &a );
*( p + 1 ) = 5;

在实际写代码时,我们当然不可能犯这样低级的错误,但有时可能会由某个bug 导致类似的操作,这是需要留意的。

1.6.3  指针和数组

我们已经知道了指针相当于内存数组的下标。因为是下标,所以使用加 1 后的值应该就可以定位到数组中的下一个元素了吧。也就是说,

m[ 1 ] = 0;

char* p = &m[ 0 ];
*( p + 1 ) = 0;

是等价的。

实际上,在C+ + 中,下面的写法也是允许的。

char* p = &m[ 0 ];
p[ 1 ] = 0;

虽然p 是指针而不是数组,但是也可以使用和数组相同的写法。另外,下面的写法也是没有问题的。

*( m + 1 ) = 0;

m 虽然是数组而非指针,但是这里也可以采用和指针相同的写法。

也就是说,在C+ + 中可以认为数组变量等同于指针①。可以认为,

a[ 3 ];

*( a + 3 );

的缩略形式,因为后者的写法比较麻烦。

 数组定义的内容

我们不妨再深入看看,下面这行代码到底做了哪些事情呢?

char a[ 3 ];

① 在某些情况下是不能通用的,因为严格来讲这种说法不完全正确。

如果读者还记得前面说过的“所有的变量其实都存放在内存数组中的某个位置”,就能理解通过这行代码并不会“嘭”地一下生成只存放三个 char类型变量的内存了。这行代码实际上只是在查找内存中可用的空白区域,并将该地址放入名为 a的变量中。因为存放地址的变量是个指针,所以数组变量其实就是个指针。再看一下下面这种写法。

char* b;

两种写法的区别在于,后面这种写法省略了查找内存空白区域并将其地址保存到变量中的过程。因此,b中并未存放可用的内存地址,在使用前必须对其进行赋值。也就是说,数组变量是“一开始就被初始化过的指针”。

1.6.4  值为 0 的指针

对指针而言,值为 0 表示其未指向任何位置。从内存数组的角度来看,值为 0 意味着指向的是内存数组中的第一个元素,这是不允许的。

下面这段代码中的写法违反了这种规定,将导致程序错误。

int* p = 0;
*p = 4; // 写入错误
int a = *p; // 读取错误

将销毁后的指针设置为 0,正是在利用这个性质进行 debug。如果销毁后指针仍保存了原来的值,那么再次使用该指针时就很可能会错误地访问内存中的某个位置。这就好比给搬了家的好友原来的家打电话一样。为了防止出现这种情况,现实中我们会将好友原来家里的电话号码抹去。把不再有用的指针设置为 0 也是同样的道理。

由于这个特性,整数 0 可以被赋值给指针。但需要注意的是,只有 0 才可以这样操作。

int* p = 0;

是正确的,而下面这种写法则是错误的。请读者注意 ①。

int* p = 1;

1.6.5  指针的类型

到目前为止我们讨论的主要是 char,其实 C++ 中还存在 int和其他各种类型的指针。类型名称不过是为了防止编译器对类型不同的指针进行复制而使用的检测标记而已,本质上都是内存数组中的一个位置索引。

不过,不同类型的指针在执行加法运算时稍有区别。假设有下列代码,

int* ip;
char* cp;
① 这个特性有时会导致混乱。譬如某个类存在两个构造函数,原型分别是整数参数的A(int) 和指针参数的A(B*)。当代码中写了A(0) 时,由于无法匹配唯一的函数,编译器将提示错误。如果是普通函数的话还能够修改名称,但是构造函数的话就行不通了。

那么,

ip[ 0 ];
cp[ 0 ];

意味着将指针所指位置的内容取出。假如空间足够,下面这段代码将取出下一个元素。

ip[ 1 ];
cp[ 1 ];

但是,因为char 占1 字节,而int 占4 字节①,所以它们和下一个元素的距离分别是1 和4。

图像说明文字

为了使

*( ip + 1 );

和ip[1] 表示的意义相同,需要赋予+1“前进1 个int 类型长度的距离”之意,不过这样一来实际上内部增加了4。因此,当存在类型T 时,

T* p;
p += 2;

会将p 中的地址变为T 类型大小的2 倍。

address += sizeof( T ) * 2

有了这个规则后,数组下标定位和指针加法运算之间的关系就变得简单了。

1.6.6  new 和 delete

C+ + 中一般通过new 来创建类,那么,new 到底做了什么操作呢?

new 有两个功能:一个是在内存中查找空白位置并将该位置索引存入指针;另一个是在该处调用相应的构造函数。

delete 的功能和new 相反,调用析构函数后释放内存。

通常所说的使用new 来分配内存,指的就是在内存中查找空白位置并返回该位置索引。

new 功能中的内存管理程序负责检测内存是否空白可用。不过就像之前提到的那样,内存不过是一个特别大的数组而已,只要传入合适的下标值,就能够强行访问相应位置。因此,如果将

int* p = new int;
*p = 5;

改为

int* p = reinterpret_cast< int* >( 1000000 );
*p = 5;
① 根据平台的不同,int 类型也有可能是2 字节或者8 字节,但在本书中都当作4 字节。

则也有可能顺利运行。

因此,不能理解为 new的过程就是“嘭”地一下凭空生成了变量,而应该是下面这种情形。乘客在车站找空着的椅子,找到后声明“这是我的位置”。这就类似猫生小猫和在空地上搭建房屋的区别,猫的数量可以一直增加,还可以随意走动;而土地的数量是固定的,搭建好的房屋不会随意移动。另外,相对于小猫死亡,将建好的房屋摧毁更接近 delete的过程。因为土地的位置是固定的。

虽然在 C# 和 Java 中不用 delete也能够自动识别并释放那些不再使用的内存,但 C++ 没有这样的功能,因此必须手动执行 delete。习惯 C# 或其他语言的读者可能会觉得有些奇怪,为了便于理解,我们来想象一下打扫卫生间的例子。显然,如果是自己一个人生活,那就得自己打扫卫生间;而如果是车站的卫生间,则会有专门的工作人员来打扫。差别就在于卫生间的使用人数。如果有很多人使用,那么派专人打扫是最有效率的。反之,一个人用的话,自己打扫反而更轻松。C ++ 正是一种“需要自己打扫卫生间”的语言,这可能是因为它在很多人都需要使用的卫生间出现之前就被发明出来了。在 C++ 设计之初,设计者可能没有想到在大规模的程序中会有上万处用到 new。

此外,自动 delete机制还存在一个弊端,就好比车站的卫生间在打扫时将被停用,实际上 C#和 Java 也有类似的问题,当内存清理程序启动时,其他程序的处理会被暂停。由于使用 C ++ 时往往对运行速度要求比较高,如果不能确切地知道何时进行垃圾清理,就会存在一定的风险。

1.6.7  数组和 new 的区别

请读者再来思考一下下面这个问题。

int p[ 5 ];

int* p = new int[ 5 ];

有什么区别呢?全部使用数组的写法不是很好吗?

C+ + 的数组中存在一些new 所没有的规定,那就是在编译时必须确定所需内存的大小,以及在函数结束时必须释放内存①。有了这些规定,在查找可用内存区域时就轻松多了。

实际上new 操作会更慢一些,大约相当于1000 次加法运算所花费的时间。而在数组操作中,因为查找内存的操作都在编译时进行,当编译结束时所有的地址索引都已确定,所以操作过程中完全没有多余的处理,速度很快。

数组和new 的区别就只有这些,在查找可用内存区域并返回地址索引方面,二者是相同的。

1.6.8 关于指针的小结

指针就是内存数组的地址索引。如此而已。

剩下的就是一些语法上的问题,比如a[3] 和*(a +3) 的写法本质上是相同的、数组变量只不过是保存了可用内存的地址索引的指针,等等,了解了这些应该就不会有什么问题了。如果还能进一步理解new 和delete 的执行过程,那就更完美了。建议读者试着自己创建内存分配和内存释放函数,这样有助于加深对内部机制的理解。

① 全局变量因为不在函数中创建,所以不存在这样的限制。

1.7 补充内容:引用

C+ + 中有个叫引用(reference)的概念,简单地说就是别名。请参考下面的代码。

int a = 5;
int& aRef = a;
cout << aRef; // 输出5

因为aRef 是a 的别名,所以用cout 输出aRef,将会输出a 的值,也就是5。此外,像下面这样将aRef 赋值为6 的话,a 的值就会变为6。

aRef = 6;
cout << a; // 输出6

举个例子,大毛是毛毛的别名,那么毛毛死了的话大毛肯定也死了。因为虽然名字不同,但其实是同一个人。

不过别名这种抽象的解释也不能说完全正确。只有搞清楚内部执行了哪些处理,才能保证写出的程序没有问题。

1.7.1 引用和指针

按照笔者的理解,引用是去掉了某些功能的指针。

下面让我们来对比一下指针和引用。首先是在创建时:

int a = 5;
// 创建时
int& aRef = a;
int* aPtr = &a;

创建指针时必须通过& 符号获取目标地址,引用则不需要。

其次是在使用时:

// 使用时
aRef = 10;
*aPtr = 10;

使用指针时必须添加,引用则不需要。我们暂且可以将引用看作“编译器会自动添加 的指针”。下面我们来列出它和指针的不同之处。

  • 必须初始化

指针可以在创建时不指向任何位置,如下所示。

int* aPtr;

引用则必须初始化。如果像下面这样写,程序将无法通过编译。

int& aRef;
  • 不能变更指向的位置

指针在使用过程中可以修改指向的位置,如下所示。

int a;
int* aPtr = &a;
int b;
aPtr = &b;

引用则不行。

int a;
int& aRef = a;
int b;
aRef = b;

上面的代码只是将b 的值赋给a 而已,并未修改引用指向的位置。

  • 无法使用下标索引和数字加法

指针可以用数组的形式访问,也可以通过加上数字来指向目标元素,引用则不行。比如,指针像下面这样写是可以的。

int* aPtr = &a;
aPtr[ 3 ] = 2;
aPtr += 2;

但如果引用采用这样的写法,将无法达到相同的目的,如下所示。

int& aRef = a;
aRef[ 3 ] = 2; // 错误!
aRef += 2; // 只是对a 的值加上2
  • 小结

综上,引用具备下列特性。

● 必须指向某个变量,因此不会出现忘记初始化的 bug

● 不允许修改指向的位置,因此不容易出现错误修改

● 无法采用数组的访问形式,因此不容易产生访问错误

可以看到,引用很好地规避了指针的一些危险特性。例如,我们经常会把指针传给函数,并将计算结果回传,这时通过引用来传值会安全得多。也就是说,将

void calc( int* a ){
   *a = 计算结果;
}

改写为:

void calc( int& a ){
   a = 计算结果;
}

当遇到无法使用返回值的情况时会把指针传递给参数,不过这里可以通过引用来避开指针的一些风险操作。因为引用必须指向某个变量,所以不必担心会出现使用指针时忘记初始化的问题,下面这种隐患绝对不会出现。

void calc( int* a ){
   a[ 3 ] = 计算结果;
}

除此之外,在其他一些情况下也可以使用引用,下面我们就来看一下。

1.7.2 用于改善性能的指针和引用

假设有如下一个类。

class T{
public:
   int a[ 1000 ];
};

另外有如下一个以该类为参数计算总和的函数。

int sum( T t ){
   int ret = 0;
   for ( int i = 0; i < 1000; ++i ){ ret += t.a[ i ]; }
   return ret;
}

在调用该函数时,该类发生了复制。

int r = sum( t );

上面这句看似平淡无奇,其实包含了一个T 类型对象的复制操作。在C + + 中,当类作为参数被直接传递给函数时,将发生复制,这是一个基本事实。上面的例子中会对1000 个int 逐一进行复制,因此处理速度非常慢。

在这种情况下,可以使用指针,如下所示。

int sum( const T* t ){
   int ret = 0;
   for ( int i = 0; i < 1000; ++i ){ ret += t->a[ i ]; }
   return ret;
}

注意这里不打算修改t 的内容,所以将其声明为了const T*。直接传递对象时会发生复制,函数后续操作对原有的内容不会有影响。但是如果按指针类型传递,函数中的操作就可能会修改原对象,所以为了杜绝这种可能,就需要添加const。调用函数的代码为

int r = sum( &t );

这样就避免了类复制的发生,只需传递一个地址即可,处理速度也变得极快。美中不足的是,该方法仍然使用了指针这种“危险”的东西,我们可以将其换成引用。

int sum( const T& t ){
   int ret = 0;
   for ( int i = 0; i < 1000; ++i ){ ret += t.a[ i ]; }
   return ret;
}

注意这里和使用指针时一样,都要加上const。调用函数的代码和最初相同。

int r = sum( t );

不再需要加上&,并且也回避了指针带来的风险,函数中的代码变得清晰有条理。像这样,当向函数传递int 和float 等基本数据类型以外的类时,一般都会使用引用,很少直接传递类,另外也没有必要使用指针。

当然,引用也存在着一些问题。

1.7.3 引用的不足之处

传递引用的函数与直接传递对象的函数的调用代码是一样的。在上面的例子中,二者的调用代码如下所示。

int r = sum( t );

单凭这句代码,程序员无法得知该函数传递的是否为引用。如果像下面这样将参数加上const就没有问题了,因为t 不允许被修改。

int sum( const T& t );

但问题在于,只通过观察调用的代码,仍无法判断出函数中是否有修改参数对象的可能性。

int r = sum( t );

程序员在写完上述代码后,可能没有意识到函数中t 被修改了,进而导致bug。当然,通过查看sum 的函数声明,就可以确认参数是否有const,但是如果每遇到一个函数就去查看头文件就太不方便了。而假如这里使用的是指针,程序员在遇到下面这种写法时可能就会意识到此处可能修改了参数内容,而引用则无法起到这种作用。

int r = sum( &t );

笔者的习惯是,当变量会被修改时传递指针,否则传递引用。按照这个原则,一看到带有&,马上就可以知道函数的参数可以被修改。

但这毕竟只是笔者个人的习惯。就像前面所说的那样,函数中传递指针参数可能会带来一些风险,比如可以随意访问数组、指针的值会发生改变等。如果程序员在写代码时能够意识到传递的参数有可能被修改,那么即使全部使用引用也不要紧。毕竟这样就可以消除使用指针所带来的危险。当然这些都依赖于个人习惯。

要是能够简单看一眼就知道哪些参数会被修改就好了。

比如下面这行代码有多个参数,不查看头文件就无法知道哪些参数会被修改,效率太低了。

someFunc( a, b, c, d, e, f, g );

而按照笔者的原则,采用下面这种写法,就能一眼看出a、b、c 会被修改。

someFunc( &a, &b, &c, d, e, f, g );

但是这里有一个前提,那就是必须遵循笔者的原则,不保证这样的写法适用于其他人写的代码,而且就算是笔者自己也有可能弄错。考虑到这一点,如果要确保绝对安全,除了时时提醒自己所有参数都有可能在函数内被修改之外,或许别无他法了。

1.7.4 返回引用

我们经常可以看到返回成员变量指针的函数。

class T{
public:
   const A* getA() const { return &a; }
private:
   A a;
};

如果A 不是int 或者float 类型,复制操作会很费时,因此就将函数的返回类型写成指针。又因为不希望返回值被修改,所以添加了const。为了回避使用指针的风险,还可以改为以下使用引用的方式。

class T{
public:
   const A& getA() const { return a; }
private:
   A a;
};

像下面这样写也完全没有问题。

const A& a = t.getA();

但问题在于,它也可以写成下面这种形式。

A a = t.getA();

如果A 是一个特别庞大的类,这里就会发生规模巨大的复制操作,代码运行速度很慢。如果函数返回的是指针,如下所示,在前面加上* 后,就不会发生复制操作,代码的执行效率也不会变低。

A a = *t.getA();

因此,尽管可能有些不方便,但笔者仍坚持“成员类变量一律通过指针返回”的原则。当然,这种做法必须在前面加上*,略显麻烦,在对性能要求不高的情况下,未必都需要使用指针,大家根据具体情况来选择即可。

  • 不允许返回局部引用

需要注意的是,指针也好,引用也好,都很容易出现一种错误的使用方式。

T& foo(){
   T a;
   return a;
}

如上所示,函数返回了函数内所创建的对象的引用。当函数结束时,a 将被析构释放,导致引用的内容发生错误。

int* a = new int; //new
int& aRef = *a; // 传递给引用
delete a; // 销毁原始对象
aRef = 5; // 错误!引用所指对象已不存在!

参考上面的代码中的注释,很容易就能理解问题出在哪里。尽管引用强制要求初始化,但是无法阻止所引用的对象被销毁。当然这里换成指针也一样。

T* foo(){
   T a;
   return &a;
}

不过很少有人会这么写,因为指针的写法比较容易看出问题。从这一点来看,引用的写法往往会在不经意间造成bug。请读者牢记,引用其实就是一种特殊的指针。

1.8 本章小结

本章试着开发了一个简单的《箱子搬运工》游戏。虽然很难说开发到什么程度才可以,但现阶段读者只要能够参考笔者的示例代码开发出可运行的程序就够了。

另外,本章对C+ + 进行了一些介绍。如果读者感到基础没有打好,最好先找本入门书学习,当然也可以继续往下阅读,到完全无法理解时再去查阅。

补充内容中介绍了位运算、指针和引用这三个概念。三者之中最为重要的是指针,这部分内容无论如何都必须掌握。如果没有理解就继续阅读下去,后面的内容学习起来会非常吃力,而且这样的状态也不适合开发大型游戏程序。另外两个概念相对没有那么重要,读者也可以在有必要时再回过头来温习,或者干脆完全不使用它们。

下一章我们将试着向游戏中添加图片素材,工作量也不大。在理解了本章的内容之后,学习起来应该是非常轻松的。

目录

  • 译者序
  • 前言
  • 第1部分 2D 游戏
  • 第1章 第一个游戏
  • 第2章 从像素开始学习2D 图形处理
  • 第3章 使用图片素材
  • 第4章 实时游戏
  • 第5章 简单的状态迁移
  • 第6章 文本绘制方法
  • 第7章 动作游戏初体验
  • 第8章 2D 平面内的碰撞处理
  • 第9章 各种输入设备 
  • 第10章 状态迁移详解
  • 第11章 播放声音
  • 第12章 旋转、缩放与平移
  • 第13章 显卡的力量
  • 第2部分 3D 游戏
  • 第14章 绘制立体物体
  • 第15章 类库的封装方法
  • 第16章 伪XML 文件的读取
  • 第17章 编写高性能的代码
  • 第18章 3D 碰撞处理
  • 第19章 《机甲大战》的设计
  • 第20章 光照
  • 第21章 角色动画 
  • 第3部分 通往商业游戏之路
  • 第22章 高效的碰撞检测 
  • 第23章 数据加载
  • 第24章 float 的用法 
  • 第25章 随书类库概要
  • 第26章 bug 的应对方法
  • 第27章 进阶方向