C 语言中的指针与数组

本文主要总结了《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
2
int a[10] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
int a[10] = {0}; // 与上面等价

注意,上述是一个特例,两种写法之所以等价是因为 int a[10] 存储在内存栈上,它所有的元素默认为 0,第二种写法只是初始化了第一个元素为 0 而已,如果把 0 改为 2,两者是不等价的。

  • 给数组赋初值时,如果数据的个数已经确定,则可以不指定数组的长度:
1
2
int a[5] = {1, 2, 3, 4, 5}; // 可以写成如下形式
int a[] = {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]; 后,paa 具有相同的值。因为数组名所代表的就是该数组最开始的一个元素的地址,因此, 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
2
3
char c1, c2;
c1 = 'a';
c2 = 97; // 没有看错,这是合法的,c2 变量此时也存放字符 'a'

在所有的编译系统中都规定以一个字节来存放一个字符,或者说,一个字符变量在内存中占一个字节。

  • 字符串常量

或许你已经发现了,在 C 语言中,基本数据类型并没有“字符串”类型,因此只能通过“字符数组”的方式来存放字符串。字符串常量是通过双引号括起来的字符序列。例如:”hello, world!”、”CHINA” 等等。

注意:不要将字符常量和字符串常量混淆,’a’ 是字符常量,”a” 是字符串常量,两者是不同的,更不能把一个字符串常量赋给一个字符变量。

1
2
3
4
char c;
c = 'a'; // 合法
c = "a"; // 错误的
c = "CHINA"; // 也是错误的

字符串常量是一个字符数组。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
2
3
4
char c = 'a';
char *str = "hello, world!";
printf("%c", c);
printf("%s", str);

指针常量与常量指针

  • 指针常量

指针本身是一个常量,它指向的地址不可以发生变化,但指向的地址的内容可以变化,声明方式:

1
int * const p;
  • 常量指针

指向常量的指针,也称为常指针,即指针指向的地址对应的值不可变,但指针可以指向其它(常量)地址,声明方式:

1
2
int const * p;
const int * p;
  • 指向常量的常指针
1
const int * const p;

指针函数与函数指针

  • 指针函数

它是一个函数,即返回指针值的函数。对于一个函数,可以返回一个整型值、字符值、实型值等,也可以返回指针类型的数据,即地址,声明形式如下:

1
类型名 * 函数名(参数列表)

示例:

1
int * func(int x, int y);

此处,func 为函数名,调用它后将得到一个指向整型数据的指针(地址)。

  • 函数指针

它是一个指针,即指向函数的指针,声明形式如下:

1
类型名 (* 指针变量名)(函数参数列表)

示例:

1
2
3
4
5
6
int a, b;
int max(int, int); // 声明一个函数 max(假设已在其它地方实现)
int (* p)(int, int); // 声明一个函数指针 *p
p = max; // 把函数名 max 赋值给函数指针 *p
a = max(1, 2); // 通过函数名调用
b = (* p)(1, 2); // 通过函数指针调用

与数组名代表数组首个元素地址类似,函数名代表该函数的入口地址,赋值语句 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
2
char ** p = &names[0]; // 等价于
char ** p = names;

上述 p 前面有两个 * 号,它相当于 * (* p),其中 * p 是指针变量的声明形式,它表示定义了一个指向字符数据的指针变量,现在在它前面又加了一个 * 号,表示指针变量 p 此时是指向“一个字符指针变量”的,即 p 是一个“指向指针的指针”。(有点绕,慢慢理解)

接下来对双指针 p 的相关操作就相当于是对指针数组 names 的操作,我们不再赘述。

空指针与野指针

  • void 指针类型

ANSI 标准增加了一种 void 指针类型,即可定义一个指针变量,但它不指定它是指向哪一种类型数据的。

它可以用来指向一个抽象的类型的数据,在将它的值赋给另一个指针变量时,要进行强制类型转换使之适合于被赋值的变量的类型,例如:

1
2
3
4
char *p1;
void *p2;
// ...
p1 = (char *)p2;

同样可以用 (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 程序设计》教材这两本书,你将会有更多收获。