第9章 指针总结
指针也是C语言中最为困难的一部分,在学习中除了要正确理解基本概念,还必须要多编程,上机调试。
计算机中所有的数据都必须放在内存中,不同类型的数据占用的字节数不一样,例如 int 占用4个字节,char 占用1个字节。为了正确地访问这些数据,必须为每个字节都编上号码,就像门牌号、身份证号一样,每个字节的编号是唯一的,根据编号可以准确地找到某个字节。
我们将内存中字节的编号称为地址(Address)或指针(Pointer)。地址从 0 开始依次增加,对于 32 位环境,程序能够使用的内存为 4GB,最小的地址为 0,最大的地址为 0XFFFFFFFF。(个人:32位环境用32位表示一个指针,也就是地址)
下面的代码演示了如何输出一个地址:
#include <stdio.h> int main(){ int a = 100; char str[20] = "www.baidu.com"; printf("%#x, %#X\n", &a, str); return 0; }
- %#X表示以十六进制形式输出,并附带前缀0X。
- C语言中有一个控制符%p,专门用来以十六进制形式输出地址,不过 %p 的输出格式并不统一,有的编译器带0x前缀,有的不带,
一切都是地址:
- C语言用变量来存储数据,用函数来定义一段可以重复使用的代码,它们最终都要放到内存中才能供 CPU 使用。 数据和代码都以二进制的形式存储在内存中,计算机无法从格式上区分某块内存到底存储的是数据还是代码。当程序被加载到内存后,操作系统会给不同的内存块指定不同的权限,拥有读取和执行权限的内存块就是代码,而拥有读取和写入权限(也可能只有读取权限)的内存块就是数据。
- CPU 只能通过地址来取得内存中的代码和数据,程序在执行过程中会告知 CPU 要执行的代码以及要读写的数据的地址。如果程序不小心出错,或者开发者有意为之,在 CPU 要写入数据时给它一个代码区域的地址,就会发生内存访问错误。这种内存访问错误会被硬件和操作系统拦截,强制程序崩溃,程序员没有挽救的机会。
- CPU 访问内存时需要的是地址,而不是变量名和函数名!变量名和函数名只是地址的一种助记符,当源文件被编译和链接成可执行程序后,它们都会被替换成地址。编译和链接过程的一项重要任务就是找到这些名称所对应的地址。假设变量 a、b、c 在内存中的地址分别是 0X1000、0X2000、0X3000,那么加法运算c = a + b;将会被转换成类似下面的形式:
0X3000 = (0X1000) + (0X2000);
( )表示取值操作,整个表达式的意思是,取出地址 0X1000 和 0X2000 上的值,将它们相加,把相加的结果赋值给地址为 0X3000 的内存。
- 变量名和函数名为我们提供了方便,让我们在编写代码的过程中可以使用易于阅读和理解的英文字符串,不用直接面对二进制地址,那场景简直让人崩溃。 需要注意的是,虽然变量名、函数名、字符串名和数组名在本质上是一样的,它们都是地址的助记符,但在编写代码的过程中,我们认为变量名表示的是数据本身,而函数名、字符串名和数组名表示的是代码块或数据块的首地址。
数据在内存中的地址也称为指针,如果一个变量存储了一份数据的指针,我们就称它为指针变量。
CPU 读写数据必须要知道数据在内存中的地址,普通变量和指针变量都是地址的助记符,虽然通过 *p 和 a 获取到的数据一样,但它们的运行过程稍有不同:a 只需要一次运算就能够取得数据,而 *p 要经过两次运算,多了一层“间接”。 假设变量 a、p 的地址分别为 0X1000、0XF0A0,它们的指向关系如下图所示:
程序被编译和链接后,a、p 被替换成相应的地址。使用 *p 的话,要先通过地址 0XF0A0 取得变量 p 本身的值,这个值是变量 a 的地址,然后再通过这个值取得变量 a 的数据,前后共有两次运算;而使用 a 的话,可以通过地址 0X1000 直接取得它的数据,只需要一步运算。 也就是说,使用指针是间接获取数据,使用变量名是直接获取数据,前者比后者的代价要高。
指针变量保存的是地址,本质上是一个整数,可以进行部分运算,例如加法、减法、比较等,指针变量加减运算的结果跟数据类型的长度有关,而不是简单地加 1 或减 1,这是为什么呢? 以 a 和 pa 为例,a 的类型为 int,占用 4 个字节,pa 是指向 a 的指针,如下图所示:
刚开始的时候,pa 指向 a 的开头,通过 *pa 读取数据时,从 pa 指向的位置向后移动 4 个字节,把这 4 个字节的内容作为要获取的数据,这 4 个字节也正好是变量 a 占用的内存。
- 如果pa++;使得地址加 1 的话,就会变成如下图所示的指向关系:
这个时候 pa 指向整数 a 的中间,*pa 使用的是红色虚线画出的 4 个字节,其中前 3 个是变量 a 的,后面 1 个是其它数据的,把它们“搅和”在一起显然没有实际的意义,取得的数据也会非常怪异。
- 如果pa++;使得地址加 4 的话,正好能够完全跳过整数 a,指向它后面的内存,如下图所示:
- 我们知道,数组中的所有元素在内存中是连续排列的,如果一个指针指向了数组中的某个元素,那么加 1 就表示指向下一个元素,减 1 就表示指向上一个元素,这样指针的加减运算就具有了现实的意义。
不过C语言并没有规定变量的存储方式,如果连续定义多个变量,它们有可能是挨着的,也有可能是分散的,这取决于变量的类型、编译器的实现以及具体的编译模式,所以,对于指向普通变量的指针,我们往往不进行加减运算,虽然编译器并不会报错,但这样做没有意义,因为不知道它后面指向的是什么数据。
下面的例子是一个反面教材,警告读者不要尝试通过指针获取下一个变量的地址:
#include <stdio.h> int main(){ int a = 1, b = 2, c = 3; int *p = &a; int i; for(i=0; i<8; i++){ printf("%d, ", *(p+i) ); } return 0; }
可以发现,变量 a、b、c 并不挨着,它们中间还参杂了别的辅助数据。
指针变量除了可以参与加减运算,还可以参与比较运算。当对指针变量进行比较运算时,比较的是指针变量本身的值,也就是数据的地址。如果地址相等,那么两个指针就指向同一份数据,否则就指向不同的数据。另外需要说明的是,不能对指针变量进行乘法、除法、取余等其他运算,除了会发生语法错误,也没有实际的含义。
定义数组时,要给出数组名和数组长度,数组名可以认为是一个指针,它指向数组的第 0 个元素。在C语言中,我们将第 0 个元素的地址称为数组的首地址。数组名的本意是表示整个数组,也就是表示多份数据的集合,但在使用过程中经常会转换为指向数组第 0 个元素的指针,所以上面使用了“认为”一词,表示数组名和数组首地址并不总是等价。初学者可以暂时忽略这个细节,把数组名当做指向第 0 个元素的指针使用即可,下面的例子演示了如何以指针的方式遍历数组元素:
#include <stdio.h> int main() { int arr[] = { 99, 15, 100, 888, 252 }; int len = sizeof(arr) / sizeof(int); //求数组长度 int i; for (i = 0; i < len; i++) { printf("%d ", *(arr + i)); //*(arr+i)等价于arr[i] } printf("\n"); return 0; }
int arr[] = { 99, 15, 100, 888, 252 }; int *p = arr;
arr 本身就是一个指针,可以直接赋值给指针变量 p。arr 是数组第 0 个元素的地址,所以int *p = arr;也可以写作int *p = &arr[0];。也就是说,arr、p、&arr[0] 这三种写法都是等价的,它们都指向数组第 0 个元素,或者说指向数组的开头。
数组指针指向的是数组中的一个具体元素,而不是整个数组,所以数组指针的类型和数组元素的类型有关,上面的例子中,p 指向的数组元素是 int 类型,所以 p 的类型必须也是int *。
反过来想,p 并不知道它指向的是一个数组,p 只知道它指向的是一个整数,在求数组的长度时不能使用sizeof(p) / sizeof(int),因为 p 只是一个指向 int 类型的指针,编译器并不知道它指向的到底是一个整数还是一系列整数(数组),所以 sizeof(p) 求得的是 p 这个指针变量本身所占用的字节数,而不是整个数组占用的字节数。 也就是说,根据数组指针不能逆推出整个数组元素的个数,以及数组从哪里开始、到哪里结束等信息。
C语言中没有特定的字符串类型,我们通常是将字符串放在一个字符数组中,
#include <stdio.h> #include <string.h> int main() { char str[] = "http://www.baidu.com"; int len = strlen(str), i; //直接输出字符串 printf("%s\n", str); //每次输出一个字符 for (i = 0; i < len; i++) { printf("%c", str[i]); } printf("\n"); char* pstr = str; //使用*(pstr+i) for (i = 0; i < len; i++) { printf("%c", *(pstr + i)); } printf("\n"); //使用pstr[i] for (i = 0; i < len; i++) { printf("%c", pstr[i]); } printf("\n"); //使用*(str+i) for (i = 0; i < len; i++) { printf("%c", *(str + i)); } printf("\n"); return 0; }
除了字符数组,C语言还支持另外一种表示字符串的方法,就是直接使用一个指针指向字符串,例如:
const char *str = "http://www.baidu.com";
或者:
const char* a; a = "http://www.cdsy.xyz";
上面不能这样:
char *str = "http://www.baidu.com";
- vs2022中会报错误:
错误(活动) E0144 "const char *" 类型的值不能用于初始化 "char *" 类型的实体
- clion中会报错:
ISO C++11 does not allow conversion from string literal to 'char *'
字符串中的所有字符在内存中是连续排列的,str 指向的是字符串的第 0 个字符;我们通常将第 0 个字符的地址称为字符串的首地址。字符串中每个字符的类型都是char,所以 str 的类型也必须是char *。
下面的例子演示了如何输出这种字符串:
#include <stdio.h> #include <string.h> int main(){ const char *str = "http://www.baidu.com"; int len = strlen(str), i; //直接输出字符串 printf("%s\n", str); //使用*(str+i) for(i=0; i<len; i++){ printf("%c", *(str+i)); } printf("\n"); //使用str[i] for(i=0; i<len; i++){ printf("%c", str[i]); } printf("\n"); return 0; }
这一切看起来和字符数组是多么地相似,它们都可以使用%s输出整个字符串,都可以使用*或[ ]获取单个字符,这两种表示字符串的方式是不是就没有区别了呢? 有!它们最根本的区别是在内存中的存储区域不一样,
- 字符数组存储在全局数据区或栈区,
- 第二种形式的字符串存储在常量区。
全局数据区和栈区的字符串(也包括其他数据)有读取和写入的权限,而常量区的字符串(也包括其他数据)只有读取权限,没有写入权限。
printf() 输出字符串时,要求给出一个起始地址,并从这个地址开始输出,直到遇见字符串结束标志\0。
其实,数组元素的访问形式可以看做 address[offset],address 为起始地址,offset 为偏移量:c1 = str[4]表示以地址 str 为起点,向后偏移4个字符。c5 = (str+1)[5]表示以地址 str+1 为起点,向后偏移5个字符。
字符与整数运算时,先转换为整数(字符对应的ASCII码)。
像数组、字符串、动态分配的内存等都是一系列数据的集合,没有办法通过一个参数全部传入函数内部,只能传递它们的指针,在函数内部通过指针来影响这些数据集合。
数组是一系列数据的集合,无法通过参数将它们一次性传递到函数内部,如果希望在函数内部操作数组,必须传递数组指针。
int max(int *intArr, int len){ int i, maxValue = intArr[0]; //假设第0个元素是最大值 for(i=1; i<len; i++){ if(maxValue < intArr[i]){ maxValue = intArr[i]; } } return maxValue; }
参数 intArr 仅仅是一个数组指针,在函数内部无法通过这个指针获得数组长度,必须将数组长度作为函数参数传递到函数内部。
用数组做函数参数时,参数也能够以“真正”的数组形式给出。
int max(int intArr[6], int len){ int i, maxValue = intArr[0]; //假设第0个元素是最大值 for(i=1; i<len; i++){ if(maxValue < intArr[i]){ maxValue = intArr[i]; } } return maxValue; }
读者也可以省略数组长度,把形参简写为下面的形式:
int max(int intArr[], int len){ int i, maxValue = intArr[0]; //假设第0个元素是最大值 for(i=1; i<len; i++){ if(maxValue < intArr[i]){ maxValue = intArr[i]; } } return maxValue; }
int intArr[6]好像定义了一个拥有 6 个元素的数组,调用 max() 时可以将数组的所有元素“一股脑”传递进来。
int intArr[]虽然定义了一个数组,但没有指定数组长度,好像可以接受任意长度的数组。
需要强调的是,不管使用哪种方式传递数组,都不能在函数内部求得数组长度,因为 intArr 仅仅是一个指针,而不是真正的数组,所以必须要额外增加一个参数来传递数组长度。
C语言为什么不允许直接传递数组的所有元素,而必须传递数组指针呢? 参数的传递本质上是一次赋值的过程,赋值就是对内存进行拷贝。所谓内存拷贝,是指将一块内存上的数据复制到另一块内存上。 对于像 int、float、char 等基本类型的数据,它们占用的内存往往只有几个字节,对它们进行内存拷贝非常快速。而数组是一系列数据的集合,数据的数量没有限制,可能很少,也可能成千上万,对它们进行内存拷贝有可能是一个漫长的过程,会严重拖慢程序的效率,为了防止技艺不佳的程序员写出低效的代码,C语言没有从语法上支持数据集合的直接赋值。 除了C语言,C++、Java、Python 等其它语言也禁止对大块内存进行拷贝,在底层都使用类似指针的方式来实现。
#include <stdio.h> int* func() { int n = 100; return &n; } int main() { int* p = func(), n; n = *p; printf("value = %d\n", n); return 0; }
#include <stdio.h> int* func() { int n = 100; return &n; } int main() { int* p = func(), n; printf("www.baidu.com\n"); n = *p; printf("value = %d\n", n); return 0; }
函数运行结束后会销毁所有的局部数据,这个观点并没错,大部分C语言教材也都强调了这一点。但是,这里所谓的销毁并不是将局部数据所占用的内存全部抹掉,而是程序放弃对它的使用权限,弃之不理,后面的代码可以随意使用这块内存。对于上面的两个例子,func() 运行结束后 n 的内存依然保持原样,值还是 100,如果使用及时也能够得到正确的数据,如果有其它函数被调用就会覆盖这块内存,得到的数据就失去了意义。 第一个例子在调用其他函数之前使用 *p 抢先获得了 n 的值并将它保存起来,第二个例子显然没有抓住机会,有其他函数被调用后才使用 *p 获取数据,这个时候已经晚了,内存已经被后来的函数覆盖了,而覆盖它的究竟是一份什么样的数据我们无从推断(一般是一个没有意义甚至有些怪异的值)。
一个指针变量可以指向计算机中的任何一块内存,不管该内存有没有被分配,也不管该内存有没有使用权限,只要把地址给它,它就可以指向,C语言没有一种机制来保证指向的内存的正确性,程序员必须自己提高警惕。
很多初学者会在无意间对没有初始化的指针进行操作,这是非常危险的,请看下面的例子:
#include <stdio.h> int main(){ char *str; gets(str); printf("%s\n", str); return 0; }
这段程序没有语法错误,能够通过编译和链接,但当用户输入完字符串并按下回车键时就会发生错误,
- 在 Linux 下表现为段错误(Segment Fault),
- 在 Windows 下程序直接崩溃。
- 如果你足够幸运,或者输入的字符串少,也可能不报错,这都是未知的。
未初始化的局部变量的值是不确定的,C语言并没有对此作出规定,不同的编译器有不同的实现,我曾警告大家不要直接使用未初始化的局部变量。上面的代码中,str 就是一个未初始化的局部变量,它的值是不确定的,究竟指向哪块内存也是未知的,大多数情况下这块内存没有被分配或者没有读写权限,使用 gets() 函数向它里面写入数据显然是错误的。
强烈建议对没有初始化的指针赋值为 NULL,例如:
char *str = NULL;
NULL 是“零值、等于零”的意思,在C语言中表示空指针。从表面上理解,空指针是不指向任何数据的指针,是无效指针,程序使用它不会产生效果。
很多库函数都对传入的指针做了判断,如果是空指针就不做任何操作,或者给出提示信息。更改上面的代码,给 str 赋值 NULL,看看会有什么效果:
#include <stdio.h> int main(){ char *str = NULL; gets(str); printf("%s\n", str); return 0; }
运行程序后发现,还未等用户输入任何字符,printf() 就直接输出了(null)。我们有理由据此推断,gets() 和 printf() 都对空指针做了特殊处理:
- gets() 不会让用户输入字符串,也不会向指针指向的内存中写入数据;
- printf() 不会读取指针指向的内容,只是简单地给出提示,让程序员意识到使用了一个空指针。
我们在自己定义的函数中也可以进行类似的判断,例如:
void func(char *p){ if(p == NULL){ printf("(null)\n"); }else{ printf("%s\n", p); } }
这样能够从很大程度上增加程序的健壮性,防止对空指针进行无意义的操作。
其实,NULL 是在stdio.h中定义的一个宏,它的具体内容为:
#define NULL ((void *)0)
(void *)0表示把数值 0 强制转换为void *类型,最外层的( )把宏定义的内容括起来,防止发生歧义。从整体上来看,NULL 指向了地址为 0 的内存,而不是前面说的不指向任何数据。在进程的虚拟地址空间中,最低地址处有一段内存区域被称为保留区,这个区域不存储有效数据,也不能被用户程序访问,将 NULL 指向这块区域很容易检测到违规指针。现在读者只需要记住,在大多数操作系统中,极小的地址通常不保存数据,也不允许程序访问,NULL可以指向这段地址区间中的任何一个地址。
注意,C语言没有规定 NULL 的指向,只是大部分标准库约定成俗地将 NULL 指向 0,所以不要将 NULL 和 0 等同起来,例如下面的写法是不专业的:
int *p = 0;
而应该坚持写为:
int *p = NULL;
注意NULL和NUL的区别:
- NULL 表示空指针,是一个宏定义,可以在代码中直接使用。
- 而 NUL 表示字符串的结束标志 '\0',它是ASCII码表中的第 0 个字符。NUL 没有在C语言中定义,仅仅是对 '\0' 的称呼,不能在代码中直接使用。(个人:也就是说NULL是实际代码中定义过的,可以直接用,而NUL只是ASCII码表中的第0个字符,没有在C语言中有定义,当然不能在代码中使用)
void *表示一个有效指针,它确实指向实实在在的数据,只是数据的类型尚未确定,在后续使用过程中一般要进行强制类型转换。C语言动态内存分配函数 malloc() 的返回值就是void *类型,在使用时要进行强制类型转换,'malloc' is defined in header '<cstdlib>',请看下面的例子:
#include <stdio.h> //'malloc' is defined in header '<cstdlib>' #include <cstdlib> int main(){ //分配可以保存30个字符的内存,并把返回的指针转换为 char * char *str = (char *)malloc(sizeof(char) * 30); gets(str); printf("%s\n", str); return 0; }
void *,它不是空指针的意思,而是实实在在的指针,只是指针指向的内存中不知道保存的是什么类型的数据。
数组和指针不等价的一个典型案例就是求数组的长度,这个时候只能使用数组名,不能使用数组指针,前面我们已经强调过了,这里不妨再来演示一下:
#include <stdio.h> int main() { int a[6] = { 0, 1, 2, 3, 4, 5 }; int* p = a; int len_a = sizeof(a) / sizeof(int); int len_p = sizeof(p) / sizeof(int); printf("len_a = %d, len_p = %d\n", len_a, len_p); return 0; }
- 数组是一系列数据的集合,没有开始和结束标志,p 仅仅是一个指向 int 类型的指针,编译器不知道它指向的是一个整数还是一堆整数,对p使用 sizeof 求得的是指针变量本身的长度。也就是说,编译器并没有把p和数组关联起来,p仅仅是一个指针变量,不管它指向哪里,sizeof 求得的永远是它本身所占用的字节数。
- 站在编译器的角度讲,变量名、数组名都是一种符号,它们最终都要和数据绑定起来。变量名用来指代一份数据,数组名用来指代一组数据(数据集合),它们都是有类型的,以便推断出所指代的数据的长度。数组也有类型,我们可以将 int、float、char 等理解为基本类型,将数组理解为由基本类型派生得到的稍微复杂一些的类型。sizeof 就是根据符号的类型来计算长度的。
- 对于数组 a,它的类型是int [6],表示这是一个拥有 6 个 int 数据的集合,1 个 int 的长度为 4,6 个 int 的长度为 4×6 = 24,sizeof 很容易求得。
- 对于指针变量 p,它的类型是int *,在 32 位环境下长度为 4,在 64 位环境下长度为 8。
归根结底,a 和 p 这两个符号的类型不同,指代的数据也不同,它们不是一码事,sizeof 是根据符号类型来求长度的,a 和 p 的类型不同,求得的长度自然也不一样。
站在哲学的高度看问题:
- 编程语言的目的是为了将计算机指令(机器语言)抽象成人类能够理解的自然语言,让程序员能够更加容易地管理和操作各种计算机资源,这些计算机资源最终表现为编程语言中的各种符号和语法规则。
- 整数、小数、数组、指针等不同类型的数据都是对内存的抽象,它们的名字用来指代不同的内存块,程序员在编码过程中不需要直接面对内存,使用这些名字将更加方便。
- 编译器在编译过程中会创建一张专门的表格用来保存名字以及名字对应的数据类型、地址、作用域等信息,sizeof 是一个操作符,不是函数,使用 sizeof 时可以从这张表格中查询到符号的长度。
- 与普通变量名相比,数组名既有一般性也有特殊性:一般性表现在数组名也用来指代特定的内存块,也有类型和长度;特殊性表现在数组名有时候会转换为一个指针,而不是它所指代的数据本身的值。
数据集合包含了多份数据,直接使用一个集合没有明确的含义,将数组名转换为指向数组的指针后,可以很容易地访问其中的任何一份数据,使用时的语义更加明确。
C语言标准规定,
- 当数组名作为数组定义的标识符(也就是定义或声明数组时)、sizeof 或 & 的操作数时,它才表示整个数组本身,
- 在其他的表达式中,数组名会被转换为指向第 0 个元素的指针(地址)。
C语言标准还规定:
数组下标与指针的偏移量相同。通俗地理解,就是对数组下标的引用总是可以写成“一个指向数组的起始地址的指针加上偏移量”。
假设现在有一个数组 a 和指针变量 p,它们的定义形式为:
int a = {1, 2, 3, 4, 5}, *p, i = 2;
读者可以通过以下任何一种方式来访问 a[i]:
- 对数组的引用 a[i] 在编译时总是被编译器改写成*(a+i)的形式,C语言标准也要求编译器必须具备这种行为。
- 取下标操作符[ ]是建立在指针的基础上,它的作用是使一个指针和一个整数相加,产生出一个新的指针,然后从这个新指针(新地址)上取得数据;假设指针的类型为T *,所产生的结果的类型就是T。
- 取下标操作符的两个操作数是可以交换的,它并不在意操作数的先后顺序,就像在加法中 3+5 和 5+3 并没有什么不一样。以上面的数组 a 为例,如果希望访问第 3 个元素,那么可以写作a[3],也可以写作3[a],这两种形式都是正确的,只不过后面的形式从不曾使用,它除了可以把初学者搞晕之外,实在没有什么实际的意义。a[3] 等价于 *(a + 3),3[a] 等价于 *(3 + a),仅仅是把加法的两个操作数调换了位置。
- 使用下标时,编译器会自动把下标的步长调整到数组元素的大小。数组 a 中每个元素都是 int 类型,长度为 4 个字节,那么a[i+1]和a[i]在内存中的距离是 4(而不是 1)。
C语言标准规定,作为“类型的数组”的形参应该调整为“类型的指针”。在函数形参定义这个特殊情况下,编译器必须把数组形式改写成指向数组第 0 个元素的指针形式。编译器只向函数传递数组的地址,而不是整个数组的拷贝。这种隐式转换意味着下面三种形式的函数定义是完全等价的:
void func(int *parr){ ...... } void func(int arr[]){ ...... } void func(int arr[5]){ ...... }
在函数内部,arr 会被转换成一个指针变量,编译器为 arr 分配 4 个字节的内存,用 sizeof(arr) 求得的是指针变量的长度,而不是数组长度。要想在函数内部获得数组长度必须额外增加一个参数。
参数传递是一次赋值的过程,赋值也是一个表达式,函数调用时不管传递的是数组名还是数组指针,效果都是一样的,相当于给一个指针变量赋值。
把作为形参的数组和指针等同起来是出于效率方面的考虑。数组是若干类型相同的数据的集合,数据的数目没有限制,可能只有几个,也可能成千上万,如果要传递整个数组,无论在时间还是内存空间上的开销都可能非常大。而且绝大部分情况下,我们其实并不需要整个数组的拷贝,我们只想告诉函数在那一时刻对哪个特定的数组感兴趣。
关于数组和指针可交换性的总结:
- 用 a[i] 这样的形式对数组进行访问总是会被编译器改写成(或者说解释为)像 *(a+i) 这样的指针形式。
- 指针始终是指针,它绝不可以改写成数组。你可以用下标形式访问数组,一般都是指针作为函数参数时,而且你知道实际传递给函数的是一个数组。
- 在特定的环境中,也就是数组作为函数形参,也只有这种情况,一个数组可以看做是一个指针。作为函数形参的数组始终会被编译器修改成指向数组第一个元素的指针。
- 当希望向函数传递数组时,可以把函数参数定义为数组形式(可以指定长度也可以不指定长度),也可以定义为指针。不管哪种形式,在函数内部都要作为指针变量对待。
二维数组在概念上是二维的,有行和列,但在内存中所有的数组元素都是连续排列的,它们之间没有“缝隙”。以下面的二维数组 a 为例:
int a[3][4] = { {0, 1, 2, 3}, {4, 5, 6, 7}, {8, 9, 10, 11} };
从概念上理解,a 的分布像一个矩阵:
0 1 2 3 4 5 6 7 8 9 10 11
但在内存中,a 的分布是一维线性的,整个数组占用一块连续的内存:
C语言中的二维数组是按行排列的,也就是先存放 a[0] 行,再存放 a[1] 行,最后存放 a[2] 行;每行中的 4 个元素也是依次存放。数组 a 为 int 类型,每个元素占用 4 个字节,整个数组共占用 4×(3×4) = 48 个字节。
C语言允许把一个二维数组分解成多个一维数组来处理。对于数组 a,它可以分解成三个一维数组,即 a[0]、a[1]、a[2]。每一个一维数组又包含了 4 个元素,例如 a[0] 包含 a[0][0]、a[0][1]、a[0][2]、a[0][3]。
假设数组 a 中第 0 个元素的地址为 1000,那么每个一维数组的首地址如下图所示:
为了更好的理解指针和二维数组的关系,我们先来定义一个指向 a 的指针变量 p:
int (*p)[4] = a;
数组名 a 在表达式中也会被转换为和 p 等价的指针!(个人:也就是说,二维数组的元素是一个一维数组)
括号中的*表明p是一个指针,它指向一个数组,数组的类型为int [4],这正是 a 所包含的每个一维数组的类型。[ ]的优先级高于*,( )是必须要加的,如果赤裸裸地写作int *p[4],那么应该理解为int *(p[4]),p 就成了一个指针数组,而不是二维数组指针。
对指针进行加法(减法)运算时,它前进(后退)的步长与它指向的数据类型有关,p 指向的数据类型是int [4],那么p+1就前进 4×4 = 16 个字节,p-1就后退 16 个字节,这正好是数组 a 所包含的每个一维数组的长度。也就是说,p+1会使得指针指向二维数组的下一行,p-1会使得指针指向数组的上一行。
下面我们就来探索一下如何使用指针p来访问二维数组中的每个元素。按照上面的定义:
- p指向数组 a 的开头,也即第 0 行;p+1前进一行,指向第 1 行。
- *(p+1)表示取地址上的数据,也就是整个第 1 行数据。注意是一行数据,是多个数据,不是第 1 行中的第 0 个元素,下面的运行结果有力地证明了这一点:
#include <stdio.h> int main() { int a[3][4] = { {0, 1, 2, 3}, {4, 5, 6, 7}, {8, 9, 10, 11} }; int(*p)[4] = a; printf("%d\n", sizeof(*(p + 1))); return 0; }
- *(p+1)+1表示第 1 行第 1 个元素的地址。*(p+1)单独使用时表示的是第 1 行数据,放在表达式中会被转换为第 1 行数据的首地址,也就是第 1 行第 0 个元素的地址,因为使用整行数据没有实际的含义,编译器遇到这种情况都会转换为指向该行第 0 个元素的指针(个人:也就是数组作为一个整体,不像定义时,sizeof,或者&时有办法使用,这里为了使用,得通过指针的形式才能使用数组里面对的元素,所以编译器才有这样的转化);就像一维数组的名字,在定义时或者和 sizeof、& 一起使用时才表示整个数组,出现在表达式中就会被转换为指向数组第 0 个元素的指针。
根据上面的结论,可以很容易推出以下的等价关系:
a+i == p+i a[i] == p[i] == *(a+i) == *(p+i) a[i][j] == p[i][j] == *(a[i]+j) == *(p[i]+j) == *(*(a+i)+j) == *(*(p+i)+j)
使用指针遍历二维数组:
#include <stdio.h> int main() { int a[3][4] = { 0,1,2,3,4,5,6,7,8,9,10,11 }; int(*p)[4]; int i, j; p = a; for (i = 0; i < 3; i++) { for (j = 0; j < 4; j++) printf("%2d ", *(*(p + i) + j)); printf("\n"); } return 0; }
指针数组和二维数组指针在定义时非常相似,只是括号的位置不同:
int *(p1[5]); //指针数组,可以去掉括号直接写作 int *p1[5]; int (*p2)[5]; //二维数组指针,不能去掉括号
一个函数总是占用一段连续的内存区域,函数名在表达式中有时也会被转换为该函数所在内存区域的首地址,这和数组名非常类似。我们可以把函数的这个首地址(或称入口地址)赋予一个指针变量,使指针变量指向函数所在的内存区域,然后通过指针变量就可以找到并调用该函数。这种指针就是函数指针。
函数指针的定义形式为:
returnType (*pointerName)(param list);
用指针来实现对函数的调用:
#include <stdio.h> //返回两个数中较大的一个 int max(int a, int b) { return a > b ? a : b; } int main() { int x, y, maxval; //定义函数指针 int (*pmax)(int, int) = max; //也可以写作int (*pmax)(int a, int b) printf("Input two numbers:"); scanf_s("%d %d", &x, &y); maxval = (*pmax)(x, y); printf("Max value: %d\n", maxval); return 0; }
pmax 是一个函数指针,在前面加 * 就表示对它指向的函数进行调用。
C语言标准规定:
对于一个符号的定义,编译器总是从它的名字开始读取,然后按照优先级顺序依次解析。对,从名字开始,不是从开头也不是从末尾,这是理解复杂指针的关键!
对于初学者,有几种运算符的优先级非常容易混淆,它们的优先级从高到低依次是:
- 定义中被括号( )括起来的那部分。
- 后缀操作符:括号( )表示这是一个函数,方括号[ ]表示这是一个数组。
- 前缀操作符:星号*表示“指向xxx的指针”。
接下来我们就由浅入深,逐个击破上面的指针定义。
int *p1[6];
从 p1 开始理解,它的左边是 *,右边是 [ ],[ ] 的优先级高于 *,所以编译器先解析p1[6],p1 首先是一个拥有 6 个元素的数组,然后再解析int *,它用来说明数组元素的类型。从整体上讲,p1 是一个拥有 6 个 int * 元素的数组,也即指针数组。
int (*p3)[6];
从 p3 开始理解,( ) 的优先级最高,编译器先解析(*p3),p3 首先是一个指针,剩下的int [6]是 p3 指向的数据的类型,它是一个拥有 6 个元素的一维数组。从整体上讲,p3 是一个指向拥有 6 个 int 元素数组的指针,也即二维数组指针。
int (*p4)(int, int);
从 p4 开始理解,( ) 的优先级最高,编译器先解析(*p4),p4 首先是一个指针,它后边的 ( ) 说明 p4 指向的是一个函数,括号中的int, int是参数列表,开头的int用来说明函数的返回值类型。整体来看,p4 是一个指向原型为int func(int, int);的函数的指针。
char *(* c[10])(int **p);
以 c 作为变量的名字,先来看括号内部(绿色粗体),[ ] 的优先级高于 *,编译器先解析c[10],c 首先是一个数组,它前面的*表明每个数组元素都是一个指针,只是还不知道指向什么类型的数据。整体上来看,(* c[10])说明 c 是一个指针数组,只是指针指向的数据类型尚未确定。
跳出括号,根据优先级规则(() 的优先级高于 *)应该先看右边(红色粗体):
( )说明是一个函数,int **p是函数参数。
再看左边(橘黄色粗体):
char *是函数的返回值类型。
从整体上看,我们可以将定义分成两部分:
绿色粗体表明 c 是一个指针数组,红色粗体表明指针指向的数据类型,合起来就是:c 是一个拥有 10 个元素的指针数组,每个指针指向一个原型为char *func(int **p);的函数。
int (*(*(*pfunc)(int *))[5])(int *);
从 pfunc 开始理解,先看括号内部(绿色粗体):
pfunc 是一个指针。
跳出括号,看它的两边(红色粗体):
根据优先级规则应该先看右边的(int *),它表明这是一个函数,int *是参数列表。再看左边的*,它表明函数的返回值是一个指针,只是指针指向的数据类型尚未确定。
将上面的两部分合成一个整体,如下面的蓝色粗体所示,它表明 pfunc 是一个指向函数的指针,现在函数的参数列表确定了,也知道返回值是一个指针了(只是不知道它指向什么类型的数据)。
蓝色粗体以外的部分,就用来说明函数返回什么类型的指针。
我们接着分析,再向外跳一层括号(红色粗体):
[ ] 的优先级高于 *,先看右边,[5] 表示这是一个数组,再看左边,* 表示数组的每个元素都是指针。也就是说,* [5] 是一个指针数组,函数返回的指针就指向这样一个数组。
那么,指针数组中的指针又指向什么类型的数据呢?再向外跳一层括号(橘黄色粗体):
先看橘黄色部分的右边,它是一个函数,再看左边,它是函数的返回值类型。也就是说,指针数组中的指针指向原型为int func(int *);的函数。
将上面的三部分合起来就是:pfunc 是一个函数指针(蓝色部分),该函数的返回值是一个指针,它指向一个指针数组(红色部分),指针数组中的指针指向原型为int func(int *);的函数(橘黄色部分)。
main() 是C语言程序的入口函数,有且只能有一个,它实际上有两种标准的原型:
int main(); int main(int argc, char *argv[]);
第二种原型在实际开发中也经常使用,它能够让我们在程序启动时给程序传递数据。
一个程序在启动时允许系统或用户给它传递数据,Windows 和 Linux 都支持,这些数据以字符串的形式存在,多份数据之间以空格分隔。也就是说,用户输入的多份数据在程序中表现为多个字符串。
给程序传递数据的一种方法就是从控制台运行程序,
- 在 Windows 下就是从 cmd(命令提示符程序)运行,
- 在 Linux 下就是从终端(Terminal)运行,
在第二个原型中,argc 表示传递的字符串的数目,argv 是一个指针数组,每个指针指向一个字符串(一份数据)。包括程序名以及它后面的字符串都会被程序所接收。
main() 函数的第二种原型有非常实际的应用,在 Linux 中,每个 Shell 命令都需要一个程序来解释,如果这个程序是由C语言编写的,那么 main() 函数就可以接收这个命令以及它后面附带的参数。
判断用户输入的是否是素数:
#include <stdio.h> #include <math.h> #include <stdlib.h> int isPrime(int n); int main(int argc, char *argv[]){ int i, n, result; if(argc <= 1){ printf("Error: no input integer!\n"); exit(EXIT_SUCCESS); } for(i=1; i<argc; i++){ n = atoi(argv[i]); result = isPrime(n); if(result < 0){ printf("%3d is error.\n", n); }else if(result){ printf("%3d is prime number.\n", n); }else{ printf("%3d is not prime number.\n", n); } } return 0; } //判断是否是素数 int isPrime(int n){ int i, j; if(n <= 1){ //参数错误 return -1; }else if(n == 2){ //2是特例,单独处理 return 1; }else if(n % 2 == 0){ //偶数不是素数 return 0; }else{ //判断一个奇数是否是素数 j = (int)sqrt(n); for(i=3; i<=j; i+=2){ if (n % i == 0){ return 0; } } return 1; } }
总结:
- 程序在运行过程中需要的是数据和指令的地址,变量名、函数名、字符串名和数组名在本质上是一样的,它们都是地址的助记符:在编写代码的过程中,我们认为变量名表示的是数据本身,而函数名、字符串名和数组名表示的是代码块或数据块的首地址;程序被编译和链接后,这些名字都会消失,取而代之的是它们对应的地址。
- 给指针变量赋值时,要将一份数据的地址赋给它,不能直接赋给一个整数,例如int *p = 1000;是没有意义的,使用过程中一般会导致程序崩溃。
- 使用指针变量之前一定要初始化,否则就不能确定指针指向哪里,如果它指向的内存没有使用权限,程序就崩溃了。对于暂时没有指向的指针,建议赋值NULL。
- 数组也是有类型的,数组名的本意是表示一组类型相同的数据。在定义数组时,或者和 sizeof、& 运算符一起使用时数组名才表示整个数组,表达式中的数组名会被转换为一个指向数组首元素的指针。