C语言的精髓 —— 指针
1、指针到底是什么?
(1)指针就是一个普通的变量——指针变量
(2)指针的作用:
-
实现间接访问
-
CPU的间接寻址方式是CPU设计时决定的—>决定了汇编使用简介寻址—>C语言也需要间接寻址
(CPU 的间接寻址:CPU通过寄存器来寻找内存)
-
-
高级语言(如Java、C#)没有指针,是因为语言本身帮我们封装了
(3)指针使用三部曲:
-
定义指针变量
-
关联(绑定)指针变量
-
解引用
-
解引用之前需要先绑定
-
绑定的意义:让指针指向一个可以访问,应该访问的地方,防止野指针的出现(瞄准);
指针的解引用是为了间接访问目标变量(开枪)
-
2、指针中一些符号的理解
(1)星号 *
- *可表示乘号,也表示指针符号(两者毫无关联)
- *用于指针相关功能两种中用法:
- 指针定义时,*结合前边的数据类型 ,用于表明要定义的指针类型(数据类型 *)
- 指针解引用时,解引用时用*p指向的变量本身
(2)取地址符 &
- 使用时,直接加在一个变量的前面,取地址符和变量加起来构成一个新的符号,这个符号表示这个变量的地址
(3)指针定义的同时初始化 和 指针定义之后赋值的区别
-
指针定义的同时初始化
int a = 23; int *p = &a; // 指针定义的同时初始化 // 指针定义的同时初始化与普通变量定义的同时初始化没有任何区别
-
定义时不进行初始化,指针变量先定义,再初始化
int a = 23; int *p; p = &a; // 正确,地址赋值给指针 *p = &a; // 错误,地址赋值给了指针所指的内存空间 // 将内存地址直接赋值给p: p = (int *)4; // 将4强制类型转换为指针类型,才能将4这个内存地址赋值给p p = 4; // 编译器不允许的,指针变量存储的应该是另外一个变量的地址,不能用来存储int类型的数
// printf中%p的含义: // %p是为了打印指针变量的值;打印普通变量的地址(通过与指针的绑定才能打印普通变量的地址) // %p本质上是以16进制的方式打印指针变量的值 int a = 23; int *p; printf("p = %ld.\n", p); // 打印p里面存的随机数字(整形打印) // 打印指针变量的值 printf("p = %p.\n", p); // 打印p里面存的随机的数字(这个数字就是一个地址) printf("p = %#x.\n", p); // 验证:%p打印指针和%x打印指针,打印出的值是一样的 // 打印普通变量的地址 p = &a; // 普通变量与指针变量绑定 printf("p = %p.\n", p); // 打印变量a所在的地址 printf("p = %#x.\n", p); // %#x就是p的值以16进制打印,与%p打印出来的值是一样的 // 普通变量的打印 printf("a = %p.\n", a); // a的值以16进制方式打印 printf("a(H) = %#x.\n", a); // a的值以16进制方式打印
(4)左值和右值
- 赋值操作:左值 = 右值
- 变量做左值,编译器认为这个变量符号的含义是,这个变量所对应的那个内存空间
- 变量做右值,编译器认为这个变量符号的含义是,这个变量的值,就是这个变量所对应的内存空间中存储的数
3、野指针问题
(1)什么是野指针
- 野指针:指向随机的,不正确的,没有明确限制的位置的指针
(2)野指针从哪里来
- 指针变量在定义时没有进行初始化,值也就是随机的,指针变量的值就是别的变量的地址。所以意味着这个指针指向了一个地址不确定的变量,去解引用指针就是去访问这个地址中存储的数值,地址不确定,所以结果是未知的。
- 注意:局部变量,分配在栈上
(3)野指针的危害
-
野指针在运行时很有可能触发段错误(sgmentation fault(core dumped))
-
野指针因为指向地址是不可预知的,有3种情况:
-
指向一个不可访问的地址,操作系统不允许访问的地址,如内核空间,
结果:触发段错误
-
指向一个可用的空间,而且是没有什么特殊意义的空间(曾经使用过但是已经不用的炸空间或堆空间)
结果:运行是不会出错,也不会对程序产生损害,这种情况下掩盖了程序错误,其实是有问题的
-
指向一个可用的空间,而且这个空间正在被程序所使用,如程序的一个变量x,那么野指针的解引用就会改变这个变量x,导致这个变量被莫名的改变,程序出现错误
结果:最终导致程序崩溃、或者数据损害,这种是危害极大的
-
(4)指针变量如果是局部变量
- 指针变量分配在栈上,遵从栈的规律。栈使用的多少是会影响这个默认值的,因此野指针是有一定规律的,但这个规律是没有意义的。
- 栈的规律:反复使用,使用完不擦除,本次是上次余留的值
(5)怎样避免野指针
-
错误的来源:指针在定义之后没有进行初始化,也没有赋值(没有指向一个正确的内存空间),然后就解引用了
-
避免方法:在解引用之前,一定确保指针指向一个绝对可用的空间
-
常规做法:
-
在定义指针的同时初始化为NULL
-
在指针解引用之前先去判断这个指针是否为NULL
-
在指针用完之后,将其赋值为NULL
-
正确的使用指针是在解引用指针之前,给指针绑定一个可用地址空间
int *p = NULL; // 在定义指针的同时初始化为NULL int a; // 中间省略400行代码。。。。。 p = &a; // 正确的使用指针的方式,是解引用指针前跟一个绝对可用的地址绑定 if (NULL != p) // 判断这个指针是否为NULL { *p = 4; } p = NULL; // 指针用完之后,将其赋值为NULL
-
(6)NULL到底是什么
-
NULL在C/C++中的定义
#ifdef _cplusplus // 定义这个符号就表示当前是C++环境 #define NULL 0 // 在C++中NULL就是0 #else #define NULL (void *)0 // 在C中NULL是强制类型转换为void *的0 #endif
- 在C语言中,int *p; 可以p = (int *)0; 但不可以p = 0; 类型不同
-
NULL的实质其实就是0,给指针赋初值为NULL,其实就是让指针指向0地址处
- 为什么指向0地址处:0地址处是一个特殊的地址,指针指向0地址就表示指针没有被初始化,也就表示是野指针,明确的进行野指针的标记;0地址在操作系统中是不能被访问的,如果程序员在解引用指针时没有判断指针是否等于NILL,直接去解引用,那么就会造成段错误,这已经是最好的结果了。
-
判断指针是否为NULL(野指针)的通常写法
- if (NULL != p) 而不是if (p != NULL)
- 原因:如果NULL写在后边,粗心的人将 == 写成 = 时,程序员已经错,但是编译器不会报错,这个错误很难检查出来;如果NULL写在前边,错误的将 == 写成 = 时,编译器是会报错的,就能找到错误(NULL是常数,不能使用 = 来赋值,常数不能被赋值)
4、const关键字与指针
- const关键字修饰变量,表示变量是常量,如果重新赋值,则会报错
(1)const关键字修饰指针的四种形式
-
const int *p; // p是可变的(变量),p指向的int类型的数是不可变的(常量) int const *p; // p是可变的(变量),p指向的int类型的数是不可变的(常量) int * const p; // p是不可变的(常量),p指向的int类型的数是可变的(变量) const int * const p; // p是不可变的(常量),p指向的int类型的数也是不可变的(常量)
(2)const修饰的变量真的不能改了吗
-
在gcc环境下,const修饰的变量其实是可以修改的,通过指针间接访问的方式修改
const int a = 5; // a = 6; // assignment of read-only variable ‘a’ int *p; // p = &a; // 这里报警告可以通过强制类型转换消除 p = (int *)&a; *p = 10; printf("a = %d.\n", a); // 总结:虽然a不能改,但是p是可以改的,p指向的变量也是可以改的,所以将a的地址与p绑定,就可以通过p来改变a的值
-
在某些单片机环境下,const修饰的变量是不可以修改的,能不能修改取决于具体的环境,C语言并没有严格的限制
-
gcc中const修饰的变量能被修改的原因:
- gcc中把const修饰的变量放在了date段,通过编译器认定这个变量是const的,运行时并没有const的标记,只要骗过编译器就可以修改了,const通过编译器在编译时检查来实现的,const变量不能改是编译错误而不是运行错误,骗过编译器也就可以更改const修饰的值了。
(3)const究竟怎么用
- const是在编译器中实现的,编译时检查,并非不能骗过,所以C语言使用const,是一种道德约束,并非法律约束,所以使用const更多的是传递信息,告诉编译器、读程序的人,这个变量是不应该被修改的。
5、数组的深入
(1)从内存角度理解数组
- 内存角度来讲,数组变量是一次分配多个变量,也就是一次分配多个连续的存储空间
- 数组多个变量虽必须单独访问,但因为地址彼此相连,很适合指针来操作。数组与指针的结合
(2)从编译器角度理解数组
- 数组变量也是变量,变量的本质是内存地址,内存地址的数值与变量名绑定,变量类型决定地址的延续长度
- 变量:变量名与内存地址绑定 变量名:定义的符号 变量类型:地址延续的长度
(3)数组中几个关键符号的理解
-
a,a[0],&a,&a[0],前提int a[10];
-
a是数组名
- a做左值,整个数组的所有空间(10*4=10字节),因数组不能整体操作,所以a不做左值
- a做右值,数组首元素a[0]的首地址(首地址是4字节的第一个地址),等同于&a[0]
- a只是a[0]的地址,a+1就是a[1]
-
a[0]是数组的首元素
- a[0]做左值,数组的第一个元素对应的内存空间(连续4字节)
- a[0]做右值,数组的第一个元素的值(内存空间中存储的值)
-
&a是数组名取地址,数组的地址
- &a做左值,不能做左值,&a地址是一个常量,不能被赋值
- &a做右值,整个数组的首地址 &a+1增加10个int的长度
-
&a[0]是数组第一个元素的首地址([ ]优先级高于&)
- &a[0]做左值,地址是常量不能做左值
- &a[0]做右值,数组首元素首地址,等同于a
-
总结:
- &a和a做右值时的区别:&a是整个数组的首地址,而a是数组首元素的首地址。在数值上相等,意义却是不同的,p = a; p++; // 指向下一个元素a[1]
&a+1; // 增加10个int型内存的长度 - a和&a[0]做右值时意义和数值完全相同 a=&a[0]
- &a和a做右值时的区别:&a是整个数组的首地址,而a是数组首元素的首地址。在数值上相等,意义却是不同的,p = a; p++; // 指向下一个元素a[1]
6、指针与数组是天生关联的
(1)以指针方式访问数组元素
-
数组不能整体访问,单独访问有两种方式:数组方式、指针方式
-
数组方式访问:数组名[下标]; (下标从0开始)
-
指针方式访问:*(指针+偏移量)
*(a+3); 或者 p = a; *(p+3) // 若指针是数组首元素(a或&a[0])地址,那么偏移量就是下标; // 指针也可以不是数组首元素地址而是其他那个元素地址,偏移量就要具体考虑
-
两种方式实质都是一样的,在编译器内部都是用指针方式来访问数组元素的,数组下标只是一种壳。指针访问数组才是本质。
-
(2)指针和数组类型的匹配
int *p, a[5];
p = a; // 类型是匹配的
p = &a; // 类型不匹配
// p是int *类型,a是数组首元素首地址,是一个指向int类型指针;&a是整个数组的地址,也就是数组的指针,称为数组指针(*a)[5],即&a = (*a)[5],因此和p类型是不匹配的。
-
&a、a、&a[0]从数值上看完全相等
-
从意义上看,a和&a[0]是数组首元素的地址,&a是整个数组的地址
-
从数据类型上看,a和&a[0]是数组元素的指针,也就是int 类型;
&a是整个数组的指针,是数组指针类型,即int (*)[5]
-
(3)指针类型决定了指针如何参与运算
- 指针参与时,指针变量本身存储的数值是地址,所以运算也是地址的运算
- 特点:指针变量+1,并不是真的+1,而是1*sizeof(指针指向的变量的类型)。指针+1后刚好指向下一个元素。
- 如果指针类型是int *,则+1就表示实际地址+4
- 如果指针类型是char *,则+1就表示实际地址+1
- 如果指针类型是double *,则+1就表示实际地址+8
- 如果指针类型是int (*)[N],则+1就表示实际地址+4×N
7、指针与强制类型转换
(1)指针的数据类型的含义
-
本质:变量,指针就是指针变量
-
一个指针涉及两个变量
- 指针变量自己本身
- 指针变量指向的那个变量
-
int *p; 定义指针变量时,p(指针变量本身)是int *类型,
*p(指针指向的那个变量)是int类型的
-
int * 就是指针类型,只要是指针类型就占4个字节空间(32位系统)
所有的指针类型(int *、char *、double *)的解析方式都是按照地址的方式来解析的,指针变量里面存的是一个32位的二进制表示的一个地址(印证了指针变量都是4字节的)
(2)指针数据类型转换实例分析1(int * ----> char *)
- 都是整形,类型兼容,有时候正确,有时候错误
- int 4字节,char 1字节, int范围大于char;
- char范围内,int和char互相转换都不会出错;超过char范围,char转int正确,int转char出错
(3)指针数据类型转换实例分析1(int * ----> float*)
- int和float的解析方式是完全不同的,不相互兼容,所以int *转成float *再去访问绝对会出错
8、指针、数组与sizeof运算符
(1)sizeof运算符
- sizeof是C语言中的一个运算符,它不是函数,只是像函数。
- 作用:用来返回( )里边的变量或者数据类型占用的内存字节数
- 存在的价值:在不同平台下,各种数据类型所占的内存字节数不尽相同(int在32位环境下占4字节,在16位环境下占2字节),所以程序中需要使用sizeof来判断当前变量/数据类型在当前环境下占几个字节
(2)指针和数组在C语言中的使用
-
char str[] = "hello"; sizeof(str) = 6('\0'还要占1字节); sizeof(str[0]) = 1; strlen(str) = 5(测字符串的长度,不包括'\0'); str[] = "hello" ---> str[6] = "hello"
-
char *p = str; sizeof(p) = 4(指针本身占4字节); sizeof(*p) = 1; strlen(p) = 5; // strlen是C库函数,用来返回一个字符串的长度,strlen接受的参数必须是一个字符串(字符串的特征:以'\0'结尾)
-
int b[100]; sizeof(int) = 100 * 4 = 400; int n = 10; sizeof(n) = 4; sizeof(int) = 4;
-
void fun(int b[100]) // 函数传参,形参可以用数组,但是传递的不是整个数组,而是数组首元素的首地址 { // 也就是说实际传入的是一个指向数组首元素首地址的指针 sizeof(b); } void func1(int *b, int num); func1(b, sizeof(b)); // 数组传参只能传入数组首元素首地址,不能将大小传进去,用sizeof计算数组大小
-
int a[100] ; int b = sizeof(a) / sizeof(a[0]); // 整个数组所占的字节数/一个元素所占的字节数=有多少个元素 printf("b = %d\n", b); // b = 100
-
#define dpchar char * // 只是重新命名 typedef char * tpchar // 重命名类型,制造用户自定义类型 dpchar p1, p2; sizeof(p1) = 4; sizeof(p2) = 1; // dpchar p1, p2;等同于char *p1, p2;也就是char *p1; char p2; tpchar p3, p4; sizeof(p3) = 4; sizeof(p4) = 4; // tpchar p1, p2;等同于char *p3; char *p4;
9、指针与函数传参
(1)普通变量作为函数传参
- 形参和实参名字可以相同,也可以不同,实际都是用实参替代对应的形参
- 在函数内部,形参的值等于实参,函数调用时,将实参的值赋值给了形参
- 这也就是 “传值调用”,实参做右值,形参做左值
(2)数组作为函数形参
- 数组名作为函数形参传参,实际传递的是数组首元素的首地址。在函数内部,传进来的就是一个指向数组首元素首地址的指针。所以sizeof(形参变量) = 4;
- 这就是“传址调用 ”,调用子函数时,调用的是地址(也就是指针),通过传进来的地址访问实参。子函数得到的地址和外边数组的首元素首地址是相同的
- 数组作为函数形参时,[ ]是可有可无的,因为,数组名作为形参传递的实际是指针,没有数组长度的信息
(3)指针作为函数形参
- 和数组作为形参是一样的,就好像指针方式访问数组元素和数组方式访问数组元素的结果是一样的
(4)结构体作为函数形参
- 和普通变量(类似于int之类的)传参时表现是一样的,结构体变量其实也是普通变量
- 因为结构体一般都很大,如果用结构体变量直接进行传参,函数调用效率就会很低(传参是将实参赋值给形参,赋值一份)
- 解决方法:不传结构体变量,传入结构体变量的指针
- 常规的操作都是传入结构体变量的指针,默认传入的是结构体中首元素的首地址;这样效率会更高,当然C语言也允许传入结构体,就是效率低
(5)传值调用和传址调用
-
// 传值调用 void swap1(int a, int b) { int tmp; tmp = a; a = b; b = tmp; printf("in swap1, a = %d, b = %d.\n", a, b); } int m = 3, n = 5; swap1(m, n); printf("m = %d, n = %d.\n",m ,n); // 交换失败 m和n并没有真正的进入swap1函数内部,而是赋值了一份自己的副本进入了函数swap1.然后在子函数中交换的实际是副本,而不是真正的m、n本身。所以swap1内部确实是交换了,但是外部的m和n并没有交换。
-
// 传址调用 void swap2(int *a, int *b) { int tmp; tmp = *a; *a = *b; *b = tmp; printf("in swap2, *a = %d, *b = %d.\n", *a, *b); } int m = 3, n = 5; swap2(&m, &n); printf("m = %d, n = %d.\n",m ,n); m和n真身还是没有进入swap2函数内部,而是swap2函数内部跑出来把外面的m、n真身改了;实际上实参m、n永远无法真身进入子函数内部(进去的只是一份拷贝),但是在swap2把m、n的地址传给了子函数,于是子函数内通过指针解引用的反射光hi从函数内部访问到外部的n和m真身,从而改变了m和n。
-
结论:这个世界上根本没有传值和传址两种方式,C语言本身函数调用一直是传值的,只不过传的值可以是变量名,也可以是变量的指针。
10、输入型参数和输出型参数
(1)函数为什么需要形参与返回值
- 函数名(当作地址来调用)只是一个符号,表示整个函数代码段的首地址,实质是一个指针变量
- 函数体是函数的关键,也是函数实际做的工作
- 形参列表是输入部分、返回值是输出部分
- 若没有形参和返回值,函数也无法对数据进行加工;全部使用全局变量就不需要形参和返回值。
- 模块化的编程尽量不适用全局变量;全局变量的好处:省略了函数传参的开销,效率高一些,若参数很多开销就很大,通常把很多参数打包成一个结构体,传结构体的指针进去。
(2)函数传参中使用const指针
-
const用在函数参数列表中,用法是:const int *p
- 意义:指针变量p本身是可变的,而p所指向的变量是不可变的
-
const用来修饰指针做函数传参的作用:
- 声明在函数内部不会改变这个指针所指向的内容。所以给这个函数传一个不可改变的指针(char *p = “linux”)不会触发错误;
- 如果形参是未声明为const指针的函数(形参指针指向的变量可以改变),那么你给了他一个不可改变的指针((char *p = “linux”))的时候就要小心了,编译器不会报错,执行会出现段错误。
-
// 区别: char *p = "linux"; // 指针定义字符串,本身是不可改变的(存储在数据段) char pstr[6] = "linux" // 数组定义字符串,是可以改变的
(3)函数需要向外部返回多个值怎么办?
- 函数参数可以是多个,但返回值只有1个,无法返回多个返回值
- 一个函数需要返回多个值时,做法是用参数来做返回值。
- 典型的linux风格函数中,返回值是不会用来返回结果的,而是用来返回0或者负数,用来表示程序执行结果是对是错,是成功还是失败
- 函数需要返回多个值时,函数输入输出都是靠函数参数实现的
- 输入型参数、输出型参数
- 输出型参数是用来让函数内部把数据输出到函数外部的
(4)总结:根据函数原型(声明)判断那个参数是输出型参数那个是输入型参数
- 传参为普通变量(不是指针)那肯定是输入型参数
- 传参为指针:
- 作输入:在函数内部只需要读取这个参数而不会改变它,就在指针前面加const来修饰;输入型参数不可改变
- 做输出:指针变量并且还没加const修饰,也就是要改变的的
- 输入型参数:普通变量、加const的指针
- 输出型参数:不加const的指针
- 例如:char *strcpy(char *dest, const char *src); // dest是输出型参数,src是输入型参数