C语言探索之旅 | 第二部分第五课:预处理

431 阅读18分钟

作者 谢恩铭,公众号「程序员联盟」(微信号:coderhub)。 转载请注明出处。 原文:www.jianshu.com/p/cb83bb7e9…

《C语言探索之旅》全系列

内容简介


  1. 前言
  2. include 指令
  3. define 命令
  4. 条件编译
  5. 总结
  6. 第二部分第六课预告

1. 前言


上一课 C语言探索之旅 | 第二部分第四课:字符串 ,我们结束了关于字符串的旅程。

大家在一起经历了前三课:指针、数组和字符串的“疲劳轰炸”之后,这一课回归轻松。

就像刚在沙漠里行走了数日,突然看到一片绿洲,还有准备好的躺椅,清澈的小湖,冷饮,西瓜,一台顶配电脑(又暴露了程序员的本质...)... 脑补一下这个画面还是挺开心的。

前面三课我们一下子学了不少新知识点,虽然我没有那么"善良",但也不至于不给大家小憩的机会啊。

这一课我们来聊聊“预处理器”,这个程序就在编译之前运行。

当然了,虽然这一课不难,可以作为中场休息,但不要认为这一课的内容不重要。相反,这一课的内容非常有用(读者:“你就说哪一课的内容不是非常有用吧...”)。

2. include 指令


在这个系列教程最初的某一课里,我们已经向大家解释过:在源代码里面总有那么几行代码是很特别的,称之为预处理命令

这些命令的特别之处就在于它们总是以 # 开头,所以很容易辨认。

预处理命令有好几种,我们现在只接触了一种:以 #include 开始的预处理命令。

#include 命令可以把一个文件的内容包含到另一个文件中。

在之前的课程里我们已经学习了如何用 #include 命令来包含头文件(以 .h 结尾的)。

头文件有两种,一种是 C语言的标准库定义的头文件(stdio.h,stdlib.h,等),另一种是用户自定义的头文件。

  • 如果要导入 C语言标准库的头文件(位于你安装的 IDE(集成开发环境)的文件夹或者编译器的文件夹里),需要用到尖括号 <>。如下所示:
#include <stdio.h>
  • 如果要导入用户自己项目中定义的头文件(位于你自己项目的文件夹里),需要用到双引号。如下所示:
#include "file.h"

事实上,预处理器(preprocessor)在编辑之前运行,它会遍历你的源文件,寻找每一个以 # 开头的预处理命令。

例如,当它遇到 #include 开头的预处理命令,就会把后面跟的头文件的内容插入到此命令处,作为替换。

假设我有一个 C 文件(就是 .c 文件),包含我的函数的实现代码;还有一个 H 文件(就是 .h 文件),包含函数的原型。

我们可以用下图来描绘预处理的时候发生的情况:

如上图所示,H 文件的所有内容都将替换 C 文件的那一行预处理命令(#include "file.h")。

假设我们的 C 文件内容如下所示:

#include "file.h"

int myFunction(int thing, double stuff)
{
/* 函数体 */
}

void anotherFunction(int value)
{
/* 函数体 */
}

我们的 H 文件内容如下所示:

int myFunction(int thing, double stuff);
void anotherFunction(int value);

编辑之前,预处理器就会用 H 文件的内容替换那一行 #include "file.h"

经过替换之后,C 文件内容如下:

int myFunction(int thing, double stuff);
void anotherFunction(int value);

int myFunction(int thing, double stuff)
{
/* 函数体 */
}

void anotherFunction(int value)
{
/* 函数体 */
}

3. define 命令


现在我们一起来学习一个新的预处理命令,就是 #define 命令。

define 表示“定义”。

这个命令使我们可以定义预处理常量,也就是把一个值绑定到一个名称。例如:

#define LIFE_NUMBER 7

我们必须按照以下顺序来写:

  • #define
  • 要绑定数值的那个名称
  • 数值

注意:虽然说这里的名称是大写字母(因为习惯如此,你也可以小写),但是这与我们之前学过的 const 变量还是很不一样。

const 变量的定义是像这样的:

const int LIFE_NUMBER = 7;

上面的 const 变量在内存中是占用空间的,虽然其不能改变,但是它确确实实储存在内存的某个地方。但是预处理常量却不是这样。

那预处理常量是怎样运作的呢?

事实上,预处理器会把由 #define 定义的所有的名称替换成对应的值。

如果大家使用过微软的软件 Word,那应该对“查找并替换”的功能比较熟悉。我们的 #define 就有点类似这个功能,它会查找当前文件的所有 #define 定义的常量名称,将其替换为对应的数值。

你也许要问:“用预处理常量意义何在呢?有什么好处?”

问得好。

  • 第一,因为预处理常量不用储存在内存里。就如我们之前所说,在编译之前,预处理常量都被替换为代码中的数值了。

  • 第二,预处理常量的替换会发生在所有引入 #define 语句的文件里。如果我们在一个函数里定义一个 const 变量,那么它会在内存里储存,但是如果前面不加 static 关键字(关于 static,请参看之前的课程)的话,它只在当前函数有效,函数执行完就被销毁了。然而预处理常量却不是这样,它可以作用于所有函数,只要函数里有那个名称,都会替换为对应的数值。这样的机制在有些时候是非常有用的。特别对于嵌入式开发,内存比较有限,经常能看到预处理常量的使用。

“能否给出一个实际使用 #define 的例子呢?”

当然可以。例如,当你用 C语言来创建一个窗口时,你需要定义窗口的宽度和高度,这时候就可以使用 #define 了:

#define WINDOW_WIDTH 800
#define WINDOW_HEIGHT 600

看到使用预处理常量的好处了么?之后如果你要修改窗口的宽度和高度,不必到代码里去改每一个值,只需要在定义处修改就好了,非常节省时间。

注意:通常来说,#define 语句放在 .h 头文件中,和函数原型那些家伙在一起。 如果有兴趣,大家可以去看一下标准库的 .h 文件,例如 stdio.h,你可以看到有不少 #define 语句。

用于数组大小(维度)的 #define


我们在 C语言编程中也可以使用预处理常量(#define 语句定义)来定义数组的大小。例如:

#define MAX_DIMENSION 2000

int main(int argc, char *argv[])
{
    char string1[MAX_DIMENSION], string2[MAX_DIMENSION];
    // ...
}

你也许会问:“但是,不是说我们不能在数组的中括号中放变量,甚至是 const 变量也不可以吗?”

对,但是 MAX_DIMENSION 并不是一个变量,也不是一个 const 变量啊!就如之前说的,预处理器会在编译之前把以上代码替换为如下:

int main(int argc, char *argv[])
{
    char string1[2000], string2[2000];
    // ...
}

这样有一个好处,就如之前所说,如果将来你觉得你的数组大小要修改,可以直接修改 MAX_DIMENSION 的数值,非常便捷。

在 #define 中的计算


我们还可以在定义预处理常量时(#define 语句中)做一些计算。

例如,以下代码首先定义了两个预处理常量 WINDOW_WIDTH(表示“窗口宽度”)和 WINDOW_HEIGHT(表示“窗口高度”),接着我们可以利用这两个预处理常量来定义第三个预处理常量:PIXEL_NUMBER(意思是“像素数目”,等于“窗口宽度” x “窗口高度”),如下:

#define WINDOW_WIDTH 800
#define WINDOW_HEIGHT 600
#define PIXEL_NUMBER (WINDOW_WIDTH * WINDOW_HEIGHT)

在编译之前,PIXEL_NUMBER 会被替换为 800 x 600 = 480000 。

当然预处理常量对于基本的运算:+-*/% 都是支持的。

注意:用 #define 定义预处理常量时要尽量多用括号括起来,不然会出现意想不到的结果,因为预处理常量只是简单的替换。

系统预先定义好的预处理常量


我们自己可以定义很多预处理常量,C语言系统也为我们预先定义了几个有用的预处理常量。

这些 C语言预定义的预处理常量一般都以两个下划线开始,两个下划线结束,例如:

  • __LINE__ :当前行号。
  • __FILE__ :当前文件名。
  • __DATE__ :编译时的日期。
  • __TIME__ :编译时的时刻。

这些预处理常量对于标明出错的地方和调试是很有用的,用法如下:

printf("错误在文件 %s 的第 %d 行\n", __FILE__, __LINE__);
printf("此文件在 %s %s 被编译\n", __DATE__, __TIME__);

输出如下:

错误在文件 main.c 的第 10 行
此文件在 Jun 8 2020 09:11:01 被编译

不带数值的 #define


我们也可以像如下这样定义预处理常量:

#define CONSTANT

很奇怪吧,后面竟然没有对应的数值。

以上语句用于告诉预处理器:CONSTANT 这个预处理常量已经定义了,仅此而已。虽然它没有对应的数值,但是它“存在”。

你也许要问:“这样有什么意义呢?”

这样做的用处暂时还不明显,但是我们在这一课里马上会学到,请继续读下去。

4. 宏


我们现在知道用 #define 语句可以把一个数值绑定到一个名称上,然后在预处理阶段(编译之前)预处理器就可以在代码里用数值替换所有的预处理常量了,非常方便。例如:

#define NUMBER 10

意味着接下来你的代码里所有的 NUMBER 都会被替换为 10。是简单的“查找-替换”。

但是 #define 预处理命令还可以做更厉害的事。

#define 还可以用来替换… 一整个代码体。当我们用 #define 来定义一个预处理常量,这个预处理常量的值是一段代码的时候,我们说我们创建了一个“宏”。

“宏”,英语是 macro。一开始可能不太好理解。这是一个编程术语,台湾一般翻成“巨集”。可以说是一种抽象,但在 C语言里就只用于简单的“查找-替换”。

趣事:之前某网站出现一个词:“王力巨集”,原来这个网站在做简体中文到繁体中文转换时,把明星“王力宏”的名字中的“宏”替换为了“巨集”,我们的力宏就这么“躺枪”了…

没有参数的宏


下面给出一个很简单的宏的定义:

#define HELLO() printf("Hello\n");

可以看到,与之前的预处理常量不太一样的是:名称后多了一对括号,我们马上就来看这有什么用处。

我们用一段代码来测试一下:

#include <stdio.h>

#define HELLO() printf("Hello\n");

int main(int argc, char *argv[])
{
    HELLO()

    return 0;
}

运行输出:

Hello

是不是有点意思?不过暂时还不是那么新颖。

需要理解的是:宏不过是在编译之前的一些代码的简单替换。

上面的代码在编译前会被替换为如下:

int main(int argc, char *argv[])
{
    printf("Hello\n");

    return 0;
}

如果你理解了这个,那对于宏的基本概念也差不多理解了。

你也许会问:“那我们每一个宏只能写在一行上么?”

不是的,只需要在每一行的结尾写上一个 \(反斜杠),就可以开始写新的一行了,而预处理器会把这些行看成一行。可以说 \ 起到了链接的作用。例如:

#include <stdio.h>

#define PRESENT_YOURSELF() printf("您好, 我叫 Oscar\n"); \
                           printf("我住在浙江杭州\n"); \
                           printf("我喜欢游泳\n");

int main(int argc, char *argv[])
{
    PRESENT_YOURSELF()

    return 0;
}

运行输出:

您好,我叫 Oscar
我住在浙江杭州
我喜欢游泳

我们注意到了,调用宏的时候,在末尾是没有分号的。事实上,因为

PRESENT_YOURSELF()

这一行是给预处理器来处理的,所以没必要以分号结尾。

有参数的宏


我们刚学习了无参的宏,也就是括号里没有带参数的宏。这样的宏有一个好处就是可以使代码里经常出现的较长的代码段变得短一些,看起来简洁。

但是,宏带了参数才真正变得有趣起来。

#include <stdio.h>

#define MATURE(age) if (age >= 18) \
                    printf("你成年了\n");

int main(int argc, char *argv[])
{
    MATURE(25)

    return 0;
}

运行输出:

你成年了

这样是不是就有点像函数了?就是这么酷炫。

上面的宏是怎么运作的呢?

age 这个参数在实际调用宏的时候,会被替换为括号里的数值,这里是 25。所以,整个宏就替换为了:

if (25 >= 18)
    printf("你成年了\n");

不就是我们熟悉的老朋友:if 语句么。

上面的宏定义中,我们也可以用一个 else 来处理“你还未成年”的条件,自己动手试一下吧,不难。

当然我们也可以创建带多个参数的宏,例如:

#include <stdio.h>

#define MATURE(age, name) if (age >= 18) \
                          printf("你已经成年了,%s\n", name);

int main(int argc, char *argv[])
{
    MATURE(32, "Oscar")

    return 0;
}

运行输出:

你已经成年了,Oscar

好了,对于宏我们需要了解的也差不多介绍完了。如果使用得当,宏是相当有用的。

但是有些时候,滥用宏也会产生很多难以调试的错误,所以宏是 C语言的一把双刃剑。

通常我们在 C语言的编程中是不需要经常使用宏的,因为宏有一个缺点:

宏只是简单的替换,根本不检查变量和参数类型,所以用得不好会出问题。

不过,很多复杂的库,例如擅长图形界面编程的 wxWidgets 和 Qt,就大量使用了宏。

所以对于宏,我们需要理解。

5. 条件编译


预处理命令除了有以上三个作用以外,还可以实现“条件编译”。听起来有点玄乎,但是只要语文没有还给小学体育老师,那应该不难理解。

开个玩笑。我们还是一起来看看如下的例子:

#if 条件1
/* 如果条件1为真,将会被编译的代码 */
#elif 条件2
/* 如果条件2为真,将会被编译的代码 */
#endif

是不是有点类似之前学过的 if 语句?

可以看到:

  • 关键字 #if 是一个条件编译块的起始,在后面可以插入一个条件。
  • 关键字 #elif(elif 是 else if 的缩写)的后面可以插入另一个条件。
  • 关键字 #endif(end 表示“结束”)是一个条件编译块的结束。

与 if 语句不同的是,条件编译没有大括号。

你会发现“条件编译”是相当有用的,它使我们可以按照不同的条件来选择编译哪些代码。

与 if 语句类似,条件编译块必须有且只能有一个 #if,可以没有或有多个 #elif,必须有且只能有一个 #endif

如果条件为真,那么后面跟着的代码会被编译,如果条件为假,后面的代码就会在编译时被忽略。

#ifdef 和 #ifndef


现在我们就来看看之前介绍的“没有数值的 #define”的用处。

还记得吗?

#define CONSTANT

我们可以

  • #ifdef 来表述:“如果此名称已经被定义”。因为 ifdef 是 if defined 的缩写,表示“如果已被定义”。
  • #ifndef 来表述:“如果此名称没有被定义”。因为 ifndef 是 if not defined 的缩写,表示“如果没有被定义”。

不得不重提英语对于编程进阶的重要性,可以参看我之前写的文章:对于程序员, 为什么英语比数学更重要? 如何学习

例如我们有如下代码:

#define WINDOWS

#ifdef WINDOWS
/* 当 WINDOWS 已经被定义的时候要编译的代码 */
#endif

#ifdef LINUX
/* 当 LINUX 已经被定义的时候要编译的代码 */
#endif

#ifdef MAC
/* 当 MAC 已经被定义的时候要编译的代码 */
#endif

可以看到,用这样的方法,可以很方便地应对不同平台的编译,使我们的代码实现跨平台。

比如,我要编译针对 Windows 系统的代码,那就在开始处写:

#define WINDOWS

我要编译针对 Linux 系统的代码,那就改成:

#define LINUX

如果是 macOS 系统,那就改成:

#define MAC

当然了,每次修改代码之后都要重新编译(毕竟没有那么神奇)。

使用 #ifndef 来避免“重复包含”


#ifndef 是非常有用的,经常用于 .h 头文件中,以避免“重复包含”。

什么是“重复包含”呢?

其实不难理解,设想以下情况:

我有两个头文件,分别命名为 A.h 和 B.h 。在 A.h 中我写了

#include "B.h"

而不巧在 B.h 中我写了

#include "A.h"

要知道,在代码复杂度提高以后,这样的情况不是不可能发生的。很多时候,我们一个文件里要 inculde 好多个头文件,很容易晕。

这样一来,A.h 文件需要 B.h 来运行,而 B.h 文件需要 A.h 来运行。

如果我们稍加思索,就不难想到会发生什么:

  • 电脑读入 A.h 文件,发现需要包含 B.h。

  • 电脑在 A.h 中包含进 B.h 文件的内容,可是在 B.h 文件的内容里又发现需要包含 A.h。

  • 如此循环往复,什么时候是个头啊…

你肯定认为这会永不止息…

事实上,碰到这种情况,预处理器会停止,并且抛出“我受不了这么多包含啦!”的错误,你的程序就不能通过编译。

那如何来避免这样的悲剧呢?

下面就是解方。并且从今以后,我强烈建议大家在每一个 .h 头文件中都这样做!让我任性一回吧...

#ifndef DEF_FILENAME  // 如果此预处理常量还未被定义,即是说这个文件未被包含过
#define DEF_FILENAME  // 定义此预处理常量,以避免重复包含

/* file.h 文件的内容 (其他的 #include,函数原型,#define,等...) */

#endif

如上所示,在 #ifndef#endif 之间我们放置 .h 文件的内容(其他的 #include,函数原型,#define,等)。

我们来理解一下到底这段代码是怎么起作用的?(我自己第一次碰到这个技术时也有点不太理解):

假使我们的 file.h 文件是第一次被包含(被 include),预处理器读到开头的那句话:

#ifndef DEF_FILENAME

意思是“DEF_FILENAME 这个预处理常量还没有被定义”,这个条件是真的。所以预处理器就进入 #if 语句内部啦(和普通的 if 语句类似的机制)。

接着预处理器就读到第二句命令:

#define DEF_FILENAME

这句的意思是“定义 DEF_FILENAME 这个预处理常量”。所以预处理器乖乖地执行,定义 DEF_FILENAME。

接着它就将 file.h 头文件的主体内容都包含进调用 #include "file.h" 或者 #include <file.h> 的那个文件。

这样的话。下一次这个 file.h 头文件再被其他文件包含时,`

#ifndef DEF_FILENAME

这个条件就不为真了,预处理器就不会执行条件编译内部的语句了,自然就不会再把头文件的主体内容包含了。

这样就能巧妙地避免“重复包含”。

当然,那个预处理常量的名称不一定要和我的一样,也不一定要大写,但是最好大写,是约定俗成的用法。

但是每一个头文件的所用常量名称必须不同,否则,只有第一个头文件会被包含。

非常建议大家有空去看一下标准库的头文件,如 stdio.h,stdlib.h,等。你会发现它们都是以这样的方式写的(开头 #ifndef,结尾 #endif)。

6. 总结


  1. 预处理器是这样一个程序,它在编译之前执行,它先分析源代码,然后做出一定修改。

  2. 预处理命令有好几种。#include 命令用于在一个文件中插入另一个文件的内容。

  3. #define 命令定义一个预处理常量。之后预处理器就会把代码里所有 #define 定义的常量名称替换成对应的值。

  4. 宏是一些代码块,定义也要借助 #define。宏可以接受参数。

  5. 我们也可以用预处理器的语言来写一些预处理条件,以实现条件编译。一般我们使用关键字:#if#elif#endif 等。

  6. 为了防止一个头文件被多次包含,我们会用条件编译和预处理常量的组合来“保护”它。之后我们写的 .h 头文件都会采用这种方式,也很建议采用。

7. 第二部分第六课预告


今天的课就到这里,一起加油吧!

下一课:C语言探索之旅 | 第二部分第六课:创建你自己的变量类型


我是 谢恩铭,公众号「程序员联盟」(微信号:coderhub)运营者,慕课网精英讲师 Oscar 老师,终生学习者。 热爱生活,喜欢游泳,略懂烹饪。 人生格言:「向着标杆直跑」