第 16 讲:数组和指针
上文我们基本上了解清楚了指针的基本用法和操作,不过,光是变量操作的指针模型可能过于单一,所以今天我们要看的是数组和指针的用法。
一维数组和指针
首先讲一下简单的一维数组和指针。首先你要明白一个东西,数组它其实除了是一系列数据的统一管理的集合外,它还有一个用的方式:指针形式。
我们可以使用指针,将数组改变为指针,然后对变量名操作来达到操作内部数据的方式。比如之前我们讲到的数组的声明和使用。我们先来看一下基本的使用数组的方式。
这样写起来很轻松,不过呢,我们除了使用中括号来找出数据外,还有一种写法:
注意看清楚,*p 和 b 变量,一个是指针变量,另一个则是一个普通的存数值的变量。我们再看右侧,a 只是一个数组名啊,为什么数组名字还可以加一个 2 和 3 呢?
这是因为,数组名本身除了表示它是一个数组的名字外,它也是一个指针,且这个指针指向 a[0]。换句话说,数组名除了表示数组的名称外,它还是一个指针变量,这个指针的位置上存的数据是 a[0] 的地址。
那么,对于地址加 2 来说,我们就很清楚了。因为数组是顺序存储数据的,每一个数据都是挨着放的,所以加 2 的话,自然就表示 a[2] 了。不过,a + 2 是真的 a[2] 吗?当然不是。刚说到,a 是指针,所以加了 2,还是一个指针,这个性质是没有变的。不会因为它是指针,加了数值之后变成了数值。所以 a + 2 其实是 &a[2]。换句话说:
p 和 q
p 和 q 也是一样的,只要没有找出数组存储的范围(如果数组只有 5 个元素,那么这里的 C 就最好别超过 4,如果 C = 5 甚至大于 5,都会出现超出访问数组的范围的隐藏 bug)。
所以,为了取出数值,还需要取值符号 *。所以相当于这样:
看懂上面的写法了吗?那么现在来做个等效代替:
因为取地址和取值是一对相反的运算,所以可以同时去掉。
另外,它还可以写作
你可以理解一下,这一点是为什么。
一维数组配合自增自减运算符
一个复杂但重要的例子
我们来看一下,指针变量在配合自增自减运算符后的意思的理解。我们先来看示例和运行后的结果,来理解它们。
这里提到了七个不同的语句写法:
*p++(*p)++*(p++)*(p+1)*p+1*++p*(++p)
最后发现数值除了一个数有些奇怪(*t + 1)外,其他都还算比较正常。现在我们来一个一个理解。
解释
第一个为什么是 1 呢?因为 *p++ 的自增符号在后,所以先不会改变值,而是先被取值。p 最开始指向了 a,就表示语句 p = &a[0]。由于自增符号在后,所以输出的应为 a[0],即 1。
第二个为什么还是 1 呢?因为刚说过了,自增符号在后,所以打不打括号其实都是先取值。所以输出的值也是 1。
第三个为什么是 2 呢?好像怎么看,自增运算符都不执行啊,即使有这个括号。这其实是因为,刚才 (*q)++ 式子最开始取出了值,但因为自增在后,所以输出原来的 1,但后面的自增符号使得 a[0] 从 1 变为了 2。所以 *(r++) 虽然打了括号,但由于在后的关系,数值不会发生改变,但 a[0] 已经变为了 2,所以自然取出数值时,就是 2 了。
第四个则比较好理解,*(s + 1) 自然取的是 a[1] 的值,故为 6。
第五个,这里括号不见了,可是为什么变成3了呢?*t + 1 语句下,是先取了 a[0] 的值,然后对值再加 1,所以自然是 2 + 1(注意是 2 哈,刚才已经改为 2 了,不要带 1 进去)。
第六个,*++u,自增符号在前,所以自然是对原本指向的 a[0] 往后移动一位,变为指向 a[1]。或者换句话说,++u 其实也就是 a + 1,或者是 &a[1]。所以加星号表示取其值,所以是 a[1] 的值,也就是 6 了。
第七个,和刚才不加括号是一样的效果。
可能你会问第一个,为什么 *p++ 和 (*p)++ 不同。我是这么解释的:*p++ 是相当于 *(p++) 的,这个自增符号不管在哪里,始终要记得,因为它挨着变量,所以它是先会被打括号的;而取值符号 *,不一定会直接挨着变量,所以有时候并不会先计算。所以我们有一个结论:*p++和*(p++)一样,*++p和*(++p)一样。刚才我们在计算第六个的时候,就知道了,自增是挨着变量的,不论前后的关系,既然挨着,就肯定是先计算的咯。
所以,优先级什么的都喂狗去吧!根本不需要去死记硬背的。
从中我们可以总结以下内容:
*p++等价于*(p++)等价于*((&a[0])++)等价于*(&a[0]++)等价于两个语句a[0]; &a[1];,自增符号在后,显示数据时,自增符号不会起作用,即使使用完毕后,因为后面是&a[1],对后续变量的操作也没有任何影响(指针变动而不是数值变动),所以并没有用。*++p等价于*(++p)等价于*(++(&a[0]))等价于*(++&a[0])等价于*(&a[1])等价于a[1],自增符号在前,取a[1]。*p + 1等价于*(&a[0]) + 1等价于a[0] + 1。(*p)++等价于(*(&a[0]))++等价于(a[0])++等价于a[0]++,即a[0]使用完毕后自增 1。
最后考一个问题:
看一下,注释表示当前位置输出的数值。请尝试理解一下为什么。
二维数组和指针
二维数组是既有行又有列的数组,所以相当于行、列均有数组的指针。
和刚才一样,我们先定义一个数组。
然后,这个 a 变量除了是数组的变量名外,还是一个指针。不过因为是二维的,就稍显麻烦。
我们可以尝试将这里的a理解成一个二重指针。它是指向指向数值的指针的指针。很绕对吧~
其实是这个意思:
因为说的是二重指针,所以在声明指针变量时,需要两个星号。它等效于这句话:
首先我们使用一重指针指向行上的位置,因为数组名的缘故,所以指针指向的是最初的位置 a[0];但是因为 a[0] 在二维数组下并不是一个数,而是一系列数据(可以看作是一个数组,数组内各自又有一个单独的数组),所以,&a[0]实际还不够,还要取出列下标为 0 的情况,所以有两层括号和两层取地址符号。
那么,普通的指针可以这么表示:
a[0][1]
*a 表示是 a 数组的第一行的所有数据,即 &a[0],所以上述语句可以等效为
但是依然不够。因为 a[0] 我们刚才说过,a[0] 是一组数据,虽然光秃秃在这里写着,没有地址符也没有取值符,但始终记得它相当于是一个数组名叫 a[0]。
然后 a[0] + 1 表示将列往后移动一位,即相当于一维数组的指针 a + 1 这样的存在。
所以这里就表示为了 &a[0][1]。可为什么还带个指针呢?因为刚才就说过,一维数组的指针就带有取地址符号,即 a + 1 等效于 &a[1],所以自然二维数组的指针也有这样的符号了,故 a[0] + 1 就表示 &a[0][1] 了。所以取出值还得需要一个取值符号,故 *(&a[0][1]) 就变为了 a[0][1] 了。
所以上述语句就变为:
明白意思了吗?
所以第 i 行第 j 列的数值用指针表示为
三维数组?
三维数组也是,只是指针变为三重指针罢了,只是就不多阐述了。它的表示可以类比这个推导出来:
把数组赋值给指针的退化赋值形式
前文提到了很多有关数组的声明(定义)和赋值方式。不过数组是可以赋值给一个指针的。比如下面这样:
这样表示出来的 series 是一个指针,它依然指向的是 series 赋值的这个整型数组的首元素地址。所以这种方式几乎可以认为是等价于 int series[] = { 1, 3, 5, 7, 9 };,唯一的区别是,数组格式的声明可以为中括号指定元素总数(5),而指针形式则不可能这么写了。
我们常把数组赋值给指针的这种赋值行为被称为退化赋值(Degenerated Assignment),因为它损失了数组长度这一个信息。不过,这种退化赋值却在 C 语言里使用得非常广泛。下面要讲到的数组传参就是典型的其中一种使用方式。
数组传参
数组是可以用于传参的。换句话说,数组是可以当形式参数(形参)传递的。不过,古老的 C 语言只允许数组的指针写法传递,而且还有着另外的写法。因为这里用的类型不多,所以只讲解一维和二维数组的传参。
不过在此之前,我们要搞清楚数组指针和指针数组的区别。这个名字有些绕,我们不用管名字,看它怎么用。
数组指针和指针数组
首先我们来看以下两种写法:
p[20]
换句话说,我们借用本文最开头的第三种理解思路:
其实就看得很清楚了。它是一个数组,有 20 个元素组成。不过这个数组全存放的是指针罢了。
这种数组称为指针数组(存放指针的数组)。
下面这个呢?被打了括号,所以完全不同。
因为括号的关系,星号不可被拆开。所以它是什么呢?它是一个可以指向一个数组的指针。换句话说,如果我有100 个数据,被认为划分为了 5 个数组,每一个数组都有 20 个元素,并且它们是随便划分的,所以毫无关联。
这个时候,我们可以让这个指向数组的指针(即数组指针)分别去指向这不同的 5 个数组,来测试这些数据。
有了这些基础的理论后,我们来看一下示例。
一维数组传参
首先我们试想一个场景,求一个数组下的最大数值。这个时候,我们可以这么做:
这样,一个数组的最大值的算法就写好了。接下来我们来看一下,指针写法(因为 C 语言要用数组的指针写法来传递形参)。
所以首先,我们要把上面的 a[] 这个诡异的中括号里值都没有的写法给它改掉,改为指针写法:
就可以了。所以在声明时,按照可以去掉变量名称的手段,我们可以将刚才的程序的完整版写成这样:
当然,max 内的 a[i] 等使用到数组的地方的,也可以改写为指针写法。当然,这里不改也没有关系。
注意数组传参的过程,直接把数组名称写上去,而不要地址符号,也不要中括号,因为它传递的是一个地址数值,表示
&a[0]。
&a[0]、a 和 &a?
或许有小伙伴会对一维数组的指针三种写法提出问题:如果我们把数组命名为 a 的话,它同时等价于首元素的地址,即 &a[0],那么,&a 表示什么?按照道理来说,&a 表示的是这个数组变量本身的地址,而这个变量的地址不应该就是 &a[0] 吗?所以这么说来的话,a、&a 和 &a[0] 三者不是就完全一样了吗?
我想告诉你的是,这个说法目前来说确实是正确的。我们也确实经常写 a 来表示首元素地址,也有时候会用 &a 来“严谨地”表达数组变量本身的地址,而由于数组变量本身而言,从这里开始存放的一系列元素就是这数组里的东西,所以 &a 和 a 这么看是等价的。不过……
在使用 + 和 - 的运算符的时候,就可以看出它们的区别。
来看这个执行逻辑,x、y 和 z 都得到什么结果?第一个是把数组地址加上 1,它表示往下移动 1 个单位,所以等价于 &a[0] + 1,即 &a[1];而 z 也就是这个结果。那么中间的 y 呢?它其实表示的是,以数组整体大小为单位,往下跨越一个完整大小的空间,所以它一口气就相当于往后移动到指向不存在的 a[4] 上去了。所以,请不要这样使用指针来操作数组,会立马超出访问范围,出现致命的 bug。
二维数组传参
二维数组依然要取出最大值,就不容易了,因为它有两个维度。于是我们先要改写上面的 max 函数。
注意这里,传参的写法,是一个数组指针,即指向数组的指针。这一点也很好理解。因为二维数组本身应当在处理成指针写法时可以改为二重指针,但因为传参的缘故,它的行列都可以变动,所以我们不能在处理函数时,行、列同时变动,即必须是“一静一动”,不然程序就不受控了。所以我们要保证其中一个维度是静止的(即数组形式),而另外一个维度则用指针来操作,就很轻松。所以写成了数组指针写法。刚才也告诉大家,数组指针是一种指针,不过这个指针是指向一个数组而不是一个数值的。当改变指针的值时,也就相当于移动了指针,达到测试和使用二维数组之中“一个维度下的数组”的操作。
那么它的声明就省略变量名即可,但是因为只能省略变量名,所以括号是不能少的。
令人遗憾的返回数组的函数
听说你想返回数组?
我们有时候也尝试把一个数组作为返回值返回到外界来,但很遗憾的是,我们无法把返回数组的函数写成符合我们原本理解的可能写法:
或
这两种写法都是不允许的。所以我们如何保证返回一个数组呢?
返回数组只能使用前文提到的退化赋值的方式。如果你有一个数组需要返回,我们只能写成指针的方式返回,所以实际上的写法是这样的
这种写法没有问题,不过,很遗憾的是,它少了一个提示信息,即数组的长度,这样返回的数组很容易出现越界的问题,所以你可能还会在这种函数里加上一个 out 参数表示数组长度。
不过…… 还有一个遗憾的地方。在 C 语言里,所有在函数里声明的变量都是跟着栈内存走的。这有什么意义呢?跟着栈走的变量声明,会在函数执行完毕后自动销毁,所以,如果你尝试返回一个函数内定义出来的数组的话,只能很遗憾地通知你,在函数执行完毕后,这块内存会被销毁,所以你返回的这个指针其实是销毁前的这个结果的这块内存,但它目前已经被销毁。
所以,目前我们只靠前文学到的知识点还无法做到不销毁这些自己声明和定义的内容。等我们接触到结构体和堆内存的时候,我们才会提到了。
总结、悬空指针和野指针的概念
所以,这一节的所有文字其实就想告诉你两个点:第一,返回数组的函数是做不到的。非得返回数组,你也只能退化赋值,返回一个指针;第二,返回的数组对象的这个指针,即使被成功返回了,也逃不过函数销毁时立刻销毁内部定义的所有变量的厄运。所以,不要尝试在函数里声明(定义)一个数组,然后用指针形式返回这个数组(的首地址),这样依然会失败。而且,这个指针由于失去了原本指向的内容,所以这个时候这个对象还被称为悬空指针(Dangling Pointer)。
另外,还存在一个说法,叫做野指针(Wild Pointer),它表示一个没有初始化的指针变量。这个指针由于没有初始化,就和普通变量一样,它内部存储的值是不明的。不过由于它是指针的关系,内部的数值很可能是随机的,所以这意味着这个指针表示的意思就是“随机指向”。这是一个很可怕的概念。随机指向很有可能破坏电脑。
static 修饰数组参数
当数组传入函数里的时候,我们常常可以对这个函数的参数加以修饰。在 C99 标准里,我们甚至可以对一个数组函数的长度作出修饰。
我们如果把数组参数用数组格式书写到参数上的时候,我们可以使用 static 数字 的方式来说明,这个数组参数传入的元素总数最多只能有 10 个。当然,这个用法有时候很有用,但有时候很鸡肋。
指针的运算
在 C 语言里,我们为指针也提供了方便的运算符,使得我们可以通过运算符来操作和计算内存地址。
上文我们其实已经用到了一部分这样的运算符,诸如 ++、-- 这些符号,下面要提到的是这样的一些运算符:
+:计算两个地址的和,或是为一个地址增加偏移量。比如a是一个数组,a + 3将会把当前地址往后移动 3 个元素单位。-:和上面的算法相反,这里是减法。*:将地址数值相乘。这个用法很少。/:将地址数值相除。这个用法也很少。%:把两个地址数值以整数方式执行取模运算。这个用法也很少。
distance 是直接把两个指针相减,得到的是 p 和 q 它们两个在内存里相差的距离,而 valueDifference 表示的是 p 和 q

