预处理指令、typedef、条件编译、多文件代码

简介: 预处理指令、typedef、条件编译、多文件代码

预处理指令

源代码中,以井号#开头的并不是C语言中的语句。它们属于预处理指令。
在代码被编译前,预处理器会先处理预处理指令,并根据预处理指令的意义修改C语言源码。
修改后的代码将另存为中间文件或直接输入到编译器。并不会保存到源文件中。所以,预处理器不会改动源文件。

预处理指令#include

预处理指令#include,会将文件stdio.h中的代码复制到该预处理指令出现处,并删除该预处理指令。
修改后的代码将另存为中间文件或直接输入到编译器。并不会保存到源文件中。所以,,预处理器不会改动源文件。

#include的两种形式

#include <文件名>
#include "文件名"

  1. 文件名在尖括号内:将会在编译器的包含目录中搜索文件。
  2. 文件名在双引号内:先在当前目录中搜索文件,再到编译器的包含目录中搜索文件。

对于stdio.h文件来说,它是编译器自带的文件,在编译器的包含目录中。所以使用尖括号,即可找到该文件。

define预处指令

define 宏 替换体

一旦预处理在程序中找到宏后,就会用替换体替换该宏。
宏的命名规则遵循C语言标识符的命名规则:只能使用字母、数字、下划线,且首字符不能是数字。
替换体不仅仅限于值,它的形式非常丰富,唯一的要求就是替换到代码后,代码还能正常通过编译。

宏的替换是无差别的,它仅仅把代码当做文本来处理,遇到宏就替换为宏对应的替换体。

带参数的#define

#define中使用参数可以创建外形和作用与函数类似的宏函数。
#define 宏(参数1, 参数2,...,参数n) 替换体
虽然由带参数的#define定义的宏函数,在使用方法上很像函数。但是,它的本质依然是将宏替换为对应的替换体。由此,如果简单地将其当做函数使用,会出现一些问题。

保证宏函数按照预期运行

由于宏函数仅仅是完成替换操作,将参数替换并拼接到替换体的表达式中。而不是先让参数运算得到结果后,再进行运算。因此,为了保证参数不被其他运算符优先级影响,请在参数两边加上括号
此外,宏函数展开后的表达式,如果作为一个更大表达式的子表达式,那么它有可能受到左右两边运算符优先级的影响。因此,为了保证宏函数展开后的表达式能够优先计算,请在替换体两边加上括号
最后,为了保证不要在一个表达式中对同一个变量多次进行自增、自减操作。若宏函数的替换体内在一个表达式中多次使用同一个参数,那么请不要在宏函数的参数内填自增、自减表达式

宏函数内两个有用运算符

井号

一般情况下,宏函数的参数会替换替换体内的对应参数。但是,若在替换体内参数前加上井号#。替换后,会用双引号包括这个参数。

双井号

双井号可以将替换体中的两个记号组合成一个记号。
例如,有两组变量。变量由前缀和变量名组成。

// 第一组变量,group1
int group1Apple = 1, group1Orange = 2;
// 第二组变量,group2
int group2Apple = 100, group2Orange = 200;

前缀:group1或group2
变量名:Apple或Orange
使用宏函数来组合前缀与变量名,让它们成为一个完整的变量。

#define VARNAME(group, name) group ## name
VARNAME(group1, Apple) 展开为 group1Apple 。
VARNAME(group1, Orange) 展开为 group1Orange 。
VARNAME(group2, Apple) 展开为 group2Apple 。
VARNAME(group2, Orange) 展开为 group2Orange 。
如果不使用双井号 ##:
#define VARNAME(group, name) group name
VARNAME(group1, Apple) 展开为 group1 Apple 。
VARNAME(group1, Orange) 展开为 group1 Orange 。
VARNAME(group2, Apple) 展开为 group2 Apple 。
VARNAME(group2, Orange) 展开为 group2 Orange 。

不使用双井号,展开后的两个参数之间留有空格,无法正常使用。

如果去掉替换体中的空格:
#define VARNAME(group, name) groupname

现在,宏函数出现了问题,它具有两个参数:group和name。但是,替换体中没有与参数对应的记号。
因此,双井号 ## 的存在是有意义的。

取消宏定义

#include <stdio.h>
#define NUM 100
#define NUM 101
int main()
{
printf("%d\n", NUM);
return 0;
}

在Visual Stduio 2019中,覆盖定义宏并不会导致编译报错而停止
不过,更妥当的做法是:使用预处理指令#undef,取消这个宏的定义,再重新定义它。

#include <stdio.h>
#define NUM 100
// 取消宏定义NUM
#undef NUM
// 重新定义宏NUM为101
#define NUM 101
int main()
{
    printf("%d\n", NUM);
    return 0;
}

typedef关键词

给整型类型取个别名

给类型起一个别名有什么意义

C语言标准并未规定这些数据类型的大小范围,具体的实现交由了编译器和平台决定。
也就是说,intVisual Studio 2019中占用4字节大小,数据范围为-2147483648到2147483647。它也
有可能在另一个平台上,仅占用2字节大小,数据范围为-32768到32767。
如果我们要求程序需要满足在不同的平台上均能正确的运行,不会因为整型数据范围不同而产生数据溢出。那么,我们可以为整型取一些别名。

作用范围

别名如果定义在代码块中,那么它就具有块作用域。别名的作用域从别名声明开始,直到包含声明的代码块结束。
如果定义在块外,那么它具有文件作用域。别名的作用域从声明开始,直到该源文件结束。

函数 add 中无法使用别名 int32_t

作用域内均可使用别名 int32_t 。

typedef 用于结构

typedef 并没有创建任何新类型,它只是为某个已存在的类型增加了一个方便使用的别名。

#include <stdio.h>
typedef struct {
    char name[20];
    int gender;
    double height;
    double weight;
}person;
int main()
{
    person p = { "timmy", 1, 170.00, 60.00 }; // 无需关键词struct
    printf("name:%s\n", p.name);
    printf("gender:%d\n", p.gender);
    printf("height:%.2f\n", p.height);
    printf("weight:%.2f\n", p.weight);
    return 0;
}
输出结果
name:timmy
gender:1
height:170.00
weight:60.00

typedef与#define的区别

  1. typedef 只能用于给类型取别名,不能用于值。
  2. typedef 由编译器解释,而不是预处理器。
  3. typedef 在某些情况下,比 #define 更合适。

提高整型可移植性

包含头文件 stdint.h ,即可使用别名。
打开头文件 stdint.h ,可以看到这些别名的定义。

为保证函数printf转换规范的可移植性,需要编译器提供的另外一个头文件inttype.h
以Visual Studio 2019中为例,打开头文件 inttype.h ,可以找到如下定义。

// 有符号
#define PRId8 "hhd"
#define PRId16 "hd"
#define PRId32 "d"
#define PRId64 "lld"
// 无符号
#define PRIu8 "hhu"
#define PRIu16 "hu"
#define PRIu32 "u"
#define PRIu64 "llu"

在其他平台下,头文件inttype.h将根据本平台中整型的别名,定义对应的转换规范。若int32_t是整型long的别名,则打印32位有符号整型的宏PRId32的定义为"ld"。

#include <stdio.h>
#include <inttypes.h>
int main()
{
    int32_t n = 123;
    printf("n = %" PRId32 "\n", n);
    return 0;
}

在Visual Studio 2019中,"n = %" PRId32 "\n"会被替换为"n = %" "d" "\n",而相邻的字符串将会被拼接为一个字符串,即"n = %d\n"
int32_t是整型long的别名的平台下,"n = %" PRId32 "\n"会被替换为"n = %" "ld" "\n",而相邻的字符串将会被拼接为一个字符串,即"n = %ld\n"

条件编译

#if#elif#else

#if后无需括号,直接填写条件表达式,并用空格隔开。
不同于if#if要求条件表达式为一个常量表达式。常量表达式中不允许出现变量。
由于预处理指令中不使用花括号,无法将多条语句组成一条复合语句,所以需要用#endif指令标记指令块结束。就算#if下仅有一条语句,也需要使用#endif标记指令块结束。

区别

预处理中的#if
预处理指令将在编译前,由预处理器处理。预处理器根据预处理指令的意图,修改代码。类似于#define指令,替换代码中出现的宏。#if指令会根据分支的走向,保留需要走向分支的代码,删除被跳过分支的代码。
关键词if:
编译后,程序运行时,计算条件表达式的结果。根据表达式结果,让程序走向不同的分支。

由于在预处理时就需要计算出条件表达式N == 1的结果。此时,程序还未编译并运行,不能使用任何变量。所以,条件表达式必须为一个常量表达式。
N是由#define定义的符号常量,值为0,表达式结果为假。那么,#if#endif组成的指令块中的代码将被删除。

#include <stdio.h>
#define N 0
int main()
{
#if N == 1
    printf("111111\n");
    printf("222222\n");
    printf("333333\n");
#elif N == 2
    printf("AAAAAA\n");
    printf("BBBBBB\n");
    printf("CCCCCC\n");
#else
    printf("******\n");
#endif
    return 0;
}

#ifdef#ifndef

#ifdef指令是ifdefined的缩写,意为是否定义了某某宏。
若定义了该宏,则保留指令块内的代码。否则,则删除代码块内的代码。

与之相反,#ifndef指令是ifnot defined的缩写,意为是否未定义了某某宏。
若定义了该宏,则删除指令块内的代码。否则,则保留代码块内的代码。

多文件代码

  1. 预处理:执行预处理指令,修改源代码。
  2. 编译:将预处理后的源代码转换为二进制目标文件。
  3. 链接:将需要用到的目标文件合并成可执行文件。

对于源文件来说,编译器是单个独立编译的,并生成对应的目标文件。
例如:
main.c 经过编译后,生成目标文件 main.obj 。
print.c 经过编译后,生成目标文件 print.obj 。
编译完成后,将会启动链接器。将所有的目标文件中,需要用到的代码链接为一个可执行文件

以“模仿printf”为例

print.c
#include <stdio.h>
void print(const char* str)
{
    while (*str != '\0')
    {
        putchar(*str);
        str++;
    }
}
main.c
#include "print.c"  //需要用双引号,而非尖括号
int main()
{
    print("Hello World\n");
    return 0;
}


为了正确编译main.c,我们需要包含print.c,让函数print先定义后使用。目标文件main.obj文件中有一份print函数。而print.obj文件,也有一份print函数。链接时,出现了同名函数的现象。因此,将链接失败。
问题的关键在于编译器是单个独立编译的,编译main.c时,编译器不知道标识符print具体是什么

函数声明替换include

除了函数定义可以让编译器正确识别print标识符,此外,函数声明也可以。
将文件main.c中的#include指令先暂时去掉,换成函数print的函数声明。
文件 main.c

void print(const char* str);
int main()
{
    print("Hello World\n");
    return 0;
}

这样,在编译main.c时,虽然不知道print这个函数里面具体做了什么。但是,编译器知道这是一个函数,并且可以传什么参数给它,编译依然可以继续。编译生成的目标文件main.obj中,指明需要一份print函数的实现。

链接时,目标文件main.obj表示需要print函数的具体实现。而正好print.obj中有该函数的具体实现。这样,它们可以被链接为一个可执行文件

将文件print.c中的代码删除,看看会发生什么


函数main.obj文件中的函数main需要print函数的具体实现,而现在无法提供print函数的具体实现。因此,出现链接错误。

现在恢复代码

目前,文件 print.c 里面只定义了一个函数。若 print.c 里面定义的函数较多,在其他文件里面需要使
用这些函数时,那么还需要重复声明这些函数。
例如:文件 print.c 内定义了N个函数。若文件 main.c 中需要使用这些函数,则需要在文件 main.c 中
声明这些函数。

文件 main.c
void print1(const char* str);
void print2(const char* str);
void print3(const char* str);
void print4(const char* str);
void print5(const char* str);
...
void printN(const char* str);
int main()
{
    print("Hello World\n");
    return 0;
}

那么,不如把这些声明单独写在一个文件里面,谁需要使用这些函数,就包含这个文件就好。并且,这种文件不需要经过编译器编译,仅供被其他文件包含。具有这种性质的文件被称作头文件。区别于需要被编译器编译的文件,其后缀名用.h

将函数print的声明写入文件print.h

文件 print.h
void print(const char* str);
将文件 main.c 中的函数声明改为包含头文件。
#include "print.h"
int main()
{
    print("Hello World\n");
    return 0;
}

这种文件不需要经过编译器编译,仅供被其他文件包含。具有这种性质的文件被称作头文件。

一般情况下,系统自带函数的源文件被预先编译为了库,而编译器默认链接了该库。所以,我们无需做其他配置,也看不到这些函数实现的源文件。

更复杂的多文件代码

#include <stdio.h>
typedef struct {
    char name[20 + 1];
    int gender;
    double height;
    double weight;
}Person;
Person newPerson()
{
    Person p;
    printf("intput name (No more than %d letters):", 20);
    scanf("%s", p.name);
    printf("input gender (1.male 2.female):");
    scanf("%d", &p.gender);
    printf("intput height:");
    scanf("%lf", &p.height);
    printf("intput weight:");
    scanf("%lf", &p.weight);
    return p;
}
void printPerson(const Person* p)
{
    printf("\nname\tgender\theight\tweight\n");
    //使用了成员间接运算符->
    printf("%s\t%d\t%.2f\t%.2f\n", p->name, p->gender, p->height, p->weight);
}
int main()
{
    Person p;
    p = newPerson();
    printPerson(&p);
    return 0;
}

我们定义一个人员类型,类型名为Person。它由名称、性别、身高、体重几个成员组成。性别用整型表示,1代表男生、2代表女生。
姓名的长度限制为20个字符。别忘了,结尾标记'\0'也要占用一个字节的空间。因此,name数组的长度为21。
接着我们定义一个人员信息输入函数。这个函数提示用户输入对应的信息,最后返回一个Person类型的结构。
在调用函数printPerson时,函数实参将被传递给函数形参。若传递的数据为Person,则需要将整个结构传递进入函数,传递的数据量为sizeof(Person)字节。
为了减少数据在函数之间传递的开销,我们将传递结构Person改为,传递指针Person *p进入printPerson函数。改为传递指针后,函数间传递的数据量仅需要sizeof(Person *)字节。指针的大小在32位程序下为4,64位程序下为8。比起传递整个结构,还是小多了。
此外,函数printPerson仅仅是读取各成员数据用于显示,并不会修改任何信息。因此,我们在指针上使用const关键词,限定为只读。保证不会因为误操作而修改了数据。同时,使用这个函数的人看到后,也知道这个函数不会修改Person结构的数据。
最后,函数main中,声明一个Person结构变量。调用上述两个函数录入、显示成员信息

将代码进行模块化

代码中,出现了两个20,均指代人员名称的最大字符长度。若以后需要增加人员名称长度,那么我们需要同时修改两个数值。如果不小心,还会漏改。不如将人员名称长度定义为一个符号常量,以后仅需修改符号常量的数值,即同步修改所有用到该符号常量的地方。

文件 person.h
#define NAME_LENGTH 20
typedef struct {
    char name[NAME_LENGTH + 1];
    int gender;
    double height;
    double weight;
}Person;
Person newPerson();
void printPerson(const Person* p);
文件 person.c
#include <stdio.h>
#include "person.h" \\ 定义或声明来自于person.h
Person newPerson()
{
    Person p;
    printf("sizeof person in person.c %d", sizeof(Person));
    printf("intput name (No more than %d letters):", NAME_LENGTH);
    scanf("%s", p.name);
    printf("input gender (1.male 2.female):");
    scanf("%d", &p.gender);
    printf("intput height:");
    scanf("%lf", &p.height);
    printf("intput weight:");
    scanf("%lf", &p.weight);
    return p;
}
void printPerson(const Person* p)
{
    printf("\nname\tgender\theight\tweight\n");
    printf("%s\t%d\t%.2f\t%.2f\n", p->name, p->gender, p->height, p->weight);
}
文件 main.c
#include "person.h" \\ 定义或声明来自于person.h
int main()
{
    Person p;
    p = newPerson();
    printPerson(&p);
    return 0;
}

由于main.c中没有标识符Person的声明或定义。编译main.c时,将无法识别标识符Person
所以,我们将Person结构类型的定义与符号常量NAME_LENGTH,在文件person.h中也写了一遍。

为什么没有重定义报错

代码中出现了重复的声明或定义,构建时为什么不会出现重定义报错呢?
这是因为,重复的代码出现在不同文件中。
我们知道作用域分为两种:

  1. 块作用域:定义或声明在代码块内。
  2. 文件作用域:定义或声明在代码块外。

这里的定义或声明均在函数外。那么它们的作用域都是文件作用域。而重复代码在不同的文件中,作用域并未重叠。因此,能够构建成功。
但是,若以后需要调整代码,必须保证它们同时调整。例如,文件person.c中的Person结构类型增加了一个成员。那么,文件person.h中的Person结构类型也需要相应的调整。否则,两边的 Person 不一致,虽然可以通过编译,但是运行时将有可能发生崩溃。
如果能让它们使用同一份代码就比较完美了。
我们将文件person.c中的重复代码删除,使用#include "person.h"指令,包含文件person.h。这样,就能保证定义是唯一的。
预处理后,文件main.c以及文件person.c的关于人员的声明或定义均来自于文件person.h。这样,就
能保证它们用的是同一份代码了。虽然,文件person.h中的函数声明没有必要出现在文件person.c中,但是,这样做并不碍事。

多文件代码小结

  1. 源文件person.c: 函数定义。
  2. 头文件person.h: 符号常量、函数宏、函数声明、结构声明、类型定义。
  3. 源文件person.c需要头文件person.h中的声明或定义。因此,需要在源文件中#include "person.h"
  4. 使用者,例如文件main.c。包含头文件person.h后,即可使用头文件中的声明或定义以及调用头文件中声明过的函数。

头文件守卫

重复包含

文件 main.c
#include "person.h" // 对person.h包含一次
#include "person.h" // 对person.h包含两次
int main()
{
    Person p;
    p = newPerson();
    printPerson(&p);
    return 0;
}

这种情况会导致文件main.c因为标识符重定义而编译失败

更隐蔽的重复包含

文件 main.c
#include "person.h"
#include "students.h"
int main()
{
    Student s;
    s = newStudent();
    printStudent(&s);
    return 0;
}

假设,头文件students.h内包含了person.h。这样依然会导致头文件person.h被重复包含的问题。并且,若嵌套层次更加复杂,会比较难排查。

头文件守卫

借助条件编译,使同一个头文件,只允许被包含一次。
添加的位置是在头文件内。
#define PERSON_H戳,用于记录是否定义
预处理指令#ifndef用于测试其后跟着的宏是否没有被定义。

  1. 若没有被定义,则保留从#ifndef#endif之间的代码。
  2. 若被定义,则删除从#ifndef#endif之间的代码。
main.c
// -----------第一次包含-----------
#ifndef PERSON_H
#define PERSON_H
person.h头文件代码
#endif
// -----------第二次包含-----------
#ifndef PERSON_H
#define PERSON_H
person.h头文件代码
#endif

int main()
{
    ...
}

第一次包含时,预处理指令#ifndef测试到宏PERSON_H未定义。因此,将保留从#ifndef PERSON_H开始,直到#endif的代码。而这段代码内,定义了宏**PERSON_H**
第二次包含时,预处理指令#ifndef测试到宏PERSON_H已经定义。因此,将删除从#ifndef PERSON_H开始,直到#endif的代码。

嵌套重复包含

文件main.c包含了头文件person.hstudents.h。假设,头文件students.h内又包含了person.h

main.c
// -----------person.h-----------
#ifndef PERSON_H
#define PERSON_H
person.h头文件代码
#endif
// -----------student.h-----------
#ifndef STUDENT_H
#define STUDENT_H
// -----------person.h-----------
#ifndef PERSON_H
#define PERSON_H
person.h头文件代码
#endif
student.h头文件代码
#endif

int main()
{
    ...
}

直接包含person.h时,预处理指令#ifndef测试到宏PERSON_H未定义。因此,将保留从#ifndef PERSON_H开始,直到#endif的代码。而这段代码内,定义了宏PERSON_H
嵌套包含person.h时,预处理指令#ifndef测试到宏PERSON_H已经定义。因此,将删除从#ifndef PERSON_H开始,直到#endif的代码。

补充说明

头文件守卫中测试和定义的宏名称可以随意设置。例如,PERSON_H,只要不和其他头文件一样即可。
这样,除了第一次包含的代码外,其他包含的代码被删除。保证了,在一个文件内,同一个头文件仅被包含一次。
注意,这并不是意味着这个头文件不能再被其他文件包含了。由于,宏定义的作用域是文件作用域。头文件守卫仅保证在该文件内,一个头文件只能被包含一次。

#pragma once指令

如果编译器支持#pragma once指令。在头文件首部使用#pragma once。也可以达到头文件守卫的效果。
两种形式的防止多重包含的示例如下:

使用条件编译指令
#ifndef PERSON_H
#define PERSON_H
#define NAME_LENGTH 20
typedef struct {
    char name[NAME_LENGTH + 1];
    int gender;
    double height;
    double weight;
}Person;
Person newPerson();
void printPerson(const Person* p);
#endif
使用 #pragma once指令
#pragma once
#define NAME_LENGTH 20
typedef struct {
    char name[NAME_LENGTH + 1];
    int gender;
    double height;
    double weight;
}Person;
Person newPerson();
void printPerson(const Person* p);
目录
相关文章
|
3月前
|
编译器 C#
C# 预处理指令
C# 预处理指令
21 0
|
6月前
|
安全 编译器 程序员
C语言(16)----预处理中的宏以及预处理指令
C语言(16)----预处理中的宏以及预处理指令
56 2
|
6月前
|
算法 C++ 开发者
【C/C++ 基础】条件编译相关的预编译指令
【C/C++ 基础】条件编译相关的预编译指令
53 0
|
6月前
|
编译器 C语言
预处理指令
预处理指令
39 0
|
编译器 C语言
C语言宏定义与条件编译
C语言宏定义与条件编译
111 0
|
编译器 C语言
预处理指令、typedef、条件编译、多文件代码
预处理指令、typedef、条件编译、多文件代码
75 0
|
编译器 C语言
【C语言】文件包含及条件编译
【C语言】文件包含及条件编译
125 0
|
C++ 测试技术
C++ 内置宏定义 与 预编译指令
内置宏和预编译指令, 在代码调试、单元测试、跨平台代码中经常会用到。这里记录一下。 1. 内置宏 (文件名,当前行号,当前日期,当前时间,当前执行方法名) __FILE____LINE____DATE____TIME__ __FUNCTION__ 2.
1028 0