第03章 C语言提高专题(上)

声明:仅为个人学习总结,还请批判性查看,如有不同观点,欢迎交流。

摘要

本文总结了以下主题的相关知识点,包括聚合数据类型“数组”,特殊的字符数组“字符串”,以及对数据进行访问的“指针”,特别是“指针”与“数组”的密切关系。


1、数组

数组:含有多个数据值,每个数据值(元素,element)具有相同的数据类型,可以根据“元素在数组中所处的位置”访问它们。

标量(scalar)变量:存储单一数值,例如 int、float、char 类型变量
聚合(aggregate)变量:存储成组的数值,例如 数组(array)、结构(structure)类型变量

1.1 一维数组

在一维数组中,元素一个接一个地排成一行。

1.1.1 声明数组

// 元素类型 数组名标识符[代表元素数量的常量表达式]; 
int a[5];

1.1.2 数组取下标(subscripting)或进行索引(indexing)

// 数组名[数组下标/元素索引/任何整数表达式];
// 是左值(lvalue),可以像普通变量一样使用
++a[0];

1.1.3 数组初始化

数组初始化器(array initializer)格式:用花括号 { } 括起来的常量表达式列表,常量表达式之间用逗号(,)进行分隔。

// 1. 依次为每个元素赋值
int a[5] = {1, 2, 3, 4, 5};

// 2. 初始化器比数组短,剩余元素赋值为0
int a[5] = {1, 2, 3};

// 3. 初始化为全0,初始化器完全为空是非法的,长度比数组长也是非法的
int a[5] = {0};

// 4. 给定初始化器后,可以省略数组的长度,编译器利用初始化器的长度来确定数组的大小
int a[] = {1, 2, 3, 4, 5};

// 5.1 指示器(designated initializer,C99):由方括号 [] 和其中的整型常量表达式(数组下标)组成
// 为指示器指定元素(顺序任意)赋值,其它元素为默认值0
int a[10] = {[0] = 29, [9] = 7, [3] = 48};

// 5.2 初始化器中可以同时使用老方法(逐个元素初始化)和新方法(指示器),没有指定值的元素为默认值0
int b[10] = {5, 1, 9, [4] = 3, 7, 2, [8] = 6}; // [3]、[7]、[9] 为0
int c[10] = {5, 1, 9, [4] = 3, 7, 2, [2] = 6}; // [2] 会覆盖前面的初始化值9

// 5.3 如果数组的长度省略,指示器可以指定任意非负整数,编译器根据最大的值推断出数组的长度
int d[] = {[0] = 29, [6] = 23};         // 长度为 7
int e[] = {[0] = 29, [6] = 4, 9, 10};   // 长度为 9

1.1.4 数组赋值

int a[5] = {5,3,2,8};
int b[5];

a[4] = 1;  //【√】可以借助数组下标(或索引)给数组元素逐一赋值
b = a;     //【×】不允许把数组作为一个整体赋给另一个数组(需要采用内存复制)
b[5] = {5,3,2,8}; //【×】不允许使用花括号列表的形式赋值(初始化和赋值不一样)

1.1.5 计算数组大小

float a[5] = {0};
sizeof(a);                // 计算数组的大小(字节数)
sizeof(a[0]);             // 计算数组元素的大小
sizeof(a) / sizeof(a[0]); // 得到数组的长度(元素数量)

1.2 多维数组

数组可以有任意维数,C语言按照“行主序”存储数组,即首先存储第0行的元素,然后存储第1行的元素,以此类推。

1.2.1 二维数组初始化

// 1. 通过嵌套一维初始化器的方法可以产生二维数组的初始化器
int m[3][4] = {{1,2,3,4}, {5,6,7,8}, {9,10,11,12}};

// 2. 如果初始化器没有填充所有行,后面的行将赋值为0
int m[3][4] = {{1,2,3,4}};

// 3. 如果一行中没有填充所有列,行中的剩余元素将赋值为0
int m[3][4] = {{1,2,3}, {5,6}};

// 4. 可以省略内层的花括号,编译器填满一行后,开始填充下一行
int m[3][4] = {1,2,3,4,5,6,7,8,9,10};

// 5. C99的指示器对多维数组也有效,没有指定值的元素都默认值为0
int m[3][4] = {[1][2]=1, [2][0]=6};

// 6. 给定初始化器后,可以省略第1维数组的长度,编译器利用初始化器的长度来确定数组的大小
int m[][4] = {1,2,3,4,5,6,7,8,9,10};
int m[][4] = {{1,2,3}, {0}, {5,6}};

1.2.2 其它多维数组

二维数组的相关内容同样适用于三维数组或更多维的数组。通常,处理二维数组要使用2重嵌套循环(nested loop),处理三维数组要使用3重嵌套循环。对于其它多维数组,以此类推。

int arr[ROWS][COLS][DEPTH];
...
for (int i = 0; i < ROWS; i++) {
  for (int j = 0; j < COLS; j++) {
    for (int k = 0; k < DEPTH; k++) {
      printf("%d ", arr[i][j][k]);
    }
  }
}

1.3 变长数组(C99)

变长数组(variable-length array, VLA):在创建数组时,可以使用变量指定数组的长度。也就是,数组长度是在程序执行时计算的,而不是在程序编译时计算的。(C11 把 VLA 设定为可选)

  • 长度可以用变量或表达式来指定 int a[n];
  • 可以是多维的 int c[m][n];
  • 限制:必须是自动存储期,没有初始化器

1.4 数组型参数

把数组名作为实际参数时,传递给函数的不是整个数组,而是数组的地址。

1.4.1 数组形式参数

// 当形式参数是一维数组时,通常省略数组的长度,因为对元素定位不需要长度
int sum_array(int a[], int n);

// 对于多维数组,C语言首先将其看做是一维数组,一维数组的每个元素又是数组类型
// 当形式参数是多维数组时,只能省略第一维的长度,因为需要其它维度的长度来计算行的大小,这样才能进行每行的定位
int sum2d(int a[][10], int n);

1.4.2 变长数组形式参数(C99)

一维变长数组形式参数示例:

// 1. 第一个参数(n)的值确定了第二个参数(a)的长度,需要先声明长度参数 n
int sum_array(int n, int a[n]);

// 2. 可以用 *(星号)取代数组长度,因为函数声明时,形参名字是可选的,数组的长度与形参列表中前面的参数相关
int sum_array(int n, int a[*]);
int sum_array(int, int [*]);

// 3. 方括号为空也是合法的(因为缺少关系说明,所以不推荐)
int sum_array(int n, int a[]);
int sum_array(int, int []);

// 4. 形参的长度可以是任意表达式,甚至可以调用其它函数
int concatenate(int m, int n, int a[m], int b[n], int c[m+n]);

多维变长数组形式参数示例:

int sum2d(int n, int m, int a[n][m]);
int sum2d(int n, int m, int a[*][*]);
int sum2d(int n, int m, int a[][m]);
int sum2d(int n, int m, int a[][*]);
int sum2d(int, int, int a[*][*]);

1.4.3 在数组参数声明中使用 static(C99)

// 将 static 放在数字 3 之前,表明数组 a 的长度至少可以保证是 3(便于编译器优化)
int sum_array(int a[static 3], int n);

// 如果数组参数是多维的,static 仅可用于第一维
int sum2d(int a[static 3][10], int n);

1.4.4 复合字面量参数(C99)

复合字面量(compound literal)是通过“指定其包含的元素”而创建的“没有名字的数组”。

格式:(类型名)初始化器

  • 类型名:去掉声明中的数组名,留下的就是类型名 int a[2] => int [2]
  • 初始化器:可以使用指示器,可以提供不完全的初始化
  • 是左值,其元素的值可以改变

典型用法:作为实参,传入函数前不必先创建数组

// 复合字面量创建了一个由3个整数组成的临时数组,也可以显式指明长度,如(int[3]){3, 0, 3}
int total = sum_array((int []){3, 0, 3}, 5);

// 函数内部创建的复合字面量可以包含任意的表达式,不限于常量
int total = sum_array((int []){2 * i, i + j, j * k}, 3);

// 也可用于多维数组
// 示例:声明一个指向二维数组的指针,存储二维 int 数组的地址
int (*pt2)[4];
pt2 = (int [2][4]) { {1,2,3,-9}, {4,5,6,-8} };

2、指针

2.1 指针变量

通常,内存中的每个字节(byte)都有唯一的地址(address),即按字节编址。地址的取值范围和操作不同于整数的取值范围和操作,所以需要用特殊的指针变量(pointer variable)来存储地址。也就是,指针就是地址,而指针变量就是存储地址的变量。

程序中的每个变量占有一个或多个字节内存,把第一个字节的地址称为变量的地址。在用指针变量 p 存储变量 i 的地址时,我们说 p “指向” iint i; int *p = &i;

2.1.1 指针变量的声明

// 必须在指针变量名字前放置星号(*),其它与“普通变量的声明”一样
// p 是指向 int 类型对象的指针变量 (因为指针可以指向不属于变量的内存区域,所以我们用术语对象代替变量)
// 每个指针变量只能指向一种特定类型(引用类型)的对象,引用类型并没有限制,如果是指针类型,那么指针变量就是指向指针的指针
int *p;

2.1.2 取地址运算符、间接寻址运算符

  • 使用 &(取地址)运算符,找到变量的地址(&x 是变量 x 在内存中的地址)
  • 使用 *(间接寻址)运算符,访问指针所指向的对象(*p 表示指针 p 指向的对象),有时也称为:间接运算符(indirection operator)、解引用运算符(dereferencing operator)
int i = 0, *p = &i;
printf("%d %p %p\n", *p, p, &p);

2.1.3 指针赋值

可以把地址赋给指针,例如,数组名、带地址运算符(&)的变量名、另一个指针。前提是两个指针具有相同的类型。

2.1.4 指针作为参数

C语言用值进行参数传递,所以在函数调用中,用作实际参数的变量无法被改变。但将指针作为参数时,则可以在函数内部,访问和修改指针所指向的对象。

如果希望函数不要改变指针参数所指向的对象,可以使用关键字 const 来保护参数:

// 函数 f 不能改变指针 p 指向的对象,但可以改变 p 自身
void f(const int *p);

// 函数 f 不能改变指针 p 自身,但可以改变 p 指向的对象(很少用,很少有理由保护 p)
void f(int * const p);

// 函数 f 既不能改变 p,也不能改变 p 指向的对象(罕见)
void f(const int * const p);

2.1.5 指针作为返回值

不要返回指向自动存储期变量的指针。

int *f(void) {
  int i;
  ...
  return &i; // 一旦 f 返回,变量 i 就不存在了,所以指向变量 i 的指针将是无效的
}

2.2 指针运算

指针可以指向数组元素,而不仅仅是普通变量。假设 a 为数组,则 &a[i] 是指向 a 中元素 i 的指针。

int a[10], *p;  // 假设已经声明 a 和 p
p = &a[0];      // 使 p 指向 a[0]
*p = 5;         // 通过 p 访问 a[0],把值 5 存入 a[0] 中

当指针指向数组元素时,可以对指针进行算术运算(加法和减法),从而用指针代替数组下标对数组进行操作。

2.2.1 指针算术运算

C语言支持3种(而且只有3种)格式的指针算术运算:指针加上整数、指针减去整数、两个指针相减。

int a[10], *p, *q, i;

// 1. 指针加上整数
// 如果 p 指向数组元素 a[i],那么 p+j 指向 a[i+j](前提是 a[i+j] 必须存在)
p = &a[2];
q = p + 3; // q 指向 a[5]
q++;       // q 指向 a[6]

// 2. 指针减去整数
// 如果 p 指向数组元素 a[i],那么 p-j 指向 a[i-j]
p = &a[8];
q = p - 2;  // q 指向 a[6]
q--;        // q 指向 a[5]

// 3. 两个指针相减(需要指向同一个数组)
// 结果为指针之间的距离,用数组元素的个数来度量
// 如果 p 指向 a[i] 且 q 指向 a[j],那么 p-q 就等于 i-j 
i = p - q; // i is 3
i = q - p; // i is -3

指针加 1 指的是增加一个存储单元,指针的值增加它所指向类型的大小(以字节为单位)。
对数组而言,加 1 后的地址是下一个元素的地址,而不是下一个字节的地址。

2.2.2 指针比较

可以用关系运算符(<、<=、> 和 >=)和判等运算符(== 和 !=)对指向同一数组的指针进行比较,比较的结果依赖于数组中两个元素的相对位置。

p = &a[5];
q = &a[1];
p <= q // 值是 0
p >= q // 值是 1

2.2.3 指向复合字面量的指针(C99)

// p 指向一个5元素数组的第一个元素
int *p = (int []){3, 0, 3, 4, 1};

// 等价于,先声明一个数组变量,然后用指针 p 指向数组的第一个元素(相对麻烦)
int a[] = {3, 0, 3, 4, 1};
int *p = &a[0];

2.2.4 *运算符和++运算符的组合

表达式含义
*p++*(p++)表达式的值是自增前的 *p,获得值以后再自增 p
(*p)++表达式的值是自增前的 *p,获得值以后再自增 *p
*++p*(++p)先自增 p,表达式的值是自增后的 *p
++*p++(*p)先自增 *p,表达式的值是自增后的 *p

2.3 指针和数组

2.3.1 用数组名作为指针

数组名是数组第一个元素的地址,可以用数组名作为指向数组第一个元素的指针。对于 int a[10];,表达式 a == &a[0] 的值为真。

通常情况下:

  • a+i 等同于 &a[i],两者都表示指向数组 a 中第 i 个元素的指针
  • *(a+i) 等同于 a[i],两者都表示第 i 个元素本身

数组名在传递给函数时,总是被视为指针。对于形式参数而言,声明为数组跟声明为指针是一样的。

// 声明 a 是指针就相当于声明它是数组,编译器把这两类声明看作完全一样
int find_largest(int a[], int n);
int find_largest(int *a, int n);

// 为了指明形式参数不会被函数改变,可以在声明中包含 const 关键字
int find_largest(const int a[], int n);
int find_largest(const int *a, int n);

2.3.2 用指针作为数组名

C语言允许把指针看作数组名进行取下标操作。

#define N 10
...
int a[N], i, sum = 0, *p = a;
... 
for (i = 0; i < N; i++)
  sum += p[i]; // 编译器把 p[i] 看作 *(p+i)

2.3.3 数组变量 ≠ 指针变量

// 对变量而言,声明为数组跟声明为指针是不同的
int a[10];  // 编译器会分配 10 个整数的空间
int *a;     // 编译器会分配 1 个指针变量空间

2.4 指针和多维数组

这里只讨论二维数组,但所有内容都可以应用于更高维的数组。

2.4.1 处理多维数组的元素

C语言按行主序存储二维数组,如果使指针 p 指向二维数组中的第一个元素(即第0行第0列的元素),就可以通过重复自增 p 的方法访问数组中的每一个元素。

// 示例:把二维数组的所有元素初始化为 0
int a[NUM_ROWS][NUM_COLS];

// 方式1:用嵌套的 for 循环,通过数组下标访问元素
for (int row = 0; row < NUM_ROWS; row++) {
  for (int col = 0; col < NUM_COLS; col++) {
    a[row][col] = 0;
  }
}

// 方式2:采用单循环,把多维数组看作一维数组来处理
// 当 p 到达 a[0][NUM_COLS-1] 时,再次对 p 自增将使它指向 a[1][0],连续自增直到访问所有元素
for (int *p = &a[0][0]; p <= &a[NUM_ROWS - 1][NUM_COLS - 1]; p++) {
  *p = 0;
}

2.4.2 处理多维数组的行

对于二维数组 a,为了访问第 i 行的元素,需要初始化指针变量 p 使其指向数组 a 中第 i 行第一个元素:

p = &a[i][0];

由于表达式 a[i] 是指向第 i 行中第一个元素的指针,上面的语句可以简写为:

p = a[i];

&a[i][0] 等同于 a[i] 的推导:
对于任意数组 a,表达式 a[i] 等同于 *(a+i)
因此 a[i][0] 等同于 *(a[i]+0)
因此 &a[i][0] 等同于 &(*(a[i] + 0)),而后者等同于 &*a[i],因为 & 和 * 运算符可以抵消,所以也就等同于 a[i]

// 示例:把二维数组的第 i 行清零
int a[NUM_ROWS][NUM_COLS], *p, i;
...
for (p = a[i]; p < a[i] + NUM_COLS; p++) {
  *p = 0;
}

2.4.3 处理多维数组的列

因为数组是按行存储,而不是按列存储,所以处理二维数组一列中的元素会麻烦一些。

// 示例:把二维数组的第 i 列清零
// p 为指向长度为 NUM_COLS 的整型数组的指针
int a[NUM_ROWS][NUM_COLS], (*p)[NUM_COLS], i;
...
// 表达式 p++ 把 p 移到下一行的开始位置
for (p = &a[0]; p < &a[NUM_ROWS]; p++) {
  // *p 代表 a 的一整行,(*p)[i] 代表该行第 i 列的那个元素
  (*p)[i] = 0;
}

2.4.4 用多维数组名作为指针

任意数组的名字都可以作为指针,是数组第一个元素的地址,不论是一维,还是多维。

对于 int a[NUM_ROWS][NUM_COLS];a 不是指向 a[0][0] 的指针,也就是 a 的类型不是 int *a 是指向 a[0] 的指针,也就是 a 的类型是 int (*)[NUM_COLS],是指向长度为 NUM_COLS 的整型数组的指针。C语言将 a 看做是一维数组,并且这个一维数组的每个元素又是一维数组。

// 使用数组名作为指针,把二维数组的第 i 列清零
for (p = a; p < a + NUM_ROWS; p++) {
  (*p)[i] = 0;
}

如果不关注行和列的信息,也可以让处理一维数组的函数来处理多维数组:

// 示例:使用 find_largest 函数找到二维数组中的最大元素
int find_largest(int *p, int count);

// a 的类型为 int (*)[NUM_COLS],a[0] 的类型为 int *,虽然它们都指向元素 a[0][0] 的位置
int largest = find_largest(a[0], NUM_ROWS * NUM_COLS);

2.4.5 指针的兼容性

指针之间的赋值,要比数值类型之间的赋值更严格。下面是一个简单的和一个稍复杂的示例:

int n = 5;
double x;
int* p1 = &n;
double* pd = &x;
x = n;    // 隐式类型转换,把 int 类型的值赋给 double 类型的变量
pd = p1;  // warning: incompatible types - from 'int *' to 'double *'
int *pt;
int(*pa)[3];
int ar1[2][3];
int ar2[3][2];
int **p2;         // 一个指向指针的指针

pt = &ar1[0][0];  // 都是指向 int 的指针
pt = ar1[0];      // 都是指向 int 的指针
pt = ar1;         // warning: 'int *' differs in levels of indirection from 'int (*)[3]'
pa = ar1;         // 都是指向内含 3 个 int 类型元素数组的指针
pa = ar2;         // warning: different array subscripts: 'int (*)[3]' and 'int (*)[2]'
p2 = &pt;         // 都是指向 int* 的指针
*p2 = ar1[0];     // 都是指向 int 的指针
p2 = ar1;         // warning: 'int **' differs in levels of indirection from 'int (*)[3]'

关于指针的这种兼容性考虑,实际上是很好的,如果C语言没这么挑剔,我们可能会犯各种各样编译器注意不到的指针错误。

2.5 指针和变长数组(C99)

普通的指针变量可以用于指向一维变长数组的元素。

void f(int n) {
  int a[n], *p;
  p = a;
  ...
}

如果变长数组是多维的,指针的类型取决于除第一维外每一维的长度。

void f(int m, int n) {
  // p 的类型依赖于变量 n,所以 p 具有变量修改类型(声明必须出现在函数体内部或者在函数原型中)
  int a[m][n], (*p)[n];
  p = a;
  ...
}

变长数组中的指针算术运算和一般数组中的指针算术运算一样。

void f(int m, int n) {
  int a[m][n], (*p)[n], i;
  ...
  // 把二维数组的第 i 列清零
  for (p = a; p < a + m; p++) {
    (*p)[i] = 0;
  }
}

2.6 动态存储分配

  • C语言的数据结构,通常是固定大小的,一旦程序完成编译,数据元素的数量就固定了;
  • 对于变长数组(C99),长度在运行时确定,但在数组的生命周期内仍然是固定长度的;
  • 动态存储分配,可以在程序执行期间分配内存单元,可以根据需要扩大(和缩小)存储空间。

虽然动态存储分配适用于所有类型的数据,但主要用于字符串、数组和结构。相关内存分配函数,声明在 <stdlib.h> 头中。

// 1. 分配 size 字节的内存块(不对内存块进行初始化),并且返回指向该内存块的指针
// 因为函数无法知道计划存储在内存块中的数据是什么类型的,所以函数会返回 void * 类型的值
// 如果找不到满足需要的足够大的内存块,函数会返回空指针 NULL(null pointer)
void *malloc(size_t size); 

// 1.1 为 n 个字符的字符串分配内存空间
char *p = malloc(n + 1);

// 1.2 为 n 个整数构成的数组分配内存空间
int *a = malloc(n * sizeof(int));

// 2. 为 nmemb 个元素的数组分配内存空间(会对内存块进行清零),其中每个元素的长度都是 size 字节
void *calloc(size_t nmemb, size_t size);

// 2.1 为 1 个结构分配内存空间,结构成员 x 和 y 都会被设为零
struct point { int x, y; } *p;
p = calloc(1, sizeof(struct point));

/**
 * @brief  3. 调整(扩大/减少)先前分配的内存块大小
 *            扩展内存块时,对于新加进内存块的字节,不会进行初始化
 *            原内存块中的数据,如果在新块的尺寸范围内,会保持不变
 * @param[in]  ptr 是先前通过 malloc、calloc 或 realloc 的调用获得的内存块
 *                 如果值为 NULL,行为同 malloc 函数
 * @param[in]  size 表示内存块的新尺寸,新尺寸可能会大于或小于原有尺寸
 *                  如果值为 0,会释放内存块
 * @retval  NULL,不能按要求扩大内存块(原内存块中的数据不会发生改变)
 * @retval  新内存块指针,如果不同于 ptr,需要对指向原内存块的所有指针进行更新
 */
void *realloc(void *ptr, size_t size);

// 4. 释放 ptr 所指向的,先前通过 malloc、calloc 或 realloc 的调用获得的内存块
void free(void *ptr);

2.7 指向函数的指针

变量会占用内存空间,函数同样也会占用内存空间,所以每个函数也都有地址(入口地址),也就会有指向函数的指针(函数指针)。

函数指针可以像数据指针一样使用,包括:

  • 作为其它函数的实际参数
  • 作为其它函数的返回值
  • 把函数指针存储在变量中
  • 作为数组的元素
  • 作为结构或联合的成员

2.7.1 函数指针作为参数

// 示例:使用 integrate 函数求“函数 f 在 a 点和 b 点之间的积分”
// 通过把函数 f 作为实际参数传入,可以让函数 integrate 尽可能具有一般性

// 1. 函数 integrate 的原型(2种方式)
double integrate(double (*f)(double), double a, double b);
double integrate(double f(double), double a, double b);

// 2. 函数 integrate 内部调用 f 所指向的函数(2种方式)
y = (*f)(x);  // f 是指向函数的指针,*f 表示 f 所指向的函数
y = f(x);     // 看上去更自然一些

// 3. 调用 integrate 函数,计算 sin 函数从 0 到 π/2 的积分
// sin 的后边没有圆括号,可类比数组:
// 如果 a 是数组的名字,那么 a[i] 就表示数组的元素,而 a 本身则作为指向数组的指针
// 如果 f 是函数的名字,那么 f(x) 就表示函数的调用,而 f 本身则作为指向函数的指针
double result = integrate(sin, 0.0, PI / 2);

2.7.2 函数指针变量

// 示例:声明函数指针变量,用于存储指向函数的指针
// pf 可以指向任何带有 int 型形式参数并且返回 void 型值的函数
void (*pf)(int);

// 假设函数 f 的原型为:void f(int); 
// 可以让变量 pf 指向函数 f(在 f 的前面不需要取地址符号 &)
pf = f;

// 通过 pf 调用函数 f (2种方式)
(*pf)(i);
pf(i);

2.7.3 函数指针数组

// 示例:实现一个菜单,其中包含多个可供选择的命令,把与命令对应的函数的指针存储在数组中
void (*file_cmd[])(void) = {new_cmd,
                            open_cmd,
                            close_cmd,
                            close_all_cmd,
                            save_cmd,
                            save_as_cmd,
                            save_all_cmd,
                            print_cmd,
                            exit_cmd};
// 如果用户选择命令 n(范围0~8),可以对数组 file_cmd 取下标,并调用相应的函数:(2种方式)
(*file_cmd[n])();
file_cmd[n]();

2.8 其它

2.8.1 受限指针(C99)

为了让程序达到可能的最佳性能,而进行微调时,可以使用 restrict。

// 用 restrict 声明的指针叫作受限指针(restricted pointer)
int * restrict p;

// p 可以指向动态分配的内存块、变量或者数组元素的地址
// p 指向的对象只允许通过指针 p 访问
p = malloc(sizeof(int));

2.8.2 弹性数组成员(C99)

在为结构分配内存时(而不是定义结构时),确定其数组成员的长度。

// 弹性数组成员(flexible array member)
// 当结构的最后一个成员是数组时,其长度可以省略(不占空间),sizeof 操作在计算结构大小时忽略其大小
// 具有弹性数组成员的结构是不完整类型(incomplete type),因为缺少用于确定所需内存大小的信息
struct vstring {
  int len;
  char chars[];
};

// 成员 chars 数组的长度在为 vstring 结构分配内存时确定,示例中为 n 个字符
struct vstring *str = malloc(sizeof(struct vstring) + n);
str->len = n;

3、字符串

3.1 字符串常量

字符串常量,在C标准中称为字面串(string literal)

  • 字面串,是源文件的组成部分,是一串用双引号(" ")括起来的文本/字符序列(是字面意义上的字符串),经过程序编译后生成字符串;
  • 字符串,位于系统存储器中、以空字符终止的字符序列。

3.1.1 字面串中的转义序列

字面串可以像字符常量一样包含转义序列,关于转义序列,可以回顾 入门专题(上)=> 2.3.2 字符常量

在字面串中,需要小心使用八进制数和十六进制数的转义序列:

  • 八进制数的转义序列,在3个数字之后结束,或者在第一个非八进制数字符处结束。
  • 十六进制数的转义序列,不限于3个数字,直到第一个非十六进制数字符截止,但大部分编译器的转义序列范围限制在 \x0~\xff。

3.1.2 多行字面串

如果字面串太长,不方便全部放在一行内,可以采用如下方式:

  • 方式1:在第一行用字符 \ 结尾,然后在下一行延续字面串
// 除了行尾的(看不到的)换行符,在同一行内,\ 后面不可以有其它字符
// 延续的字面串必须从下一行的起始位置继续
printf("When you come to a fork in the road, take it.     \
--Yogi Berra");
  • 方式2:把字符串分割放在两行或者更多行中
// 当两条或更多条字面串相邻时(仅用空白字符分隔),编译器会把它们合并成一条字符串
printf("When you come to a fork in the road, take it. "
       "--Yogi Berra");

按照C89标准,编译器最少要支持 509 个字符长的字面串;C99增加到 4095 个字符。

3.1.3 存储字面串

C语言把字面串作为字符数组来处理,并把它看作 char * 类型的指针。
编译器遇到长度为 n 的字面串时,为其分配长度为 n+1 的内存空间,前 n 个空间存储字面串中的字符,最后 1 个空间为空字符 \0,标志字符串末尾。

3.1.4 字面串的操作

通常情况下可以在任何允许使用 char * 指针的地方使用字面串。

// 字面串可以出现在赋值运算符的右边
// 赋值操作不是复制 "abc" 中的字符,而是使 p 指向字符串的第一个字符
char *p = "abc";

// 字面串是字符数组,因此可以对字面串取下标
// 如果下标为 3,ch 的值为空字符 '\0'
char ch = "abc"[0];

3.2 字符串变量

任何一维的字符数组都可以用来存储字符串,字符串的长度取决于结尾空字符的位置。

3.2.1 初始化字符串变量

字符串变量可以在声明时进行初始化:

// 1. 使用字面串初始化器
// 编译器把字符串 "June 14" 中的字符复制到数组 date1 中,然后追加一个空字符作为字符串结尾
char date1[8] = "June 14";

// 2. 使用字符数组初始化器
char date1[8] = {'J', 'u', 'n', 'e', ' ', '1', '4', '\0'};

// 3. 如果初始化器太短,不能填满字符串变量,编译器会添加空字符
// date2 的最后两个字节均为 '\0'
char date2[9] = "June 14";

// 4. 字符串变量的声明中,可以省略它的长度,编译器会自动计算长度
char date3[] = "June 14";

3.2.2 字符数组与字符指针

// 在声明为数组时,date 是数组名,数组名是常量,不能赋新的值
// 在声明为指针时,date 是变量,可以在程序执行期间指向其它字符串

// 在声明为数组时,可以修改存储在 date 中的字符元素
// 在声明为指针时,date 指向字面串,字面串是不可以修改的

char date[] = "June 14";
char *date = "June 14";

// 如果需要修改指针指向的字符串
// 方式1. 建立字符数组存储字符串,让指针指向已经存在的字符串变量
char str[STR_LEN+1], *p = str;

// 方式2. 分配内存空间存储字符串,让指针指向动态分配的字符串空间
char *p = (char *) malloc(n + 1);

3.2.3 访问字符串中的字符

字符串是以数组的方式存储的,因此可以使用下标来访问字符串中的字符,也可以使用指针来访问字符串中的字符。

3.3 字符串数组

3.3.1 字符串数组存储方式

// 方式1:二维数组,每一行有相同的长度
// 并非所有的字符串都足以填满数组的一整行,C语言会用空字符来填补,这会造成一定的空间浪费
char planets[][8] = {"Mercury", "Venus", "Earth",
                     "Mars", "Jupiter", "Saturn",
                     "Uranus", "Neptune"};

// 方式2:指针数组,每个指针指向的字符串有不同的长度,没有空间浪费
char *planets[] = {"Mercury", "Venus", "Earth",
                   "Mars", "Jupiter", "Saturn",
                   "Uranus", "Neptune"};

3.3.2 命令行参数/程序参数

/**
 * @brief  “主”程序函数,C程序从 main() 开始执行
 * @param[in]  argc 参数计数,是命令行参数的数量(包括程序名本身)
 * @param[in]  argv 参数向量,是指向命令行参数的指针数组,这些命令行参数以字符串的形式存储
 *                  argv[0] 指向程序名
 *                  argv[1] 到 argv[argc-1] 指向余下的命令行参数
 *                  argv[argc] 这个元素始终是一个空指针 NULL
 * @return  在程序终止时,向操作系统返回状态码
 */
int main(int argc, char *argv[]) {
  // 方式1:通过数组的下标访问参数
  int i;
  for (i = 1; i < argc; i++) {
    printf("%s\n", argv[i]);
  }

  // 方式2:通过数组元素指针访问参数
  char **p;
  for (p = &argv[1]; *p != NULL; p++) {
    printf("%s\n", *p);
  }
  
  ...
}

3.4 字符串的输入/输出

C语言的输入/输出库是标准库中最大且最重要的部分,本节函数均来自输入/输出函数的主要存储位置 <stdio.h> 头。

3.4.1 标准输入/输出流

基于标准输入/输出流的输入/输出函数,可以回顾:入门专题(上)=> 3、输入/输出函数,包括:

  • 3.1 格式化输入/输出(printf、scanf),其中,转换说明 %s 用于读写字符串
  • 3.2 字符的输入/输出(putchar、getchar)
  • 3.3 行的输入/输出(puts、gets)

3.4.2 字符串输入/输出

sprintf 函数、snprintf 函数和 sscanf 函数,是 printf 函数(具有格式化能力)和 scanf 函数(具有模式匹配能力)的变体,允许我们使用字符串作为流读写数据。

// sprintf 函数主要用来实现格式化
// 类似于 printf 函数,不同的是,它把输出写入(第一个实参指向的)字符数组而不是流中
// 完成字符串写入时,会添加一个空字符,并且返回所存储字符的数量(不计空字符)
// 如果遇到编码错误(宽字符不能转换成有效的多字节字符),则返回负值
int sprintf(char * restrict s, const char * restrict format, ...);

// snprintf 函数(C99新增)与 sprintf 函数一样,但多了一个参数 n
// 表明最多向字符串 s 中写入 n 个字符,最后一个是空字符
int snprintf(char *restrict s, size_t n, const char * restrict format, ...);

// sscanf 函数主要用来从字符串中提取数据
// 类似于 scanf 函数,不同的是,它从(第一个参数指向的)字符串而不是流中读取数据
// 返回成功读入并存储的数据项的数量,如果在找到第一个数据项之前到达了字符串的末尾(用空字符标记),那么返回EOF
int sscanf(const char * restrict s, const char * restrict format, ... );

3.4.3 可变参数输入/输出

3.4.3.1 可变参数

printf 和 scanf 这样的函数允许任意数量的参数,通过 <stdarg.h> 头提供的工具,我们自己也能够编写带有变长参数列表的函数。

/**
 * @brief  示例:在任意数量的整数参数中找出最大数
 * @param[in]  n 指明后面有几个参数
 *             带有可变数量参数的函数,必须至少有一个“正常的”形式参数
 * @param[in]  ... (省略号)表示参数 n 后面有可变数量的参数
 *             省略号总是出现在形式参数列表的末尾,在最后一个正常参数的后边
 * @return  参数中找出最大数
 */
int max_int(int n, ...) {
  
  // 1. 声明 va_list 类型的变量,用于访问可变参数
  va_list ap;

  // 2. 指出参数列表中可变长度部分开始的位置(这里从 n 后边开始)
  va_start(ap, n);

  // 3. 获取可变参数中的第1个参数,然后自动前进到下一个参数处
  // va_arg 的第2个参数 int 表明即将获取的参数是 int 类型的
  // 编译器会执行默认实参提升,char 和 short 提升为 int,float 提升为 double,因此把 char、short 或 float 类型作为参数传递给 va_arg 是没有意义的
  int largest = va_arg(ap, int);

  for (int i = 1; i < n; i++) {
    // 4. 在循环内部,逐个获取余下的参数
    // 在获取当前参数后,va_arg 始终会前进到下一个参数的位置上
    int current = va_arg(ap, int);
    if (current > largest) {
      largest = current;
    }
  }

  // 5. 调用 va_end 进行“清理”,否则可以调用 va_start 并且再次遍历参数列表
  // 通过 va_copy(C99),可以记住参数列表中的位置,以后可以回到同一位置继续处理
  // va_start(或 va_copy)必须与 va_end 成对使用,这些成对的调用必须在同一个函数中
  // 所有的 va_arg 调用必须出现在 va_start(或 va_copy)及其配对的 va_end 调用之间
  va_end(ap);

  return largest;
}

// 函数的调用
int largest = max_int(3, 10, 30, 20);

3.4.3.2 调用带有可变参数列表的函数

调用带有可变参数列表的函数,存在潜在的风险:

  • 带有可变参数列表的函数无法确定参数的数量和类型,这一信息必须被传递给函数或者由函数来假定。

    • 示例中的 max_int 函数依靠第一个参数来指明后面有多少参数,并且它假定参数都是 int 类型的;
    • 而像 printf 和 scanf 这样的函数则是依靠格式串来描述其它参数的数量以及每个参数的类型。
  • 关于以 NULL 作为参数:NULL 通常用于表示 0。当把 0 作为参数传递给带有可变参数列表的函数时,编译器会假定它表示一个整数——无法用于表示空指针。解决这一问题的方法就是添加一个强制类型转换,用 (void *)NULL(void*)0 来代替 NULL

3.4.3.3 v…printf 函数

int vprintf(const char * restrict format, va_list arg);
int vfprintf(FILE * restrict stream,
             const char  *restrict format, va_list arg);
int vsprintf(char * restrict s,
             const char * restrict format, va_list arg); 
int vsnprintf(char * restrict s, size_t n,
              const char * restrict format, va_list arg); // C99,与 snprintf 函数相对应

v…printf 函数和 printf、fprintf 以及 sprinf 函数密切相关。不同的是,v…printf 函数具有固定数量的参数。每个 v…printf 函数的最后一个参数都是一个 va_list 类型的值,这表明 v…printf 函数将由带有可变参数列表的函数调用。

// 示例:为每条出错消息添加前缀:** Error n,其中 n 是消息序号,从 1 开始递增
// 是一个具有可变数量参数的“包装”函数,包装函数会把参数传递给 v…printf 函数
char *error_str(const char *format, ...) {
  static char buf[256];
  static int num_errors = 0;

  snprintf(buf, sizeof(buf), "** Error %02d: ", ++num_errors);

  va_list ap;
  va_start(ap, format);
  vsprintf(buf + strlen(buf), format, ap);
  va_end(ap);

  return buf;
}

3.4.3.4 v…scanf 函数(C99)

int vscanf(const char * restrict format, va_list arg);
int vsscanf(const char * restrict s,
            const char * restrict format, va_list arg);
int vfscanf(FILE * restrict stream,
            const char * restrict format, va_list arg);

vscanf、vfscanf 和 vsscanf 分别与 scanf、fscanf 和 sscanf 等价,区别在于,前者具有一个 va_list 类型的参数用于接受可变参数列表。
与 v…printf 函数一样,v…scanf 函数也主要用于具有可变数量参数的包装函数。包装函数需要在调用 v…scanf 函数之前调用 va_start,并在 v…scanf 函数返回后调用 va_end。

#include <stdio.h>
#include <stdarg.h>

int call_vsscanf(char *tokenstring, char *format, ...) {
    va_list arglist;
    va_start(arglist, format);
    int result = vsscanf(tokenstring, format, arglist);
    va_end(arglist);
    return result;
}

int main(void) {
    char  tokenstring[] = "15 12 14...";
    char  s[81];
    char  c;
    int   i;
    float f;

    call_vsscanf(tokenstring, "%80s", s); // 值为 15,max 80 character string
    call_vsscanf(tokenstring, "%c", &c);  // 值为 1
    call_vsscanf(tokenstring, "%d %f", &i, &f);  // 值为 15 12.000000
}

3.5 字符串库函数

为实现对字符串的操作,C语言的函数库提供了丰富的函数集,这些函数的原型在 <string.h> 头中,可以分为五类:

  1. 复制函数,将字符从内存中的一处复制到另一处;
  2. 拼接函数,向字符串末尾追加字符;
  3. 比较函数,用于比较两个字符数组;
  4. 搜索函数,在字符数组中搜索一个特定字符、一组字符或一个字符串;
  5. 其它函数,初始化字符数组或计算字符串的长度。

其中,除了针对字符串(以空字符结尾)的处理函数外,还包括用于字符数组(不需要以空字符结尾)的处理函数,这类函数的名字以 mem 开头,以表明它们处理的是内存块而不是字符串。这些内存块可以包含任何类型的数据,因此 mem 函数的参数类型为 void * 而不是 char*

3.5.1 复制函数

/**
 * @brief  复制函数将字符(字节)从内存的一处(源)复制到另一处(目的地)
 * @param[in]  dest 指向目的地
 * @param[in]  src  指向源
 *             存在限定符 restrict 时,如果源和目的地之间有重叠,函数的行为是未定义的
 * @return  返回第一个参数(即指向目的地的指针)
 */

// 从源向目的地复制 n 个字符,其中 n 是函数的第三个参数
void *memcpy(void * restrict dest, const void * restrict src, size_t n);

// 与 memcpy 函数类似,只是在源和目的地重叠时它也可以正常工作
void *memmove(void *dest, const void *src, size_t n);

// 将一个以空字符结尾的字符串从源复制到目的地
char *strcpy(char * restrict dest, const char * restrict src);

// 与 strcpy 类似,只是它不会复制多于 n 个字符,其中 n 是函数的第三个参数
// 如果 n 太小,可能无法复制结尾的空字符
// 如果遇到源字符串中的空字符,会向目的字符串不断追加空字符,直到写满 n 个字符为止
char *strncpy(char * restrict dest, const char * restrict src, size_t n);

3.5.2 拼接函数

/**
 * @brief  将函数的第二个参数追加到第一个参数的末尾,两个参数都必须是以空字符结尾的字符串,
 *         函数会在拼接后的字符串末尾添加空字符
 * @param[in]  dest 指向目标数组,该数组包含了一个 C 字符串
 * @param[in]  src  指向要追加的字符串
 *             存在限定符 restrict 时,如果字符数组之间有重叠,函数的行为是未定义的
 * @return  返回第一个参数
 */

char *strcat(char * restrict dest, const char * restrict src); 

// 数与 strcat 函数基本一致,只是它的第三个参数会限制所追加的最大字符数
char *strncat(char * restrict dest, const char * restrict src, size_t n);

3.5.3 比较函数

/**
 * @brief  比较两个字符数组的内容,用第一个数组中的字符逐一地与第二个数组中的字符进行比较,
 *         在遇到第一个不匹配的字符时返回,根据比较结束时字符的比较情况确定返回值
 * @param[in]  s1 指向第一个字符数组的指针
 * @param[in]  s2 指向第二个字符数组的指针
 * @param[in]  n  要被比较的最大字节数
 * @retval   < 0,表示 s1 小于 s2
 * @retval   > 0,表示 s1 大于 s2
 * @retval   = 0,表示 s1 等于 s2
 */

// 空字符同样参与比较,用第三个参数 n 限制参与比较的字符个数
int memcmp(const void *s1, const void *s2, size_t n);

// 只能用于以空字符结尾的字符串,在任意一个字符数组中遇到空字符时停止比较
int strcmp(const char *s1, const char *s2);

// 与 strcmp 函数类似,但比较的结果依赖于当前的地区(LC_COLLATE 的位置设置)
int strcoll(const char *s1, const char *s2);

// 当比较的字符数达到 n 个或在其中任意一个字符数组中遇到空字符时停止比较
int strncmp(const char *s1, const char *s2, size_t n); 

/**
 * @brief  根据区域选项中的 LC_COLLATE 对 src 字符串进行变换,并将变换的结果放置在字符串 dest 中
 *         函数通常会被调用两次:一次用于判断变换后字符串的长度,一次用来进行变换
 *         用两个变换后的字符串作为参数调用 strcmp 函数所产生的结果,应该与用原始字符串作为参数调用 strcoll 函数所产生的结果相同
 * @param[in]  dest 指向目标数组的指针,如果参数 n 为 0,则它是一个空指针
 * @param[in]  src  要被变换为当前区域设置的 C 字符串
 *             存在限定符 restrict 时,如果字符数组之间有重叠,函数的行为是未定义的
 * @param[in]  n  限制向 dest 数组输出字符的最大个数
 * @return  返回变换后字符串的长度,不包括空字符
 */
size_t strxfrm(char * restrict dest, const char * restrict src, size_t n);

3.5.4 搜索函数

// 在字符串 s 中搜索字符 c,返回一个指针,指向第一次出现字符 c 的位置,如果找不到字符 c,则返回空指针
char *strchr(const char *s, int c);

// 与 strchr 类似,但是,会在搜索了指定数量的字符后停止搜索,而不是当遇到首个空字符时停止
void *memchr(const void *s, int c, size_t n);

// 与 strchr 类似,但是,它是反向搜索字符,首先找到字符串末尾的空字符,然后从后向前搜索第一次出现字符 c 的位置
char *strrchr(const char *s, int c);

// 比 strchr 更通用,它返回一个指针,指向 s1 中第一次出现目标字符的位置,目标字符是字符集合 s2 中的任意一个字符,如果找不到,则返回空指针
// 也就是,依次检验字符串 s1 中的字符,当被检验字符在字符集合 s2 中也包含时,则停止检验,并返回该字符位置
char *strpbrk(const char *s1, const char *s2);

// 与 strpbrk 类似,但是,它返回的是目标字符的下标(而不是指针),如果找不到,返回 s1 的长度值
// 也可以将返回值理解为:在字符串 s1 开头,不属于指定字符集合 s2 的字符的最长“跨度”(span)
size_t strcspn(const char *s1, const char *s2);

// 与 strcspn 类似,但是,它的目标字符是“不在字符集合 s2 中的字符”
// 也就是,依次检验字符串 s1 中的字符,当被检验字符不在字符集合 s2 中时,则停止检验,并返回该字符的下标
// 也可以将返回值理解为:在字符串 s1 开头,属于指定字符集合 s2 的字符的最长“跨度”(span)
size_t strspn(const char *s1, const char *s2);

// 在字符串 s1 中搜索第二个字符串 s2,返回一个指针,指向第一次出现 s2 的位置,如果找不到,则返回空指针
char *strstr(const char *s1, const char *s2);

/**
 * @brief  分解字符串 str 为一组非空子字符串,delim 为分隔符集合,子字符串中的字符不在 delim 中。
 *         第1次调用时,str 指向待分解的字符串,向后搜索 delim 中的任意字符,如果找到则将其变为空字符;
 *         后续调用时,str 是一个空指针,从前一次返回子字符串尾部的空字符之后继续进行搜索。
 * @param[in]  str 要被分解成一组子字符串的字符串,如果是第一次之后的分解,则它是一个空指针
 * @param[in]  delim  包含一到多个分隔符(字符)的 C 字符串
 *             存在限定符 restrict 时,如果字符数组之间有重叠,函数的行为是未定义的
 * @return  返回被分解的第一个子字符串,如果找不到子字符串,则返回一个空指针
 */
char *strtok(char * restrict str, const char * restrict delim); 

3.5.5 其它函数

// 复制字符 c(一个无符号字符)到参数 s 所指向的对象的前 n 个字符中,返回指针 s
// c 以 int 形式传递,但是函数在填充内存块时,使用的是该值的无符号字符形式
void *memset(void *s, int c, size_t n);

// 计算字符串的长度,字符串末尾的空字符不计算在内
size_t strlen(const char *s);

// 标准库中的一些函数通过向 <errno.h> 中声明的 int 类型 errno 变量存储一个错误码(正整数)来表示有错误发生
// 当以错误码为参数调用 strerror 时,函数会返回一个指针,它指向一个描述这个错误的字符串
char *strerror(int errnum);

参考

  1. [美] K. N. 金(K. N. King)著,吕秀锋,黄倩译.C语言程序设计:现代方法(第2版·修订版).人民邮电出版社.2021:209.
  2. [美] 史蒂芬·普拉达著.C Primer Plus(第6版 中文版 最新修订版).人民邮电出版社.2019:115.

宁静以致远,感谢 Vico 老师。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值