复杂声明、多维数组、字符串指针……

C 指针进阶专题

C 指针很有用、很复杂、很危险。接下来的几个问题都与指针有关,在此整理,希望帮助自己理解。

复杂声明

读取顺序

我们来看这个声明:

int a;

该语句声明了一个变量 a,类型是 int。再来看这个声明:

int *a;

该语句声明了一个变量 a,它是指向 int 的指针。再看这个:

int a[3];

该语句声明了一个变量 a,它是一个大小为 3 的数组,每个元素的类型是 int。最后看这个:

int a(int x, int *y);

该语句声明了一个函数 a,其参数为 int 型的 x 和指向 int 型的指针 y,返回类型为 int

以上声明是 C 中最基础的内容。由上,我们可以发现 *(表示指针)、[数字](表示数组)、(形参表)(表示函数)是声明中的关键符号。把它们嵌套起来会发生什么?来看这个:

char (*(*a[3])(int x, char (*y)[5]))[5];

这是一个合法的声明语句。它声明了什么?首先我们需要了解以上符号的优先级:

  1. 括号。用括号括起来的部分优先运算。
  2. 后缀的 [数字](形参表)。离变量名近的优先运算。
  3. 前缀的 *

根据括号—后缀—前缀的顺序,我们可以归纳出一个「右左法则」的算法:

  1. 找到声明语句中的第一个变量名(即找出该语句声明的变量名,而不是函数形参名),读取它。
  2. 从已读取部分的右边开始,依次序读取 [数字](形参表),直到遇到右括号为止;
  3. 从已读取部分的左边开始,读取 *,直到遇到左括号为止;
  4. 跳出括号,返回步骤 2。
  5. 直到最后剩下一个类型名。

按顺序理解信息

按照这个顺序,每一步读取的信息,都需要下一步来详细说明。比如,如果第一步读到 (形参表),那么我们知道这是一个函数(还知道了它的形参表)。那么这个函数返回什么类型?如果下一步读到 *,说明函数返回一个指针。那么这个指针指向什么类型?如果再下一步读到 [数字],说明指针指向的是一个数组(还知道了数组的长度)。那么这个数组存储的是什么类型?……边读边问,直到读完为止。

这种方法可以叫做「连环发问法」。让我们结合实例分析一下:

int *a[3];

首先,我们找到变量名 a。向右读取,遇到 [3]。我们立即可以断定,a 是数组,且长度为 3。问:存储什么的数组?

右边读到了头,我们倒转读左边,遇到 *。答:存储指针的数组。问:指针指向什么类型?

还剩下一个类型名,int。答:指向 int 类型。所以,a 是存储 int 指针的数组,长度为 3。我们把存储指针的数组叫做指针数组。

下一个例子:

int (*a)[5];

首先,我们找到变量名 a。向右读取,遇到括号。掉头向左读取,遇到 *。我们断定 a 是指针。问:指向什么的指针?

跳出括号,向右读取,遇到 [5]。答:指向数组的指针,该数组长度为 5。问:数组存储什么类型?

还剩下一个类型名,int。答:指向 int 类型。所以,a 是指向[长度为 5 的 int 数组]的指针。我们把指向数组的指针叫做数组指针。

来一个带函数的例子:

int (*a)(int *b);

首先,找到变量名 a。向右读取,遇到括号。掉头向左读取,遇到 *,表示 a 是指针。问:指向什么?

跳出括号,向右读取,遇到 (形参表),答:指向函数。问:函数返回什么?

还剩下一个类型名 int,答:返回 int。对于形参表内的元素,再单独分析即可(这里是一个指向整数的指针)。最后得到:a 是函数指针,该函数返回 int,形参表是一个指向 int 的指针

最后我们来看最开始的例子:

char (*(*a[3])(int x, char (*y)[5]))[5];

首先找到变量名 a。向右读取遇到 [3]a 是数组。问:存储什么?

向左读取遇到 *。答:存储指针。问:指向什么?

跳出括号向右看,遇到 (形参表)。答:指向函数。问:返回什么?

遇到括号,向左读取 *。答:返回指针。问:指向什么?

跳出括号,向右读到 [5]。答:指向数组。问:存储什么?

还剩下类型名 char,答:存储 char。再分析之前的形参表,有:a 是含有 3 个元素的数组,该数组存储[指向函数的指针],该函数返回指向[含有 5 个元素的字符数组]的指针,其形参表是[一个整数,和一个指向[含有 5 个元素的字符数组]的指针]

typedef 简化声明

我们发现,在这个声明中,函数的返回值和形参表中的一个参数都是「指向[含有 5 个元素的字符数组]的指针」。这种情况下,我们可以用 typedef 语句简化声明。typedef 的作用就是为给定类型取一个别名。用法很简单:先为这个类型写一个声明,然后在最前面加上 typedef,再把声明中的变量名换成想取的别名即可。例如,我们想为「指向[含有 5 个元素的字符数组]的指针」取一个别名,先写一个普通的声明:

char (*a)[5];

在声明语句前加上 typedef,再把 a 换成我们想要的别名(这里是 ptr_str),就得到 typedef 语句:

typedef char (*ptr_str)[5];

那么,上面的那个声明就可以简化为:

ptr_str (*a[3])(int x, ptr_str y);

这样就容易理解多了。

有时我们会用 const 限定符来限定声明的对象,使其不能被修改。在复杂声明中,const 作用于哪个符号?如果 const 在声明起始,那么它作用于类型名;如果 const 位于中间,那么它作用于它左侧的 * 或类型名。例如 const float *p;float const *p; 都表示该指针指向的 float 值不可被修改;而 float * const p; 表示该指针本身不能被修改。

要说明的是,一般的程序中并不会遇到如此复杂的类型。但是,若能理解这个例子,那么声明一些简单的复合类型时就绝对不会出错了。

多维数组和高阶指针

数组名与 const 指针

最基础的知识点:

  1. 每个数组的名称都可以看作指向该数组首个元素的 const 指针。(我们后面会详细讨论「看作」)
  2. n 是整数,那么 p[n] = *(p + n)

一个例子:

int a[5] = {0, 1, 2, 3, 4};
int* p = a;
p++;

因为 a看作指向 a[0]const 指针,所以它可以直接赋给 p。所不同的是,由于 a看作 const 指针,所以 a 不能被修改,而 p 可以。p 自加后, p[2] = *(p + 2) = *(a + 1 + 2) = *(a + 3) = a[3] = 4

二维数组元素表示法

定义一个二维数组:

int a[3][5] = {
  {0, 1, 2, 3, 4},
  {5, 6, 7, 8, 9},
  {10, 11, 12, 13, 14}
};

如果我们用前面的方法解释,a 是一个含有 3 个元素的数组,分别为 a[0]a[1]a[2] ,它们每个都是含有 5 个元素的 int 数组。由于数组名可看作指向数组首个元素的指针,所以a看作指向 a[0] 这个数组的指针,a[0]a[1]a[2] 分别可看作指向 a[0][0]a[1][0]a[2][0] 的指针,即 int 指针。画图表示:

要注意,虽然我们一般把这个二维数组理解成 3 行 5 列,但在内存中它其实只有一维,是一段连续空间。

如果我们要表示「13」这个元素,有哪些方法呢?

我们当然可以直接采用数组表示法,表示为 a[2][3];也可以先用数组表示法找到第三个数组的首地址,然后加上 3 个位置,再解引用,表示为 *(a[2] + 3);还可以先找到 a,然后加上 2 解引用得到第三个数组的首地址,再加上 3 解引用得到 *(*(a + 2) + 3)

弄懂二维数组,三维及以上的数组就不难理解了。

二维数组、二级指针、数组指针和指针数组

这些概念有些难以区分和使用。下面是一些声明:

int aa[3][5] = {
  {0, 1, 2, 3, 4},
  {5, 6, 7, 8, 9},
  {10, 11, 12, 13, 14}
};            /* 声明二维数组 */
int **pp;     /* 声明二级指针 */
int (*pa)[5]; /* 声明数组指针 */
int *ap[3];   /* 声明指针数组 */

我们首先来看关于二维数组二级指针的一个常见误区。很多人认为,既然数组名可以看作指针,那么二维数组名也可以看作二级指针,所以我们可以直接赋值:

pp = aa;

然而报错:

[Warning] assignment from incompatible pointer type

这说明,二维数组名和二级指针是绝对不能划等号的。为了方便说明,我们先退回一维数组说起:

int a[4] = {0, 1, 2, 3};
int *p;
p = a;
a[2] = 5;

尽管经常将数组名看成指针,但数组和指针完全是两回事。实际上,a 的类型是 int [4],即长度为 4 的 int 数组。a 指的是放置长度为 4 的 int 数组的这一整段内存单元(而不是首元素或它的地址); p 的类型是 int *p 指代放置 int 指针的这一段内存单元。

只不过,在大多数时候,a 这个数组名被隐式转换为该数组首元素的地址。在这里,a 是一个 int 数组,其首元素的地址自然可以赋给 int 指针,所以 p = a; 这个语句没有问题的。再比如,用数组表示法表示 a[2] 的时候,a 先被转换成首元素地址,然后 a[2] 被替换成 *(a + 2),表示首元素后面的第二个元素,即第三个数 2。

但是要注意,这样的隐式转换不是任何时候都成立,比如使用 sizeof 运算符来计算对象大小时。执行以下语句:

printf("a has a size of %d bytes, while ", sizeof(a));
printf("p has a size of %d bytes.", sizeof(p));

在我的电脑上输出的结果为:

a has a size of 16 bytes, while p has a size of 8 bytes.

a 是长度为 4 的 int 数组,int 现在一般是 4 字节,所以 a 占 4×4=16 字节。p 是指针,我使用 64 位操作系统,所以指针占 8 字节。在这里,a 是没有转换为首元素地址的。

还要再说明一点:对于高维数组,这种隐式转换一次只转换一级。这就很容易解释为什么 aa 不能赋给 pp:因为在赋值语句 pp = aa; 中,aa 被隐式转换为首元素(即 aa[0])的地址,而 aa[0] 本身也是一个数组(不再转换为指针),其类型是 int [5]ppint 的二级指针,只接收类型为 int * 对象的地址,当然不能用 aa[0] 的地址来赋值了!

在关乎类型的情况下,指针就是指针,数组就是数组,一定要注意区分。

如果我们确实要为 aa 取一个别名,就要用到数组指针了。刚才我们声明了 int (*pa)[5];pa 就是一个数组指针,可以存储类型为 int [5] 对象的地址。前面说过,aa 会被隐式转换成 aa[0] 的地址,它是可以直接赋给 pa 的:

pa = aa;

在这里,aa看作指向 aa[0],而pa 确实指向 aa[0],所以 paaa 是等价的。那我们就可以用 pa 来指代 aa 进行操作了,比如 pa[1][3] 就是 aa[1][3],即 8。

但有一点不同:我们现在可以修改 pa 的值,让它指向其他位置。如:

pa++;

现在 pa 指向 aa[0] 的下一个,即 aa[1]。这时的 pa[1][3] 就变成了原来的 aa[2][3],即 13。

二维数组作函数形参时也要用到数组指针。如果我们要把二维数组名 aa 作为函数实参,由于调用函数时 aa 会被转化为指向首元素(类型为 int [5])的指针,那么声明该函数时就该这样写:

T func(int (*pa)[5]); // 而不是 int **pa

也可以写成:

T func(int pa[][5]);

我们再来看指针数组。指针数组就是一个数组,其元素都是指针。在二维数组 aa 中,aa 是由 aa[0]aa[1]aa[2] 组成的数组,它们都可看作指向 int 元素的指针,所以 aa 也可以看作是指针数组。

现在我们定义了 int *ap[3];ap 是一个指针数组。如果我们要把 aa 这个指针数组赋给 ap,怎么办?

ap = aa;

这么写当然是错的:ap看作一个 const 指针,所以不能被赋值。我们只能这样写:

int i;
for (i = 0; i < 3; i++) {
  ap[i] = aa[i];
}

现在,我们尽可以用 ap 来代替 aa 表示原数组中的元素。比如,ap[2][3]aa[2][3] 一样,都表示13,因为 ap[2][3] 可以写成 *(ap[2] + 3),而 ap[i] = aa[i]

同样,我们现在可以修改 ap[i] 的值。假若我们执行下列语句:

ap[1] += 2;
ap[2]++;

ap[1] 原本指向 aa[1][0],加 2 后指向它后面的第二个,即 aa[1][2],为 7。同样地,ap[2] 指向 11。这时, ap[2][3] 就是 11 后面的第三个数,14。

另一种情况,假若我们执行下列语句:

int *tmp;
tmp = ap[1];
ap[1] = ap[2];
ap[2] = tmp;

这时 ap[1] 指向 10,ap[2] 指向 5。ap[2][3] 实际上是 aa[1][3],即 8。我们后面会看到,在字符串操作中,指针数组是经常使用的。

还有一点:由于指针数组 ap 的名字被转换为其首元素的地址,而其元素是指针,所以 ap 可被看作是一个二级指针。它可以直接被赋值给 pp

pp = ap;

pp 指向 ap[0],它是一个 int 指针。pp 可以自加,将指向 ap[1]

字符串指针

我们知道,字符串其实就是 char 数组,以 '\0' 作为结束符。不过它本身也有一些有趣的特性。

字符串字面量

char *s = "I'm a string!";

在这里,用双引号括起来的 I'm a string! 就是一个字符串字面量。编译程序时,编译器会自动在其后面加上 '\0'。程序运行时,这一字符串被存放进内存中,如果没有指针指向它,我们就只能用 "I'm a string!" 来访问它。"I'm a string!" 就如同这个字符串的名字,我们甚至可以这样写:

putchar("I'm a string!"[4]);

会输出 a

现在我们将 s 指向了该字符串(的首字符),就可以用 s 来操作了,如 s[0]'I's[7]'t'。但是,不能通过 s 来修改字符串的值,因为字符串字面量是 const 常量。若强行修改,程序会异常退出。

但如果这么定义:

char t[] = "I'm a string!";

就不同了。程序运行时,先将 "I'm a string!" 存储到内存中,再开辟一段大小刚好的内存空间,将 "I'm a string!"(包括后面的 '\0')拷贝到此空间。这时,字符串 t 是可以被修改的,但修改的是 t 本身,而不是字符串字面量 "I'm a string!"。如果在方括号中加数字,就指定了这段内存空间的大小。注意,一定要确保能够容纳后面的字符串字面量和末尾的 '\0'

在同一程序中,相同的字符串字面量往往会被当作同一个来存储。来看以下代码:

char *a = "I'm a string!";
char *b = "I'm a string!";
printf("%p %p", a, b);

在我的电脑上输出如下:

0000000000404000 0000000000404000

可以看到,两个 "I'm a string!" 其实被存储到了内存的同一位置。

用指针数组处理多个字符串

char *l[100];

我们现在定义了一个指针数组 l,长度为 100。处理字符串用指针数组比用二维数组方便得多,因为指针数组是可以修改值的。例如在字符串排序中,我们可以直接交换指针数组中两个指针的值,而不用费劲将两个字符串交换,如图:

现在问题是:如何读取这 100 个字符串?

int i;
for (i = 0; i < 100; i++)
  scanf("%s", l[i]);

这种写法错误,且十分危险!l 中的每一个元素都还是未初始化的指针,怎么能写入数据?

一种正确的方法,是再定义一个二维字符数组,用它来读取,再把指针数组指向它。这样比较占内存,因为每个字符串可能长度不一,我们在定义二维数组时需要照顾最长的字符串。我们可以用 malloc 函数,灵活地为每个字符串分配内存:

int i;
char s[1000] /* 假定字符串的长度最大可能为 1000 */
for (i = 0; i < 100; i++) {
  scanf("%s", s);
  l[i] = (char *)malloc(sizeof(char) * (strlen(s) + 1));
  strncpy(l[i], s, 1000);
}

这样,对于每个字符串,其分配到的大小刚好合适,不会浪费空间。


本文所有内容都来自课件、《C Primer Plus》和中文互联网,一定有很多不严谨或者错误的地方。我觉得最重要的内容就是复杂声明的阅读方法,和数组与指针的区别,剩下的内容都是知识的运用。祝使用指针愉快!

留下评论

注意 评论系统在中国大陆加载不稳定。

回到顶部 ↑