C语言头文件包含与宏
从扫雷项目看#include、宏和条件编译这几道 C 语言题终于串起来了作业题目下面先把截图中的题目列出来答案和理由放在后文读的时候可以先自己想一遍。第 1581 题以下关于头文件说法正确的是 。A.#include filename.h编译器寻找头文件时会从当前编译的源文件所在的目录去找。B.#include filename.h编译器寻找头文件时会从通过编译选项指定的库目录去找。C. 多个源文件同时用到的全局整数变量它的声明和定义都放在头文件中是好的编程习惯。D. 在大型项目开发中把所有自定义的数据类型、函数声明都放在一个头文件中各个源文件都只需要包含这个头文件即可省去了要写很多#include语句的麻烦是好的编程习惯。第 1585 题下面哪个不是宏和函数的区别 。A. 函数可以递归宏不能递归。B. 函数参数有类型检查宏参数无类型检查。C. 函数的执行速度更快宏的执行速度慢。D. 由于宏是通过替换完成的所以操作符的优先级会影响宏的求值应该尽量使用括号明确优先级。第 1586 题下面哪个是条件编译指令 。A.#defineB.#ifdefC.#pragmaD.#error。从题目回到源码这次作业有三道选择题头文件怎么找、宏和函数有什么区别、哪个指令用于条件编译。只看选项时我很容易把它们记成三条互不相干的结论。后来翻了自己的《C学习记录7.27》《C学习记录7.28》《C学习记录8.18》《C学习记录8.19》又找出扫雷项目和以前的课堂代码才发现它们其实都发生在预处理和多文件编译这条线上。这篇文章就用手头的代码把三道题讲明白。文末附有完整的复现实验以及我实际编译、运行到哪一步的记录。先从我的扫雷项目说起扫雷项目不是只有一个main文件而是拆成了game.h、game.c和test.c。game.h开头是这样的摘取与本文有关的部分#pragmaonce#includestdio.h#includetime.h#includestdlib.h#defineROW9#defineCOL9#defineROWSROW2#defineCOLSCOL2#defineEASY_COUNT10voidInitBoard(charboard[11][11],intr,intc,charset);voidDisplayBoard(charboard[ROWS][COLS],intr,intc);voidSetMine(charmine[ROWS][COLS],intr,intc);voidFindMine(charmine[ROWS][COLS],charshow[ROWS][COLS],intr,intc);game.c和test.c都写了#include game.h。在test.c里可以看到宏真正参与了数组声明和函数调用charmine[ROWS][COLS]{0};charshow[ROWS][COLS]{0};InitBoard(mine,ROWS,COLS,0);InitBoard(show,ROWS,COLS,*);SetMine(mine,ROW,COL);我以前看到#include game.h会简单地说“引入头文件”。现在更准确的理解是预处理器先处理包含和宏展开编译器随后分别编译各个.c文件最后链接器把需要的定义组合起来。#include game.h让当前.c文件看见声明和宏不会自动把game.c的函数实现编译进来。如果构建配置只编译test.c而漏掉game.c可能到链接阶段才报找不到函数定义。这个区别在《C学习记录7.27》的多文件例子里也写过。双引号和尖括号究竟差在哪项目中的两种写法正好都出现了#includegame.h/* 项目自己的头文件 */#includestdio.h/* 工具链提供的标准库头文件 */在我用的 MSVC 环境中双引号形式会先从包含该指令的文件所在目录开始找之后还会查找相关的包含路径尖括号形式从配置的包含路径查找。用 MSVC 命令行时这些路径包括/I选项与INCLUDE环境变量在 Visual Studio IDE 中项目配置的包含目录起作用。具体搜索顺序属于编译器实现不能把“尖括号先找当前源文件目录”当作所有 C 编译器的规则。微软的#include文档列出了 MSVC 的查找顺序。这里还有一个我之前混淆的词包含目录是找头文件的地方库目录通常与链接时找库文件有关。作业选项 B 的意思是双引号形式还可能通过编译器指定的目录寻找头文件理解这道题时应把那里的“库目录”按“头文件包含目录”来读。为什么头文件里放声明定义通常留在.cgame.h放了InitBoard等函数声明game.c放它们的函数定义。这样test.c可以知道函数怎么调用而定义仍然只有一份。普通全局变量也要留心《C学习记录7.28》举过一个容易踩坑的例子/* shared.h供多个 .c 文件使用的声明 */externintyear;/* shared.c唯一的定义 */intyear2025;如果把int year 2025;直接写进被多个.c文件包含的头文件每个翻译单元都会获得一个定义链接时通常会报重复定义。#pragma once只是在同一个翻译单元内避免同一头文件被重复处理不能把跨多个.c文件的普通全局变量定义变成一份。扫雷项目用了#pragma once。它在 MSVC 等工具链上很方便如果要写更强调可移植性的 C 头文件也可以采用传统的包含保护#ifndefGAME_H#defineGAME_H/* 头文件声明放这里 */#endif/* GAME_H */这段是说明另一种写法不是对原项目的修改。关于#pragma once的支持范围可以看微软文档。宏展开不认识我想表达的“一个整体”扫雷代码里的ROWS很适合拿来检查自己是否真的理解宏#defineROW9#defineROWSROW2在char mine[ROWS][COLS]中ROWS展开成92所以第一维是 11符合当前 9×9 棋盘加边框的设计。但如果写ROWS * 2预处理后的关键部分是9 2 * 2按 C 表达式优先级算出来是13不是想象中的 22。所以我现在会把这一类宏写成#define ROWS (ROW 2)这样ROWS * 2才会按(9 2) * 2计算。这里只是在说明更稳妥的写法仓库里的原始源码仍是#define ROWS ROW2。另外原项目的InitBoard参数还把数组第二维直接写成11。如果以后改COL只改宏并不足以保证所有声明同步。这是读源码时看到的耦合点本篇没有调整扫雷接口也没有验证修改棋盘大小后的行为。《C学习记录8.18》和《C学习记录8.19》还有函数式宏的例子#defineMAX(x,y)((x)(y)?(x):(y))这个写法把参数和整个结果都加了括号解决了不少运算符优先级问题但它没有解决参数重复求值。比如传入a和b比较时两边各执行一次比较后被选中的那个实参还要再执行一次。我的study8_19.c课堂文件 里有MAX(a, b)与Max(a, b)的对照思路但这一段当时是注释代码不能把它写成原项目的运行结果。为了把结果看准我把相关定义提出来另写了一个可独立编译的复现实验完整代码如下也单独保存为C语言头文件与宏_源码复现实验.c#includestdio.h/* 与 game.h 一样故意保留未加括号的替换列表以观察展开结果。 */#defineROW9#defineROWSROW2/* 从 study8_19.c 中被注释的课堂片段提取定义单独复现。 */#defineMAX(x,y)((x)(y)?(x):(y))staticintMax(intx,inty){returnxy?x:y;}intmain(void){inta10;intb20;intmacro_resultMAX(a,b);printf(macro: result%d, a%d, b%d\n,macro_result,a,b);a10;b20;intfunction_resultMax(a,b);printf(function: result%d, a%d, b%d\n,function_result,a,b);printf(ROWS%d, ROWS*2%d, (ROW2)*2%d\n,ROWS,ROWS*2,(ROW2)*2);return0;}我用 MSVC 以 Debug x64、/W4且警告视为错误编译了这个独立程序运行结果是macro: result21, a11, b22 function: result20, a11, b21 ROWS11, ROWS*213, (ROW2)*222第一行可以自己手算最初a10, b20。条件里的a b用旧值比较条件为假此时a11, b21。随后又执行一次b表达式取得旧值 21最后b22。普通函数调用会先算好两个实参再把各自的值传进去所以第二行里a、b都只增加了一次。这个例子说明的是具体宏的求值次数不能据此推出“宏一定比函数快”或“函数一定比宏快”。现代编译器还可能内联小函数代码大小、优化设置和实际工作量都影响性能。宏替换与副作用的注意事项也见微软#define文档。还有一个细节宏形参x、y本身没有像函数形参那样声明 C 类型但宏展开后的 C 表达式仍会接受编译器的类型检查。说“宏完全没有类型检查”就过头了。#ifdef是编译前选择代码不是运行时if我在另一个真实源码study9_2.c里找到了正在使用的条件编译#includeQueue.h#includeLeetCode225.h#includestdio.h#includestdlib.h#ifdef_WIN32#includewindows.h#endifintmain(void){#ifdef_WIN32SetConsoleOutputCP(CP_UTF8);#endif/* 后面是队列与栈的练习代码 */}这里的意思是当_WIN32已定义时预处理阶段保留 Windows 头文件与设置控制台编码的语句否则跳过这些内容。运行时if做不到“让当前平台没有的头文件或函数调用不参与本轮编译”。上面只摘了与条件编译有关的代码没有在本次写博客时编译整个队列项目。#ifdef DEBUG判断的是宏有没有定义。就算写了#define DEBUG 0它也会进入#ifdef DEBUG分支要按数值开关判断应使用#if DEBUG同时明确DEBUG的默认值。我的《C学习记录8.19》把这两种情况放在了一起重新看代码后就不容易混淆了。具体语法可查微软#ifdef文档。回头做三道题第 1581 题关于头文件哪个说法正确我选B但会在旁边补一句“这里应说包含目录”。选项我的判断原因A.filename.h会从当前源文件目录找错尖括号形式通常走工具链配置的包含路径不能概括为先找当前源文件目录。B.filename.h会通过编译选项指定的目录找对本地目录找不到时还会考虑编译器配置的包含路径具体顺序依实现而定。C. 多个源文件共用的全局变量其声明和定义都放头文件错普通全局变量定义放进被多个.c包含的头文件容易造成多个定义可在头文件用extern声明在一个.c文件中定义。D. 所有自定义类型和函数声明塞进同一个大头文件错这样会让无关源文件也依赖整份大头文件按模块提供需要的声明更清楚。这题里最容易混的是 A、B 的搜索目录以及 C 中“声明”和“定义”不是一回事。第 1585 题哪个不是宏和函数的区别我选C。选项我的判断原因A. 函数可以递归宏不能像函数那样递归基本正确C 函数可以调用自身宏自引用不会像函数调用那样无限递归展开。B. 函数参数有类型检查宏参数没有函数形参式的类型声明基本正确宏替换后的表达式仍要由编译器检查不能理解成“宏不受类型规则约束”。C. 函数执行速度更快宏执行速度慢错没有这种固定的快慢关系。上面的实验检验的是求值次数不是性能基准。D. 宏替换可能改变运算优先级书写时要留意括号对ROWS * 2的 13 和预期的 22 就是现成例子。我之前会只记“宏是替换、函数是调用”现在更愿意先问替换后到底长什么样参数会算几次第 1586 题哪个属于条件编译指令答案是B#ifdef。#define用来定义宏#pragma用于实现相关的编译器指示#error用于发出预处理错误它们都是预处理指令但题目问的是条件编译。#ifdef正好用于按宏是否已定义选择代码。完整指令分类可查微软预处理指令列表。这次实际验证到了哪里扫雷项目我把原始game.h、game.c、test.c和项目文件复制到隔离目录用 MSVC Debug x64 构建成功运行程序输入0看到了菜单与“退出游戏”进程正常退出。构建仍报告_CRT_SECURE_NO_WARNINGS重复定义C4005和GetMinecount可能缺少返回值C4715。只验证了退出路径未验证扫雷玩法这两个警告也不是本篇博客对源码做过修复后的结果。宏实验上面的独立程序用 MSVC/W4和警告视为错误编译成功实际输出已列出。它使用了扫雷头文件的宏定义和study8_19.c里被注释的课堂思路不是直接运行原课堂文件得出的结果。study9_2.c本篇引用其已有的_WIN32条件编译写法没有把整个队列项目的编译或运行算作已验证。写完后我给自己留下的一句提醒是遇到预处理题先看看自己的.h、.c到底怎么配合遇到宏再把它展开成普通表达式算一遍。这样比背“宏快、函数慢”之类的口诀可靠得多。笔记与源码出处个人笔记《C学习记录7.27》《C学习记录7.28》《C学习记录8.18》《C学习记录8.19》以上链接指向我仓库中对应版本的game.h、game.c、test.c、study8_19.c和study9_2.c。编译器相关规则以文中链接的微软官方文档为准。