框架代码导读
暂时废弃
这个页面正在维护。 目前你可以移步代码组织与程序构建子讲义。
难度警示
我们在上手任何任务时都存在一个学习曲线。 在项目任务中,尤其是对于零基础的同学,这将会是学习曲线最为陡峭之处。 请充分重视第一阶段,否则你很有可能无法完成后续所有阶段。
这一部分要求完成的任务很少,而我们仍为它安排了一周的时间,但这不是中间休息时间。 我们希望你能用一周的时间认真阅读讲义,并确保理解了框架的代码。
关于 GPT 的使用
请尽量不要让 GPT 等大语言模型直接帮你生成作业代码。 你可以让它为你解释讲义中你看不懂的技术细节,也可以让它为你提供某样技术细节的实现乃至示例代码。
但如果你直接让 GPT 生成项目需要添加的代码,可能会有如下几点后果:
- 你可能并不懂 GPT 的代码为何能够工作,这可能令你在后续需要修改该代码时遇到困难。
- GPT 的代码可能是错误的,而你没有进行足够的阅读或理解工作,导致你无法进行项目。
RTFSC 是 Reading The Fucking Source Code 的缩写,来自于 Linus 在邮件中的「友善」嘴臭。
虽然骂人不好,但这话更像自言自语,有如「It's time to read the fucking source code」。 它提醒我们:无论是在初步接手一个项目,抑或是遇到了一些疑难杂症时,沉下心来读一读源代码都很可能对我们大有裨益。
但不少同学作为初学者,哪怕对于小型项目的结构和代码都感到一头雾水: 为什么有这么多源文件? 各种代码文件之间有什么关系? 代码里怎么到处都是我没见过的单词和语法? 该怎么编译整个项目? 要知道这些问题的答案,必须对一些 C/C++ 的高级语法,以及 C/C++ 项目的构建具有基本的知识。
由此,作为第一阶段的开端,在本部分中我们为大家介绍阅读框架代码所必须的前置知识,并通过阅读代码了解框架代码的大略架构。
运用 Make 进行构建
一条条手动运行构建项目的命令既麻烦又容易出错。我们在第零阶段介绍了可用于构建的自动化工具 Make 来帮我们解决这一难题。在学习了构建项目的流程之后,现在我们进一步学习如何利用 Make 来自动化构建项目。
首先,在某一目录下执行 make
命令时,make
会自动寻找指定目录(默认是当前目录,当然也可以通过 -C DIRECTORY
指定)下寻找 Makefile
或 makefile
文件,并按照其中的指示来执行项目的构建。可以看出,运用 Make 的核心就是学会编写 Makefile。
我们接下来简要介绍一下编写 Makefile 的基础知识。
Makefile 的基本结构
我们首先介绍 Makefile 中的几个核心概念:
- 目标(target)。表示一个 Makefile 需要生成的文件。
- 依赖(prerequisites)。表示目标所依赖的文件(可以是其他目标)。
- 制法(recipe)。表示生成一个目标需要执行的命令。
以上三部分合起来描述了目标及目标的生成方法,并构成了 Makefile 的一个基本执行单元(本文称其为「规则」),格式如下:
target ... : prerequisites ...
recipe
...
当执行 make
时,Make 会自动寻找文件中第一个出现的目标并以它为生成目标。你也可以用 make target
来指定 Make 的生成目标。
Makefile 生成一个目标时,会先检查依赖是否存在,或者是需要构建的目标,如果是需要构建的目标则递归生成目标。
然后 Makefile 检查目标及所有依赖的更新时间,如果目标文件不存在,亦或者某一依赖的最后修改时间比目标的最后修改时间要迟,那么 Makefile 就会执行制法中指定的命令。
Makefile 中还可以定义所谓的伪目标。伪目标的目标名并不是真正的文件,而只是一个操作名,它被 Makefile 生成时不会产生对应的目标文件,只会执行对应的制法。Makefile 会试图自动识别哪些目标是伪目标,你也可以用 .PHONY: targets
来指定。
注意缩进
Make 对 Makefile 中的缩进敏感。recipe
的内容需要有一个 Tab 的缩进。
附加依赖
Makefile 的语法很灵活。你可以在定义一个规则的前后为目标添加其它依赖。语法形如 target ... : prerequisites ...
,不需要添加 recipe
。
结合实际
试试阅读项目的 Makefile,你发现了哪些目标?这些目标的依赖关系是怎样的?哪些目标是伪目标?
你现在可能还看不懂部分 $(OBJ)
之类的变量名,可以暂时跳过它们。
Makefile 中的模式匹配:*
与 %
大家也许在理论课堂上已经学过了正则表达式或类似的模式匹配机制。模式匹配能令我们利用计算机迅速筛选出一类符合要求的字符串。
最常用的模式匹配方法是利用通配符来匹配:一个通配符可以匹配空串或任意大小的串,也即「通用匹配」,相当于正则表达式中的 .*
。在多数场景中,通配符是 *
,可以试试在项目根目录执行 echo minitui/include/*.h
,感受一下通配符的作用。
而在 Makefile 中,通配符是 %
或 *
。其中,%
是一个「对应」的通配符——在同一上下文中,%
匹配一次后,后续出现的 %
代表着和第一个 %
相同的值。于是,后续多个含有 %
的模式就像是一种以通配字符串为词根的「派生模式」。
上述模式能方便地用于进行模式替换(将匹配串替换为某一派生串)和模式规则定义(一种为某一模式的目标批量定义规则的方法,其中依赖由目标的派生串指定)。我们会在后面提及它们。
模式规则
Makefile 具有为同一模式的文件批量类似的规则的功能,我们将其称为模式规则。模式规则利用 %
通配符匹配某一规则的目标,然后对它进行派生以得到依赖列表。举个简单的例子,我们希望将所有的 .c
文件编译为同目录下的同名 .o
文件,于是我们可以写出这样的模式规则:
%.o : %.c
gcc %.c -o %.o
然而很遗憾,以上规则并不能运作,因为 Make 不会将 Recipe 中的 %
进行替换,要在 Recipe 中引用目标名、依赖名、「词根」等,我们必须引入所谓特殊变量,以下是一些常用的特殊变量:
$@
:目标。$<
:第一个依赖。$^
:所有的依赖(去重)。$+
:所有的依赖(不去重)。$?
: 所有比目标更新的依赖。$*
: 「词根」,即%
匹配的字符串。
于是我们可以改写上面的规则作:
%.o : %.c
gcc $< -o $@
这样我们就为所有 .c
结尾的文件定义了一个编译规则。
Makefile 变量
我们在上文提及了一些 Makefile 的特殊变量,现在我们来详细介绍一下 Makefile 变量。
Makefile 的变量本质上是字符串,引用变量的方式是 $
,如果一个变量名是 VAR
,对其值的引用就是 $(VAR)
。
变量可按照四种方法进行定义:
- 递归赋值。
VAR=EXPR
表示定义VAR
为EXPR
,EXPR
中可以含有变量,其值使用的是最终的变量值。也就是会递归计算完引用的变量的变量值再计算VAR
的值。这种方法可能导致死循环,应当避免循环引用。 - 直接赋值。
VAR:=EXPR
同样表示定义VAR
为EXPR
,但引用变量使用的是当前的变量值。 - 追加赋值。
VAR+=EXPR
表示在VAR
的后面添加一个空格和EXPR
的值。引用的变量如果未定义,则采用递归赋值风格,否则以该变量定义时使用的赋值风格为准。 - 缺省赋值。
VAR?=EXPR
表示当VAR
此时未定义时将VAR
定义为EXPR
。
Makefile 中可以使用系统内的环境变量。但同名变量以 Makefile 内定义的为优先。
同时,变量还有作用域之分。普通定义的变量是只在本文件中生效的,而有些变量则是可继承的,在 recipe 中执行的 make
子进程会继承它们的值。为定义可继承的变量,可以利用 export
关键字来修饰变量定义,或在 make
时利用 make VAR=XXX
指定变量的值。另外,可以用 override
关键字来覆盖从父进程继承的变量。
值得一提的是,Makefile 中还有变量内联模式替换的语法,如 $(VAR:%.o=%.c)
能将所有 VAR
中 .o
结尾的元素替换为 .c
结尾。
什么是元素?
在 Makefile 中,元素是字符串(变量或常量)中以空格为边界的子串(其实一般把它们叫做 token,这里姑且译作元素)。下文将字符串作为函数参数时,它被视为以空格分隔的元素的列表。当我们需要组合多个元素时,也将它们用空格分隔拼接成字符串(比如上文$^
,在引用它时所有依赖就以空格组合为一个字符串)。
Makefile 函数
Makefile 还有一些内置函数供编写者引用。函数引用的格式和变量引用相似,即 $(func args...)
,其中参数按照要求以逗号分隔。我们列举几个典型的内置函数,其他的内置函数大家在碰到时自行搜索或者在文档中查询:
- 字符串/路径处理函数。
$(basename STRS)
将 STRS 每个元素去除后缀名后的结果返回。$(patsubst PAT1, PAT2, STRS)
将 STRS 中符合模式 PAT1 的元素替换为模式 PAT2 后返回。$(addprefix PREFIX, STRS)
,将 STRS 的所有元素添加 PREFIX 作为前缀后返回。$(wildcard PAT)
,将搜索路径中所有符合 PAT 模式的路径或文件名返回。
- 打印输出函数。
$(error MSG)
。$(warning MSG)
。$(echo MSG)
。
- Shell 调用函数。
$(shell cmd)
,执行cmd
对应的 Shell 命令,并将其输出的内容返回。
注意命令中的$
需用$$
转义。
结合实际
阅读项目代码,现在你能够看懂项目
条件语句
Makefile 具有通过条件语句选择性保留代码的功能。其基本格式如下:
if-statement (condition)
...
else
...
endif
条件语句包含的代码可以是变量声明/定义等,也可以是 recipe
中的内容。在实际执行时,只有满足条件的代码会被保留。
条件语句具有四种形式的 if-statement
:
ifdef ()
ifndef ()
ifeq
ifneq
结合实际
阅读项目 Makefile 中的条件语句,你觉得它们起到了什么功能?
进一步学习
要进一步了解 Makefile 的编写和 Make 的使用,可以参考两样资料。
- GNU make 官方文档。这里有对于 Make 和 Makefile 用法和规范的最详尽和权威的描述。
- 跟我一起写 Makefile。这是一个初学者友好,循序渐进的中文教程。上文介绍 Makefile 的内容也参考了此教程。该教程的作者陈皓于 2023 年 5 月猝然去世。R. I. P.。
Makefile 思考题
结合上文所述的 Makefile 基本知识和项目 Makefile 的代码内容,完成如下思考题:
-
指出
Makefile
中CXXFLAGS
变量最终的值。假设要为游戏的所有源代码文件增加一个common/include
的#include
搜索路径,应该如何修改Makefile
(当然,你不要真的修改Makefile
)? -
ansi.h
并未被Makefile
文件中的代码指定为任何目标的依赖,本身也没有被指定为目标。为什么在修改ansi.h
后,执行make compile
会导致部分源代码被重新编译?或者说,Makefile
是如何识别头文件依赖的?你的回答应该尽可能详细。
提示:查看编译后build
目录下的*.d
文件,并查阅g++
中-MMD
命令的作用。
代码架构
代码整体架构大观
玩转宏定义:ansi.h
与 debug.h
条件编译
条件编译是代码跨平台的重要保证。
从结构体到面向对象:Minitui 组件库
大家在 OJ 的练习中,一定已经使用过了结构体的语法。我们现在来回顾一下结构体的定义:
- 结构体是一个自定义数据类型。我们知道 C/C++ 中有一些基础数据类型,如
int
,float
附录:本篇思考题汇总
代码组织与构建
- 假设现欲添加一个名为
glob_value
的int
类型全局变量,并要求源代码通过包含一个头文件glob_value.h
就可以访问该变量,glob_value.h
里应该如何写?源代码应该作何修改(可以添加新文件)? - 指出
Makefile
中CXXFLAGS
变量最终的值。假设要为游戏的所有源代码文件增加一个common/include
的#include
搜索路径,应该如何修改Makefile
(当然,你不要真的修改Makefile
)? ansi.h
并未被Makefile
文件中的代码指定为任何目标的依赖,本身也没有被指定为目标。为什么在修改ansi.h
后,执行make compile
会导致部分源代码被重新编译?或者说,Makefile
是如何识别头文件依赖的?你的回答应该尽可能详细。
提示:查看编译后build
目录下的*.d
文件,并查阅g++
中-MMD
命令的作用。
玩转宏定义
"\033[?1049h"
可以启用所谓的备用缓冲区。请用ansi.h
中定义的宏写出启用备用缓冲区的代码,你的代码应该尽可能短。- 解释
## __VA_ARGS__
的意思。为什么debug.h
中使用## __VA_ARGS__
而不是__VA_ARGS__
? - 假设现欲兼容 MacOS,原来的条件编译代码可以写为:
兼容 MacOS 后,条件编译代码应该写作什么形式?
#ifdef _WIN64 // some code functions in Windows #else // some code functions in Linux #endif
面向对象初步
- 列出
mainscr
类的所有成员变量和函数,并分别指出它们是被继承关系上的哪个类定义的。如果是虚函数,分别指出该虚函最初继承自哪个抽象类,并是在哪个类被实现的? - 菱形继承和虚继承是 C++ 继承机制中最麻烦的一环,容易产生错综复杂,难以理清的编译问题。上网查询资料,哪些面向对象的高级语言对此进行了改进?进行了什么改进?
附加题
- 学习
sed
。Makefile
编译%.cpp
时采用了如下规则:请问这两行$(BUILD_DIR)/%.o : %.cpp # ... @sed -i 's/[A-Z]:\//\/\l&/g' $(patsubst %.o, %.d, $@) @sed -i 's/:\//\//g' $(patsubst %.o, %.d, $@)
sed
命令目的为何?
学习cygpath
命令的使用,请给出一种更好的达成前述目的的方案。