考研计算机整理
考研计算机相关知识

更新于 

C语言基础

指针

指针是什么?首先,它是一个值,这个值代表一个内存地址,因此指针相当于指向某个内存地址的路标。

字符*表示指针,通常跟在类型关键字的后面,表示指针指向的是什么类型的值。比如,char*表示一个指向字符的指针,float*表示一个指向float类型的值的指针。

1
int* intPtr;

上面示例声明了一个变量intPtr,它是一个指针,指向的内存地址存放的是一个整数。

星号*可以放在变量名与类型关键字之间的任何地方,下面的写法都是有效的。

1
2
3
int   *intPtr;
int * intPtr;
int* intPtr;

本书使用星号紧跟在类型关键字后面的写法(即int* intPtr;),因为这样可以体现,指针变量就是一个普通变量,只不过它的值是内存地址而已。

这种写法有一个地方需要注意,如果同一行声明两个指针变量,那么需要写成下面这样。

1
2
3
4
5
// 正确
int * foo, * bar;

// 错误
int* foo, bar;

上面示例中,第二行的执行结果是,foo是整数指针变量,而bar是整数变量,即*只对第一个变量生效。

一个指针指向的可能还是指针,这时就要用两个星号**表示。

1
int** foo;

上面示例表示变量foo是一个指针,指向的还是一个指针,第二个指针指向的则是一个整数。

函数

参数的传值引用

如果函数的参数是一个变量,那么调用时,传入的是这个变量的值的拷贝,而不是变量本身。

1
2
3
4
5
6
7
8
void increment(int a) {
a++;
}

int i = 10;
increment(i);

printf("%d\n", i); // 10

上面示例中,调用increment(i)以后,变量i本身不会发生变化,还是等于10。因为传入函数的是i的拷贝,而不是i本身,拷贝的变化,影响不到原始变量。这就叫做“传值引用”。

所以,如果参数变量发生变化,最好把它作为返回值传出来。

1
2
3
4
5
6
7
8
9
int increment(int a) {
a++;
return a;
}

int i = 10;
i = increment(i);

printf("%d\n", i); // 11

再看下面的例子,Swap()函数用来交换两个变量的值,由于传值引用,下面的写法不会生效。

1
2
3
4
5
6
7
8
9
10
void Swap(int x, int y) {
int temp;
temp = x;
x = y;
y = temp;
}

int a = 1;
int b = 2;
Swap(a, b); // 无效

上面的写法不会产生交换变量值的效果,因为传入的变量是原始变量ab的拷贝,不管函数内部怎么操作,都影响不了原始变量。

如果想要传入变量本身,只有一个办法,就是传入变量的地址。

1
2
3
4
5
6
7
8
9
10
void Swap(int* x, int* y) {
int temp;
temp = *x;
*x = *y;
*y = temp;
}

int a = 1;
int b = 2;
Swap(&a, &b);

上面示例中,通过传入变量xy的地址,函数内部就可以直接操作该地址,从而实现交换两个变量的值。

虽然跟传参无关,这里特别提一下,函数不要返回内部变量的指针。

1
2
3
4
5
int* f(void) {
int i;
// ...
return &i;
}

上面示例中,函数返回内部变量i的指针,这种写法是错的。因为当函数结束运行时,内部变量就消失了,这时指向内部变量i的内存地址就是无效的,再去使用这个地址是非常危险的。

数组

数组指针

1
2
3
4
5
int a[5] = {11, 22, 33, 44, 55};

int* p = &a[0];
// 等同于
int* p = a;

上面示例中,&a[0]和数组名a是等价的。

1
2
3
4
5
6
int a[4][2];

// 取出 a[0][0] 的值
*(a[0]);
// 等同于
**a

上面示例中,由于a[0]本身是一个指针,指向第二维数组的第一个成员a[0][0]。所以,*(a[0])取出的是a[0][0]的值。至于**a,就是对a进行两次*运算,第一次取出的是a[0],第二次取出的是a[0][0]。同理,二维数组的&a[0][0]等同于*a

1
2
3
4
5
6
7
8
int a[5] = {1, 2, 3, 4, 5};

// 写法一
int b[5] = a; // 报错

// 写法二
int b[5];
b = a; // 报错

数组名指向的地址是不能更改的,上面两种写法都会更改数组b的地址,导致报错。

1
2
3
4
5
int a[5] = {11, 22, 33, 44, 55};

for (int i = 0; i < 5; i++) {
printf("%d\n", *(a + i));
}

上面示例中,通过指针的移动遍历数组,a + i的每轮循环每次都会指向下一个成员的地址,*(a + i)取出该地址的值,等同于a[i]。对于数组的第一个成员,*(a + 0)(即*a)等同于a[0]

1
a[b] == *(a + b)

上面代码给出了数组成员的两种访问方式,一种是使用方括号a[b],另一种是使用指针*(a + b)

数组指针加减法

1
2
3
4
5
6
7
8
9
10
11
12
13
int sum(int* start, int* end) {
int total = 0;

while (start < end) {
total += *start;
start++;
}

return total;
}

int arr[5] = {20, 10, 5, 39, 4};
printf("%i\n", sum(arr, arr + 5));

通过start++让变量start指向下一个成员。上面示例中,arr是数组的起始地址,arr + 5是结束地址。只要起始地址小于结束地址,就表示还没有到达数组尾部。

1
2
3
4
5
6
7
int arr[4][2];

// 指针指向 arr[1]
arr + 1;

// 指针指向 arr[0][1]
arr[0] + 1

上面示例中, 对于多维数组,数组指针的加减法对于不同维度,含义是不一样的。

数组复制

1
memcpy(a, b, sizeof(b));

上面示例中,将数组b所在的那段内存,复制给数组a。这种方法要比循环复制数组成员要快。

作为函数参数

1
2
3
4
5
6
7
8
9
int sum_array(int a[][4], int n) {
// ...
}

int a[2][4] = {
{1, 2, 3, 4},
{8, 9, 10, 11}
};
int sum = sum_array(a, 2);

上面示例中,函数sum_array()的参数是一个二维数组。第一个参数是数组本身(a[][4]),这时可以不写第一维的长度,因为它作为第二个参数,会传入函数,但是一定要写第二维的长度4

1
2
int sum_array(int, int [*]);
int sum_array(int, int []);

函数原型可以省略参数名,所以变长数组的原型中,可以使用*代替变量名,也可以省略变量名。

1
2
3
4
5
// 原来的写法
int sum_array(int a[][4], int n);

// 变长数组的写法
int sum_array(int n, int m, int a[n][m]);

变长数组作为函数参数有一个好处,就是多维数组的参数声明,可以把后面的维度省掉了。

字符串

简介

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
2
3
4
5
// 写法一
char s[14] = "Hello, world!";

// 写法二
char* s = "Hello, world!";
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
2
char* s = "Hello, world!";
s[0] = 'z'; // 错误

上面代码使用指针,声明了一个字符串变量,然后修改了字符串的第一个字符。这种写法是错的,会导致难以预测的后果,执行时很可能会报错。

如果使用数组声明字符串变量,就没有这个问题,可以修改数组的任意成员。

1
2
char s[] = "Hello, world!";
s[0] = 'z';

第二个差异是,指针变量可以指向其它字符串。

1
2
char* s = "hello";
s = "world";

上面示例中,字符指针可以指向另一个字符串。

但是,字符数组变量不能指向另一个字符串。

1
2
char s[] = "hello";
s = "world"; // 报错

上面示例中,字符数组的数组名,总是指向初始化时的字符串地址,不能修改。

同样的原因,声明字符数组后,不能直接用字符串赋值。

1
2
char s[10];
s = "abc"; // 错误

为什么数组变量不能赋值为另一个数组?原因是数组变量所在的地址无法改变,或者说,编译器一旦为数组变量分配地址后,这个地址就绑定这个数组变量了,这种绑定关系是不变的。C 语言也因此规定,数组变量是一个不可修改的左值,即不能用赋值运算符为它重新赋值。

想要重新赋值,必须使用 C 语言原生提供的strcpy()函数,通过字符串拷贝完成赋值。这样做以后,数组变量的地址还是不变的,即strcpy()只是在原地址写入新的字符串,而不是让数组变量指向新的地址。

1
2
char s[10];
strcpy(s, "abc");

上面示例中,strcpy()函数把字符串abc拷贝给变量s,这个函数的详细用法会在后面介绍。

strlen()

strlen()函数返回字符串的字节长度,不包括末尾的空字符\0。该函数的原型如下。

1
2
// string.h
size_t strlen(const char* s);

注意,字符串长度(strlen())与字符串变量长度(sizeof()),是两个不同的概念。

1
2
3
char s[50] = "hello";
printf("%d\n", strlen(s)); // 5
printf("%d\n", sizeof(s)); // 50

strcpy()

内存管理

简介

C 语言的内存管理,分成两部分。一部分是系统管理的,另一部分是用户手动管理的。

系统管理的内存,主要是函数内部的变量(局部变量)。这部分变量在函数运行时进入内存,函数运行结束后自动从内存卸载。这些变量存放的区域称为”栈“(stack),”栈“所在的内存是系统自动管理的。

用户手动管理的内存,主要是程序运行的整个过程中都存在的变量(全局变量),这些变量需要用户手动从内存释放。如果使用后忘记释放,它就一直占用内存,直到程序退出,这种情况称为”内存泄漏“(memory leak)。这些变量所在的内存称为”堆“(heap),”堆“所在的内存是用户手动管理的。

void 指针

向系统请求内存的时候,有时不确定会有什么样的数据写入内存,需要先获得内存块,稍后再确定写入的数据类型

1
2
3
4
int x = 10;

void* p = &x; // 整数指针转为 void 指针
int* q = p; // void 指针转为整数指针

注意,由于不知道 void 指针指向什么类型的值,所以不能用*运算符取出它指向的值。

1
2
3
4
char a = 'X';
void* p = &a;

printf("%c\n", *p); // 报错

void 指针的重要之处在于,很多内存相关函数的返回值就是 void 指针,只给出内存块的地址信息,所以放在最前面进行介绍。

malloc()

malloc()函数用于分配内存

1
void* malloc(size_t size)

有时候为了增加代码的可读性,可以对malloc()返回的指针进行一次强制类型转换。

1
int* p = (int*) malloc(sizeof(int));
1
2
3
4
5
6
7
8
9
10
int* p = malloc(sizeof(int));

if (p == NULL) {
// 内存分配失败
}

// or
if (!p) {
//...
}

上面示例中,通过判断返回的指针p是否为NULL,确定malloc()是否分配成功。

1
int* p = (int*) malloc(n * sizeof(int));

malloc()最常用的场合,就是为数组和自定义数据结构分配内存。malloc()用来创建数组,有一个好处,就是它可以创建动态数组,即根据成员数量的不同,而创建长度不同的数组。

注意,malloc()不会对所分配的内存进行初始化,里面还保存着原来的值。如果没有初始化,就使用这段内存,可能从里面读到以前的值。程序员要自己负责初始化,比如,字符串初始化可以使用strcpy()函数。

1
2
char* p = malloc(4);
strcpy(p, "abc");

free()

1
void free(void* block)
1
2
3
4
int* p = (int*) malloc(sizeof(int));

*p = 12;
free(p);

一个很常见的错误是,在函数内部分配了内存,但是函数调用结束时,没有使用free()释放内存。

1
2
3
4
void gobble(double arr[], int n) {
double* temp = (double*) malloc(n * sizeof(double));
// ...
}

上面示例中,函数gobble()内部分配了内存,但是没有写free(temp)。这会造成函数运行结束后,占用的内存块依然保留,如果多次调用gobble(),就会留下多个内存块。并且,由于指针temp已经消失了,也无法访问这些内存块,再次使用。

calloc()

calloc()函数的作用与malloc()相似,也是分配内存块。该函数的原型定义在头文件stdlib.h

两者的区别主要有两点:

(1)calloc()接受两个参数,第一个参数是某种数据类型的值的数量,第二个是该数据类型的单位字节长度。

1
void* calloc(size_t n, size_t size);

calloc()的返回值也是一个 void 指针。分配失败时,返回 NULL。

(2)calloc()会将所分配的内存全部初始化为0malloc()不会对内存进行初始化,如果想要初始化为0,还要额外调用memset()函数。

1
2
3
4
5
int* p = calloc(10, sizeof(int));

// 等同于
int* p = malloc(sizeof(int) * 10);
memset(p, 0, sizeof(int) * 10);

上面示例中,calloc()相当于malloc() + memset()

calloc()分配的内存块,也要使用free()释放。

realloc()

realloc()函数用于修改已经分配的内存块的大小,可以放大也可以缩小,返回一个指向新的内存块的指针。如果分配不成功,返回 NULL。该函数的原型定义在头文件stdlib.h

1
void* realloc(void* block, size_t size)

它接受两个参数。

  • block:已经分配好的内存块指针(由malloc()calloc()realloc()产生)。
  • size:该内存块的新大小,单位为字节。

realloc()可能返回一个全新的地址(数据也会自动复制过去),也可能返回跟原来一样的地址。realloc()优先在原有内存块上进行缩减,尽量不移动数据,所以通常是返回原先的地址。如果新内存块小于原来的大小,则丢弃超出的部分;如果大于原来的大小,则不对新增的部分进行初始化(程序员可以自动调用memset())。

下面是一个例子,b是数组指针,realloc()动态调整它的大小。

1
2
3
4
int* b;

b = malloc(sizeof(int) * 10);
b = realloc(b, sizeof(int) * 2000);

上面示例中,指针b原来指向10个成员的整数数组,使用realloc()调整为2000个成员的数组。这就是手动分配数组内存的好处,可以在运行时随时调整数组的长度。

realloc()的第一个参数可以是 NULL,这时就相当于新建一个指针。

1
2
3
char* p = realloc(NULL, 3490);
// 等同于
char* p = malloc(3490);

如果realloc()的第二个参数是0,就会释放掉内存块。

由于有分配失败的可能,所以调用realloc()以后,最好检查一下它的返回值是否为 NULL。分配失败时,原有内存块中的数据不会发生改变。

1
2
3
4
5
6
float* new_p = realloc(p, sizeof(*p * 40));

if (new_p == NULL) {
printf("Error reallocing\n");
return 1;
}

注意,realloc()不会对内存块进行初始化。

restrict 说明符

声明指针变量时,可以使用restrict说明符,告诉编译器,该块内存区域只有当前指针一种访问方式,其他指针不能读写该块内存。这种指针称为“受限指针”(restrict pointer)。

1
2
int* restrict p;
p = malloc(sizeof(int));

上面示例中,声明指针变量p时,加入了restrict说明符,使得p变成了受限指针。后面,当p指向malloc()函数返回的一块内存区域,就意味着,该区域只有通过p来访问,不存在其他访问方式。

1
2
3
4
5
int* restrict p;
p = malloc(sizeof(int));

int* q = p;
*q = 0; // 未定义行为

上面示例中,另一个指针q与受限指针p指向同一块内存,现在该内存有pq两种访问方式。这就违反了对编译器的承诺,后面通过*q对该内存区域赋值,会导致未定义行为。

memcpy()

memcpy()用于将一块内存拷贝到另一块内存。该函数的原型定义在头文件string.h

1
2
3
4
5
void* memcpy(
void* restrict dest,
void* restrict source,
size_t n
);

上面代码中,dest是目标地址,source是源地址,第三个参数n是要拷贝的字节数n。如果要拷贝10个 double 类型的数组成员,n就等于10 * sizeof(double),而不是10。该函数会将从source开始的n个字节,拷贝到dest

destsource都是 void 指针,表示这里不限制指针类型,各种类型的内存数据都可以拷贝。两者都有 restrict 关键字,表示这两个内存块不应该有互相重叠的区域。

memcpy()的返回值是第一个参数,即目标地址的指针。

因为memcpy()只是将一段内存的值,复制到另一段内存,所以不需要知道内存里面的数据是什么类型。下面是复制字符串的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <string.h>

int main(void) {
char s[] = "Goats!";
char t[100];

memcpy(t, s, sizeof(s)); // 拷贝7个字节,包括终止符

printf("%s\n", t); // "Goats!"

return 0;
}

上面示例中,字符串s所在的内存,被拷贝到字符数组t所在的内存。

memcpy()可以取代strcpy()进行字符串拷贝,而且是更好的方法,不仅更安全,速度也更快,它不检查字符串尾部的\0字符。

1
2
3
4
5
6
7
8
9
10
11
12
char* s = "hello world";

size_t len = strlen(s) + 1;
char *c = malloc(len);

if (c) {
// strcpy() 的写法
strcpy(c, s);

// memcpy() 的写法
memcpy(c, s, len);
}

上面示例中,两种写法的效果完全一样,但是memcpy()的写法要好于strcpy()

使用 void 指针,也可以自定义一个复制内存的函数。

1
2
3
4
5
6
7
8
9
10
11
void* my_memcpy(void* dest, void* src, int byte_count) {
char* s = src;
char* d = dest;

while (byte_count--) {
*d++ = *s++;
}

return dest;

}

上面示例中,不管传入的destsrc是什么类型的指针,将它们重新定义成一字节的 Char 指针,这样就可以逐字节进行复制。*d++ = *s++语句相当于先执行*d = *s(源字节的值复制给目标字节),然后各自移动到下一个字节。最后,返回复制后的dest指针,便于后续使用。

memmove()

memmove()函数用于将一段内存数据复制到另一段内存。它跟memcpy()的主要区别是,它允许目标区域与源区域有重叠。如果发生重叠,源区域的内容会被更改;如果没有重叠,它与memcpy()行为相同。

memcmp()

memcmp()函数用来比较两个内存区域。它的原型定义在string.h

它接受三个参数,前两个参数是用来比较的指针,第三个参数指定比较的字节数。

它的返回值是一个整数。两块内存区域的每个字节以字符形式解读,按照字典顺序进行比较,如果两者相同,返回0;如果s1大于s2,返回大于0的整数;如果s1小于s2,返回小于0的整数。

1
2
3
char* s1 = "abc";
char* s2 = "acd";
int r = memcmp(s1, s2, 3); // 小于 0

上面示例比较s1s2的前三个字节,由于s1小于s2,所以r是一个小于0的整数,一般为-1。

下面是另一个例子。

1
2
3
4
5
6
char s1[] = {'b', 'i', 'g', '\0', 'c', 'a', 'r'};
char s2[] = {'b', 'i', 'g', '\0', 'c', 'a', 't'};

if (memcmp(s1, s2, 3) == 0) // true
if (memcmp(s1, s2, 4) == 0) // true
if (memcmp(s1, s2, 7) == 0) // false

上面示例展示了,memcmp()可以比较内部带有字符串终止符\0的内存区域。

struct 结构

C 语言没有其他语言的对象(object)和类(class)的概念,struct 结构很大程度上提供了对象和类的功能。

1
2
3
4
5
6
7
8
9
struct fraction {
int numerator;
int denominator;
};
// 声明该类型的变量
struct fraction f1;

f1.numerator = 22;
f1.denominator = 7;

struct 的数据类型声明语句与变量的声明语句,可以合并为一个语句。

1
2
3
4
5
struct book {
char title[500];
char author[100];
float value;
} b1;

上面的语句同时声明了数据类型book和该类型的变量b1。如果类型标识符book只用在这一个地方,后面不再用到,这里可以将类型名省略。

1
2
3
4
5
struct {
char title[500];
char author[100];
float value;
} b1;

上面示例中,struct声明了一个匿名数据类型,然后又声明了这个类型的变量b1

与其他变量声明语句一样,可以在声明变量的同时,对变量赋值。

1
2
3
4
5
6
struct {
char title[500];
char author[100];
float value;
} b1 = {"Harry Potter", "J. K. Rowling", 10.0},
b2 = {"Cancer Ward", "Aleksandr Solzhenitsyn", 7.85};

typedef命令可以为 struct 结构指定一个别名,这样使用起来更简洁。

1
2
3
4
5
6
typedef struct cell_phone {
int cell_no;
float minutes_of_charge;
} phone;

phone p = {5551234, 5};

指针变量也可以指向struct结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct book {
char title[500];
char author[100];
float value;
}* b1;

// 或者写成两个语句
struct book {
char title[500];
char author[100];
float value;
};
struct book* b1;

上面示例中,变量b1是一个指针,指向的数据是struct book类型的实例。

struct 结构也可以作为数组成员。

1
2
3
4
struct fraction numbers[1000];

numbers[0].numerator = 22;
numbers[0].denominator = 7;

在有必要的情况下,定义 Struct 结构体时,可以采用存储空间递增的顺序,定义每个属性,这样就能节省一些空间。类似于java的对齐

1
2
3
4
5
6
struct foo {
char c;
int a;
char* b;
};
printf("%d\n", sizeof(struct foo)); // 16

上面示例中,占用空间最小的char c排在第一位,其次是int a,占用空间最大的char* b排在最后。整个strct foo的内存占用就从24字节下降到16字节。

struct 指针

(*t).age这样的写法很麻烦。C 语言就引入了一个新的箭头运算符(->),可以从 struct 指针上直接获取属性,大大增强了代码的可读性。

1
2
3
void happy(struct turtle* t) {
t->age = t->age + 1;
}

struct 嵌套

赋值的时候有多种写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 写法一
struct fish shark = {"shark", 9, {"Selachimorpha", 500}};

// 写法二
struct species myBreed = {"Selachimorpha", 500};
struct fish shark = {"shark", 9, myBreed};

// 写法三
struct fish shark = {
.name="shark",
.age=9,
.breed={"Selachimorpha", 500}
};

// 写法四
struct fish shark = {
.name="shark",
.age=9,
.breed.name="Selachimorpha",
.breed.kinds=500
};

printf("Shark's species is %s", shark.breed.name);

上面示例展示了嵌套 Struct 结构的四种赋值写法。另外,引用breed属性的内部属性,要使用两次点运算符(shark.breed.name)。

下面是另一个嵌套 struct 的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct name {
char first[50];
char last[50];
};

struct student {
struct name name;
short age;
char sex;
} student1;

strcpy(student1.name.first, "Harry");
strcpy(student1.name.last, "Potter");

// or
struct name myname = {"Harry", "Potter"};
student1.name = myname;

上面示例中,自定义类型studentname属性是另一个自定义类型,如果要引用后者的属性,就必须使用两个.运算符,比如student1.name.first。另外,对字符数组属性赋值,要使用strcpy()函数,不能直接赋值,因为直接改掉字符数组名的地址会报错。

struct 结构内部不仅可以引用其他结构,还可以自我引用,即结构内部引用当前结构。比如,链表结构的节点就可以写成下面这样。

1
2
3
4
struct node {
int data;
struct node* next;
};

上面示例中,node结构的next属性,就是指向另一个node实例的指针。下面,使用这个结构自定义一个数据链表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct node {
int data;
struct node* next;
};

struct node* head;

// 生成一个三个节点的列表 (11)->(22)->(33)
head = malloc(sizeof(struct node));

head->data = 11;
head->next = malloc(sizeof(struct node));

head->next->data = 22;
head->next->next = malloc(sizeof(struct node));

head->next->next->data = 33;
head->next->next->next = NULL;

// 遍历这个列表
for (struct node *cur = head; cur != NULL; cur = cur->next) {
printf("%d\n", cur->data);
}

上面示例是链表结构的最简单实现,通过for循环可以对其进行遍历。

弹性数组成员

很多时候,不能事先确定数组到底有多少个成员。如果声明数组的时候,事先给出一个很大的成员数,就会很浪费空间。C 语言提供了一个解决方法,叫做弹性数组成员(flexible array member)。

如果不能事先确定数组成员的数量时,可以定义一个 struct 结构。

1
2
3
4
struct vstring {
int len;
char chars[];
};

上面示例中,struct vstring结构有两个属性。len属性用来记录数组chars的长度,chars属性是一个数组,但是没有给出成员数量。

chars数组到底有多少个成员,可以在为vstring分配内存时确定。

1
2
struct vstring* str = malloc(sizeof(struct vstring) + n * sizeof(char));
str->len = n;

上面示例中,假定chars数组的成员数量是n,只有在运行时才能知道n到底是多少。然后,就为struct vstring分配它需要的内存:它本身占用的内存长度,再加上n个数组成员占用的内存长度。最后,len属性记录一下n是多少。

这样就可以让数组charsn个成员,不用事先确定,可以跟运行时的需要保持一致。

弹性数组成员有一些专门的规则。首先,弹性成员的数组,必须是 struct 结构的最后一个属性。另外,除了弹性数组成员,struct 结构必须至少还有一个其他属性。

typedef 命令

typedef 也可以用来为数组类型起别名。

1
2
3
typedef int five_ints[5];

five_ints x = {11, 22, 33, 44, 55};

上面示例中,five_ints是一个数组类型,包含5个整数的

typedef 为函数起别名的写法如下。

1
typedef signed char (*fp)(void);

上面示例中,类型别名fp是一个指针,代表函数signed char (*)(void)

(2)为 struct、union、enum 等命令定义的复杂数据结构创建别名,从而便于引用。

1
2
3
4
5
struct treenode {
// ...
};

typedef struct treenode* Tree;

上面示例中,Treestruct treenode*的别名。

(5)简化类型声明

C 语言有些类型声明相当复杂,比如下面这个。

1
char (*(*x(void))[5])(void);

typedef 可以简化复杂的类型声明,使其更容易理解。首先,最外面一层起一个类型别名。

1
2
typedef char (*Func)(void);
Func (*x(void))[5];

这个看起来还是有点复杂,就为里面一层也定义一个别名。

1
2
3
typedef char (*Func)(void);
typedef Func Arr[5];
Arr* x(void);

上面代码就比较容易解读了。

  • x是一个函数,返回一个指向 Arr 类型的指针。
  • Arr是一个数组,有5个成员,每个成员是Func类型。
  • Func是一个函数指针,指向一个无参数、返回字符值的函数。

Union 结构

有时需要一种数据结构,不同的场合表示不同的数据类型。比如,如果只用一种数据结构表示水果的“量”,这种结构就需要有时是整数(6个苹果),有时是浮点数(1.5公斤草莓)。

C 语言提供了 Union 结构,用来自定义可以灵活变更的数据结构。它内部包含各种属性,1但所有属性共用一块内存,导致这些属性都是对同一个二进制数据的解读,其中往往只有一个属性的解读是有意义的。并且,后面写入的属性会覆盖前面的属性,这意味着同一块内存,可以先供某一个属性使用,然后再供另一个属性使用。这样做的最大好处是节省内存空间。

1
2
3
4
5
union quantity {
short count;
float weight;
float volume;
};

Enum 类型

网道(WangDoc.com),互联网文档计划

如果一种数据类型的取值只有少数几种可能,并且每种取值都有自己的含义,为了提高代码的可读性,可以将它们定义为 Enum 类型,中文名为枚举。

1
2
3
4
5
enum colors {RED, GREEN, BLUE};

printf("%d\n", RED); // 0
printf("%d\n", GREEN); // 1
printf("%d\n", BLUE); // 2

上面示例中,假定程序里面需要三种颜色,就可以使用enum命令,把这三种颜色定义成一种枚举类型colors,它只有三种取值可能REDGREENBLUE。这时,这三个名字自动成为整数常量,编译器默认将它们的值设为数字012。相比之下,RED要比0的可读性好了许多。

注意,Enum 内部的常量名,遵守标识符的命名规范,但是通常都使用大写。

使用时,可以将变量声明为 Enum 类型。

1
enum colors color;

上面代码将变量color声明为enum colors类型。这个变量的值就是常量REDGREENBLUE之中的一个。

1
2
color = BLUE;
printf("%i\n", color); // 2

预处理器(Preprocessor)

#define

1
#define MAX 100
1
#define SQUARE(X) X*X

上面示例中,宏SQUARE可以接受一个参数X,替换成X*X

注意,宏的名称与左边圆括号之间,不能有空格。

这个宏的用法如下。

1
2
// 替换成 z = 2*2;
z = SQUARE(2);

这种写法很像函数,但又不是函数,而是完全原样的替换,会跟函数有不一样的行为。

1
2
3
4
#define SQUARE(X) X*X

// 输出19
printf("%d\n", SQUARE(3 + 4));

上面示例中,SQUARE(3 + 4)如果是函数,输出的应该是49(7*7);宏是原样替换,所以替换成3 + 4*3 + 4,最后输出19。

可以看到,原样替换可能导致意料之外的行为。解决办法就是在定义宏的时候,尽量多使用圆括号,这样可以避免很多意外。

1
#define SQUARE(X) ((X) * (X))

#undef

#undef指令用来取消已经使用#define定义的宏。

1
2
#define LIMIT 400
#undef LIMIT

上面示例的undef指令取消已经定义的宏LIMIT,后面就可以重新用 LIMIT 定义一个宏。

有时候想重新定义一个宏,但不确定是否以前定义过,就可以先用#undef取消,然后再定义。因为同名的宏如果两次定义不一样,会报错,而#undef的参数如果是不存在的宏,并不会报错。

GCC 的-U选项可以在命令行取消宏的定义,相当于#undef

1
$ gcc -ULIMIT foo.c

上面示例中的-U参数,取消了宏LIMIT,相当于源文件里面的#undef LIMIT

#include

#include指令用于编译时将其他源码文件,加载进入当前文件。它有两种形式。

1
2
3
4
5
// 形式一
#include <foo.h> // 加载系统提供的文件

// 形式二
#include "foo.h" // 加载用户提供的文件

形式一,文件名写在尖括号里面,表示该文件是系统提供的,通常是标准库的库文件,不需要写路径。因为编译器会到系统指定的安装目录里面,去寻找这些文件。

形式二,文件名写在双引号里面,表示该文件由用户提供,具体的路径取决于编译器的设置,可能是当前目录,也可能是项目的工作目录。如果所要包含的文件在其他位置,就需要指定路径,下面是一个例子。

1
#include "/usr/local/lib/foo.h"

GCC 编译器的-I参数,也可以用来指定include命令中用户文件的加载路径。

1
$ gcc -Iinclude/ -o code code.c

上面命令中,-Iinclude/指定从当前目录的include子目录里面,加载用户自己的文件。

#include最常见的用途,就是用来加载包含函数原型的头文件(后缀名为.h),参见《多文件编译》一章。多个#include指令的顺序无关紧要,多次包含同一个头文件也是合法的。

#if…#endif

#if...#endif指令用于预处理器的条件判断,满足条件时,内部的行会被编译,否则就被编译器忽略。

1
2
3
#if 0
const double pi = 3.1415; // 不会执行
#endif

上面示例中,#if后面的0,表示判断条件不成立。所以,内部的变量定义语句会被编译器忽略。#if 0这种写法常用来当作注释使用,不需要的代码就放在#if 0里面

没有定义过的宏,等同于0。因此如果UNDEFINED是一个没有定义过的宏,那么#if UNDEFINED为伪,而#if !UNDEFINED为真。

#if的常见应用就是打开(或关闭)调试模式。

1
2
3
4
5
6
#define DEBUG 1

#if DEBUG
printf("value of i : %d\n", i);
printf("value of j : %d\n", j);
#endif

上面示例中,通过将DEBUG设为1,就打开了调试模式,可以输出调试信息。

#ifdef…#endif

#ifdef...#endif指令用于判断某个宏是否定义过。

#ifdef...#else...#endif可以用来实现条件加载。

1
2
3
4
5
6
7
#ifdef MAVIS
#include "foo.h"
#define STABLES 1
#else
#include "bar.h"
#define STABLES 2
#endif

上面示例中,通过判断宏MAVIS是否定义过,实现加载不同的头文件。

defined 运算符

上一节的#ifdef指令,等同于#if defined

1
2
3
#ifdef FOO
// 等同于
#if defined FOO

#ifndef…#endif

#ifndef...#endif指令跟#ifdef...#endif正好相反。它用来判断,如果某个宏没有被定义过,则执行指定的操作。

#ifndef等同于#if !defined

1
2
3
#ifndef FOO
// 等同于
#if !defined FOO

预定义宏

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

int main(void) {
printf("This function: %s\n", __func__);
printf("This file: %s\n", __FILE__);
printf("This line: %d\n", __LINE__);
printf("Compiled on: %s %s\n", __DATE__, __TIME__);
printf("C Version: %ld\n", __STDC_VERSION__);
}

/* 输出如下

This function: main
This file: test.c
This line: 7
Compiled on: Mar 29 2021 19:19:37
C Version: 201710

*/

#line

#line指令用于覆盖预定义宏__LINE__,将其改为自定义的行号。后面的行将从__LINE__的新值开始计数。

1
2
// 将下一行的行号重置为 300
#line 300

上面示例中,紧跟在#line 300后面一行的行号,将被改成300,其后的行会在300的基础上递增编号。

#line还可以改掉预定义宏__FILE__,将其改为自定义的文件名。

1
#line 300 "newfilename"

上面示例中,下一行的行号重置为300,文件名重置为newfilename

#error

#error指令用于让预处理器抛出一个错误,终止编译。

1
2
3
#if __STDC_VERSION__ != 201112L
#error Not C11
#endif

上面示例指定,如果编译器不使用 C11 标准,就中止编译。GCC 编译器会像下面这样报错。

#pragma

#pragma指令用来修改编译器属性。

1
2
// 使用 C99 标准
#pragma c9x on

上面示例让编译器以 C99 标准进行编译。

变量说明符

const

const说明符表示变量是只读的,不得被修改。

1
2
const double PI = 3.14159;
PI = 3; // 报错

这两者可以结合起来。

1
const char* const x;

static

static说明符对于全局变量和局部变量有不同的含义。

(1)用于局部变量(位于块作用域内部)。

static用于函数内部声明的局部变量时,表示该变量的值会在函数每次执行后得到保留,下次执行时不会进行初始化,就类似于一个只用于函数内部的全局变量。由于不必每次执行函数时,都对该变量进行初始化,这样可以提高函数的执行速度,详见《函数》一章。

(2)用于全局变量(位于块作用域外部)。

static用于函数外部声明的全局变量时,表示该变量只用于当前文件,其他源码文件不可以引用该变量,即该变量不会被链接(link)。

static修饰的变量,初始化时,值不能等于变量,必须是常量。

1
2
int n = 10;
static m = n; // 报错

上面示例中,变量mstatic修饰,它的值如果等于变量n,就会报错,必须等于常量。

只在当前文件里面使用的函数,也可以声明为static,表明该函数只在当前文件使用,其他文件可以定义同名函数。

1
static int g(int i);

auto

extern

extern说明符表示,该变量在其他文件里面声明,没有必要在当前文件里面为它分配空间。通常用来表示,该变量是多个文件共享的。

1
extern int a;

上面代码中,aextern变量,表示该变量在其他文件里面定义和初始化,当前文件不必为它分配存储空间。

但是,变量声明时,同时进行初始化,extern就会无效。

1
2
3
4
5
// extern 无效
extern int i = 0;

// 等同于
int i = 0;

上面代码中,extern对变量初始化的声明是无效的。这是为了防止多个extern对同一个变量进行多次初始化。

函数内部使用extern声明变量,就相当于该变量是静态存储,每次执行时都要从外部获取它的值。

函数本身默认是extern,即该函数可以被外部文件共享,通常省略extern不写。如果只希望函数在当前文件可用,那就需要在函数前面加上static

1
2
3
extern int f(int i);
// 等同于
int f(int i);

register

volatile

volatile说明符表示所声明的变量,可能会预想不到地发生变化(即其他程序可能会更改它的值),不受当前程序控制,因此编译器不要对这类变量进行优化,每次使用时都应该查询一下它的值。硬件设备的编程中,这个说明符很常用。

1
2
volatile int foo;
volatile int* bar;

volatile的目的是阻止编译器对变量行为进行优化,请看下面的例子。

1
2
3
int foo = x;
// 其他语句,假设没有改变 x 的值
int bar = x;

上面代码中,由于变量foobar都等于x,而且x的值也没有发生变化,所以编译器可能会把x放入缓存,直接从缓存读取值(而不是从 x 的原始内存位置读取),然后对foobar进行赋值。如果x被设定为volatile,编译器就不会把它放入缓存,每次都从原始位置去取x的值,因为在两次读取之间,其他程序可能会改变x

restrict

restrict说明符允许编译器优化某些代码。它只能用于指针,表明该指针是访问数据的唯一方式。

1
int* restrict pt = (int*) malloc(10 * sizeof(int));

上面示例中,restrict表示变量pt是访问 malloc 所分配内存的唯一方式。

下面例子的变量foo,就不能使用restrict修饰符。

1
2
int foo[10];
int* bar = foo;

上面示例中,变量foo指向的内存,可以用foo访问,也可以用bar访问,因此就不能将foo设为 restrict。

如果编译器知道某块内存只能用一个方式访问,可能可以更好地优化代码,因为不用担心其他地方会修改值。

restrict用于函数参数时,表示参数的内存地址之间没有重叠。

1
2
3
4
5
6
void swap(int* restrict a, int* restrict b) {
int t;
t = *a;
*a = *b;
*b = t;
}

上面示例中,函数参数声明里的restrict表示,参数a和参数b的内存地址没有重叠。

多文件项目

简介

一个软件项目往往包含多个源码文件,编译时需要将这些文件一起编译,生成一个可执行文件。

假定一个项目有两个源码文件foo.cbar.c,其中foo.c是主文件,bar.c是库文件。所谓“主文件”,就是包含了main()函数的项目入口文件,里面会引用库文件定义的各种函数。

1
2
3
4
5
6
// File foo.c
#include <stdio.h>

int main(void) {
printf("%d\n", add(2, 3)); // 5!
}

上面代码中,主文件foo.c调用了函数add(),这个函数是在库文件bar.c里面定义的。

1
2
3
4
5
// File bar.c

int add(int x, int y) {
return x + y;
}

现在,将这两个文件一起编译。

1
2
3
4
$ gcc -o foo foo.c bar.c

# 更省事的写法
$ gcc -o foo *.c

上面命令中,gcc 的-o参数指定生成的二进制可执行文件的文件名,本例是foo

这个命令运行后,编译器会发出警告,原因是在编译foo.c的过程中,编译器发现一个不认识的函数add()foo.c里面没有这个函数的原型或者定义。因此,最好修改一下foo.c,在文件头部加入add()的原型。

1
2
3
4
5
6
7
8
// File foo.c
#include <stdio.h>

int add(int, int);

int main(void) {
printf("%d\n", add(2, 3)); // 5!
}

现在再编译就没有警告了。

你可能马上就会想到,如果有多个文件都使用这个函数add(),那么每个文件都需要加入函数原型。一旦需要修改函数add()(比如改变参数的数量),就会非常麻烦,需要每个文件逐一改动。所以,通常的做法是新建一个专门的头文件bar.h,放置所有在bar.c里面定义的函数的原型。

1
2
3
// File bar.h

int add(int, int);

然后使用include命令,在用到这个函数的源码文件里面加载这个头文件bar.h

1
2
3
4
5
6
7
8
// File foo.c

#include <stdio.h>
#include "bar.h"

int main(void) {
printf("%d\n", add(2, 3)); // 5!
}

上面代码中,#include "bar.h"表示加入头文件bar.h。这个文件没有放在尖括号里面,表示它是用户提供的;它没有写路径,就表示与当前源码文件在同一个目录。

然后,最好在bar.c里面也加载这个头文件,这样可以让编译器验证,函数原型与函数定义是否一致。

1
2
3
4
5
6
// File bar.c
#include "bar.h"

int add(int a, int b) {
return a + b;
}

现在重新编译,就可以顺利得到二进制可执行文件。

1
$ gcc -o foo foo.c bar.c