声明:仅为个人学习总结,还请批判性查看,如有不同观点,欢迎交流。
摘要
本文主要总结了4部分主题相关的知识点,包括3种可以自定义的数据类型“结构、联合和枚举”,为自定义或其他类型命名的关键字“typedef”,对数据进行底层访问和设计的“位操作”,以及可以帮助程序提高可读性和可维护性的“预处理”。
目录
1、结构
结构(structure),是可以具有不同类型的值(成员)的集合。与数组比较:
- 数组:所有元素具有相同的类型,通过元素的位置访问数组元素,元素在内存中顺序存储
- 结构:成员可以具有不同的类型,通过成员的名字访问结构成员,成员在内存中按照声明的顺序存储
1.1 结构类型
结构是一种“用户自定义的”数据类型,为了便于使用自定义的类型,我们需要给结构类型命名。C语言提供了两种命名结构的方法:声明“结构标记”、使用 typedef
定义类型名。
1.1.1 声明结构标记
struct student {
char name[20];
int age;
float score;
};
结构声明(structure declaration)描述了一个结构的组织布局:
- 首先是关键字 struct,它表明跟在后面的是一个结构;
- 然后是一个可选的 结构标记(structure tag),标识结构的名字(示例中是 student),稍后程序中可以使用该标记引用该结构;
- 用一对花括号 { } 括起来的结构成员列表,成员可以是任意一种C的数据类型,包括其他结构;
- 右花括号后面的分号(;)表示结构声明结束。
使用“结构标记”声明变量:
// 不能省略 struct 关键字,因为结构标记 student 不是类型名
struct student student1, student2;
// 可以将“结构标记”的声明和“结构变量”的声明合并在一起
struct student {
char name[20];
int age;
float score;
} student1, student2;
// 如果不需要复用结构,一次性声明全部结构变量,也可以不声明结构标记
struct {
char name[20];
int age;
float score;
} student1, student2;
1.1.2 定义结构类型
除了声明结构标记,还可以用 typedef 定义“类型名”。
// 类型名字 Student 必须出现在定义的末尾,而不是在关键字 struct 的后边
typedef struct {
char name[20];
int age;
float score;
} Student;
// 可以像C语言内置类型一样使用 Student (不允许书写 struct Student)
Student student1, student2;
// 也可以同时有标记名和 typedef 名,他们甚至可以是一样的
typedef struct student {
char name[20];
int age;
float score;
} student;
1.1.3 命名方式选择
在结构中,存在“指向当前结构类型的”指针成员时(如示例 node ),要求使用结构标记。
// 没有 node 标记,就没有办法声明 next 的类型
struct node {
int value;
struct node *next;
};
扩展:两个结构都包含“指向对方的”指针成员时,可以使用不完整的结构类型声明(incomplete declaration)
struct s1; // 不完整的结构类型声明,可以通过创建指向这一类型的指针进行使用
struct s2 {
...
struct s1 *p;
...
};
struct s1 {
...
struct s2 *q;
...
};
1.1.4 结构变量的初始化
struct student student1 = {"Musk", 52, 80.f};
struct student student2 = {"Altman", 38};
类似"数组初始化器"的原则,在“结构初始化器”中:
- 必须是常量表达式(C99 只针对静态存储期变量)
- 可以只为部分成员赋值,其他成员默认用 0 作为初始值
1.1.5 指示器(C99)
struct student student1 = {.age = 52, .score = 80.f, .name = "Musk"};
struct student student2 = {.name = "Altman", 38, .score = 75.f};
结构成员的指示器 和 数组元素的指示器(复习回顾:提高专题(上)=> 1.1.3 数组初始化 )比较,不同的是:
- 指示器由
.成员名称
组成
相同的是:
- 顺序任意
- 值前面不一定要有指示器
- 没有指定值的成员都默认为 0
1.1.6 对结构的操作
- 成员访问:
结构名.成员名
,类似数组取下标,它们都是左值 - 赋值运算:不同于数组,结构可以用
=
运算符复制(对于类型兼容的结构)- 注:除了赋值运算,C语言没有提供其他用于整个结构的操作
// 初始化器可以是另一个结构变量,或类型兼容的任意表达式
struct student student2 = student1;
1.1.7 结构作为参数和返回值、结构指针
函数可以有结构类型的实际参数和返回值。
给函数传递结构和从函数返回结构都需要生成结构中所有成员的副本,增加了一定的系统开销,可以使用传递指向结构的指针来代替传递结构本身。
// 结构类型的参数
void print_student(struct student s) {
printf("Student name: %s\n", s.name);
}
// 指针类型的参数
void print_student(struct student* ps) {
// 运算符 -> 是运算符 * 和运算符 . 的组合,(*ps).name
// 先对 ps 间接寻址以定位所指向的结构,然后再选择结构的成员 name
printf("Student name: %s\n", ps->name);
}
// 结构类型的返回值
struct student create_student(const char* name, int age, float score) {
struct student s;
// 结构拥有自己的名字空间,形式参数名和结构成员名相同是合法的
strcpy(s.name, name);
s.age = age;
s.score = score;
return s;
}
// 指针类型的返回值
struct student* create_student(const char* name, int age, float score) {
struct student* ps = (struct student*)malloc(sizeof(struct student));
strcpy(ps->name, name);
ps->age = age;
ps->score = score;
return ps;
}
1.1.8 复合字面量(C99)
复合字面量可以创建没有名字的数组,复习回顾:提高专题(上)=> 1.4.4 复合字面量参数
复合字面量也可以“实时”创建一个结构,作为函数参数、返回值,或者赋值给变量。
// 作为函数参数
print_student((struct student){"Musk", 52, 80.f});
// 赋值给变量
student1 = (struct student){"Musk", 52, 80.f};
1.1.9 嵌套结构
嵌套结构,在一个结构中包含另一个结构。
struct person_name {
char first[FIRST_NAME_LEN+1];
char middle_initial;
char last[LAST_NAME_LEN+1];
}
struct student {
struct person_name name;
int age;
float score;
} student1, student2;
strcpy(student1.name.first, "Fred");
匿名结构(anonymous structure)
- 是结构或者联合的成员
- 没有名称
- 被声明为结构类型,但是只有成员列表(而没有标记?)
// 匿名结构成员的成员 c 和 f 属于 struct tag,即包含该结构的上层结构。
struct tag
{
int i;
struct st {int j, k;}; // 有标记的成员
struct {char c; float f;}; // 无标记且未命名的成员
struct {double d;} s; // 命名的成员
} t;
t.i = 2006;
t.j = 5; // 合法?
t.k = 6; // 非法?
t.c = 'x';
t.f = 2.0;
t.s.d = 22.2;
// 匿名结构成员的初始化器,依然需要采用花括号 { } 形式
struct t {char c; struct {int x;};};
struct t t = {'x', {1}};
1.2 结构数组
结构和数组的组合没有限制。结构可以包含数组和结构作为成员,数组也可以将结构作为元素。
1.2.1 结构数组示例
struct student students[50];
print_student(students[i]);
1.2.2 结构数组的初始化
初始化结构数组与初始化多维数组的方法非常相似。复习回顾:提高专题(上)=> 1.2 多维数组
每个结构都拥有自己的带有花括号 { } 的初始化器,数组的初始化器简单地在结构初始化器的外围括上另一对花括号 { }。
// 初始化结构数组的原因之一是,我们打算把它作为程序执行期间不改变的信息的数据库。
struct dialing_code {
char *country; // 指向字面串的指针
int code;
};
// 每个结构值两边的内层花括号是可选的。然而,基于书写风格的考虑,最好不要省略它们。
const struct dialing_code country_codes[] =
{{"Argentina", 54}, {"Bangladesh", 880},
{"Brazil", 55}, {"Burma (Myanmar)", 95},
{"China", 86}, {"Colombia", 57},
{"Congo, Dem. Rep. of", 243}, {"Egypt", 20},
{"Ethiopia", 251}, {"France", 33},
{"Germany", 49}, {"India ", 91},
{"Indonesia", 62}, {"Iran", 98},
{"Italy", 39}, {"Japan", 81},
{"Mexico", 52}, {"Nigeria", 234},
{"Pakistan", 92}, {"Philippines", 63},
{"Poland", 48}, {"Russia", 7},
{"South Africa", 27}, {"Korea", 82},
{"Spain", 34}, {"Sudan", 249},
{"Thailand", 66}, {"Turkey", 90},
{"Ukraine", 380}, {"United Kingdom", 44},
{"United States", 1}, {"Vietnam", 84}};
// 访问数组元素
const struct dialing_code *p = country_codes;
size_t count = sizeof(country_codes) / sizeof(struct dialing_code);
for (; p < &country_codes[count]; p++) {
printf("country: %s (%d) \n", p->country, p->code);
}
从C99开始,结构数组的初始化器也允许使用指示器
// [0].age 中,指示器 [0] 用于选择数组元素 0,指示器 .age 用于选择结构中的成员
// [0].name[0],使用了3个指示器,最后一个 [0] 选择 name 数组的元素 0
struct student students[50] =
{[0].age = 19, [0].score = 100, [0].name[0] = '\0'};
2、联合
联合(union),和结构类似,可以由一个或多个不同类型的成员构成;不同之处在于,编译器只为联合中最大的成员分配足够的内存空间,成员之间共享同一存储空间。这样的结果是,联合每次只能存储一个成员,无法同时存储全部成员。
联合的性质和结构的性质几乎一样:
- 可以用声明结构标记和类型的方法来声明联合的标记和类型
- 可以使用运算符 = 进行复制
- 可以作为函数参数,以及函数返回值
2.1 联合的初始化
// 通常只有第一个成员可以获得初始值
// 花括号是必需的,内部的表达式必须是常量(C99 只针对静态存储期变量)
union hold {
int digit;
double bigfl; // 占用空间最大
char letter;
} u = {0}; // 初始化 digit 成员
// 通过指示器(C99)可以指定需要对联合中的哪个成员进行初始化(只能初始化一个成员,但不一定是第一个)
union hold u = {.bigfl = 118.2};
// 用另一个联合来初始化
union hold u2 = u;
2.2 联合的作用
- 节省空间
// 联合成员之间共享同一存储空间
// 当联合的两个或多个成员是结构,这些结构最初的一个或多个成员相匹配(类型兼容、顺序相同)时,
// 如果当前某个结构有效,则其他结构中的匹配成员也有效。
struct catalog_item {
int stock_number;
double price;
int item_type;
union {
struct {
char title[TITLE_LEN + 1];
char author[AUTHOR_LEN + 1];
int num_pages;
} book;
struct {
char design[DESIGN_LEN + 1];
} mug;
struct {
char design[DESIGN_LEN + 1];
int colors;
int sizes;
} shirt;
} item;
};
- 构造混合的数据结构
// 把联合嵌入一个结构中,为联合添加“标记字段”,标记当前存储在联合中的数据的类型
typedef struct {
enum {INT_KIND, DOUBLE_KIND} kind;
union {
int i;
double d;
} u;
} Number;
// 数组的元素是 int 值和 double 值的混合
Number number_array[1000];
- 提供数据的多个视角
// 示例:将一个短整数与文件日期相互转换
// MS-DOS 操作系统为日期分配了 16 位,每个成员后面的数指定了它所占用位的长度
// year 成员存储的是其相距 1980 年(根据微软的描述,这是DOS出现的时间)的时间
struct file_date {
unsigned int day: 5, month: 4, year: 7;
};
// 定义联合类型,支持:
// 1)以两个字节的形式获取磁盘中文件的日期,然后提取出其中的 month、day 和 year 字段的值
// 2)以 file_date 结构构造一个日期,然后作为两个字节写入磁盘中
union int_date {
unsigned short i;
struct file_date fd;
};
// 使用 int_date 联合,传入 unsigned short 参数,显示文件日期的形式
void print_date(unsigned short n) {
union int_date u;
u.i = n;
printf("%d/%d/%d\n", u.fd.month, u.fd.day, u.fd.year + 1980);
}
2.3 匿名联合(C11)
匿名联合(anonymous union)
- 是结构或者联合的成员
- 没有名称
- 被声明为联合类型,但是只有成员列表(而没有标记?)
匿名联合成员的访问规则同 1.1.10 匿名结构。
3、枚举
枚举类型(enumeration type),由程序员列出(“枚举”)每一个值(整型常量),并且为每个值命名(枚举常量)。
3.1 枚举标记和类型名
与结构和联合一样,可以用两种方法命名枚举:声明标记、使用 typedef 定义类型名。
// 声明标记,定义变量
enum spectrum {RED, ORANGE, YELLOW, GREEN, BLUE, VIOLET};
enum spectrum color;
// 定义类型名,定义变量
typedef enum {RED, ORANGE, YELLOW, GREEN, BLUE, VIOLET} Spectrum;
Spectrum color;
3.2 枚举作为整数
C语言把枚举变量和常量作为整数来处理。
// 默认情况下,编译器把整数 0, 1, 2, … 赋给枚举中的常量
// 在 spectrum 中,RED、ORANGE、YELLOW ... VIOLET 分别表示 0、1、2 ... 5
for (Spectrum color = RED; color <= VIOLET; color++) {
...
}
// 可以为枚举常量自由指定不同的值
enum spectrum {RED = 1, ORANGE = 2, YELLOW = 3, GREEN = 4, BLUE = 5, VIOLET = 6};
// 枚举常量的值可以是任意整数,可以不按顺序列出,两个或多个枚举常量甚至可以具有相同的值
enum dept {RESEARCH = 20, PRODUCTION = 10, SALES = 25};
// 在没有为枚举常量指定值时,第一个枚举常量的值默认为 0,后面的值比前一个常量的值大 1
enum EGA_colors {BLACK, LT_GRAY = 7, DK_GRAY, WHITE = 15};
4、位操作
4.1 位运算符
位运算符,对整数数据进行位运算。
序号 | 符号 | 含义 | 说明 |
---|---|---|---|
1 | << | 左移位 | 操作数是任意整数类型(包括char型),会对操作数进行整数提升;为了可移植性,建议使用无符号数 |
2 | >> | 右移位 | |
3 | ~ | 按位取反 | 一元运算符,会对操作数进行整数提升 |
4 | & | 按位与 | 二元运算符,对其操作数进行常用的算术转换 |
5 | ^ | 按位异或 | |
6 | | | 按位或 |
4.1.1 用位运算符访问位
假设 i 是目标变量,目标位的位置存储在变量 j 中,使用移位运算符来构造掩码。
序号 | 访问操作 | 惯用法 |
---|---|---|
1 | 位的设置 | i |= 1 << j; |
2 | 位的清除 | i &= ~(1 << j); |
3 | 位的测试 | if (i & 1 << j) ... |
4.1.2 用位运算符访问位域
假设 i 是一个16位的 unsigned short 变量,目标位域是第 4-6 位。
序号 | 访问操作 | 实现方式 | 说明 |
---|---|---|---|
1 | 修改位域 | i = (i & ~0x0070) | (j << 4); | 可以去掉圆括号;假设变量 j 包含了需要存入的值;首先使用按位与“清除位域”,然后使用按位或“将新的位存入位域” |
2 | 获取位域 | j = (i >> 4) & 0x0007; | 首先将位域移位至右端,然后使用运算符 & 提取位域 |
4.2 结构中的位域
声明包含位域成员的结构,可以更方便的操作位域。位域的类型必须是int(可能存在二义性)、unsigned int、signed int、_Bool及其他(C99)
// 每个成员后面的数指定了它所占用位的长度。
struct file_date {
unsigned int day: 5;
unsigned int month: 4;
unsigned int year: 7;
};
// 通常意义上讲,位域没有地址,所以C语言不允许将 & 运算符用于位域。
scanf("%d", &fd.day); // WRONG
位域的名字可以省略,未命名的位域经常用作字段间的“填充”,以保证其他位域存储在适当的位置。
struct file_time {
unsigned int : 5; // 未命名,不使用
unsigned int minutes: 6; // 其他位域正常对齐
unsigned int hours: 5;
};
struct s {
unsigned int a: 4;
unsigned int : 0; // 长度为 0 的位域是给编译器的一个信号
unsigned int b: 8; // 将下一个位域在一个存储单元的起始位置对齐
};
4.3 对象的对齐(C11)
对齐(alignment),特定类型的对象在存储器里的位置只能开始于某些特定的字节地址,这些字节地址都是某个数值 N 的特定倍数,即对象对齐于 N。例如,在有的计算机上:
- int 类型的对象,可以位于 0x00000004、0x00000008、0x0000000C 等字节地址上,都是 4 的倍数;
- long long int 类型的对象,只能位于 0x00000008、0x00000010、0x00000018、0x00000020 等字节地址上,都是 8 的倍数;
- char 类型的对象可以位于任何字节地址上,如 0x00000001、0x00000002、0x00000003 等,都是 1 的倍数。
对于完整的对象类型来说,“对齐”限制了它在存储器中可以被分配到的地址。未对齐的存储器访问对不同的计算机来说会有不同的效果,有的会变慢,有的会出错。
4.3.1 对齐运算符 _Alignof(C11)
表达式:_Alignof(类型名)
可以用运算符 _Alignof
得到指定类型的对齐值。_Alignof
运算符的操作数要求是用括号括起来的 类型名,结果类型是 size_t
。运算符不能应用于函数类型和不完整的对象类型。如果应用于数组,则返回元素类型的对齐需求。
void f(void) {
printf("%zu, %zu, %zu, %zu\n",
_Alignof(char),
_Alignof(int),
_Alignof(int[33]),
_Alignof(struct {char c; double d;})
);
}
4.3.2 对齐指定符 _Alignas(C11)
语法格式:_Alignas(常量表达式)
也可以是: _Alignas(类型名) ,等价于_Alignas(_Alignof (类型名))
对齐指定符只能在声明里使用,或者在复合字面量中使用,强制被声明的变量按指定的要求对齐。应该指定比基本对齐值更大的对齐值。
// 使 int 类型的对象 foo 和结构类型的成员 bar 按 8 字节对齐
int _Alignas(8) foo;
struct s {int a; int _Alignas (8) bar;};
5、typedef
5.1 类型定义(type definition)
通过 typedef
可以为某一类型自定义名称。方法为:首先定义目标类型的变量,变量名为“自定义类型名称”,然后在定义前面加上关键字 typedef
。
// 编译器把 Bool 类型看成是 int 类型的同义词,flag 实际就是一个普通的 int 类型变量
typedef int Bool;
Bool flag;
- 通过
typedef
定义Bool
,会导致编译器在它所识别的类型名列表中加入Bool
。这样,Bool
类型可以和内置的类型名一样用于变量声明、强制类型转换表达式以及其他地方。 - 定义类型名称的作用域取决于 typedef 定义所在的位置。定义在函数中,就具有局部作用域;定义在函数外面,就具有文件作用域。
5.2 typedef 的作用
使用 typedef 自定义名称的作用包括:
- 可以为经常出现的类型创建一个方便、易识别的类型名。
typedef struct {double x; double y; double width; double height;} Rect;
typedef char (* Pointer)(int);
typedef unsigned char Byte;
- 可以给复杂的类型命名。关于变量/函数的声明,复习回顾:入门专题(下)=> 4.5 声明符
// 把 FRPTC 声明为一个函数类型,该函数返回一个指针,该指针指向内含5个 char 类型元素的数组
typedef char (* FRPTC ()) [5];
// 建立一系列相关类型
typedef int arr5[5];
typedef arr5 * p_arr5;
typedef p_arr5 arrp10[10];
- 提高可移植性
C语言库自身使用 typedef 为那些可能因C语言实现的不同而不同的类型创建类型名。这些类型的名字经常以 _t 结尾。
typedef long int ptrdiff_t;
typedef unsigned long int size_t;
typedef int wchar_t;
6、预处理
预处理器,是一个软件,它可以在编译前处理C程序,它的行为是由预处理指令(由 # 字符开头的一些命令)控制的。
6.1 预处理指令
预处理指令类型:
序号 | 类型 | 说明 |
---|---|---|
1 | 宏定义 | #define 指令定义一个宏,#undef 指令删除一个宏定义 |
2 | 文件包含 | #include 指令导致一个指定文件的内容被包含到程序中 |
3 | 条件编译 | #if、#ifdef、#ifndef、#elif、#else 和 #endif 指令根据预处理器可以测试的条件来确定,是将一段文本块包含到程序中,还是将其排除在程序之外。 |
4 | 其他指令 | #error、#line 和 #pragma 是比较特殊的指令,较少用到 |
预处理指令的通用规则:
序号 | 规则 | 说明 |
---|---|---|
1 | 指令都以 # 开始 | # 符号不需要出现在一行的行首,只要在它之前只有空白字符就行。在 # 后是指令名,接着是指令所需要的其他信息。 |
2 | 在指令的符号之间可以插入任意数量的空格或水平制表符 | 合法指令示例:# define N 100 |
3 | 指令总是在第一个换行符处结束,除非明确地指明要延续 | 如果想在下一行延续指令,我们必须在当前行的末尾使用 \ 字符(同“多行字面串”) |
4 | 指令可以出现在程序中的任何地方 | 通常将 #define 和 #include 指令放在文件的开始,其他指令则放在后面,甚至可以放在函数定义的中间 |
5 | 注释可以与指令放在同一行 | 在宏定义的后面加一个注释来解释宏的含义是一种比较好的习惯 |
6.2 宏定义
#define
指令定义了一个宏(macro),用来代表其他对象的名字(object-like macro),或一系列操作的名字(function-like macro)。
6.2.1 简单的宏
定义格式:#define 标识符 替换列表
- 标识符,即宏的名称
- 替换列表 是一系列的预处理记号(token):
- 可以包括:标识符、关键字、数值常量、字符常量、字面串、运算符、标点符号
- 可以为空
#define PI 3.14159
#define TWO_PI (2*PI)
#define PX printf("X is %d.\n", x)
当预处理器遇到一个宏定义时,会做一个 标识符 代表 替换列表 的记录;
在后面的程序中,不管 标识符 在哪里出现,预处理器都会用 替换列表 代替它。即“扩展”宏(macro expansion),将宏替换为其定义的内容。
Tips:哪些常量需要定义成宏?
- 对于数值常量,只要不是 0 和 1,都应该定义成宏;
- 对于字符或字符串常量,因为宏定义并不总是能够提高程序的可读性,所以考虑在以下情况下使用宏
- 常量被不止一次地使用;
- 以后可能需要修改常量。
6.2.2 带参数的宏(函数式宏)
定义格式:#define 标识符(x1, x2, …, xn) 替换列表
- 其中 x1, x2, …, xn 是标识符(宏的形式参数),这些参数可以在 替换列表 中根据需要出现任意次
- 在宏的名字和左括号之间必须没有空格
- 参数列表可以为空
#define MAX(x,y) ((x)>(y)?(x):(y)) // 如果参数类似 i++,会造成非常隐匿的错误
#define TOUPPER(c) ('a'<=(c)&&(c)<='z'?(c)-'a'+'A':(c))
#define getchar() getc(stdin)
当预处理器遇到带参数的宏时,会将宏定义存储起来以便后面使用。
在后面的程序中,如果任何地方出现了 标识符(y1,y2,…,yn) 格式的宏调用(其中y1,y2,…,yn 是一系列记号,宏的实际参数),预处理器会使用 替换列表 替代宏,并使用实参替换形参,即 y1 替换 x1,y2 替换 x2,以此类推。
6.2.3 # 运算符(串化)
宏定义可以包含两个专用的运算符:#
和 ##
。
#
运算符将宏的一个参数转换为字面串,仅允许出现在带参数的宏的 替换列表 中。
// # 运算符通知预处理器根据 PRINT_INT 的参数创建一个字面串
#define PRINT_INT(n) printf(#n " = %d\n", n)
// 程序调用
PRINT_INT(i/j);
// 预处理器后变为
printf("i/j" " = %d\n", i/j);
// C语言相邻的字面串会被合并,上边的语句等价于
printf("i/j = %d\n", i/j);
6.2.4 ## 运算符(记号粘合)
##
运算符可以将两个记号(如标识符)“粘合”在一起,成为一个记号。如果其中一个操作数是宏参数,“粘合”会在形式参数被相应的实际参数替换后发生。
// 用 ## 运算符为每个版本的 max 函数构造不同的名字
#define GENERIC_MAX(type) \
type type##_max(type x, type y) \
{ \
return x > y ? x : y; \
}
// 使用 GENERIC_MAX 宏来定义一个针对 float 值的 max 函数
GENERIC_MAX(float)
// 预处理器后的代码
float float_max(float x, float y) { return x > y ? x : y; }
替换列表中依赖
#
/##
的宏通常不能嵌套调用,因为在替换列表中,位于#
/##
运算符之前和之后的宏参数在替换时不会再次被扩展(不会替换记号的片段)
6.2.5 宏的通用规则
序号 | 规则 | 说明 |
---|---|---|
1 | 宏的替换列表可以包含对其他宏的调用。 | 在首次替换后,预处理器会不断重新检查替换列表,直到将所有的宏名字都替换完为止。 |
2 | 预处理器只会替换完整的记号,而不会替换记号的片段。 | 预处理器会忽略嵌在标识符、字符常量、字面串之中的宏名,例如 BUFFER_SIZE 不会被宏 标识符 SIZE 替换。 |
3 | 宏不可以被定义两遍,除非新的定义与旧的定义是一样的。 | 小的间隔上的差异是被允许的。 |
4 | 宏定义的作用范围通常到出现这个宏的文件末尾。 | 宏是由预处理器处理的,它们不遵从通常的作用域规则。 |
5 | 宏可以使用 #undef 指令“取消定义” | #undef 指令格式:#undef 标识符,如果 标识符 之前没有被定义成一个宏,则 #undef 指令没有任何作用。 |
6.2.6 替换列表中的圆括号
添加圆括号的规则:
- 如果宏的 替换列表 中有运算符,那么始终要将 替换列表 放在括号中
#define TWO_PI (2*3.14159)
// 如果没有括号,除法会在乘法之前执行,产生非期望的结果
conversion_factor = 360/TWO_PI;
- 如果宏有参数,每个参数每次在 替换列表 中出现时都要放在圆括号中
#define SCALE(x) ((x)*10)
// 如果没有括号,会错误的扩展为 j = (i+1*10);
j = SCALE(i+1);
6.2.7 创建较长的宏
- 使用逗号运算符,让 替换列表 包含 “一系列表达式”
#define ECHO(s) (gets(s), puts(s))
ECHO(str); // 替换为 (gets(str), puts(str));
- 使用复合语句,让 替换列表 包含 “一系列语句”
#define ECHO(s) { gets(s); puts(s); }
if (echo_flag)
ECHO(str) // 不要在后面加分号(程序看起来有些怪异)
else
gets(str);
- 使用 do 循环,让 替换列表 包含 “一系列语句”
#define ECHO(s) \
do { \
gets(s); \
puts(s); \
} while (0)
// 一定要加分号以使 do 语句完整
ECHO(str);
6.2.8 预定义宏
C语言预定义宏:
序号 | 名字 | 描述 |
---|---|---|
1 | __LINE__ | 当前宏所在行的行号 |
2 | __FILE__ | 当前文件的名字 |
3 | __DATE__ | 编译的日期(格式 “mm dd yyyy”) |
4 | __TIME__ | 编译的时间(格式 “hh:mm:ss”) |
5 | __STDC__ | 如果编译器符合C标准(C89或C99),那么值为1 |
// 每次程序开始执行时,显示其编译时间,可以帮助区分同一个程序的不同版本
printf("Compiled on %s at %s\n", __DATE__, __TIME__);
// 使用 __LINE__ 宏和 __FILE__ 宏来定位错误
#define CHECK_ZERO(divisor) \
if (divisor == 0) \
printf("*** Attempt to divide by zero on line %d " \
"of file %s ***\n", __LINE__, __FILE__)
6.2.9 C99 新增的预定义宏
序号 | 名字 | 描述 |
---|---|---|
1 | __STDC_HOSTED__ | 如果是托管式实现,则值为1;如果是独立式实现,则值为0 |
2 | __STDC_VERSION__ | 支持的C标准版本 |
3 | __STDC_IEC_559__ ① | 如果支持 IEC 60559 浮点算术运算,则值为1 |
4 | __STDC_IEC_559_COMPLEX__ ① | 如果支持 IEC 60559 复数算术运算,则值为1 |
5 | __STDC_ISO_10646__ ① | 被定义为 yyyymmL 形式的整型常量,意味着可以用 wchar_t 类型来存储 ISO 10646 标准所定义的,以及在指定年月所修订和补充的 Unicode 字符 |
① 有条件定义,C99编译器可能(也可能没有)定义
__func__
标识符(C99)
__func__
与预处理器无关,但是,与许多预处理特性一样,它也有助于调试。可以在函数体中跟踪函数调用情况,也可以作为参数传递给函数,让函数知道调用它的函数的名字。
#define FUNCTION_CALLED() printf("%s called\n", __func__)
#define FUNCTION_RETURNS() printf("%s returns\n", __func__)
// 对这些宏的调用可以放在函数体中,以跟踪函数的调用
void f(void) {
FUNCTION_CALLED(); /* displays "f called" */
...
FUNCTION_RETURNS(); /* displays "f returns" */
}
6.2.10 空的宏参数(C99)
C99允许宏调用中的任意或所有参数为空,逗号用于判断被省略的参数,不可以省略。
// 1. 在大多数情况下,实际参数为空的效果是显而易见的
#define ADD(x,y) (x+y)
// 调用语句
i = ADD(,k);
// 预处理后语句
i = (+k);
// 2. 如果为空的实际参数被 # 运算符“串化”,则结果为""(空字符串)
#define MK_STR(x) #x
// 调用语句
char empty_string[] = MK_STR();
// 预处理后语句
char empty_string[] = "";
// 3. 如果 ## 运算符之后的一个实际参数为空,它将被不可见的“位置标记”记号代替;
// 把原始的记号与位置标记记号相连接,得到的还是原始的记号;
// 如果连接两个位置标记记号,得到的是一个位置标记记号;
// 宏扩展完成后,位置标记记号从程序中消失。
#define JOIN(x,y,z) x##y##z
// 调用语句
int JOIN(a,b,c), JOIN(a,b,), JOIN(a,,c), JOIN(,,c), JOIN(,,);
// 预处理后语句
int abc, ab, ac, c;
6.2.11 参数个数可变的宏(C99)
宏具有可变参数个数的主要原因是,它可以将参数传递给具有可变参数个数的函数,如 printf 和 scanf。
...
记号(省略号)出现在宏参数列表的最后,前面是普通参数。__VA_ARGS__
是一个专用的标识符,只能出现在具有可变参数个数的宏的 替换列表 中,代表所有与省略号相对应的参数。(至少有一个与省略号相对应的参数,但该参数可以为空。)
// 宏 TEST 至少要有两个参数,第一个参数匹配 condition,剩下的参数匹配省略号
#define TEST(condition, ...) ((condition)? \
printf("Passed test: %s\n", #condition): \
printf(__VA_ARGS__))
// 调用语句
TEST(voltage <= max_voltage,
"Voltage %d exceeds %d\n", voltage, max_voltage);
// 预处理器产生如下的输出
((voltage <= max_voltage)?
printf("Passed test: %s\n", "voltage <= max_voltage"):
printf("Voltage %d exceeds %d\n", voltage, max_voltage));
6.3 文件包含
#include
指令告诉预处理器打开一个指定的文件,将它的内容作为正在编译的文件的一部分“包含”进来。复习回顾:入门专题(下)=> 3.2.2.1 #include 指令
6.4 条件编译
条件编译,是指根据预处理器所执行的测试结果来包含(编译)或排除(不编译)程序代码的片段。
6.4.1 #if 指令和 #endif 指令
#if
指令格式: #if 常量表达式
#endif
指令格式:#endif
当预处理器遇到 #if
指令时,会计算 常量表达式 的值:
- 如果表达式的值为 0,那么
#if
与#endif
之间的行将在预处理过程中从程序中删除; - 否则,
#if
和#endif
之间的行会被保留在程序中,继续留给编译器处理
#define DEBUG 1
#if DEBUG // 条件满足
printf("Value of i: %d\n", i);
#endif
// #if 指令会把没有定义过的标识符当作值为 0 的宏对待
// #define DEBUG 1 // 去掉 DEBUG 的定义
#if DEBUG // 条件不满足
#if !DEBUG // 条件满足
// 可以使用 “条件屏蔽” 来屏蔽代码
#if 0
// 需要被屏蔽的代码
#endif
6.4.2 defined 运算符
除了运算符 #
和 ##
,defined
也是一个专用于预处理器的运算符。
当 defined
应用于标识符时,如果标识符是一个定义过的宏则返回 1,否则返回 0。defined
运算符通常与 #if
指令结合使用。
// 因为 defined 运算符仅检测 DEBUG 是否有定义,所以可以不给 DEBUG 赋值
#define DEBUG
// DEBUG 两侧的括号也不是必需的
#if defined(DEBUG)
...
#endif
6.4.3 #ifdef 指令和 #ifndef 指令
#ifdef
指令格式:#ifdef 标识符
#ifndef
指令格式:#ifndef 标识符
// #ifdef 指令测试一个标识符是否 “已经定义为宏”
#ifdef 标识符 // 等价于:#if defined(标识符)
当标识符被定义为宏时需要包含的代码
#endif
// #ifndef 指令测试一个标识符是否 “没有被定义为宏”
#ifndef 标识符 // 等价于:#if !defined(标识符)
当标识符未被定义为宏时需要包含的代码
#endif
// #ifdef 和 #ifndef 只能对一个宏进行测试
// 使用 #if 和 defined 运算符可以测试任意数量的宏
#if defined(FOO) && defined(BAR) && !defined(BAZ)
6.4.4 #elif 指令和 #else 指令
#elif
指令格式:#elif 常量表达式
#else
指令格式:#else
类似普通的 if 语句嵌套,#if 指令、#ifdef 指令和 #ifndef 指令也可以与 #elif 指令和 #else 指令结合使用,来测试一系列条件。
#if 表达式1
当表达式1非 0 时需要包含的代码
#elif 表达式2
当表达式1为 0 但表达式2非 0 时需要包含的代码
#else
其他情况下需要包含的代码
#endif
6.5 其他指令
6.5.1 #error 指令
指令格式:#error 消息
遇到 #error
指令预示着程序中出现了严重的错误,预处理器会显示一条包含 消息 的出错消息,有些编译器会立即终止编译,不再检查其他错误。
#error
指令通常与条件编译指令一起用于检测正常编译过程中不应出现的情况。
#if defined(WIN32)
...
#elif defined(MAC_OS)
...
#elif defined(LINUX)
...
#else
#error No operating system specified
#endif
6.5.2 #line 指令
指令格式1:#line n
指令格式2:#line n “文件”
#line
指令用来改变程序“行的编号”,以及程序“所在的文件名字”
- n 必须是 1~32 767(C99中是2 147 483 647)范围内的整数。这条指令导致程序中后续的行被编号为 n、n+1、n+2等
- n 和 文件字符串的值可以用宏指定
#line
指令的一种作用是改变 __LINE__
宏(可能还有 __FILE__
宏)的值。还可以用于那些产生C代码作为输出的程序。
#line 1000 // 把当前行号重置为 1000
#line 10 "cool.c" // 把行号重置为 10,把文件名重置为 cool.c
6.5.3 #pragma 指令
指令格式:#pragma 记号
#pragma
指令用于向编译器发出命令
- 不同编译器的命令集是不一样的,需要查阅所使用编译器的文档来了解可以使用哪些命令,以及这些命令的功能;
- 如果 #pragma 指令包含了无法识别的命令,预处理器会忽略这些指令。
// 示例:让编译器支持C9X(在开发C99时,标准被称为C9X)
#pragma c9x on
_Pragma 运算符(C99)
表达式:_Pragma (字面串)
实现在 #pragma
指令后面进行宏的扩展:
_Pragma
运算符完成“解字符串”(destringizing)的工作,把字符串转换成#pragma
需要的编译命令;- 解字符串,即移除字符串两端的双引号,把字符串中的转义序列转换成它所代表的字符(分别用字符
"
和\
代替转义序列\"
和\\
)
// 示例:定义使用 _Pragma 运算符的宏
#define DO_PRAGMA(x) _Pragma(#x)
// 调用宏,设置 GCC 支持的一种编译提示:
// 如果指定的文件(本例中是 parse.y)比当前文件(正被编译的文件)还要新,给出警告消息
DO_PRAGMA(GCC dependency "parse.y")
// 宏扩展,DO_PRAGMA 的 # 运算符导致参数“记号”被串化
_Pragma("GCC dependency\"parse.y\"")
// _Pragma 解字符串后,得到包含原始“记号”的 #pragma 指令
#pragma GCC dependency "parse.y"
参考
- [美] K. N. 金(K. N. King)著,吕秀锋,黄倩译.C语言程序设计:现代方法(第2版·修订版).人民邮电出版社.2021:209.
- [美] 史蒂芬·普拉达著.C Primer Plus(第6版 中文版 最新修订版).人民邮电出版社.2019:115.
宁静以致远,感谢 Vico 老师。