【C-Primer-Plus读书笔记】第16章:C预处理器和C库
翻译程序的第一步
在预处理之前,编译器必须对该程序进行一些翻译处理:
- 编译器把源代码中出现的字符映射到源字符集;
- 编译器定位每个反斜杠后面跟着换行符到实例,并删除它们(即把代码中本是一行但写了两行的语句转换成一行,常见于在一行字符串中插入反斜杠后跟着按下回车键形成的两行代码。即两个物理行转换成一个逻辑行,预处理表达式的长度必须是一个逻辑行);
- 编译器把文本划分成预处理记号序列、空白序列和注释序列(记号是由空格、制表符或换行符分割的项),这里需要注意的是,编译器将用一个空格字符替代每一条注释;
- 程序已经准备好进入预处理阶段,预处理器查找一行中以
#
号开始的预处理指令。
明示常量:#define
- 预处理器指令从
#
开始运行,到后面的第一个换行符为止。也就是说,指令的长度仅限于一行。 每行
#define
(逻辑行)都由3部分组成:- 第1部分是
#define
指令本身; - 第2部分是选定的缩写,也称为宏。有些宏代表值,这些宏被称为类对象宏;
- 第3部分(指令行的其余部分)称为替换列表或替换体。
- 第1部分是
- 一旦预处理器在程序中找到宏的实例后,就会用替换体代替该宏。从宏变成最终替换文本的过程称为宏展开。
- 宏可以表示任何字符串,甚至可以表示整个C表达式。
- 预处理器不做计算,不对表达式求值,这一过程在编译时进行,它只进行替换。
- 宏还可以包含其他宏,即嵌套。一般而言,预处理器发现程序中的宏后,会用宏等价的替换文本进行替换。如果替换的字符串中还包含宏,则继续替换这些宏。唯一例外的是双引号中的宏(包括替换体中),双引号中的内容是一个字符串,该字符串就是双引号中的字符本身。
- 对于绝大部分数字常量,应该使用字符常量。
- 宏常量可用于指定标准数组的大小和
const
变量的初始值。
记号
- 从技术角度来看,可以把宏的替换体看作是记号型字符串,而不是字符型字符串。
- 替换体中有多个空格时,字符型字符串和记号型字符串的处理方式不同:如果预处理器把替换体解释为字符型字符串,把空格视为替换体的一部分;解释为记号型字符串,把空格视为替换体中各记号的分隔符。
#define FOUR 2*2 //该宏就一个记号,即2*2
#define SIX 2 * 3 //该宏有3个记号,分别为2、*、3
- 重定义常量
假设定义过符号常量后又定义为其他值,这个过程称为重定义常量。
#define SIX 2 * 3
#define SIX 2 * 3 //该宏与上一条定义相同(记号相同)
#define SIX 2*3 //该宏则不同(记号不同),如果需要重定义,使用#undef指令
在#define
中使用参数
- 在
#define
中使用参数可以创建外形和作用与函数类似的类函数宏。 - 类函数宏和函数完全不同:宏不参与运算只做替换,类函数宏接受参数后代入替换体将参数替换为实际的值,再使用该替换体进行宏替换,即类似把函数内容替换出来而不是立即运算取得结果。
- 使用类函数宏时需要注意运算符优先级,否则宏进行替换后可能导致非预期的结果。替换体尽量使用圆括号且尽量不使用自增自减运算符。
- 用宏参数创建字符串:
#
运算符
C允许在字符串中包含宏参数。在类函数宏的替换体中,#
号作为一个预处理运算符,可以把记号转换成字符串。这个过程称为字符串化:
#define PSQR(X) printf("The square of X is %d.", ((X)*(X)); //注意双引号中的 X 被视为普通文本
#define PSQR(X) printf("The square of " #X " is %d.", ((X)*(X)); //注意双引号外的 #X 会被替换为传入的参数名
- 预处理器粘合剂:
##
运算符
与#
运算符类似,##
运算符可用于类函数宏的替换部分。而且##
还可用于对象宏的替换部分。##
运算符把两个记号组合成一个记号。
#define XNAME(n) x ## n //宏 XNAME(4) 展开为 x4
- 变参宏:
...
和__VA_ARGS__
一些函数(如printf()
)接受数量可变的参数。stdvar.h
头文件提供了工具,让用户自定义带可变参数的函数。
通过把宏参数列表中最后的参数写成省略号(即三个点)来实现这一功能。这样,预定义宏__VA_ARGS__
可用在替换部分中,表明省略号代表什么。
#define PR(...) printf(__VA_ARGS__)
//调用宏
PR("Howdy"); //展开为1个参数
PR("weight = %d, shippng = $%.2f\n", wt, sp); //展开为3个参数
宏和函数的选择
有些编程任务既可以用带参数的宏完成,也可以用函数完成。选择时可以参考下面的情况:
- 使用宏比使用普通函数复杂一些,稍有不慎会产生奇怪的副作用。一些编译器规定宏只能定义成一行。不过,即使编译器没有这个限制,也应该这样做。
宏和函数的选择实际上是时间和空间的权衡:
- 宏生成内联代码,即在程序中生成语句,调用多少次就会插入多少次该代码。而函数无论调用多少次,程序中只有一份函数语句的副本,所以节省了空间;
- 然而另一方面,程序的控制必须跳转至函数内,随后再返回主调程序,这显然比内联代码花费更多的时间。
- 宏的一个优点是,不用担心变量类型。这是因为宏处理的是字符串,而不是实际的值。
- 对于简单的函数,程序员通常使用宏。
文件包含:#include
- 当预处理器发现
#include
指令时,会查看后面的文件名并把文件的内容包含到当前文件中,即替换源文件中的#include
指令。这相当于把被包含文件的全部内容输入到源文件#include
指令所在的位置。 #include
指令有两种形式:
#include <stdio.h> //文件名在尖括号中
#include "mystuff.h" //文件名在双引号中
在UNIX系统中,尖括号告诉预处理器在标准系统目录中查找该文件。双引号告诉预处理器首先在当前目录中(或文件名指定的其他目录)查找该文件,如果未找到再查找标准系统目录。
- ANSI C不为文件提供统一的目录模型,因为不同的计算机所用的系统不同。一般而言,命名文件的方法因系统而异,但是尖括号和双引号的规则与系统无关。
- C语言习惯用
.h
后缀表示头文件,这些文件包含需要放在程序顶部的信息。头文件经常包含一些预处理器指令。 - 包含一个大型头文件不一定显著增加程序的大小。在大部分情况下,头文件的内容是编译器生成最终代码时所需的信息,而不是添加到最终代码中的材料。
头文件中最常用的形式如下:
- 明示常量
- 宏函数
- 函数声明
- 结构模板定义
- 类型定义
其他指令
程序员可能要为不同的工作环境准备C程序和C库包。不同的环境可能使用不同的代码类型。预处理器提供一些指令,程序员通过修改#define
的值即可生成可移植代码。
#undef
指令#undef
指令用于“取消”已定义的#define
指令。- 从C预处理器角度看已定义
处理器在识别标识符时,遵循与C相同的规则。当预处理器在预处理器指令中发现一个标识符时,它会把该标识符当做已定义的或未定义的。 条件编译
可以使用其他指令创建条件编译。也就是说,可以使用这些指令告诉编译器根据编译时的条件执行或忽略信息(或代码)块。#ifdef
、#else
和#endif
指令#ifndef
指令
#ifndef
指令与#ifdef
指令的用法类似,但是他们的逻辑相反。该指令判断后面的标识符是否是未定义的,常用于定义之前未定义的常量。#if
和#elif
指令#if
指令很想C语言中的if
。#if
后面跟整型常量表达式,如果表达式为非零,则表达式为真;- 可以按照
if else
的形式使用#elif
(早期的实现不支持#elif
); - 较新的编译器提供另一种方法测试名称是否已定义,即用
#if defined (VAX)
代替#ifdef VAX
。 defined
是一个预处理运算符,如果它的参数是用#define
定义过,则返回1;否则返回0。这种新方法优点是可以和#elif
一起使用。
#ifdef MAVIS //如果定义了MAVIS,则执行下面的指令
#include "horse.h"
#define STABLES 5
#else //如果没有定义MAVIS,则执行下面的指令
#include "cow.h"
#define STABLES 15
#endif
- 预定义宏
C标准规定了一些预定义宏:
宏 | 含义 |
---|---|
__DATE__ | 预处理的日期 |
__FILE__ | 表示当前源代码文件名的字符串字面量 |
__LINE__ | 表示当前源代码文件中行号的整型常量 |
__STDC__ | 设置为1时,表明实现遵循C标准 |
__STDC_HOSTED__ | 本机环境设置为1;否则设置为0 |
__STDC_VERSION__ | 支持C99标准,设置为199901L;支持C11标准,设置为201112L |
__TIME__ | 翻译代码的时间,格式为 “hh:mm:ss” |
C99标准提供一个名为__func__
的预定义标识符,它展开为一个代表函数名的字符串(该函数包含该标识符)。那么,__func__
必须具有函数作用域,而从本质上看宏具有文件作用域。因此,__func__
是C语言的预定义标识符,而不是预定义宏。
#line
和#error
#line
指令重置__LINE__
和__FILE__
宏报告的行号和文件名。#error
指令让预处理器发出一条错误消息,该消息包含指令中的文本。如果可能的话,编译过程应该中断。
#line 1000 //把当前行号重置为1000
#line 10 "cool.c" //把行号重置为10,把文件名重置为cool.c
//
#if __STDC_VERSION__ != 201112L //如果编译器只支持旧标准,则会编译失败,如果支持C11标准,就能成功编译
#error Not C11
#endif
#pragma
- 在现在的编译器中,可以通过命令行参数或IDE菜单修改编译器的一些设置。
#pragma
把编译器指令放入源代码中。一般而言,编译器都有自己的编译指示集。 - C99还提供
_Pragma
预处理器运算符,该运算符把字符串转换成普通的编译指示。
- 在现在的编译器中,可以通过命令行参数或IDE菜单修改编译器的一些设置。
_Pragma("nonstandardtreatmenttypeB on")
//等价于下面的指令
#pragma nonstandardtreatmenttypeB on
泛型选择(C11)
- 在程序设计中,泛型编程指那些没有特定类型,但是一旦指定一种类型,就可以转换成指定类型的代码。
- C11新增了一种表达式,叫做泛型选择表达式可根据表达式的类型选择一个值。
- 泛型表达式不是预处理器指令,但是在一些泛型编程中它常用作
#define
宏定义的一部分。 _Generic
是C11的关键字。_Generic
后面的圆括号中包含多个用逗号分隔的项。第1个项是一个表达式,后面每个项都由一个类型、一个冒号和一个值组成:
_Generic(x, int: 0, float: 1, double: 2, default: 3) //假设x是int类型,x的类型匹配int: 标签,那么整个表达式的值就是0;如果没有匹配则值就是default: 标签后的值
泛型选择语句与switch语句类似,只是前者用表达式的类型匹配标签,而后者用表达式的值匹配标签。
内联函数(C99)
- 通常,函数调用都有一定的开销,因为函数的调用过程包括建立调用、传递参数、跳转到函数代码并返回。
- 前面提到使用宏使代码内联,可以避免这样的开销。C99还提供一种方法:内联函数。
- C99和C11标准中叙述:“把函数变成内联函数建议尽可能快的调用该函数,其具体效果由实现定义”。因此,把函数变成内联函数,编译器可能会用内联代码替换函数调用,并(或)执行一些其他优化,但是也可能不起作用。
- 标准规定具有内部链接的函数可以成为内联函数,还规定了内联函数的定义与调用该函数的代码必须在同一个文件中。因此,创建内联函数最简单的方法就是使用函数说明符
inline
和存储类别说明符static
。 - 通常,内联函数应定义在首次使用它的文件中,所以内联函数也相当于函数原型。
- 无法获得内联函数的地址(实际上可以,不过这样做之后,编译器会生成一个非内联函数),另外,内联函数无法在调试器中显示。
- 因为内联函数都具有内部链接,所以建议把内联函数定义放在头文件,并在使用该内联函数的文件包含该头文件即可。一般都不在头文件中放置可执行代码,内联函数是个特例。
_Noreturn
函数(C11)
- C99新增的
inline
是唯一的函数说明符,后来C11新增了第2个函数说明符_Noreturn
,表明调用完成后函数不返回主调函数。 exit()
函数是_Noreturn
函数的一个示例,一旦调用exit()
,它不会再返回主调函数。- 注意,这与
void
返回类型不同,void
返回类型的函数在执行完毕后返回主调函数,只是它不提供返回值。
C库
访问C库
- 自动访问————只需编译就可使用
- 文件包含————通过
#include
指令包含头文件 - 库包含————在编译或链接程序的某些阶段指定库选项
- 使用库描述————了解函数文档,关键是看懂函数头。
数学库
ANSI C标准的一些数学函数
原型 | 描述 |
---|---|
double acos(double x) | 返回余弦值为x的角度(0~π 弧度) |
double asin(double x) | 返回正弦值为x的角度(-π/0~π/2 弧度) |
double atan(double x) | 返回正切值为x的角度(-π/0~π/2 弧度) |
double atan2(double y, double x) | 返回正弦值为y/x的角度(-π~π 弧度) |
double cos(double x) | 返回x的余弦值,x的单位弧度 |
double sin(double x) | 返回x的正弦值,x的单位弧度 |
double tan(double x) | 返回x的正切值,x的单位弧度 |
double exp(double x) | 返回x的指数函数的值() |
double log(double x) | 返回x的自然对数 |
double log10(double x) | 返回x的以10为底的对数值 |
double pow(double x, double y) | 返回x的y次幂 |
double sqrt(double x) | 返回x的平方值 |
double cbrt(double x) | 返回x的立方值 |
double ceil(double x) | 返回不小于x的最小整数值 |
double fabs(double x) | 返回x的绝对值 |
double floor(double x) | 返回不大于x的最大整数值 |
UNIX系统会要求使用-lm
标记指示链接器搜索数学库。
类型变体(指的是参数的类型)
sqrtf()
————(sqrt
的)float
版本sqrtl()
————(sqrt
的)long double
版本sinf()
————(sin
的)float
版本sinl()
————(sin
的)long double
版本
tgmath.h
库(C99)
C99标准提供的tgmath.h
头文件中定义了泛型类型宏。类似用同一种方式调用上面各种不同的类型变体。
通用工具库
exit()
和atexit()
函数atexit()
————指定在执行exit()
时调用的特定函数。atexit()
函数通过退出时注册被调用的函数提供这种功能,atexit()
函数接受一个函数指针作为参数。- 这个函数使用函数指针,只需把退出时要调用的函数地址传入即可。函数名作为函数参数时相当于该函数的地址。
atexit()
注册的函数应该不带任何参数且返回类型为void; - 然后,
atexit()
注册函数列表中的函数,当调用exit()
时就会执行这些函数;ANSI保证,在这个列表中至少可以放32个函数; - 最后调用
exit()
函数时(即使没有显式调用,程序结束时也会隐式调用),exit()
会执行这些函数(执行顺序与列表中函数顺序相反,即最后添加的先执行)。
- 这个函数使用函数指针,只需把退出时要调用的函数地址传入即可。函数名作为函数参数时相当于该函数的地址。
exit()
————退出程序,在main()
返回系统时将自动调用exit()
函数。exit()
执行完atexit()
指定的函数后,会完成一些清理工作:刷新所有输出流、关闭打开的流和关闭由标准I/O函数tmpfile()创建的临时文件;- 然后
exit()
把控制前返回主机环境,如果可能的话,向主机环境报告终止状态。
qsort()
函数qsort()
是一个快速排序算法。它把数组不断分成更小的数组,直到变成单元素数组。首先,把数组分成两部分,一部分的值都小于另一部分的值。这个过程一直持续到数组完全排序好为止。该函数参数如下:void
指针(即该指针可以指向任何类型,可传任何类型的指针),指向待排序数组的首元素;size_t
整数,待排序项的数量;size_t
整数,待排序数组中每个元素的大小;- 函数指针,该函数是一个比较函数用于确定排序的顺序,返回值类型是
int
整数(值规则同大多数比较函数一样,返回负数、0 或正数)。
断言库
assert.h
头文件支持的断言库是一个用于辅助调试程序的小型库。- 它由
assert()
宏组成,接受一个整型表达式作为参数。 - 如果表达式求值为假(非零),
assert()
宏就在标准错误流(stderr)中写入一条错误信息,并调试abort()
函数终止程序(abort()
函数的原型在stdlib.h
头文件中)。 assert()
宏是为了标识出程序中某些条件为真的关键位置,如果其中的一个具体条件为假,就用assert()
语句终止程序。- 通常,
assert()
的参数是一个条件表达式或逻辑表达式。如果assert()
中止了程序,它首先会显示失败的测试、包含测试的文件名和行号。 - 使用
assert()
的好处:它不仅能自动标识文件和出问题的行号,还有一种无需更改代码就能开启或关闭assert()
的机制。即可以把#define NDEBUG
宏定义写在包含assert.h
的位置前面即可禁用所有assert()
语句。
- 它由
_Static_assert
(C11)assert()
表达式是在运行时检查。C11新增了一个特性:_Static_assert
声明,可以在编译时检查assert()
表达式。因此assert()
可以导致正在运行的程序中止,而_Static_assert
可以导致程序无法通过编译。
string.h
库中的memcpy()
和memmove()
- 不能把一个数组赋值给另一个数组,所以要通过循环一一赋值每个元素。以前学过可以使用
strcpy()
和strncpy
函数来处理字符数组。memcpy()
和memmove()
函数提供类似的方法处理任意类型的数组。 - 注意
memcpy()
的参数带关键字restrict
,即该函数假设两个内存区域之间没有重叠;而memmove()
不作这样的假设,所以拷贝过程类似于先把所有字节拷贝到一个临时缓冲区,然后再拷贝到最终目的地。 - 如果使用
memcpy()
两区域重叠则其行为是未定义的,这意味着该函数可能正常工作也可能失败。作为程序员有责任确保两个区域不重叠。 - 两个函数前2个参数都是指向void的指针(从第2个参数拷贝至第1个参数并返回第1个参数),且第3个参数都是字节数而非元素个数。它们不关心数据类型只负责按字节拷贝数据。
可变参数:stdarg.h
本章前面提到过变参宏,即该宏可以接受可变数量的参数。stdarg.h
头文件为函数提供了一个类似的功能,但是用法比较复杂。必须按以下步骤进行:
- 提供一个使用省略号的函数原型;
- 在函数定义中创建一个
va_list
类型的变量; - 用宏把该变量初始化为一个参数列表;
- 用宏访问参数列表;
- 用宏完成清理工作。