在看懂数组和指针的关系之前,我们来回顾一下数组和指针的基本知识吧~~
基本知识回顾
一维数组
定义和初始化
基本格式
1 | 数据类型 标识符[数组长度] = {初始值,初始值,...}; |
- 数组长度必须是常量。
[数组长度]
只是个修饰符,而不是类型的一部分。
只定义但不初始化
这时数组中每个元素的初值都是不确定的:
1 | int ai[10]; // 元素值都是随机值 |
如果是静态的,则每个元素的初值为0:
1 | static int sai[10]; |
定义并初始化
- 初始化全部元素:
1 | int a1[5] = {1,2,3,4,5}; |
- 初始化前面几个元素,则剩下的元素会被初始化为0:
1 | int a2[5] = {1,2,3}; |
初始化部分元素的个数必须小于等于数组大小!
- 当数组有元素时,可以省略数组大小:
1 | int a3[] = {1,2,3}; |
访问数组
访问和修改数组元素
用数组名+下标访问和修改可以修改数组元素。
注意,编译器不会检查数组越界。
1 | a1[1] = 100; |
二维数组
定义和初始化
基本格式
1 | //数组长度必须是一个常量 |
跟上面的一维数组的定义和初始化差不多的,你可以只定义而不初始化,也可以定义的时候初始化。
对于初始化语句,你可以进行分开赋值,也可以进行连续赋值:
1 | //连续赋值 |
在多维数组的定义和初始化中,只能省掉一维的长度。
1 | //得到 int[2][3] |
访问数组
访问和修改数组元素
用数组名+下标访问和修改即可。
1 | arr[1][1] = 100; |
指针
在计算机内存中,每个字节都有一个唯一的标识,这个标识就是地址。
指针变量的值是一个地址。根据这个地址,可以访问存储器相应位置的数据。
指针的定义和初始化
基本格式
1 | 类型 *标识符 = 初始地址; |
其中,指针指向的类型称为基类型。
指针只能指向同一基类型的变量。唯一的例外是基类型为 void
的指针可以指向任意类型的数据。
定义语句中
*
只是一个修饰符,并不是类型的一部分。
1 | int *pi,i;//pi是指向整型数据的指针,i是整型变量 |
指针的解引用
通过指针变量的值去访问内存中相应位置的数据。
通过 *
符号,便可访问和修改指针指向单元的数据:
1 | int i = 1; |
指针作函数参数
指针本身作为一个变量,仍然是 按值传递 。但是它传递的是其他变量的地址,因此在函数内可以通过传入的地址去修改变量的值。
下面是一个交换数据的算法:
1 | void swap(int *a,int *b) |
数组名?是指针还是数组
我想各位应该见过这种,将数组名赋值给指针的写法吧:
1 | int a[10] = {1,2,3,4,5,6,7,8,9,10}; |
那,我们是不是就可以说 a
就是指针了?那可不一定!
我们先不谈数组和指针这种抽象的玩意儿,先看看 整型和浮点型吧:
1 | double d = 10; |
10
是整型,但却赋给了 double
型的变量。我想大家应该也清楚,这中间发生了类型转换。
对于指针和数组,这同样是这样。
数组和指针,实际上是两个类型。 我们可以通过 sizeof
运算符来看出数组和指针的区别:
1 | int a[10] = {1,2,3,4,5,6,7,8,9,10}; |
可以看到, a
的大小是 40
个字节,p
的大小是 8
个字节。
注意,这个程序是在 64 位环境下编译的,如果使用的 VC++ 6.0,指针占用4个字节。
如果各位有学习C++的话,应该知道,C++里面有一个引用类型。我们可以将数组赋值给数组的引用,但不能赋值给指针的引用:
1
2 int (&ra)[10] = a; // ok
int *(&rp) = a; // error:cannot bind non-const lvalue reference of type 'int*&' to an rvalue of type 'int*'数组引用的存在,说明了数组名就是数组类型。
数组和指针的关系
数组到指针的退化
我们还是继续探讨这行代码:
1 | int *p = a; |
既然数组和指针是两个类型,那么这行代码肯定发生了数组到指针的转换。没错,就是这样!
数组和指针有一个重要的关系: 数组类型在传递时,会转换成指向第一个元素的指针。这种关系我们称为 数组到指针的退化。
不过,这有一些例外:
对数组类型取地址
1 | int arr[10]; |
用 sizeof
获取数组类型的大小
1 | int arr[10]; |
将字符串字面量(实际上是一个字符数组)用于数组初始化
1 | char arr[100] = "6666666"; // "6666666" 是一个字符数组 |
这是一个更为严谨的表述:
任何数组类型的左值表达式,当用于异于
- 作为取地址运算符的操作数
- 作为 sizeof 的操作数
- 作为用于数组初始化的字符串字面量
的语境时,会经历到指向其首元素指针的隐式转换。结果为非左值。
数组和指针的这种关系使得数组在传递的过程中,只需要传递首元素地址,而不必传递整个数组,不然这得多耗费时间。毕竟,C语言一开始设计出来是要做操作系统的~~ 这样操作,提高了语言的执行效率。
函数参数中的数组
前面提到,数组在传递过程中,传递的是首元素地址,而不是整个数组。 参数作为两个函数之间传递数据的重要纽带,传递的自然也是指针这玩意儿。
形参中的数组,在编译的时候会被编译器替换成指向首元素的指针。所以函数形参中定义的数组类型,其行为和指针是一摸一样的。
不过,指向首元素的指针并不需要数组的大小,所以,形参中的数组参数自然就可以省掉数组大小了:
1 | function sort(int arr[]) |
为什么不能对数组整体进行赋值?
存储结构
一个程序的内存区域,被分成了四块:
堆区 heap:供程序员动态分配的一段空间。
栈区 stack:供系统自动分配和回收的空间,通常是局部变量。
数据区 data:程序一运行就会分配的空间,通常是全局变量和静态变量。
代码区 code:程序的代码所在区域。这些代码都是编译后的机器码,一条机器指令由操作码和操作数组成。
为了保证安全,代码区的内容通常不允许被修改。
此外,在CPU内还有很多的寄存器,用于临时存放操作数和计算的结果。但寄存器是被所有程序共用的,它不能长时间保存数据。
左值和右值
在C语言中,存在着左值和右值的说法。
左值,顾名思义,就是可以放在赋值号的左边的值。同样的,右值就是只能放在赋值号的右边的值。
不过这只是两个名字的来历,和他们的功能还是有一些区别的。
左值(lvalue) 通常是一些变量和函数。所有的左值都拥有地址。
下面的 ci
、i
和 func
都是左值。
1 | const int ci = 1234; |
一般来说,只要左值没有被 const
限制,它就可以放在赋值语句的左边。
右值(rvalue) 是不能取地址的一些量,通常是代码中直接出现的字面量,或者运算过程中得到的临时值。
代码中直接出现的字面量往往会存储在
内存的代码段,为了保证程序的安全,不被恶意篡改,C语言不允许对它们取地址。
而运算的临时结果一般在CPU的寄存器里,不在内存,所以他们压根就没有地址。
这是一个很经典的错例:
1 | int a,b; |
这个例子的错误就在于 a+b
的结果是一个右值,它不能取地址,那么计算机也就不知道该把 13
这个变量赋值到哪里了。
需要注意的是,一条机器指令占用的空间是有限的,而字符串字面量可能会占用很多的空间。所以,字符串字面量会存放在内存的数据段,而不是代码段。它也名副其实的成为了左值:
1 | char *pc = "hello world"; |
不能对数组整体进行赋值
类型转换实际上是一种运算,它和加法、减法、乘法、除法类似。所以类型转换的结果实际上是一个右值。
对于数组来说,尽管它可能是左值,即使它在赋值号左边,它仍然会转换成指向第一个元素的指针。这个指针是一个右值,不能取地址。因此,它就不能放在赋值语句的左边。
数组仍然是可复制的
或许有人会这样想,既然不能对数组整体进行赋值,那么数组肯定就不能复制了。这可不一定哦!
前文提到,不能对数组整体进行赋值是因为它转换成了右值,而不是它不能复制。
当数组放在结构体里面,结构体里面的数组就可以复制了:
1 | struct { |
不过,考虑到性能问题,不推荐直接在结构体里使用数组,因为在传递过程中,整个结构体会被反复复制。通常的做法是使用指针指向已存在的数组,或者手动分配一个数组空间:
1 | struct { |
用类型的角度看变量
在C语言中,无论是 int
、double
型的变量,还是带 const
的变量、数组和函数,他们都是变量,而且都有类型。
复杂变量的类型
有部分内容后面会再次提到哒~~
C语言中,判断一个复杂变量(如数组、指针)的类型,非常简单。
只需要将变量名舍去,便得到变量的类型。
栗子
定义一个指针 p
,那么它的类型就是 int *
:
1 | int *p; |
定义一个数组 arr
,那么它的类型就是 int [10]
:
1 | int arr[10]; |
对于函数,不仅要省掉函数名,还需要省掉参数名。
栗子
这里定义一个函数 myFloor
,它的类型是 int (double)
:
1 | int myFloor(double num) |
需要注意的是,变量的定义中,*
和 []
是修饰符,而不是类型的一部分,因此这些符号在定义时只能修饰一个变量.
栗子
下面一行代码,p
是指针,而 i
是一个整型的变量:
1 | int *p,i; |
如果类型再复杂一点,带上了括号,那该怎么办?
让我们先看一个栗子。
栗子
比如说有 int (*p)[20];
这个定义:
首先,从 p
开始读,遇到了 *
,噢,它是一个指针。
然后遇到了括号,看看括号外面的内容,右边有一个 [10]
说明这个指针指向了由10个元素的数组。
最后遇到了 int
,说明数组的元素是 int
型。
这说明,它是一个指针,指向了含有10个元素的 int
数组。
这是判断复杂变量类型的一个普遍的套路。
判断复杂变量的类型时,从变量名开始分析,顺序一般是从右往左。
当编译器解析到 *
时,表示这个变量是一个指针,之后解析的内容便是指针指向的内容。
对于数组也是这样,当编译器解析到 [数组大小]
时,表示这个变量是一个数组,后面解析到的内容便是数组元素的类型。
当 *
和 []
同时出现的时候,那编译器到底先解析 *
还是 []
呢?答案是,先解析 []
。
类似的问题还有很多,我们大体可以总结一下优先级的顺序:
符号 | 含义 |
---|---|
[] | 数组长度声明 |
() | 括号或函数参数表 |
* | 指针声明 |
& | 引用声明(C++) |
好在C语言中,常见的复杂类型也就这几种,我们把它们整理一下:
类型 | 说明 |
---|---|
int * | 指向 int 的指针 |
int [M] | 含有M个元素的 int 数组 |
int [M][N] | 含有M*N个元素的二维数组,元素类型为 int |
int *[M] | 含有M个元素的一维数组,元素类型为 int * |
int (*)[M] | 一个指针,指向含有M个元素的一维数组 |
int ** | 指向 int * 的指针 |
double (int, int) | 一个函数,参数为 int 和 int ,返回值为 double |
double (*)(int, int) | 一个指针,指向一个函数,参数为 int 和 int ,返回值为 double |
二维数组的类型
对于二维数组,我们可以把它看成一个一维数组,每个元素又是一个一维数组。
例如,下面有一个二维数组:
1 | int arr[10][88]; |
我们把它看成有 10 个元素的一维数组,每个元素又是有88个元素的数组,所以:
arr
的类型是 int [10][88]
arr[0]
又是一个数组,它的类型是 int [88]
指向数组的指针
数组在传递的过程中,会转换成指针,来提高数据传输的效率。那么当对数组本身去地址时,会得到什么呢?
这其实像极了“种瓜得瓜,种豆得豆”。对任何一个变量取地址,得到的是指向它的指针。
尽管数组在传递过程中会转换成指针,但对数组取地址,就免疫了这种“奇怪的行为”,得到的并不是指向数组第一个元素的指针,而是指向整个数组的指针:
1 | int arr[10] = {1,2,3,4,5,6,7,8,9,10}; |
指向数组的指针只是形式看起来有一些复杂,但它和普通的指针没有什么区别。
函数类型和函数指针
函数类型也是一种及其特殊的类型。
它的类型由函数参数和返回值组成。
栗子
这里定义一个函数 myFloor
,它的类型是 int (double)
:
1 | int myFloor(double num) |
也正因如此,它并不能区分函数里面有多少指令。所以,函数类型的长度是不确定的。所以,不能对函数使用 sizeof
运算符:
1 | sizeof(myFloor); //invalid application of 'sizeof' to a function type |
尽管有些编译器并不会报错,但这样的行为也是没有意义的。
函数类型在作为值进行传递的过程中,会被转换成指向自身的函数指针。 不过,在函数调用时,不会发生这样的转换。
在计算机底层中,函数的调用是通过跳转到函数的入口地址来实现的。
然而,对它取地址也会得到指向自身的指针。
对它解引用,则函数会先转换成指向自身的函数指针,再转换成函数,最后又转换回指向自身的指针。
所以,便有了这样的一些等价关系,结果都是指向函数自身的指针:
1 | myFloor <=> &myFloor <=> *myFloor; |
所以,在给函数指针赋值的时候,取地址运算符是可选的:
1 | int (*fp)(double); |
不过,函数指针也有怪异的等价关系:
1 | fp == *fp; |
*fp 得到的是函数,然后又转换成了指向这个函数的指针
需要注意的是,对函数指针取地址得到的是指向函数指针的指针,而不是自身。
函数指针的一个妙用,就是将函数作为另一个函数的参数。
栗子
比如说,我们可以给用户提供一个自定义排序的接口。
给定两个数字 a
和 b
。
如果希望 a
在 b
前面,就返回 -1
;
如果希望 a
在 b
后面,就返回 1
;
如果希望 a
和 b
保持原来的顺序,就返回 0
;
这个排序函数的原型大概就是这样:
1 | void sort(int *arr, int n, int (*func)(int, int)); |
在函数内,可以对函数指针进行调用,而传入的函数却是可以自定义的:
1 | func(1,2); |
另一个用法,就是函数指针数组。但这个用法不太多见。
函数指针数组的定义类似这样(返回值为 void
,没有参数):
1 | void (*afp[数组大小])(); |
可以尝试使用之前的套路分析一下这个看起来很复杂的变量定义。
下面通过一个例子,康康它的用处~~
栗子(选读)
这个例子有些难度,可以跳过(如果不理解的话)
比如说,一个程序有一个菜单,表示了不同的功能:
1 | 0.Exit |
我们将每个操作定义成函数。这些函数均不接受任何参数,返回值为 void
,例如:
1 | void inputRecord() |
我们可以使用一个函数指针数组,用下标代表操作的序号,值表示要执行的操作:
1 | void (*funList[12])() = { |
然后,根据传入的序号(假设为 choice
,int
型),我们就可以直接调用相应的函数:
1 | funList[choice](); |
指针和 const
的恩爱情仇
对于一个普通的变量来说,为它加上 const
是十分简单的:
1 | const int ci1 = 10; |
这说明 ci1
和 ci2
定义后就不能修改它的值。
实际上,这两种定义方式是相同的,但在实际使用时,我们一般把 const
放在前面。
但对于一个指针来说,给它加上 const
是非常复杂的。因为这会存在三种情况:
- 指针本身的值不可以修改,但是它指向的值可以修改。
- 指针本身的值可以修改,但是它指向的值不可以修改。
- 指针本身的值和它指向的值都不可以修改。
而且,在哪里加上 const
也是一个需要讲究的问题:
1 | int i = 2333; |
事实上,三种情况都是存在的,分别对应着这三种定义形式。
栗子
我们先来康康 const int *pci = &i;
这一个,使用之前的套路进行分析:
编译器先解析 *
,表示这是一个指针;
编译器再解析 int
,表示这是一个指向整型的指针;
编译器最后解析 const
,表示这是一个指向常整型的指针。
这说明,指针 pci
本身的值可以修改,但是它指向的值不可以修改。
不过,也许有人会问,那 i
是不是会变成常量了?这可不是这样。
指针定义中出现的 const
,只会影响通过指针的访问方式,而不会改变原来的访问方式。
所以,i
还是原来的亚子。
我们再看一个栗子:
栗子
我们再来康康 int * const cpi = &i;
这一个,依然使用之前的套路进行分析:
编译器先解析 *
,表示这是一个指针。
编译器再解析 int
,表示这是一个指向整型的指针。
编译器最后解析 const
,表示这是一个指向常整型的指针。
这说明,指针 pci
本身的值不可以修改,但是它指向的值可以修改。
看完这两个栗子以后,估计各位也都应该知道 const int * const cpci = &i;
这行定义的分析方法了吧。
总结一下:
定义 | 类型 | 指针本身可修改? | 指向的内容可修改? |
---|---|---|---|
const int *p; | const int * | √ | × |
int * const p; | int * const | × | √ |
const int * const p; | const int * const | × | × |
使用 typedef 大法简化类型定义
typedef
的语法很简单,只需要在类似变量定义的语句之前加上一个 typedef
就行了。
可能你们印象中的 typedef
只是简单地为类型定义一个别名:
1 | typedef int Integer; |
但这似乎并没有什么卵用,反而还把一个简单的问题搞复杂了。
但是,它真正的意义是用来简化复杂类型的声明的。比如说:
1 | typedef int (*ArrayPointer)[10]; |
相比这个:
1 | int (*ap)[10]; |
变量的定义是不是看起来更简单了~~
尤其是当一个程序多处用到同一个复杂类型的时候,使用它会带来极大的便利。只需要修改类型定义,所有定义的变量的类型全部都会发生变化。
不过,需要注意一点,typedef
并不是简单的复制粘贴,typedef
里面的 *
和 []
等都是类型的一部分。
我们来看一个栗子:
栗子
1 | typedef const int *ConstPointer; |
如果这仅仅是简单的复制粘贴,那么上面两行代码相当于:
1 | const int *p1,p2; |
结果,p1
是指针,而 p2
是整型的变量。
但实际上并不是这样。如果你尝试对 p2
赋值一个整数,便会报一个 Warning
:
1 | typedef const int *ConstPointer; |
从编译器给出的错误信息中可以看出,p2
实际上并不是 int
,而是和 p1
的类型一摸一样。
指针的应用
引用变量
占用空间大的变量(如结构体和数组),在传递过程中需要频繁复制(比如结构体的赋值,向函数传递结构体,从函数返回结构体)。这对于程序的性能是有较大损耗的。
为了避免这种性能损耗,可以采用指针传递的方式。指针的长度是固定的,它在传递过程中的性能损耗是很小的。
迭代数组
首先需要提一下,指针存储的不就是一个变量的地址,但为啥会有 int *
,double *
这么多类型呐?
要从内存中获取一段数据,需要指定它的起始地址和终止地址。而终止地址也可以根据起始地址和大小算出来。
而且从内存中获取的数据,并不一定是最终我们想看到的数据,所以这些数据可能还需要进行转化,才能变成我们想看到的。
这就是指针有很多类型的原因。
每种类型的指针有一个 偏移量 ,它是指针所指向类型的大小。
指针本身是可以参与算术计算的,但是它和整数运算不一样。
例如有指针 p
和整数 i
,p+i
得到的是指针,其值实际上为 p的值 + i * 偏移量
。
这种操作,和访问数组有着千丝万缕的联系。
数组在计算机中,是以一块连续的空间进行存储的。
比如一个数组:int arr[3] = {1,2,3};
,它的存储结构类似这样:
地址 | 值 |
---|---|
0x00000020 | 1 |
0x00000024 | 2 |
0x00000028 | 3 |
而当有一个指针 int *p = arr;
时,访问 p+1 相当于 p 的值 0x00000020
加上了4,结果一看,是 0x00000024
,刚好是 第2个元素的地址。
也就是说,指针保存了所访问数组的位置,且可以通过算术运算来改变位置。
通过指针,我们便可以访问整个数组,且无需告知数组的首地址和长度。这种保存一种数据结构状态且无需关系其实现细节的“指针”,我们称为 迭代器。
但指针并不是完整的迭代器,因为它不能检测数组越界。所以,在迭代数组的时候,需要传入一个指向数组最后一个元素的下一个元素(代表不存在的东西),来避免越界访问。
一个典型的例子就是字符串的操作,传入的是字符指针,而不是字符数组和字符的位置。当我们遇到 \0
时,便认为它到达字符串结尾。