一.const关键字
1.1 const修饰变量
先说一个最简单的道理:普通变量是可以随便改的。
int a=10; a=20;在 C 语言关键字中,const 就是变量的一把“锁”。有时候,我们希望一个变量“定死了”,谁也不许它改。这时候就可以请出 const。
const int n = 0; n = 20; // 报错!n 被 const 锁住了,语法上不允许改note:n 本质上还是个变量,只是 const 给它套了一层“语法上的锁”。你在代码里写 n = 20,编译器会直接拦下来,说不符合规则。
那这个锁真的打不开吗?也不是。如果你拿到 n 的地址,绕过去改,还是能改的,示例如下:
const int n = 0;//n前面有const,n无法修改 int* p = &n; // 拿到 n 的地址 *p = 20; // 绕过了 const,把 n 改成了 20程序跑出来,n 真的从 0 变成了 20。可是这合理吗?const 的初衷就是“不许改”,你拿个指针就把它改了,那这把锁不是白上了吗?所以,要锁就锁彻底——const 不光能锁变量,还能锁指针本身。关键看它站在 * 的哪一边,意思完全不同:
| 写法 | *左边 | *右边 |
| int*p | 没上锁 | 没上锁 |
| const int*p | 指针指向的内容不能变 | 指针本身可以变 |
| int* const p | 指针指向的内容可以变 | 指针本身不能变 |
| const int*const p | 指针指向的内容不能变 | 指针本身不能变 |
1.2 const效果举例
下面我用一个有趣的例子来帮助大家理解并记住 const 在不同位置上的效果。
(1) 普通关系int *p
男友的钱想花就花:*p = 0
男友想换就换:p = &m
想花就花,想换就换,没有任何限制。
(2)const int *p
女生给自己立了条规矩:恋爱可以谈,但我不花男朋友的钱。
花男友的钱:*p = 0不能发生,n 那 10 块不能让你花光
换男友:p = &m可以,随时能换,换到 m 之后照样不花 m 的钱
花心但独立,只图感情、不动你的钱。
(3)int * const p
这次女生立的规矩是:认定一个男友,就绝不换人。
换男友:p = &m不能发生,认准 n 就是 n
花男友的钱:*p = 0可以,只要 n 愿意给你花
专一,认定了就跟你,但钱照花。
④const int * const p
花男友的钱:*p不能发生
换男友:p=&其它不能发生
既不能花他的钱,也不许提分手。表面上像 "渣男的要求",其实是女生自己立了个又抠又不让分手的规矩。
二.野指针
野指针的指向是“不可知”的——随机的、不正确的、没有任何限制。拿这种指针去读写,就像在黑夜里乱开枪,随时可能崩。
2.1 野指针如何产生
(1)指针未初始化
int *p; // 局部变量,没初始化,里面存的是个随机值 *p = 20; // 往一个不知道的地址里写东西 → 野指针(2)越界访问
int arr[10] = {0}; int *p = &arr[0]; for (int i = 0; i <= 11; i++) { *(p++) = i; // 数组只有 10 个元素,却写到第 12 个位置了,数组越界产生野指针 }(3)指针指向的空间被释放了
int* test() { int n = 100; // 局部变量,函数一结束就“没了” return &n; // 返回它的地址,等函数返回后这个地址已经无效 } int main() { int *p = test(); // p 指向的是一块已经失效的空间 printf("%d\n", *p); // 访问一个已经不属于你的地方产生野指针 return 0; }2.2 如何避免野指针的产生
我把野指针比喻成“野狗”,野狗放任不管是非常危险的,所以我们常用这几种方法限制这条“野狗”。
(1)初始化指针(把狗拴起来)
如果明确知道指向哪,直接给地址;不知道指向哪,就给它 NULL,NULL 就是 C 语言里定义的一个常量,值是 0。地址 0 是不能用的,你读写它,编译器就会报错——等于提前把“危险”拦在了门外。
举例如下:
int num = 10; int *p1 = # // 知道指向哪,直接给地址 int *p2 = NULL; // 不知道,先拴住,赋 NULL(2)不要越界
程序申请了多少空间,指针就只能访问多少。超出范围就是越界,越界就是往别人家里跑。
(3)不用了及时置NULL,用之前检查一下
拴起来的狗,你也不能去逗它。指针也是:不用了先 p = NULL,用之前先判断 if (p != NULL),是 NULL 就不去使用。
p = NULL; // 用完了,拴起来 // ... 过了一阵 p = &arr[0]; // 重新让它指回数组开头 if (p != NULL) { // 用之前先检查 // 放心使用 }(4)不要返回局部变量的地址
局部变量随函数结束就消失了,把它的地址带出来,就是一个野指针。所以像上面那个 test() 函数,就是反面教材。
三.assert关键字
3.1 断言
assert(p != NULL);这行代码在运行时验证 p 是否不等于 NULL。如果是,程序继续跑;如果不是,程序直接停下来,还帮你把出错的文件名和行号都打印出来。排查问题的时候,这很高效且安全。
3.2 assert断言的优缺点
好处如下:
(1)自动标识出问题的文件和行号
(2)可以不改代码就开启/关闭断言——只要在 #include <assert.h> 前面定义一个 NDEBUG 宏即可。
举例:
#define NDEBUG #include <assert.h>重新编译后,所有 assert() 就都被“屏蔽”了。哪天又出问题,把 #define NDEBUG 注释掉再编译,断言就又回来了。
它的缺点也实在:
每次检查都要花一点运行时间。所以常规做法是——Debug(调试版)开着,方便排查;Release关掉,保证用户使用时不拖慢程序。在 VS 这类 IDE 里,Release 版本默认就把断言优化掉了,两全其美。
四.传址调用
4.1 什么是传址调用
顾名思义,传址调用就是将地址作为参数传递给函数,再完成功能。
4.2 传址调用实现变量交换
我们之前在关于函数的学习中学习了传值调用,在学习了指针后我们就可以使用传址调用了。
还记得我们之前在学习函数时留下的一个问题吗?如果用函数来实现两个变量的交换该怎么实现呢?
有的人会这样实现:
void Swap1(int x, int y) { int tmp = x; x = y; y = tmp; } int main() { int a, b; scanf("%d%d", &a, &b); Swap1(a, b); printf("a=%d b=%d", a, b); return 0; }是不是看起来没有什么问题,创建中间变量然后交换,但是当程序运行起来就会发现结果完全是错的,x,y根本没有交换。
输出结果:
那问题出在哪里呢?
还记得我们在函数的学习中说过:“我们在调试的时候可以观察到,x 和 y 确实得到了 a 和 b 的值,但是 x 和 y 的地址和 a 和 b 的地址是不一样的,所以我们可以理解为形参是实参的一份临时拷贝。”
main 里创建了 a、b,各有自己的地址; 调用 Swap1 时,把 a、b 的值复制给了形参 x、y; 但 x、y 是另一块独立的内存空间,跟 a、b 没关系。 所以 Swap1 里面换的只是 x 和 y 这对“复印件”,原件 a、b 纹丝不动。结论就是:实参传给形参时,形参会单独创建一份临时空间来接收,改形参不影响实参。这就是“传值调用”的坑。
如果要实现用函数对变量进行交换就要用到传址调用,我们把a,b的地址传进去,让函数通过地址直接去操作 main 里的 a 和 b
代码如下:
void Swap2(int *px, int *py) { int tmp = *px; // *px 拿到 a 的真实值 *px = *py; // 直接把 a 改成 b 的值 *py = tmp; // 把 b 改成 tmp } int main() { int a = 0, b = 0; scanf("%d %d", &a, &b); printf("交换前: a=%d b=%d\n", a, b); Swap2(&a, &b); // 传的是地址! printf("交换后: a=%d b=%d\n", a, b); return 0; }输出结果:
这次输入 10 20,交换后就是 20 10。因为 Swap2 拿到的是 a、b 的地址,*px、*py 直接操作的就是 main 里那两块内存。这种把地址传给函数的调用方式,就叫传址调用。
4.3 传址调用的使用场景
函数只需要用到主调函数里变量的值来算东西我们就用传值调用,函数内部要修改主调函数里的变量必须用传址调用。
五.数组名的实际意义
5.1 数组名与地址
之前我们用&arr[0]拿到数组第一个元素的地址,但其实数组名本来就是地址,而且是数组首元素的地址。来做个测试:
int main() { int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; printf("&arr[0] = %p\n", &arr[0]); printf("arr = %p\n", arr); return 0; }输出结果:
从输出结果可见,arr 打印出的地址与 &arr[0] 打印出的地址一模一样,所以数组名代表着数组首元素的地址。
那么这时有人会问,那这个代码是怎么回事?
int main() { int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; printf("%d\n", sizeof(arr)); return 0; }按之前所说,数组名代表首元素地址,输出结果应该是4或8呀,这个40是从哪里来的呢?
其实数组名代表首元素地址有两个特殊情况:
(1)sizeof(数组名):sizeof 中单独放数组名不能放其他的东西,这里的数组名表示整个数组,算的是整个数组的大小,单位是字节。
int main() { int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; printf("%d\n", sizeof(arr)); printf("%d\n", sizeof(arr + 0)); return 0; }这就是一种反面例子,在我们的认知里sizeof(arr)=sizeof(arr+0),但编译器不会这么想,所以,sizeof 中单独放数组名不能放其他的东西,这里的数组名才表示整个数组。
(2)&数组名:这里的数组名表示整个数组,取出的是整个数组的地址。
note:整个数组的地址和数组首元素的地址是有区别的。
除此之外,任何地方使用数组名,数组名都表示首元素的地址。
5.2 数组名的理解与使用
大家看一下的例子,想一想输出结果应该是什么?
int main() { int a[] = { 1,2,3,4 }; printf("%d\n", sizeof(a)); printf("%d\n", sizeof(a + 0)); printf("%d\n", sizeof(*a)); printf("%d\n", sizeof(a + 1)); printf("%d\n", sizeof(a[1])); printf("%d\n", sizeof(&a)); printf("%d\n", sizeof(*&a)); printf("%d\n", sizeof(&a + 1)); printf("%d\n", sizeof(&a[0])); printf("%d\n", sizeof(&a[0] + 1)); return 0; }我来给大家一一解答:
printf("%d\n", sizeof(a));对于这个代码,a为数组名,只有数组名单独在sizeof中,故a为整个数组,我们来看看输出结果。
没错!数组中有4个整形数据,每个为4字节,4*4=16。
printf("%d\n", sizeof(a + 0));对于这个代码,a为数组名,sizeof中是a+0,并不是单独的数组名,所以a不代表整个数组,而是代表首元素地址。让我们来看看输出结果。
由于我的环境是x64,指针所占内存为8字节。看来我们的分析没有问题。
printf("%d\n", sizeof(*a));对于这个代码,a为数组名,sizeof中为*a不是单纯的数组名,所以a为首元素地址,*a为解引用首元素地址,故求的是a中的首元素所占内存大小。让我们看看输出结果。
int类型的整整型数据所占内存大小为4字节。看来我们的分析没有问题。
printf("%d\n", sizeof(a + 1));对于这个代码,a为数组名,sizeof中为a+1,不是单纯的数组名,所以a为首元素地址。a+1表示第二个元素的地址,所求为第二个元素的地址所占内存的大小。让我们看看输出结果。
由于我的环境是x64,指针所占内存为8字节。看来我们的分析没有问题。
printf("%d\n", sizeof(a[1]));对于这个代码,数组名是a,sizeof中为a[1],不是单纯的数组名,a[1]代表下标为1的元素,所求为第二个元素所占内存的大小。让我们看看输出结果。
int类型的整整型数据所占内存大小为4字节。看来我们的分析没有问题。
printf("%d\n", sizeof(&a));对于这个代码,数组名为a,sizeof中为&a并不是单纯的数组名,并且&后只有单纯的数组名,所以&a代表的是整个数组的地址,故所求为整个数组的地址所占内存的大小。让我们看看输出结果。
由于我的环境是x64,指针所占内存为8字节。看来我们的分析没有问题。
printf("%d\n", sizeof(*&a));对于这个代码,数组名为a,sizeof中为*&a并不是单纯的数组名,并且&后只有单纯的数组名,所以&a代表的是整个数组的地址,*&a是解引用整个数组的地址为整个数组,故所求为整个数组所占内存的大小。让我们看看输出结果。
int类型的整整型数据所占内存大小为4字节。看来我们的分析没有问题。
printf("%d\n", sizeof(&a + 1));对于这个代码,数组名为a,sizeof中为&a+1并不是单纯的数组名,并且&后只有单纯的数组名,所以&a代表整个数组的地址,&a+1为向后跨越一个数组的大小后的地址,故所求为该地址在内存中的大小。让我们看看输出结果。
由于我的环境是x64,指针所占内存为8字节。看来我们的分析没有问题。
printf("%d\n", sizeof(&a[0]));对于这个代码,数组名为a,sizeof中为&a[0]并不是单纯的数组名,并且&后不是单纯的数组名而是a[0]代表数组的首元素,则&a[0]为数组首元素地址,故所求为数组的首元素地址所占内存的大小。让我们看看输出结果。
由于我的环境是x64,指针所占内存为8字节。看来我们的分析没有问题。
printf("%d\n", sizeof(&a[0] + 1));对于这个代码,数组名为a,sizeof中为&a[0]+1并不是单纯的数组名,并且&后不是单纯的数组名,而是a[0]代表数组的首元素,则&a[0]+1代表首元素地址向后挪一个元素的地址,也就是数组第二个元素前的地址,故所求为第二个元素前的地址在内存中的大小。让我们看看输出结果。
由于我的环境是x64,指针所占内存为8字节。看来我们的分析没有问题。
六.指针访问数组
6.1 指针和数组下标的关系
有了前面的知识,再结合数组的特点,我们就可以很方便地用指针访问数组了,指针和数组下标是密不可分的“一家人”。
int arr[10] = {0}; int sz = sizeof(arr)/sizeof(arr[0]); int* p = arr; // 数组名就是首元素地址,可以直接赋值给指针 for (int i = 0; i < sz; i++) scanf("%d", p+i); // 也可以写 scanf("%d", arr+i); for (int i = 0; i < sz; i++) printf("%d ", *(p+i));这个代码搞明白后,再试一下:数组名 arr 是首元素地址,可以赋值给 p,所以 arr 和 p 在这里是等价的。既然 arr[i] 可以访问数组元素,那 p[i] 是不是也可以?
// 把输出里的 *(p+i) 换成 p[i] for (int i = 0; i < sz; i++) printf("%d ", p[i]);同样能正常打印。所以本质上 p[i] 是等价于 *(p+i) 的。同理 arr[i] 也等价于 *(arr+i)——数组元素的访问,在编译器处理时,都是先转换成“首元素的地址 + 偏移量”求出元素地址,再解引用去访问的。
七.一维数组传参的本质
7.1 示例:利用函数求元素个数
我们将通过以下这个例子来理解一维数组传参的本质。
void test(int arr[]) { int sz2 = sizeof(arr)/sizeof(arr[0]); printf("sz2 = %d\n", sz2); } int main() { int arr[10] = {1,2,3,4,5,6,7,8,9,10}; int sz1 = sizeof(arr)/sizeof(arr[0]); printf("sz1 = %d\n", sz1); // 10 test(arr); // sz2 = 1 return 0; }我们可见,函数内部没有正确获得元素个数!那是怎么一回事呢?
这就是数组传参的本质:数组名是首元素的地址,数组传参时传的就是首元素的地址,所以形参本质上应该用指针来接收。函数内部写sizeof(arr),算的是一个地址的大小(单位字节),而不是数组的大小。正是因为形参本质是指针,所以在函数内部没办法求出数组元素个数。
所以形参写成数组形式或指针形式都可以,本质都一样。
八.二级指针
8.1 什么是二级指针
通俗的讲,二级指针就是指针的指针,我们知道指针变量也是变量,那么二级指针就是存着这一个指针的地址的指针变量。
8.2 二级指针的写法
int a = 10; int *pa = &a; // 一级指针,存 a 的地址 int **ppa = &pa; // 二级指针,存 pa 的地址内存里大概是这样的:a 存的是值 10,pa 存的是 a 的地址,ppa 存的是 pa 的地址。 二级指针的运算有两层: *ppa:通过对 ppa 中的地址解引用,找到的是 pa。比如 *ppa = &b; 就等价于 pa = &b; **ppa:先通过 *ppa 找到 pa,再对 pa 解引用(*pa),找到的是 a。比如 **ppa = 30; 等价于 *pa = 30; 也等价于 a = 30。
九.指针数组
9.1 指针数组是什么
在讲解指针数组之前我想问大家,指针数组是指针还是数组?你可以先思考一下再来看下面的讲解。
我们类比一下:整型数组是存放整型的数组,字符数组是存放字符的数组。那指针数组呢?
它是存放指针的数组,本质上是个数组。 比如 int* arr[5],它是一个有 5 个元素的数组,每个元素都是一个 int* 类型的指针,用来存放地址。指针数组的每个元素是地址,又可以指向一块区域。
9.2 用指针数组模拟二维数组
有了指针数组,我们就能用它来“模拟”二维数组。思路是:先把几个一维数组的地址装进一个指针数组里。大家可以先思考一下怎么来编写这个代码以达到我们模拟的目的。
int arr1[] = {1,2,3,4,5}; int arr2[] = {2,3,4,5,6}; int arr3[] = {3,4,5,6,7}; // 数组名是首元素地址,类型是 int*,可以存放在 parr 数组中 int* parr[3] = {arr1, arr2, arr3}; for (int i = 0; i < 3; i++) { for (int j = 0; j < 5; j++) { printf("%d ", parr[i][j]); } printf("\n"); }parr[i] 是访问 parr 数组的元素,parr[i] 找到的元素指向了一个整型一维数组,parr[i][j] 就是那个一维数组里的元素。这样就用指针数组模拟出了二维数组的效果。 但要注意,它并非完全是二维数组,因为每一行(也就是 arr1、arr2、arr3)在内存里并不是连续存放的。