本文主要总结了《The C Programming Language》和谭浩强主编的《C 程序设计》教材中指针和数组相关章节的内容。
在 C 语言中,指针与数组之间有着非常密切的关系,一般来说,通过数组下标能完成的任何操作都可以通过指针来实现。本文将介绍指针与数组的概念和关系,以及一些相关的问题。目录如下:
- 数组
- 指针
- 指针与数组的关系
- 字符串与数组
- 字符串与指针
- 指针常量与常量指针
- 指针函数与函数指针
- 指针数组与指向指针的指针
- 空指针与野指针
数组
在 C 语言中,数组用于表示相同类型的有序数据的集合,定义方式如下:
1 | 类型名 数组名[常量表达式]; |
例如:
1 | int a[10]; |
它表示定义了一个整型数组,数组名为 a
,该数组中有 10 个元素。换句话说,它定义了一个由 10 个元素组成的集合,这 10 个元素存储在相邻的内存区域中,名字分别 a[0]
、a[1]
、…、a[9]
,如下图所示:
其中,a[i]
表示该数组中的第 i 个元素(i 从 0 开始计数)。
此外,给数组元素进行初始化有如下几条规则:
- 在定义数组时对数组元素赋予初值,例如:
1 | int a[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; |
- 也可以只给一部分元素赋值,例如:
1 | int a[10] = {0, 1, 2, 3, 4}; |
表示定义 a 数组有 10 个元素,并给前 5 个元素赋初值,后 5 个元素值默认为 0。但是我们无法跳着给某些元素赋值,例如 int a[5] = {,,3,4,5};
是错误的写法。
- 给数组中的元素全部赋予相同的初值
0
,例如:
1 | int a[10] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; |
注意,上述是一个特例,两种写法之所以等价是因为 int a[10]
存储在内存栈上,它所有的元素默认为 0
,第二种写法只是初始化了第一个元素为 0
而已,如果把 0
改为 2
,两者是不等价的。
- 给数组赋初值时,如果数据的个数已经确定,则可以不指定数组的长度:
1 | int a[5] = {1, 2, 3, 4, 5}; // 可以写成如下形式 |
上述第二种写法,花括号中有 5 个数,编译器在编译时会根据此自动定义 a 数组的长度为 5。但如果要定义的数组的长度与提供的初值的个数不同,则数组长度不能忽略。例如,想定义数组长度为 10,就不能省略数组长度的定义,而必须写成:int a[10] = {1, 2, 3, 4, 5};
,表示初始化前五个元素,而后五个元素为 0。
指针
指针是一种保存变量地址的变量,定义方式如下:
1 | 类型名 * 指针变量名; |
在程序中定义了一个变量,在编译时,系统会给这个变量分配内存单元。编译系统根据程序中定义的变量类型,分配一定长度的内存空间(不同类型的长度不同,一般字符类型为 1 个字节,整型为 2 个或 4 个字节等)。在内存区的每一个存储单元都有一个编号,这就是“地址”的概念,而指针变量就是用于存放“变量地址”的变量。
如下图所示,如果变量 c 的类型为 char
,在内存中存放的位置为图中的位置,我们可以定义一个指针变量 p,指向 c 的存储位置。
1 | char * p = &c; |
指针主要有两个运算符:
&
:取址运算符*
:指针运算符(或称为“间接访问”运算符),取其指向的内容。
例如上述例子中,&c
表示变量 c 的地址,*p
表示指针变量 p 所指向的存储单元的内容(即 p 所指向的变量 c 的值)。
此外,在定义指针时声明的类型,表示该指针所指向的地址存放的内容的数据类型。而所有指针变量自身的类型都为整型,其所占的大小(字节数)在不同位数的操作系统中不一样。
思考一个问题,既然指针变量是用来存放地址的(且它自身的类型都为整型),那么好像只需要指定其为“指针型变量”即可,为什么定义指针时还要声明其指向内容的类型呢?
如前面所述,不同类型的数据在内存中所占的字节数是不同的,而对于指针的“移动”或“加减运算”,例如“使指针移动 1 个位置”或者“使指针值加 1”,这里的 “1” 代表什么呢?
“指针加 1”,表示与指针所指向的数据相邻的下一个数据的地址。举个例子,如果指针是指向一个整型变量(假设为 2 个字节),那么“使指针移动 1 个位置”意味着移动 2 个字节,“使指针值加 1” 意味着使地址值加 2 个字节。而如果指针是指向一个浮点型变量(假设为 4 个字节),则增加的不是 2 而是 4 个字节了。因此必须指定指针变量所指向的变量的类型,即“基类型”,这样才能准确地对指针进行相关位移操作。
最后需要注意的是,一个指针变量只能指向同一个类型的变量,即不能把声明为指向字符型的指针指向整型等其它类型的变量。
指针与数组的关系
在第一小节中,声明了一个数组 int a[10];
,假设这里我们又定义了一个指针变量 pa
如下:
1 | int *pa; |
则说明 pa
是一个指向整型数据的指针,那么赋值语句:
1 | pa = &a[0]; |
表示将指针 pa
指向数组 a
的第 0 个元素,也就是说,pa
的值为数组元素 a[0]
的地址,如下图:
那么赋值语句 int x = *pa;
表示将把数组元素 a[0]
中的值复制到变量 x
中,与 int x = a[0];
是等价的。
如果 pa
指向数组中的某个特定元素,那么,根据指针运算的定义,pa+1
将指向下一个元素,pa+i
将指向 pa
所指向的数组元素之后的第 i 个元素,而 pa-i
将指向 pa
所指向的数组元素之前的第 i 个元素。
因此,如果指针 pa
指向 a[0]
,那么 *(pa+1)
引用的是数组元素 a[1]
的内容,pa+i
是数组元素 a[i]
的地址,*(pa+i)
引用的是数组元素 a[i]
的内容,如下图所示:
无论数组 a 中的元素类型或者数组长度是什么,上面结论都成立。“指针加 1”就意味着,pa+1
表示 pa
所指向的元素的下一个元素。
数组下标和指针运算之间具有密切的对应关系。C 语言规定,数组名代表数组中首个元素(即序号为 0 的元素)的地址。也就是说,数组名可以理解为是一个指针常量。
所以,执行赋值语句 pa = &a[0];
后,pa
与 a
具有相同的值。因为数组名所代表的就是该数组最开始的一个元素的地址,因此, pa = &a[0];
也可以写成 pa = a;
。
对数组元素 a[i]
的引用也可以写成 *(a+i)
的形式。实际上,在编译过程中,编译器也是先把 a[i]
转换成 *(a+i)
这种指针表示形式然后再进行求值,所以这两种形式是等价的。
当我们对 a[i]
和 *(a+i)
分别进行取址运算,可以知道 &a[i]
和 & *(a+i)
(简化为 a+i
)也是相同的,a+i
表示 a 之后第 i 个元素的地址。
相应的,如果 pa
是一个指针,那么,在表达式中也可以在它的后面添加下标。pa[i]
与 *(pa+i)
是等价的。简而言之,一个通过(数组和下标)实现的表达式可以等价地通过(指针和偏移量)实现。
最后,需要注意指针和数组名两者的一个不同之处:
指针是一个变量,因此对于赋值语句 pa = a
和自增运算 pa++
都是合法的,但数组名是常量不是变量,所以类似 a = pa
或者 a++
等语句是非法的。
以上,希望对你理解指针和数组的关系能有所帮助。
下面我们将继续介绍与它们两个相关的其他一些知识点。
字符串与数组
在 C 语言中,有一种基本数据类型叫“字符型数据”,用 char
表示。
- 字符常量
用单引号括起来的一个字符,例如 ‘a’、’A’、’5’、’?’、’\n’、’\0’ 等都是字符常量,总共有 128 个符号(其中有 32 个符号是不可显示的控制符)。
- 字符变量
字符型变量用来存放字符常量,但它只能存放一个字符,不要以为在一个字符变量中可以存放一个字符串(包含若干个字符)。字符变量的定义如下:
1 | char c1, c2; |
在所有的编译系统中都规定以一个字节来存放一个字符,或者说,一个字符变量在内存中占一个字节。
- 字符串常量
或许你已经发现了,在 C 语言中,基本数据类型并没有“字符串”类型,因此只能通过“字符数组”的方式来存放字符串。字符串常量是通过双引号括起来的字符序列。例如:”hello, world!”、”CHINA” 等等。
注意:不要将字符常量和字符串常量混淆,’a’ 是字符常量,”a” 是字符串常量,两者是不同的,更不能把一个字符串常量赋给一个字符变量。
1 | char c; |
字符串常量是一个字符数组。C 语言中又规定,在每一个字符串常量的结尾加一个“字符串结束标志” \0
,以便系统据此判断字符串是否结束。例如,有一个字符串常量 “hello”,它在内存中是这样存储的:
它占的内存单元不是 5 个字符,而是 6 个字符,最后一个字符为 \0
。所以字符串常量占据的存储单元数比其字面量(双引号内的字符数)大 1。不过用 C 语言的 strlen
函数对一个字符串取长度,获得的数值并没有包括终止符 \0
。
- 字符串变量(字符数组)
因为没有“字符串类型”,所以在 C 语言中,并没有真正意义上的“字符串变量”!本节我们来介绍一下字符数组的概念及与字符串的关系。
用来存放字符型数据的数组称为“字符数组”,字符数组中的每一个元素存放一个字符。
如前面所述,C 语言中是将字符串作为字符数组来处理的。所以,对于一个字符数组,如果其结尾为空字符 \0
,那么就可以把它视为一个“字符串变量”(并不严谨),例如:
1 | char str[6] = {'h', 'e', 'l', 'l', 'o', '\0'}; |
注:对于一个字符数组不管有多长,从头开始遍历,一旦遇到结束空字符 \0
就表示字符串结束,\0
前面的字符组成字符串,\0
后面的字符元素将被忽略。所以程序一般都是通过靠检测 \0
的位置来判断字符串是否结束,而不是根据字符数组的长度来决定字符串的长度。
所以有了结束标志 \0
后,字符数组的长度就显得不那么重要了。当然了,字符串实际长度加上 \0
的总长度是不能超过存放它的字符数组的长度的。
另外,我们也可以使用字符串常量来初始化字符数组:
1 | char str[] = {"hello"}; |
此时,系统会自动在字符串常量的末尾加上一个结束空字符 \0
。也可以省略花括号,直接写成:
1 | char str[] = "hello"; |
上述两种写法与下面的初始化写法等价:
1 | char str[] = {'h', 'e', 'l', 'l', 'o', '\0'}; |
但与下面的写法是不等价:
1 | char str[] = {'h', 'e', 'l', 'l', 'o'}; |
前者的长度为 6,而后者的长度为 5。
需要说明的是,对于字符数组,并不要求它的最后一个字符为 \0
,甚至都可以不包含 \0
。但如果用字符数组来存储字符串常量,则必须在末尾有 \0
结束标示符,通常系统会自动加上,也可以人为添加。
字符串与指针
前面一节讲了可以用字符数组来表示和存储字符串,而数组又与指针有着密切的联系。所以,也可以用指针来表示和管理字符串。
对于如下定义:
1 | char str[] = "hello"; |
我们知道 str
是数组名,它代表着字符数组的首个元素的地址。因此我们可以定义一个字符指针指向它:
1 | char *pStr = &str[0]; |
根据前面讨论,此时 pStr = str
。因此,我们可以不定义字符数组,而直接定义一个字符指针,用字符指针指向字符串中的字符,如下:
1 | char *pStr = "hello"; |
其中,pStr
指向字符 ‘h’ 的地址,*pStr
的值,即 pStr[0]
,为 ‘h’,pStr+1
指向字符 ‘e’ 的地址;*(pStr+1)
的值,即 pStr[1]
,为 ‘e’,依次类推,最后一个字符为结束空字符 \0
。
这里,pStr
只是一个字符型指针变量,它指向字符串 “hello” 的第一个字符的地址(即:存放字符串常量 “hello” 的字符数组的首个元素的地址)。而不能理解为:“pStr 是一个字符串变量,在定义时把 “hello” 这几个字符赋给该字符串变量。”。
最后,在 C 语言中,对字符和字符串的输出格式符分别为 %c
和 %s
,例如:
1 | char c = 'a'; |
指针常量与常量指针
- 指针常量
指针本身是一个常量,它指向的地址不可以发生变化,但指向的地址的内容可以变化,声明方式:
1 | int * const p; |
- 常量指针
指向常量的指针,也称为常指针,即指针指向的地址对应的值不可变,但指针可以指向其它(常量)地址,声明方式:
1 | int const * p; |
- 指向常量的常指针
1 | const int * const p; |
指针函数与函数指针
- 指针函数
它是一个函数,即返回指针值的函数。对于一个函数,可以返回一个整型值、字符值、实型值等,也可以返回指针类型的数据,即地址,声明形式如下:
1 | 类型名 * 函数名(参数列表) |
示例:
1 | int * func(int x, int y); |
此处,func
为函数名,调用它后将得到一个指向整型数据的指针(地址)。
- 函数指针
它是一个指针,即指向函数的指针,声明形式如下:
1 | 类型名 (* 指针变量名)(函数参数列表) |
示例:
1 | int a, b; |
与数组名代表数组首个元素地址类似,函数名代表该函数的入口地址,赋值语句 p = max;
的作用是将函数 max 的入口地址赋给指针变量 p。
此时,p 和 max 都指向函数的开头,调用 * p
就是调用 max 函数。
因此,函数的调用可以通过函数名调用,也可以通过函数指针调用,上述 a = max(1, 2);
与 b = (* p)(1, 2)
本质上是一样的。
另外,需要说明的是,int (* p)(int, int);
表示定义了一个指向函数的指针变量 p,它并不是就固定指向哪一个函数的,而只是表示定义了这样一个类型的变量,它是专门用来存放函数的入口地址的。在程序中把另一个函数(该函数的返回值应该是整型,且有两个整型参数)的地址赋给它,它就指向这一个函数。也就是,一个函数指针变量可以先后指向同类型的不同函数。
指针数组与指向指针的指针
- 指针数组
对于一个数组,如果其元素均为指针类型的数据,称其为“指针数组”,也就是说,指针数组中的每一个元素都相当于一个指针变量,声明方式如下:
1 | 类型名 * 数组名[数组长度]; |
例如:int * p[4];
表示声明一个数组 p
,它有 4 个元素,每个元素的类型为 int *
指针类型,即每个元素都可指向一个整型变量。
指针数组的使用场景:比较适合用于存放若干个字符串(也可理解为是“字符串数组”),使字符串处理更加方便灵活。例如:
1 | char * names[] = {"Li Lei", "Han Meimei", "Kang Zubin"}; |
指针数组另一个重要作用是:作为 main 函数的形式参数,如下:
1 | int main (int argc, char * argv[]); |
- 数组指针
注意上面不要写成 int (* p)[4]
,它表示一个指向一维数组(数组长度为 4)的指针变量,也称为“数组指针”。
- 指向指针的指针
在理解了指针数组的概念的基础上,下面介绍指向指针数据的指针变量,简称为“指向指针的指针”。
例如前面定义的指针数组:
1 | char * names[] = {"Li Lei", "Han Meimei", "Kang Zubin"}; |
它的示意图如下所示:
这里,names
是一个指针数组,它的每一个元素都是一个指针类型的数据(值为地址),指向某一个字符串常量(字符数组),而数组中每个元素都有相应自己的地址。
根据前面定义,数组名 names
代表该指针数组首个元素的地址,names+i
是元素 names[i]
的地址。所以,指针数组的数组名,本身就是一个指向指针的指针。
此外,我们也可以定义一个指针变量 p,让它指向指针数组的元素(而数组的元素也是指针),那么 p 就是一个指向指针型数据的指针变量。
1 | char ** p = &names[0]; // 等价于 |
上述 p 前面有两个 *
号,它相当于 * (* p)
,其中 * p
是指针变量的声明形式,它表示定义了一个指向字符数据的指针变量,现在在它前面又加了一个 *
号,表示指针变量 p 此时是指向“一个字符指针变量”的,即 p 是一个“指向指针的指针”。(有点绕,慢慢理解)
接下来对双指针 p
的相关操作就相当于是对指针数组 names
的操作,我们不再赘述。
空指针与野指针
- void 指针类型
ANSI 标准增加了一种 void
指针类型,即可定义一个指针变量,但它不指定它是指向哪一种类型数据的。
它可以用来指向一个抽象的类型的数据,在将它的值赋给另一个指针变量时,要进行强制类型转换使之适合于被赋值的变量的类型,例如:
1 | char *p1; |
同样可以用 (void *)p1
将 p1 的值转成 void *
类型,例如:p2 = (void *)p1;
,也可以将一个函数的返回值定义一个为 void *
类型,例如:
1 | void * func(char ch1, char ch2); |
表示函数 func 返回的是一个地址,它指向“空类型”,如果需要引用此地址,需要根据情况对之进行类型转换,例如 char *p1 = (char *)func(ch1, ch2);
。
- 空指针
上述介绍的 void *
表示空指针类型,它可以转换为其他指向类型。而在 C 语言中又双叒叕定义,(void *)0
表示的空指针常量。
如果 p 是一个指针变量,当它的值为空指针常量时,即 p = (void *)0
,则此时称 p 为空指针,表示 p 指向一个空地址,即地址 0
(不是常数 0),或者说指向 NULL
。
- 野指针
根据定义,野指针是指向一个已删除的对象或未申请访问受限内存区域的指针,也就是它指向了不合法的内存区域。野指针也称作“迷途指针”或者“悬空指针”。
对于一个指针 p,当它所指向的对象被释放或者收回,但是对该指针没有作任何的修改(比如没有置为 NULL
),以至于该指针仍旧指向已经回收的内存地址,此时 p 就成为一个野指针。后续如果再对指针 p 进行操作,可能就会造成程序崩溃或产生不可预知的结果。
题外话
之前在网上看到这样一条段子:
我当时简单分析了一下,野指针一般指向一个已被删除的对象,说明它曾经有过对象,现在分手了而已,不至于太惨。如果骂:“你 TM 就是一个没有对象的空指针!”,可能会更惨一些,👻
总结
本文简要介绍了 C 语言中指针与数组的概念、关系,以及与它们相关的一些知识点,如有不当之处,欢迎指出,更多细节强烈推荐阅读《The C Programming Language》和谭浩强主编的《C 程序设计》教材这两本书,你将会有更多收获。