4445 字
22 分钟
【专题】三篇文章搞定C指针——其二

前言#

本期专题:《三篇文章搞定 C 指针》

专题地址(打不开就是没写完或者在修改):

这一期是 C 语言的指针专题,其实大部分内容也同样使用与 C++ 中,后续有的指针补充将会另出专题。

在这一篇文章中介绍了指针在数组、函数中的应用。

WARNING

本专题文章内容相对基础,过于专业的部分会省略。

本专题参考文献或网站如下(References):

  • 《C Primer Plus》
  • 《Pointer on C》
  • Akaedu
  • runoob.com

本文文案: @Hoyue


数组与指针#

一维数组#

数组名#

我们从简单的来看数组,首先看下面这两个声明:

int a;
int b[10];

我们把变量 a 称为标量,因为它是个单一的值,这个变量的类型是一个整数。

我们把变量 b 称为数组,因为它是一些值的集合。其中 b 为数组名,[] 内部为下标。使用数组和下标时有两种情况:

  1. 当数组声明时,下标内的值为一个常量,表示这个数组的长度
  2. 当下标和数组名一起在声明外使用时(即形如 b[i] 时),用于标识该集合中某个特定的值

例如,在例子中的声明中表示,创建了一个整数型的数组 b,它的长度为 10,即 0~9。

在声明之外 b[0] 表示数组 b 的第 1 个值,b[4] 表示第 5 个值。每个特定值都是一个标量,可以用于任何可以使用标量数据的上下文环境中。(数组的值从 0 开始)

那么 b[4] 的类型是整型,但 b 的类型又是什么?

在 C 中,在几乎所有使用数组名的表达式中,数组名的值是一个指针常量,也就是数组第 1 个元素的地址。

即:b = &b[0];

数组与指针相比,数组具有确定数量的元素,而指针只是一个标量值。编译器用数组名来记住这些属性。只有当数组名在表达式中使用时,编译器才会为它产生一个指针常量。

下标引用#

在对比数组的下标引用和指针的时候,我们先要给出一句话:

WARNING

C 的下标引用和间接访问表达式是一样的。

我们知道在数组中的下标引用有两种情况,我们更多用的是第二种,那么我们是否可以用间接访问来表示呢?

接着上面的例子,我们看 *( b + 3 )

首先,b 的值是一个指向整型的指针,所以 3 这个值根据整型值的长度进行调整。加法运算的结果是另一个指向整型的指针,它所指向的是数组第 1 个元素向后移 3 个整数长度的位置。然后,间接访问操作访问这个新位置,或者取得那里的值(右值),或者把一个新值存储于该处(左值)。

我们发现它和下标引用的执行过程完全相同。

那么总结一下,下面这两个表达式是等价的:

  • array[subscript]
  • *(array+(subscript))

证明:第一个表达式,在下标表达式中,子表达式 subscript 首先进行求值。然后,这个下标值在数组中选择一个特定的元素;在第 2 个表达式中,内层的那个括号保证子表达式 subscript 像前一个表达式那样首先进行求值。经过指针运算,加法运算的结果是一个指向所需元素的指针。然后,对这个指针执行间接访问操作,访问它指向的那个数组元素。

我们举个例子来理解这些,假如有:

int array[10];
int *ap = array + 2;

很明显,看这个声明,指针 ap 指向 array[2]。

我们来看看几个表达式它们表示的意思:

  • ap:这个很简单,你只要阅读它的初始化表达式就能得到答案:array+2,它还可以写成:ap=&array[2];
  • *ap:间接访问 ap 所指向的元素,即访问 array[2]*ap 就是 array[2] 所代表的值。
  • ap+6ap 指向 array[2],加法运算产生的指针所指向的元素是 array[2] 向后移动 6 个整数位置的元素,即 array+8&array[8]
  • *ap+6:间接访问的运算优先级高于加号运算,间接访问的结果再与 6 相加,所以这个表达式相当于表达式 array[2]+6
  • *(ap+6):括号迫使加法运算首先执行,所以我们这次得到的值是 array[8]
  • ap[0]:你也许会诧异,这是什么东西?ap 是一个指针变量,又不是一个数组,这怎么能成立呢?其实它是正确的,还是因为那句话:C 的下标引用和间接访问表达式是一样的。下标引用可以转化成间接访问,即 ap[0]=*(ap+(0)),其结果与前一个表达式相等。因此,它的答案就是 array[2]
  • ap[6]:同上,它可以转化为间接访问,即 ap[6]=*(ap+6)
  • &ap:这个表达式是完全合法的,我们求的是指针变量的地址,此时没有对等的涉及 array 的表达式。
  • ap[-1]:负值的下标?下标引用就是间接访问表达式,你只要把它转换为那种形式并对它进行求值。所以同上,表示 array[1](注意:数组的下标引用时不能有负数!)

我们看这些例子,可以总结出下标引用和间接访问的替换方法

a[b]=*(a+b);// a,b 必须是地址

除了这些常规的情况,我们还有一个非常规的情况:

2[array]:第一眼看,这肯定是非法的,但它是合法的。我们把它转换成对等的间接访问表达式,你就会发现它的有效性:*(2+array),加法运算的两个操作数是可以交换位置的,所以这个表达式和 *(array+2) 完全一样。也就是说,最初那个看上去颇为古怪的表达式与 array[2] 是相等的。

对编译器来说,这两种形式并无差别。但是,这么写代码就是折磨自己和别人,最好别这样写。

数组和指针的区别#

看了上面的例子后,你可能会认为指针和数组其实是相等的,但指针和数组并不是相等的,看下面这个声明:

int a[5];
int *b;

a 是一个数组(名),b 是一个指针变量。a 和 b 都具有指针值,它们都可以进行间接访问和下标引用操作。但是,它们还是存在很大的区别。

  • 声明一个数组时,编译器将根据声明所指定的元素数量为数组保留内存空间,然后再创建数组名,它的值是一个常量,指向这段空间的起始位置。
  • 声明一个指针变量时,编译器只为指针本身保留内存空间,它并不为任何整型值分配内存空间。而且,指针变量并未被初始化为指向任何现有的内存空间,如果它是一个自动变量,它甚至根本不会被初始化。

因此,上述声明之后,表达式 *a 是完全合法的,但表达式 *b 却是非法的。*b 将访问内存中某个不确定的位置,或者导致程序终止。另一方面,表达式 b++ 可以通过编译,但 a++ 却不行,因为 a 的值是个常量。

初始化数组#

在声明数组的时候我们是否可以使用指针呢?当然可以,例如:

char message1[] = "Hello";
char *message2 = "Hello";

这两个初始化看上去很像,但它们具有不同的含义。前者初始化一个字符数组的元素,而后者则是一个真正的字符串常量。这个指针变量被初始化为指向这个字符串常量的存储位置

多维数组#

如果某个数组的维数不止 1 个,它就被称为多维数组,我们来重新认识一下多维数组。

存储顺序#

考虑下面这个数组:int array[3]; 它包含 3 个元素。

继续看下面这个新的声明:int array[3][6]; 我们可能第一时间把它想成是矩阵,但实际上计算机存储是线性的,所以在内存中是按行主序排列。

这个例子说明了数组元素的存储顺序(storage order)。在 C 中,多维数组的元素存储顺序按照最右边的下标率先变化的原则,称为行主序(row major order)

我们平时可以把多维数组按矩阵或其他形式来想象,但实际上不是。

数组名#

多维数组第 1 维的元素实际上是另一个数组,多维数组可以看为是一个一维数组,包含 3 个元素,只是每个元素恰好是包含 10 个整型元素的数组。

例如:int matrix[3][10]; matrix 这个名字的值是一个指向它第 1 个元素的指针,所以 matrix 是一个指向一个包含 10 个整型元素的数组的指针。

同理,matrix[3] 也是一个地址,这个名字的值表示 10 个元素数组的首元素的地址,即:matrix[3] = &matrix[3][0];

即:当维度大于下标数时,此时是一个名字,代表下标数 + 1 的元素(数组)的首元素(数组)地址。

下标引用#

我们定义:int matrix[3][10]; 则此时 matrix 表示了指向包含 10 个整型元素的数组的指针,它指向包含 10 个整型元素的第 1 个子数组。

那么 matrix+1 又是什么呢?matrix 表示了指向包含 10 个整型元素的数组的指针,是把数组看成了一个个元素,则 matrix+1 就是向下移动一行,因为 1 这个值根据包含 10 个整型元素的数组的长度进行调整,所以它指向 matrix 的下一行。把它写成间接访问的形式:*(matrix + 1) 事实上标识了一个包含 10 个整型元素的子数组。

指向数组的指针#

我们假设有以下这些声明:

int vector[10], *vp = vector; // ok
int matrix[2][10], *mp = matrix; // error
  • 第 1 个声明是合法的。它为一个整型数组分配内存,并把 vp 声明为一个指向整型的指针,并把它初始化为指向 vector 数组的第 1 个元素。vectorvp 具有相同的类型:指向整型的指针。
  • 第 2 个声明是非法的。mp 的初始化不正确,因为 matrix 并不是一个指向整型的指针,而是一个指向整型数组的指针。

我们应该怎样声明一个指向整型数组的指针的呢?应该:int (*p)[10];

下标引用的优先级高于间接访问,但由于括号的存在,首先执行的还是间接访问。所以,p 是个指针。接下来执行的是下标引用,所以 p 指向某种类型的数组。

则和上面的声明联系在一起就是:int (*p)[10] = matrix; p 是一个指向拥有 10 个整型元素的数组的指针。当你把 p 与一个整数相加时,该整数值首先根据 10 个整型值的长度进行调整,然后再执行加法。所以我们可以使用这个指针一行一行地在 matrix 中移动。

如果你需要一个指针逐个访问整型元素而不是逐行在数组中移动,那根据之前数组名时的介绍,应该这样定义:int *pi = matrix[0]; 增加这个指针的值使它指向下一个整型元素。

初始化#

我们在输入数据到数组时,通常通过循环,例如:

int array[11];
for (int i = 1; i <= 10; i++)
scanf("%d", &array[i]);

此时使用的是下标运算,那我们可不可以用指针呢?也可以,而且在输入内容多的时候指针反而更有效率。

int array[10], *ap;
for (ap = array + 1; ap <= array + 11; ap++)
scanf("%d", ap);

指针数组#

除了类型之外,指针变量和其他变量很相似。正如你可以创建整型数组一样,你也可以声明指针数组。例如:int *api[10];

下标运算的优先级高于间接访问,所以在这个表达式中,首先执行下标引用。因此,api 是某种类型的数组。api[10] 取得第一个数组元素地址之后,随即执行的是间接访问操作。

对数组的某个元素执行间接访问操作后,我们得到一个整型值,所以 api 肯定是个数组,它的元素类型是指向整型的指针。

指针数组常用于:字符串的列表可以以矩阵的形式存储,也可以以指向字符串常量的指针数组形式存储。

易错总结#

  1. 在一个指向未指定长度的数组的指针上不能执行指针运算,即 *a[] 是非法的。
  2. 函数的指针形参大多数情况都应该声明为 const
  3. 在有些环境中,使用 register 关键字提高程序的运行时效率。

函数与指针#

参数指针#

首先是一个原则:C 函数的所有参数均以“传值调用”方式进行传递,这意味着函数将获得参数值的一份拷贝。这样,函数可以放心修改这个拷贝值,而不必担心会修改调用程序实际传递给它的参数。

所有参数都是传值调用。但是,如果被传递的参数是一个数组名,并且在函数中使用下标引用该数组的参数,那么在函数中对数组元素进行修改实际上修改的是调用程序中的数组元素。函数将访问调用程序的数组元素,数组并不会被复制。这个行为被称为“传址调用”。数组参数的这种行为似乎与传值调用规则相悖。

数组名的值实际上是一个指针,传递给函数的就是这个指针的一份拷贝。下标引用实际上是间接访问的另一种形式,它可以对指针执行间接访问操作,访问指针指向的内存位置。

所以总结一下,参数的两种情况:

  1. 传递给函数的标量参数是传值调用的。
  2. 传递给函数的数组参数在行为上就像它们是通过传址调用的那样。

下面举个例子来看传址调用的情况,假设编写一个交换函数,使 a,b 的值交换:

它希望修改调用程序传递的参数。这个函数的目的是交换调用程序所传递的这两个参数的值。我们不能直接在函数里修改,因为它实际交换的是参数的拷贝,原先的参数值并未进行交换。

为了访问调用程序的值,你必须向函数传递指向你希望修改的变量的指针。接着函数必须对指针使用间接访问操作,修改需要修改的变量。所以正确的写法:

void swap(int *x, int *y)
{
int temp;
temp = *x;
*x = *y;
*y = temp;
}

因为函数期望接受的参数是指针,所以我们应该这样调用它:swap(&a, &b);

声明数组参数#

如果你想把一个数组名参数传递给函数,正确的函数形参应该是怎样的?它是应该声明为一个指针还是一个数组?根据上面,调用函数时实际传递的是一个指针,所以函数的形参实际上是个指针。于是当我们声明数组参数时,下面两个函数原型是相等的:

int strlen(char *string);
int strlen(char string[]);

两种声明都可以使用,但更加准确的是指针。因为实参实际上是个指针,而不是数组。至于为什么函数原型中的一维数组形参无需写明它的元素数目,因为函数并不为数组参数分配内存空间。形参只是一个指针,它指向的是已经在其他地方分配好内存的空间。

作为函数参数的多维数组#

作为函数参数的多维数组名的传递方式和一维数组名相同——实际传递的是个指向数组第 1 个元素的指针。但是,两者之间的区别在于,多维数组的每个元素本身是另外一个数组,编译器需要知道它的维数,以便为函数形参的下标表达式进行求值。

假设有 int matrix[3][10]; 我们在一个函数中要调用的话,函数原型应该这样声明(两种都可以):

void f(int (*m)[10]);
void f(int m[][10]);

在编写一维数组形参的函数原型时,你既可以把它写成数组的形式,也可以把它写成指针的形式。但是,对于多维数组,只有第 1 维可以进行如此选择。

【专题】三篇文章搞定C指针——其二
https://hoyue.fun/pointer_c2.html
作者
Hoyue
发布于
2021-12-29
最后更新于
2024-01-14
许可协议
CC BY-NC-SA 4.0
评论