
指针是什么?首先,它是一个值,这个值代表一个内存地址,因此指针相当于指向某个内存地址的路标。
字符*
表示指针,通常跟在类型关键字的后面,表示指针指向的是什么类型的值。比如,char*
表示一个指向字符的指针,float*
表示一个指向float
类型的值的指针。
1 | int* intPtr; |
上面示例声明了一个变量intPtr
,它是一个指针,指向的内存地址存放的是一个整数。
星号*
可以放在变量名与类型关键字之间的任何地方,下面的写法都是有效的。
1 | int *intPtr; |
本书使用星号紧跟在类型关键字后面的写法(即int* intPtr;
),因为这样可以体现,指针变量就是一个普通变量,只不过它的值是内存地址而已。
这种写法有一个地方需要注意,如果同一行声明两个指针变量,那么需要写成下面这样。
1 | // 正确 |
上面示例中,第二行的执行结果是,foo
是整数指针变量,而bar
是整数变量,即*
只对第一个变量生效。
一个指针指向的可能还是指针,这时就要用两个星号**
表示。
1 | int** foo; |
上面示例表示变量foo
是一个指针,指向的还是一个指针,第二个指针指向的则是一个整数。
如果函数的参数是一个变量,那么调用时,传入的是这个变量的值的拷贝,而不是变量本身。
1 | void increment(int a) { |
上面示例中,调用increment(i)
以后,变量i
本身不会发生变化,还是等于10
。因为传入函数的是i
的拷贝,而不是i
本身,拷贝的变化,影响不到原始变量。这就叫做“传值引用”。
所以,如果参数变量发生变化,最好把它作为返回值传出来。
1 | int increment(int a) { |
再看下面的例子,Swap()
函数用来交换两个变量的值,由于传值引用,下面的写法不会生效。
1 | void Swap(int x, int y) { |
上面的写法不会产生交换变量值的效果,因为传入的变量是原始变量a
和b
的拷贝,不管函数内部怎么操作,都影响不了原始变量。
如果想要传入变量本身,只有一个办法,就是传入变量的地址。
1 | void Swap(int* x, int* y) { |
上面示例中,通过传入变量x
和y
的地址,函数内部就可以直接操作该地址,从而实现交换两个变量的值。
虽然跟传参无关,这里特别提一下,函数不要返回内部变量的指针。
1 | int* f(void) { |
上面示例中,函数返回内部变量i
的指针,这种写法是错的。因为当函数结束运行时,内部变量就消失了,这时指向内部变量i
的内存地址就是无效的,再去使用这个地址是非常危险的。
1 | int a[5] = {11, 22, 33, 44, 55}; |
上面示例中,&a[0]
和数组名a
是等价的。
1 | int a[4][2]; |
上面示例中,由于a[0]
本身是一个指针,指向第二维数组的第一个成员a[0][0]
。所以,*(a[0])
取出的是a[0][0]
的值。至于**a
,就是对a
进行两次*
运算,第一次取出的是a[0]
,第二次取出的是a[0][0]
。同理,二维数组的&a[0][0]
等同于*a
。
1 | int a[5] = {1, 2, 3, 4, 5}; |
数组名指向的地址是不能更改的,上面两种写法都会更改数组b
的地址,导致报错。
1 | int a[5] = {11, 22, 33, 44, 55}; |
上面示例中,通过指针的移动遍历数组,a + i
的每轮循环每次都会指向下一个成员的地址,*(a + i)
取出该地址的值,等同于a[i]
。对于数组的第一个成员,*(a + 0)
(即*a
)等同于a[0]
。
1 | a[b] == *(a + b) |
上面代码给出了数组成员的两种访问方式,一种是使用方括号a[b]
,另一种是使用指针*(a + b)
。
1 | int sum(int* start, int* end) { |
通过start++
让变量start
指向下一个成员。上面示例中,arr
是数组的起始地址,arr + 5
是结束地址。只要起始地址小于结束地址,就表示还没有到达数组尾部。
1 | int arr[4][2]; |
上面示例中, 对于多维数组,数组指针的加减法对于不同维度,含义是不一样的。
1 | memcpy(a, b, sizeof(b)); |
上面示例中,将数组b
所在的那段内存,复制给数组a
。这种方法要比循环复制数组成员要快。
1 | int sum_array(int a[][4], int n) { |
上面示例中,函数sum_array()
的参数是一个二维数组。第一个参数是数组本身(a[][4]
),这时可以不写第一维的长度,因为它作为第二个参数,会传入函数,但是一定要写第二维的长度4
。
1 | int sum_array(int, int [*]); |
函数原型可以省略参数名,所以变长数组的原型中,可以使用*
代替变量名,也可以省略变量名。
1 | // 原来的写法 |
变长数组作为函数参数有一个好处,就是多维数组的参数声明,可以把后面的维度省掉了。
C 语言没有单独的字符串类型,字符串被当作字符数组,即char
类型的数组。比如,字符串“Hello”是当作数组{'H', 'e', 'l', 'l', 'o'}
处理的。
编译器会给数组分配一段连续内存,所有字符储存在相邻的内存单元之中。在字符串结尾,C 语言会自动添加一个全是二进制0
的字节,写作\0
字符,表示字符串结束。字符\0
不同于字符0
,前者的 ASCII 码是0(二进制形式00000000
),后者的 ASCII 码是48(二进制形式00110000
)。所以,字符串“Hello”实际储存的数组是{'H', 'e', 'l', 'l', 'o', '\0'}
。
所有字符串的最后一个字符,都是\0
。这样做的好处是,C 语言不需要知道字符串的长度,就可以读取内存里面的字符串,只要发现有一个字符是\0
,那么就知道字符串结束了。
1 | // 写法一 |
1 | char s[50] = "hello"; |
上面示例中,字符数组s
的长度是50
,但是字符串“hello”的实际长度只有6(包含结尾符号\0
),所以后面空出来的44个位置,都会被初始化为\0
。
字符数组的长度,不能小于字符串的实际长度。
1 | char s[5] = "hello"; |
上面示例中,字符串数组s
的长度是5
,小于字符串“hello”的实际长度6,这时编译器会报错。因为如果只将前5个字符写入,而省略最后的结尾符号\0
,这很可能导致后面的字符串相关代码出错。
字符指针和字符数组,这两种声明字符串变量的写法基本是等价的,但是有两个差异。
第一个差异是,指针指向的字符串,在 C 语言内部被当作常量,不能修改字符串本身。
1 | char* s = "Hello, world!"; |
上面代码使用指针,声明了一个字符串变量,然后修改了字符串的第一个字符。这种写法是错的,会导致难以预测的后果,执行时很可能会报错。
如果使用数组声明字符串变量,就没有这个问题,可以修改数组的任意成员。
1 | char s[] = "Hello, world!"; |
第二个差异是,指针变量可以指向其它字符串。
1 | char* s = "hello"; |
上面示例中,字符指针可以指向另一个字符串。
但是,字符数组变量不能指向另一个字符串。
1 | char s[] = "hello"; |
上面示例中,字符数组的数组名,总是指向初始化时的字符串地址,不能修改。
同样的原因,声明字符数组后,不能直接用字符串赋值。
1 | char s[10]; |
为什么数组变量不能赋值为另一个数组?原因是数组变量所在的地址无法改变,或者说,编译器一旦为数组变量分配地址后,这个地址就绑定这个数组变量了,这种绑定关系是不变的。C 语言也因此规定,数组变量是一个不可修改的左值,即不能用赋值运算符为它重新赋值。
想要重新赋值,必须使用 C 语言原生提供的strcpy()
函数,通过字符串拷贝完成赋值。这样做以后,数组变量的地址还是不变的,即strcpy()
只是在原地址写入新的字符串,而不是让数组变量指向新的地址。
1 | char s[10]; |
上面示例中,strcpy()
函数把字符串abc
拷贝给变量s
,这个函数的详细用法会在后面介绍。
strlen()
函数返回字符串的字节长度,不包括末尾的空字符\0
。该函数的原型如下。
1 | // string.h |
注意,字符串长度(strlen()
)与字符串变量长度(sizeof()
),是两个不同的概念。
1 | char s[50] = "hello"; |
C 语言的内存管理,分成两部分。一部分是系统管理的,另一部分是用户手动管理的。
系统管理的内存,主要是函数内部的变量(局部变量)。这部分变量在函数运行时进入内存,函数运行结束后自动从内存卸载
。这些变量存放的区域称为”栈“(stack),”栈“所在的内存是系统自动管理的。
用户手动管理的内存,主要是程序运行的整个过程中都存在的变量(全局变量),这些变量需要用户手动从内存释放。如果使用后忘记释放,它就一直占用内存,直到程序退出,这种情况称为”内存泄漏“(memory leak)。这些变量所在的内存称为”堆“(heap),”堆“所在的内存是用户手动管理的。
向系统请求内存的时候,有时不确定会有什么样的数据写入内存,需要先获得内存块,稍后再确定写入的数据类型
1 | int x = 10; |
注意,由于不知道 void 指针指向什么类型的值,所以不能用*
运算符取出它指向的值。
1 | char a = 'X'; |
void 指针的重要之处在于,很多内存相关函数的返回值就是 void 指针,只给出内存块的地址信息,所以放在最前面进行介绍。
malloc()
函数用于分配内存
1 | void* malloc(size_t size) |
有时候为了增加代码的可读性,可以对malloc()
返回的指针进行一次强制类型转换。
1 | int* p = (int*) malloc(sizeof(int)); |
1 | int* p = malloc(sizeof(int)); |
上面示例中,通过判断返回的指针p
是否为NULL
,确定malloc()
是否分配成功。
1 | int* p = (int*) malloc(n * sizeof(int)); |
malloc()
最常用的场合,就是为数组和自定义数据结构分配内存。malloc()
用来创建数组,有一个好处,就是它可以创建动态数组,即根据成员数量的不同,而创建长度不同的数组。
注意,malloc()
不会对所分配的内存进行初始化,里面还保存着原来的值。如果没有初始化,就使用这段内存,可能从里面读到以前的值。程序员要自己负责初始化,比如,字符串初始化可以使用strcpy()
函数。
1 | char* p = malloc(4); |
1 | void free(void* block) |
1 | int* p = (int*) malloc(sizeof(int)); |
一个很常见的错误是,在函数内部分配了内存,但是函数调用结束时,没有使用free()
释放内存。
1 | void gobble(double arr[], int n) { |
上面示例中,函数gobble()
内部分配了内存,但是没有写free(temp)
。这会造成函数运行结束后,占用的内存块依然保留,如果多次调用gobble()
,就会留下多个内存块。并且,由于指针temp
已经消失了,也无法访问这些内存块,再次使用。
calloc()
函数的作用与malloc()
相似,也是分配内存块。该函数的原型定义在头文件stdlib.h
。
两者的区别主要有两点:
(1)calloc()
接受两个参数,第一个参数是某种数据类型的值的数量,第二个是该数据类型的单位字节长度。
1 | void* calloc(size_t n, size_t size); |
calloc()
的返回值也是一个 void 指针。分配失败时,返回 NULL。
(2)calloc()
会将所分配的内存全部初始化为0
。malloc()
不会对内存进行初始化,如果想要初始化为0
,还要额外调用memset()
函数。
1 | int* p = calloc(10, sizeof(int)); |
上面示例中,calloc()
相当于malloc() + memset()
。
calloc()
分配的内存块,也要使用free()
释放。
realloc()
函数用于修改已经分配的内存块的大小,可以放大也可以缩小,返回一个指向新的内存块的指针。如果分配不成功,返回 NULL。该函数的原型定义在头文件stdlib.h
。
1 | void* realloc(void* block, size_t size) |
它接受两个参数。
block
:已经分配好的内存块指针(由malloc()
或calloc()
或realloc()
产生)。size
:该内存块的新大小,单位为字节。realloc()
可能返回一个全新的地址(数据也会自动复制过去),也可能返回跟原来一样的地址。realloc()
优先在原有内存块上进行缩减,尽量不移动数据,所以通常是返回原先的地址。如果新内存块小于原来的大小,则丢弃超出的部分;如果大于原来的大小,则不对新增的部分进行初始化(程序员可以自动调用memset()
)。
下面是一个例子,b
是数组指针,realloc()
动态调整它的大小。
1 | int* b; |
上面示例中,指针b
原来指向10个成员的整数数组,使用realloc()
调整为2000个成员的数组。这就是手动分配数组内存的好处,可以在运行时随时调整数组的长度。
realloc()
的第一个参数可以是 NULL,这时就相当于新建一个指针。
1 | char* p = realloc(NULL, 3490); |
如果realloc()
的第二个参数是0
,就会释放掉内存块。
由于有分配失败的可能,所以调用realloc()
以后,最好检查一下它的返回值是否为 NULL。分配失败时,原有内存块中的数据不会发生改变。
1 | float* new_p = realloc(p, sizeof(*p * 40)); |
注意,realloc()
不会对内存块进行初始化。
声明指针变量时,可以使用restrict
说明符,告诉编译器,该块内存区域只有当前指针一种访问方式,其他指针不能读写该块内存。这种指针称为“受限指针”(restrict pointer)。
1 | int* restrict p; |
上面示例中,声明指针变量p
时,加入了restrict
说明符,使得p
变成了受限指针。后面,当p
指向malloc()
函数返回的一块内存区域,就意味着,该区域只有通过p
来访问,不存在其他访问方式。
1 | int* restrict p; |
上面示例中,另一个指针q
与受限指针p
指向同一块内存,现在该内存有p
和q
两种访问方式。这就违反了对编译器的承诺,后面通过*q
对该内存区域赋值,会导致未定义行为。
memcpy()
用于将一块内存拷贝到另一块内存。该函数的原型定义在头文件string.h
。
1 | void* memcpy( |
上面代码中,dest
是目标地址,source
是源地址,第三个参数n
是要拷贝的字节数n
。如果要拷贝10个 double 类型的数组成员,n
就等于10 * sizeof(double)
,而不是10
。该函数会将从source
开始的n
个字节,拷贝到dest
。
dest
和source
都是 void 指针,表示这里不限制指针类型,各种类型的内存数据都可以拷贝。两者都有 restrict 关键字,表示这两个内存块不应该有互相重叠的区域。
memcpy()
的返回值是第一个参数,即目标地址的指针。
因为memcpy()
只是将一段内存的值,复制到另一段内存,所以不需要知道内存里面的数据是什么类型。下面是复制字符串的例子。
1 |
|
上面示例中,字符串s
所在的内存,被拷贝到字符数组t
所在的内存。
memcpy()
可以取代strcpy()
进行字符串拷贝,而且是更好的方法,不仅更安全,速度也更快,它不检查字符串尾部的\0
字符。
1 | char* s = "hello world"; |
上面示例中,两种写法的效果完全一样,但是memcpy()
的写法要好于strcpy()
。
使用 void 指针,也可以自定义一个复制内存的函数。
1 | void* my_memcpy(void* dest, void* src, int byte_count) { |
上面示例中,不管传入的dest
和src
是什么类型的指针,将它们重新定义成一字节的 Char 指针,这样就可以逐字节进行复制。*d++ = *s++
语句相当于先执行*d = *s
(源字节的值复制给目标字节),然后各自移动到下一个字节。最后,返回复制后的dest
指针,便于后续使用。
memmove()
函数用于将一段内存数据复制到另一段内存。它跟memcpy()
的主要区别是,它允许目标区域与源区域有重叠。如果发生重叠,源区域的内容会被更改;如果没有重叠,它与memcpy()
行为相同。
memcmp()
函数用来比较两个内存区域。它的原型定义在string.h
。
它接受三个参数,前两个参数是用来比较的指针,第三个参数指定比较的字节数。
它的返回值是一个整数。两块内存区域的每个字节以字符形式解读,按照字典顺序进行比较,如果两者相同,返回0
;如果s1
大于s2
,返回大于0的整数;如果s1
小于s2
,返回小于0的整数。
1 | char* s1 = "abc"; |
上面示例比较s1
和s2
的前三个字节,由于s1
小于s2
,所以r
是一个小于0的整数,一般为-1。
下面是另一个例子。
1 | char s1[] = {'b', 'i', 'g', '\0', 'c', 'a', 'r'}; |
上面示例展示了,memcmp()
可以比较内部带有字符串终止符\0
的内存区域。
C 语言没有其他语言的对象(object)和类(class)的概念,struct 结构很大程度上提供了对象和类的功能。
1 | struct fraction { |
struct 的数据类型声明语句与变量的声明语句,可以合并为一个语句。
1 | struct book { |
上面的语句同时声明了数据类型book
和该类型的变量b1
。如果类型标识符book
只用在这一个地方,后面不再用到,这里可以将类型名省略。
1 | struct { |
上面示例中,struct
声明了一个匿名数据类型,然后又声明了这个类型的变量b1
。
与其他变量声明语句一样,可以在声明变量的同时,对变量赋值。
1 | struct { |
typedef
命令可以为 struct 结构指定一个别名,这样使用起来更简洁。
1 | typedef struct cell_phone { |
指针变量也可以指向struct
结构。
1 | struct book { |
上面示例中,变量b1
是一个指针,指向的数据是struct book
类型的实例。
struct 结构也可以作为数组成员。
1 | struct fraction numbers[1000]; |
在有必要的情况下,定义 Struct 结构体时,可以采用存储空间递增的顺序,定义每个属性,这样就能节省一些空间。类似于java的对齐
1 | struct foo { |
上面示例中,占用空间最小的char c
排在第一位,其次是int a
,占用空间最大的char* b
排在最后。整个strct foo
的内存占用就从24字节下降到16字节。
(*t).age
这样的写法很麻烦。C 语言就引入了一个新的箭头运算符(->
),可以从 struct 指针上直接获取属性,大大增强了代码的可读性。
1 | void happy(struct turtle* t) { |
赋值的时候有多种写法。
1 | // 写法一 |
上面示例展示了嵌套 Struct 结构的四种赋值写法。另外,引用breed
属性的内部属性,要使用两次点运算符(shark.breed.name
)。
下面是另一个嵌套 struct 的例子。
1 | struct name { |
上面示例中,自定义类型student
的name
属性是另一个自定义类型,如果要引用后者的属性,就必须使用两个.
运算符,比如student1.name.first
。另外,对字符数组属性赋值,要使用strcpy()
函数,不能直接赋值,因为直接改掉字符数组名的地址会报错。
struct 结构内部不仅可以引用其他结构,还可以自我引用,即结构内部引用当前结构。比如,链表结构的节点就可以写成下面这样。
1 | struct node { |
上面示例中,node
结构的next
属性,就是指向另一个node
实例的指针。下面,使用这个结构自定义一个数据链表。
1 | struct node { |
上面示例是链表结构的最简单实现,通过for
循环可以对其进行遍历。
很多时候,不能事先确定数组到底有多少个成员。如果声明数组的时候,事先给出一个很大的成员数,就会很浪费空间。C 语言提供了一个解决方法,叫做弹性数组成员(flexible array member)。
如果不能事先确定数组成员的数量时,可以定义一个 struct 结构。
1 | struct vstring { |
上面示例中,struct vstring
结构有两个属性。len
属性用来记录数组chars
的长度,chars
属性是一个数组,但是没有给出成员数量。
chars
数组到底有多少个成员,可以在为vstring
分配内存时确定。
1 | struct vstring* str = malloc(sizeof(struct vstring) + n * sizeof(char)); |
上面示例中,假定chars
数组的成员数量是n
,只有在运行时才能知道n
到底是多少。然后,就为struct vstring
分配它需要的内存:它本身占用的内存长度,再加上n
个数组成员占用的内存长度。最后,len
属性记录一下n
是多少。
这样就可以让数组chars
有n
个成员,不用事先确定,可以跟运行时的需要保持一致。
弹性数组成员有一些专门的规则。首先,弹性成员的数组,必须是 struct 结构的最后一个属性。另外,除了弹性数组成员,struct 结构必须至少还有一个其他属性。
typedef 也可以用来为数组类型起别名。
1 | typedef int five_ints[5]; |
上面示例中,five_ints
是一个数组类型,包含5个整数的
typedef 为函数起别名的写法如下。
1 | typedef signed char (*fp)(void); |
上面示例中,类型别名fp
是一个指针,代表函数signed char (*)(void)
。
(2)为 struct、union、enum 等命令定义的复杂数据结构创建别名,从而便于引用。
1 | struct treenode { |
上面示例中,Tree
为struct treenode*
的别名。
(5)简化类型声明
C 语言有些类型声明相当复杂,比如下面这个。
1 | char (*(*x(void))[5])(void); |
typedef 可以简化复杂的类型声明,使其更容易理解。首先,最外面一层起一个类型别名。
1 | typedef char (*Func)(void); |
这个看起来还是有点复杂,就为里面一层也定义一个别名。
1 | typedef char (*Func)(void); |
上面代码就比较容易解读了。
x
是一个函数,返回一个指向 Arr 类型的指针。Arr
是一个数组,有5个成员,每个成员是Func
类型。Func
是一个函数指针,指向一个无参数、返回字符值的函数。有时需要一种数据结构,不同的场合表示不同的数据类型。比如,如果只用一种数据结构表示水果的“量”,这种结构就需要有时是整数(6个苹果),有时是浮点数(1.5公斤草莓)。
C 语言提供了 Union 结构,用来自定义可以灵活变更的数据结构。它内部包含各种属性,1但所有属性共用一块内存
,导致这些属性都是对同一个二进制数据的解读,其中往往只有一个属性的解读是有意义的。并且,后面写入的属性会覆盖前面的属性,这意味着同一块内存,可以先供某一个属性使用,然后再供另一个属性使用。这样做的最大好处是节省内存空间。
1 | union quantity { |
网道(WangDoc.com),互联网文档计划
如果一种数据类型的取值只有少数几种可能,并且每种取值都有自己的含义,为了提高代码的可读性,可以将它们定义为 Enum 类型,中文名为枚举。
1 | enum colors {RED, GREEN, BLUE}; |
上面示例中,假定程序里面需要三种颜色,就可以使用enum
命令,把这三种颜色定义成一种枚举类型colors
,它只有三种取值可能RED
、GREEN
、BLUE
。这时,这三个名字自动成为整数常量,编译器默认将它们的值设为数字0
、1
、2
。相比之下,RED
要比0
的可读性好了许多。
注意,Enum 内部的常量名,遵守标识符的命名规范,但是通常都使用大写。
使用时,可以将变量声明为 Enum 类型。
1 | enum colors color; |
上面代码将变量color
声明为enum colors
类型。这个变量的值就是常量RED
、GREEN
、BLUE
之中的一个。
1 | color = BLUE; |
1 |
1 |
上面示例中,宏SQUARE
可以接受一个参数X
,替换成X*X
。
注意,宏的名称与左边圆括号之间,不能有空格。
这个宏的用法如下。
1 | // 替换成 z = 2*2; |
这种写法很像函数,但又不是函数,而是完全原样的替换,会跟函数有不一样的行为。
1 |
|
上面示例中,SQUARE(3 + 4)
如果是函数,输出的应该是49(7*7
);宏是原样替换,所以替换成3 + 4*3 + 4
,最后输出19。
可以看到,原样替换可能导致意料之外的行为。解决办法就是在定义宏的时候,尽量多使用圆括号,这样可以避免很多意外。
1 |
#undef
指令用来取消已经使用#define
定义的宏。
1 |
上面示例的undef
指令取消已经定义的宏LIMIT
,后面就可以重新用 LIMIT 定义一个宏。
有时候想重新定义一个宏,但不确定是否以前定义过,就可以先用#undef
取消,然后再定义。因为同名的宏如果两次定义不一样,会报错,而#undef
的参数如果是不存在的宏,并不会报错。
GCC 的-U
选项可以在命令行取消宏的定义,相当于#undef
。
1 | $ gcc -ULIMIT foo.c |
上面示例中的-U
参数,取消了宏LIMIT
,相当于源文件里面的#undef LIMIT
。
#include
指令用于编译时将其他源码文件,加载进入当前文件。它有两种形式。
1 | // 形式一 |
形式一,文件名写在尖括号里面,表示该文件是系统提供的,通常是标准库的库文件,不需要写路径。因为编译器会到系统指定的安装目录里面,去寻找这些文件。
形式二,文件名写在双引号里面,表示该文件由用户提供,具体的路径取决于编译器的设置,可能是当前目录,也可能是项目的工作目录。如果所要包含的文件在其他位置,就需要指定路径,下面是一个例子。
1 |
GCC 编译器的-I
参数,也可以用来指定include
命令中用户文件的加载路径。
1 | $ gcc -Iinclude/ -o code code.c |
上面命令中,-Iinclude/
指定从当前目录的include
子目录里面,加载用户自己的文件。
#include
最常见的用途,就是用来加载包含函数原型的头文件(后缀名为.h
),参见《多文件编译》一章。多个#include
指令的顺序无关紧要,多次包含同一个头文件也是合法的。
#if...#endif
指令用于预处理器的条件判断,满足条件时,内部的行会被编译,否则就被编译器忽略。
1 |
|
上面示例中,#if
后面的0
,表示判断条件不成立。所以,内部的变量定义语句会被编译器忽略。#if 0
这种写法常用来当作注释使用,不需要的代码就放在#if 0
里面
没有定义过的宏,等同于0
。因此如果UNDEFINED
是一个没有定义过的宏,那么#if UNDEFINED
为伪,而#if !UNDEFINED
为真。
#if
的常见应用就是打开(或关闭)调试模式。
1 |
|
上面示例中,通过将DEBUG
设为1
,就打开了调试模式,可以输出调试信息。
#ifdef...#endif
指令用于判断某个宏是否定义过。
#ifdef...#else...#endif
可以用来实现条件加载。
1 |
上面示例中,通过判断宏MAVIS
是否定义过,实现加载不同的头文件。
上一节的#ifdef
指令,等同于#if defined
。
1 |
|
#ifndef...#endif
指令跟#ifdef...#endif
正好相反。它用来判断,如果某个宏没有被定义过,则执行指定的操作。
#ifndef
等同于#if !defined
。
1 |
|
C 语言提供一些预定义的宏,可以直接使用。
__DATE__
:编译日期,格式为“Mmm dd yyyy”的字符串(比如 Nov 23 2021)。__TIME__
:编译时间,格式为“hh:mm:ss”。__FILE__
:当前文件名。__LINE__
:当前行号。__func__
:当前正在执行的函数名。该预定义宏必须在函数作用域使用。__STDC__
:如果被设为1,表示当前编译器遵循 C 标准。__STDC_HOSTED__
:如果被设为1,表示当前编译器可以提供完整的标准库;否则被设为0(嵌入式系统的标准库常常是不完整的)。__STDC_VERSION__
:编译所使用的 C 语言版本,是一个格式为yyyymmL
的长整数,C99 版本为“199901L”,C11 版本为“201112L”,C17 版本为“201710L”。下面示例打印这些预定义宏的值。
1 |
|
#line
指令用于覆盖预定义宏__LINE__
,将其改为自定义的行号。后面的行将从__LINE__
的新值开始计数。
1 | // 将下一行的行号重置为 300 |
上面示例中,紧跟在#line 300
后面一行的行号,将被改成300,其后的行会在300的基础上递增编号。
#line
还可以改掉预定义宏__FILE__
,将其改为自定义的文件名。
1 |
上面示例中,下一行的行号重置为300
,文件名重置为newfilename
。
#error
指令用于让预处理器抛出一个错误,终止编译。
1 |
上面示例指定,如果编译器不使用 C11 标准,就中止编译。GCC 编译器会像下面这样报错。
#pragma
指令用来修改编译器属性。
1 | // 使用 C99 标准 |
上面示例让编译器以 C99 标准进行编译。
const
说明符表示变量是只读的,不得被修改。
1 | const double PI = 3.14159; |
这两者可以结合起来。
1 | const char* const x; |
static
说明符对于全局变量和局部变量有不同的含义。
(1)用于局部变量(位于块作用域内部)。
static
用于函数内部声明的局部变量时,表示该变量的值会在函数每次执行后得到保留,下次执行时不会进行初始化,就类似于一个只用于函数内部的全局变量。由于不必每次执行函数时,都对该变量进行初始化,这样可以提高函数的执行速度,详见《函数》一章。
(2)用于全局变量(位于块作用域外部)。
static
用于函数外部声明的全局变量时,表示该变量只用于当前文件,其他源码文件不可以引用该变量,即该变量不会被链接(link)。
static
修饰的变量,初始化时,值不能等于变量,必须是常量。
1 | int n = 10; |
上面示例中,变量m
有static
修饰,它的值如果等于变量n
,就会报错,必须等于常量。
只在当前文件里面使用的函数,也可以声明为static
,表明该函数只在当前文件使用,其他文件可以定义同名函数。
1 | static int g(int i); |
extern
说明符表示,该变量在其他文件里面声明,没有必要在当前文件里面为它分配空间。通常用来表示,该变量是多个文件共享的。
1 | extern int a; |
上面代码中,a
是extern
变量,表示该变量在其他文件里面定义和初始化,当前文件不必为它分配存储空间。
但是,变量声明时,同时进行初始化,extern
就会无效。
1 | // extern 无效 |
上面代码中,extern
对变量初始化的声明是无效的。这是为了防止多个extern
对同一个变量进行多次初始化。
函数内部使用extern
声明变量,就相当于该变量是静态存储,每次执行时都要从外部获取它的值。
函数本身默认是extern
,即该函数可以被外部文件共享,通常省略extern
不写。如果只希望函数在当前文件可用,那就需要在函数前面加上static
。
1 | extern int f(int i); |
volatile
说明符表示所声明的变量,可能会预想不到地发生变化(即其他程序可能会更改它的值),不受当前程序控制,因此编译器不要对这类变量进行优化,每次使用时都应该查询一下它的值。硬件设备的编程中,这个说明符很常用。
1 | volatile int foo; |
volatile
的目的是阻止编译器对变量行为进行优化,请看下面的例子。
1 | int foo = x; |
上面代码中,由于变量foo
和bar
都等于x
,而且x
的值也没有发生变化,所以编译器可能会把x
放入缓存,直接从缓存读取值(而不是从 x 的原始内存位置读取),然后对foo
和bar
进行赋值。如果x
被设定为volatile
,编译器就不会把它放入缓存,每次都从原始位置去取x
的值,因为在两次读取之间,其他程序可能会改变x
restrict
说明符允许编译器优化某些代码。它只能用于指针,表明该指针是访问数据的唯一方式。
1 | int* restrict pt = (int*) malloc(10 * sizeof(int)); |
上面示例中,restrict
表示变量pt
是访问 malloc 所分配内存的唯一方式。
下面例子的变量foo
,就不能使用restrict
修饰符。
1 | int foo[10]; |
上面示例中,变量foo
指向的内存,可以用foo
访问,也可以用bar
访问,因此就不能将foo
设为 restrict。
如果编译器知道某块内存只能用一个方式访问,可能可以更好地优化代码,因为不用担心其他地方会修改值。
restrict
用于函数参数时,表示参数的内存地址之间没有重叠。
1 | void swap(int* restrict a, int* restrict b) { |
上面示例中,函数参数声明里的restrict
表示,参数a
和参数b
的内存地址没有重叠。
一个软件项目往往包含多个源码文件,编译时需要将这些文件一起编译,生成一个可执行文件。
假定一个项目有两个源码文件foo.c
和bar.c
,其中foo.c
是主文件,bar.c
是库文件。所谓“主文件”,就是包含了main()
函数的项目入口文件,里面会引用库文件定义的各种函数。
1 | // File foo.c |
上面代码中,主文件foo.c
调用了函数add()
,这个函数是在库文件bar.c
里面定义的。
1 | // File bar.c |
现在,将这两个文件一起编译。
1 | $ gcc -o foo foo.c bar.c |
上面命令中,gcc 的-o
参数指定生成的二进制可执行文件的文件名,本例是foo
。
这个命令运行后,编译器会发出警告,原因是在编译foo.c
的过程中,编译器发现一个不认识的函数add()
,foo.c
里面没有这个函数的原型或者定义。因此,最好修改一下foo.c
,在文件头部加入add()
的原型。
1 | // File foo.c |
现在再编译就没有警告了。
你可能马上就会想到,如果有多个文件都使用这个函数add()
,那么每个文件都需要加入函数原型。一旦需要修改函数add()
(比如改变参数的数量),就会非常麻烦,需要每个文件逐一改动。所以,通常的做法是新建一个专门的头文件bar.h
,放置所有在bar.c
里面定义的函数的原型。
1 | // File bar.h |
然后使用include
命令,在用到这个函数的源码文件里面加载这个头文件bar.h
。
1 | // File foo.c |
上面代码中,#include "bar.h"
表示加入头文件bar.h
。这个文件没有放在尖括号里面,表示它是用户提供的;它没有写路径,就表示与当前源码文件在同一个目录。
然后,最好在bar.c
里面也加载这个头文件,这样可以让编译器验证,函数原型与函数定义是否一致。
1 | // File bar.c |
现在重新编译,就可以顺利得到二进制可执行文件。
1 | $ gcc -o foo foo.c bar.c |