预处理指令、typedef、条件编译、多文件代码
预处理指令
源代码中,以井号#
开头的并不是C语言中的语句。它们属于预处理指令。
在代码被编译前,预处理器会先处理预处理指令,并根据预处理指令的意义修改C语言源码。
修改后的代码将另存为中间文件或直接输入到编译器。并不会保存到源文件中。所以,预处理器不会改动源文件。
预处理指令#include
预处理指令#include
,会将文件stdio.h
中的代码复制到该预处理指令出现处,并删除该预处理指令。
修改后的代码将另存为中间文件或直接输入到编译器。并不会保存到源文件中。所以,,预处理器不会改动源文件。
#include
的两种形式
#include <文件名>
#include "文件名"
- 文件名在尖括号内:将会在编译器的包含目录中搜索文件。
- 文件名在双引号内:先在当前目录中搜索文件,再到编译器的包含目录中搜索文件。
对于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语言标准并未规定这些数据类型的大小范围,具体的实现交由了编译器和平台决定。
也就是说,int
在Visual 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的区别
- typedef 只能用于给类型取别名,不能用于值。
- typedef 由编译器解释,而不是预处理器。
- 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
指令是if
和defined
的缩写,意为是否定义了某某宏。
若定义了该宏,则保留指令块内的代码。否则,则删除代码块内的代码。
与之相反,#ifndef
指令是if
和not defined
的缩写,意为是否未定义了某某宏。
若定义了该宏,则删除指令块内的代码。否则,则保留代码块内的代码。
多文件代码
- 预处理:执行预处理指令,修改源代码。
- 编译:将预处理后的源代码转换为二进制目标文件。
- 链接:将需要用到的目标文件合并成可执行文件。
对于源文件来说,编译器是单个独立编译的,并生成对应的目标文件。
例如:
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
中也写了一遍。
为什么没有重定义报错
代码中出现了重复的声明或定义,构建时为什么不会出现重定义报错呢?
这是因为,重复的代码出现在不同文件中。
我们知道作用域分为两种:
- 块作用域:定义或声明在代码块内。
- 文件作用域:定义或声明在代码块外。
这里的定义或声明均在函数外。那么它们的作用域都是文件作用域。而重复代码在不同的文件中,作用域并未重叠。因此,能够构建成功。
但是,若以后需要调整代码,必须保证它们同时调整。例如,文件person.c
中的Person
结构类型增加了一个成员。那么,文件person.h
中的Person
结构类型也需要相应的调整。否则,两边的 Person 不一致,虽然可以通过编译,但是运行时将有可能发生崩溃。
如果能让它们使用同一份代码就比较完美了。
我们将文件person.c
中的重复代码删除,使用#include "person.h"
指令,包含文件person.h
。这样,就能保证定义是唯一的。
预处理后,文件main.c
以及文件person.c
的关于人员的声明或定义均来自于文件person.h
。这样,就
能保证它们用的是同一份代码了。虽然,文件person.h
中的函数声明没有必要出现在文件person.c
中,但是,这样做并不碍事。
多文件代码小结
- 源文件
person.c
: 函数定义。 - 头文件
person.h
: 符号常量、函数宏、函数声明、结构声明、类型定义。 - 源文件
person.c
需要头文件person.h
中的声明或定义。因此,需要在源文件中#include "person.h"
。 - 使用者,例如文件
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
用于测试其后跟着的宏是否没有被定义。
- 若没有被定义,则保留从
#ifndef
到#endif
之间的代码。 - 若被定义,则删除从
#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.h
和students.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);
- 点赞
- 收藏
- 关注作者
评论(0)