C语言:深入理解指针1

现在我的C语言博客更新到指针啦,指针是C语言学习中的一个重要板块,同时也是难度较大的一个板块。因此我们需要投入的时间精力可能更高。


1. 内存和地址

1.1 内存

在讲内存之前,首先想象一下这样的场景:你在一栋楼里某一个房间,但是房间没有编号(也就是不知道具体位置)现在你的一位朋友来找你,此时他只能挨个房间去找,可能你在最后一楼的最后一个房间,这样查找的效率很低。因此我们对每一个房间都编上号,如

通过房间号你的朋友就容易找到你。如你在305房间,你的朋友就会去3楼的第五个房间,寻找编号305,这样效率就提高了。

现将此例子类比到计算机中,我们知道其实计算机中有CPU (中央处理器),CPU在处理数据时,需要的数据来自于内存。因此CPU在内存中读取数据,处理后的数据也放在内存中。那么对于电脑中的4G,8G,16G的内存空间,计算机时如何高效管理的呢?

首先,内存空间被分割为一个个小的内存单元,每个内存单元是1个字节,对于字节这些单位进行扩展如下:

 我们可以运用类比思想学习内存,想象每个内存单元其实是一间间的寝室,空间大小为1Byte,即空间容量8个比特(bit)(八人寝,一人就是一比特),一个比特为可以存进一个二进制位的0或者1,这样一个字节就可以存入八个二进制位的0或者1,我们就可以用这一串数据表示编址了。对寝室进行编号,CPU就可以快速地找到一个内存空间。在生活中这个编号叫做地址,在计算机中,这个编号被叫做指针。因此我们可以认为:内存单元的编号==地址==指针

1.2 应该如何理解编址?

CPU在访问某个内存空间时,必须要知道这个字节空间在什么位置,而又因为在计算机中内存空间对应的字节很多,所以需要给内存进行编址(类似于宿舍很多也需要进行编址区分一样)。

在计算机中,编址并不是真的给所有的字节空间都编上号记录下来,而是通过硬件设计来完成的。(为什么内存能通过硬件设计直接找到对应的地址?如下图)

就好像吉他学习一样,吉他上面不会写出各种音符不会有“多瑞米发索拉西”,但是演奏者总能准确地找到各个位置上的琴弦对应的各种音符,原因是制作乐器时制造商们在乐器硬件设计方面达成了共识,并且所有演奏者们都知道并遵守这个共识。本质上就是一种约定出来的共识。

对于硬件的编址也是如此!计算机中有很多的硬件单元,硬件单元之间互相协同工作。所谓协同,是指硬件单元之间至少能够进行数据传递。

硬件与硬件之间连通的桥梁---“线”。同样,CPU和内存之间有大量数据交互的,所以CPU和内存之间也要通过“线”连接起来。 这些线包括地址总线,数据总线和控制总线

我们可以这样理解地址线:假设我的电脑是32位机器,就有32根地址总线,每根地址总线有两种状态,线输出高电平电信号,转换成数字信号1;输出低电平,转换成数字信号0(电脉冲的有无)。一根地址线有两中含义,两根地址线有4种含义,这样,32根地址总线则有2^32种含义,每一种含义代表一个地址。同理,64位机器则有2^64个地址。

对于这三种线,我的理解:

数据总线:传递数据的桥梁(通道);

地址总线:专门用来传送地址;(相当于一个地址仓库)

控制总线:发号施令的军师。

2. 指针变量和地址

2.1 取地址操作符(&)

在理解了内存和地址后,我们再回到C语言中,在C语言中创建变量其实是向内存申请空间来存放数据。我们可以利用int a = 10;这行代码进行分析:

int a = 10;表面上看,是创建了一个整型变量a,并且对a赋值10;实际上是想内存申请了4Byte的空间,命名为a,用来存放10。对于这4个字节的空间,其地址通过调试--监视--内存窗口可以观察,结果如下:

由此可以得到:int a = 10;中的4个字节都是有地址的,观察这4个字节的地址可以用&(去地址操作符),但是只能取出第一个字节的地址(较小的地址),其他地址可以通过相邻地址相差1的规律顺藤摸瓜的找到。取地址操作符是一个单目操作符

2.2 指针变量和解引用操作符(*)

2.2.1 指针变量

我们通过取地址操作符(&)拿到的地址是一个数值,比如:0x0012ff40,这个数值是一个编号,有时也需要进行存储方便后期的使用,于是就把它存放在指针变量中。指针变量用于存放地址,所以存放在指针变量中的值都会被认为是地址(存放在指针变量中的值都会理解为地址)。

2.2.2 如何拆解指针类型

我们可以看到,pa 的类型是int *,那我们如何理解指针的类型呢?  * 说明pa是指针变量,前面的int类型说明pa指向的对象是int(整型类型)

同理,对于char类型的变量的地址,也应放在char *类型的指针中。如:

char  ch  =  ‘w’ ;

char  *  pc  =  &pc;

2.2.3 解引用操作符

我们将地址存放在指针变量中,是为了使用的。在现实生活中,通过一个地址找到一块空间。

在C语言中也一样,我们通过&(取地址操作符)拿到了地址(指针),此时增加了一个新的操作符:解引用操作符。解引用操作符和取地址操作符一来一去互相抵消,就像下面的代码,其实是类似于* & a = 0;的效果。

2.3 指针变量的大小

根据1.2的内容我们可以了解到,32位的机器假设有32根地址总线,每根地址线发生电信号与数字信号的转换,得到0或者1,,把32根地址线产生的二进制序列当成一个地址,则一个地址就是32个二进制序列,也就是32个比特位,需要4个字节的空间才能存储。不管是什么类型的指针,只要它是由指针类型创建的变量,那它就要向内存申请空间。而且同一平台下指针变量的大小相同。用代码验证:

同理,64位的机器有64根地址线,产生64位的二进制序列,64bit即8Byte。 

总结: x86环境下(32位机器)指针变量大小为4Byte,

           x84环境下(64位机器)指针变量大小为8Byte。

注意:指针变量的大小和类型是无关的,只要是指针类型的变量,在相同的平台下,大小都是相同的。

3. 指针变量的类型的意义 

既然指针变量的大小和类型是无关的,只要是指针类型的变量,在相同的平台下,大小都是相同的,那为什么还要有各种各样的指针类型呢?

3.1 指针的解引用

对比下面的两段代码,进行调试过程。

代码1 

代码2 

根据调试过程可以发现, 代码1将n的4个字节全部变成了0,而代码2只是将n的第一个字节改成了0。由此得出结论:指针类型决定了指针解引用的权限(一次能操作几个字节)。(不同类型的指针访问权限不同)

指针的一个作用就是访问内存。

就比如,char*类型的指针解引用只能访问一个字节,int*类型的指针解引用可以访问四个字节。

但是这种访问权限范围是量力而为的,如果本来只有4Byte的空间,就不可能用double *(2Byte)来访问 。

3.2 指针+-整数

先看一段代码,调试观察地址的变化。

结果如下: 

 

我们由此可以看出,char *类型的指针变量+1跳过1个字节(即跳过了1个char类型大小),int *类型的指针变量+1跳过了 4个字节(即跳过了1个int类型的大小)。

结论:指针的类型决定了指针向前或者向后走一步的距离(大小)。

3.3 void *指针

在指针类型中有一种特殊的类型是void*类型,是一种无具体类型的指针,可以用来存放各种类型的指针(地址),相当于一个垃圾桶。

下面的这段代码中本来应该把&a放入int *类型的指针中,而这里放在了char *类型的指针中了,此时编译器会报警告。因为类型不相同或者不兼容,一个是char*,而另一个是int*。如果使用void*类型的指针来接收地址,这样就不会报警告,pc(void&类型指针)就相当于一个垃圾桶。

对于下面的代码,将int*类型的地址赋值给char*类型的指针,就会出现“类型不兼容”的情况,而使用void*类型指针就不会出现这种问题。

但是使用void*类型的指针也会有一定的局限性。就比如: 

根据上面的代码我们可以知道,虽然void*类型的指针能像一个垃圾桶一样接收各种类型的地址,但是它却无法进行指针运算(比如指针加减整数)。其实void*的设计可以实现泛型编程的效果,使得一个函数可以处理多种类型的数据。

 4. const修饰指针

4.1 const修饰变量

变量的值是可以修改的,如果把变量的地址交给一个指针变量,就可以通过该指针变量来修改此变量的值。但是有时候我们希望这个变量的值不被修改,就使用了const来修饰指针变量。

上面这段代码中a的值是可以被修改的,a本质上就是变量;但是 b的值就不能被更改,b由于被const修饰,在语法上受到了限制,成为了一个常变量。具体的解释与相关代码如下:

int main()
{
	int a = 10;
	//const int a = 10;//加了一个const,使得a具有了常属性.
	// a还是不是常量呢?:虽然a是不能被修改的,但它的本质还是变量(在C语言中),
	// 也就是常变量
	//但是在C++中,由const修饰的变量叫做常量,

	a = 20;//加了const后a的值就不能改了

	return 0;
}

但如果非要改变这个常变量的值,可以绕过常变量本身,而从它的地址下手。 下面代码中,b被const修饰后无法进行修改,但是我们如果绕过b,从b的地址下手,也可以实现对b值的修改。

但是,我们之所以要使用从const修饰,其目的就是为了保护变量的值不被修改,可通过变量地址也可以修改其值,因此,我们需要学习利用const修饰指针变量。

(对于上面4.1部分知识,我们可以通过类比的方法进行理解学习:如下面代码)

/*这是一个故事*/
int main()
{
	//假设程序员是高启强
	const int a = 10;//警察组织用const来保护安欣,此时a的值就被保护起来,目的是不被改变(不能直接通过a来改变值)
	//a = 20;//此操作就是错误的(强想伤害安欣,但是不能直接动手,于是……)

	//强把a的地址给了老莫,让老莫出手
	int* p = &a;
	*p = 0;//解引用找到p所指向的对象a,并改了值

	printf("a = %d\n", a);

	//但是我们本来是为了a的值不被改变,老莫也是破坏了规则。
	return 0;
}

这样理解,编程似乎变得更加有趣了呢!

4.2 const修饰指针变量

4.2.1 const放在*右边

int main()
{
	int a = 10;//&a---0x0012ff40
	int b = 20;//&b---0x0012ff48

	int * const p = &a;//p---0x0012ff40
	//p = &b;//这种操作错误,因为const限制的是p
	*p = 100;
	printf("%d\n", a);

	//const修饰变量的时候是放在*的右边,限制的是指针变量本身
	//也就是指针变量不能再指向其他变量了
	//但是可以通过指针变量来修改指针变量所指向的对象的内容

	//限制的是p,而不是*p

	return 0;
}

4.2.2 const放在*左边

int main()
{
	int a = 10;
	int b = 20;
	int const* p = &a;
	//p = &b;//ok
	//*p = 100;//error

	//const修饰指针变量时是放在*的右边,限制的是指针所指向的对象的内容,
	//也就是不能通过指针来修改指向的内容
	//但是可以修改指针变量本身的值(修改指针变量指向的对象)

	//const限制的是*p,而不是p

	return 0;
 }

4.2.3 const在*的左右两边都有

//*的左右两边都有const
int main()
{
	int a = 10;
	int b = 20;
	int const * const p = &a;
	//p = &b;//error
	//*p = 0;//error

	return 0;
}

通过上述三个代码,可以得出结论:

1. const放在*左边,修饰的是指针所指向的内容(*p被const修饰),保证指针的内容不能通过指针来改变。但是指针变量本身的内容可以改变(p = &b;可以)。

2. const放在*右边,修饰的是指针变量本身(p本身被const修饰),保证了指针变量的内容不能修改,但是指针指向的内容(*p = 100;就可以把a的值改成100)可以通过指针来改变。

3. *左右两边都有const修饰,则const不仅修饰了指针变量本身(p),还修饰了指针变量所指向的对象(*p),所以无论是指针所指向的对象的内容还是指针变量本身,都不能再被修改了(*p = 100;和p = &b;都是错误操作) 。

5. 指针运算

5.1 指针+-整数

在3.2中我们已经介绍了指针+-整数的运算,因为数组在内存中是连续存放的,只要知道第一个元素的地址,就可以顺藤摸瓜地找到后面所有元素。

这是用前面的知识进行的数组打印输出:

int main()
{
	int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
	//打印数组的内容
	int i = 0;
	int sz = sizeof(arr) / sizeof(arr[0]);
	for (i = 0; i < sz; i++)
	{
		printf("%d ", arr[i]);
	}

	return 0;
}

现在我们使用指针来进行数组的打印输出。

以上代码就用到了指针+-整数

5.2 指针-指针

运用类比思想来理解指针-指针:日期1+天数=日期2;日期1-日期2=天数

                                                    指针1+整数=指针2;指针1-指针2=整数

 

根据上面的代码就可以得出,指针与指针之差的绝对值是两个指针之间的元素个数。画图分析如图: 中间隔了9个元素。

 例子:模拟实现strlen函数

int my_strlen(char* s)
{
	char* p = s;
	while (*p != '\0')
		p++;//指针+-整数
	return p - s;//这里是指针-指针,得到的是两个指针之间的元素个数,
	//但前提是:两个指针指向的是同一块空间
}

int main()
{
	int len = my_strlen("abc");
	printf("%d\n", len);
    
	return 0;
}

 运行结果是:3

指针-指针得到的是一个整数,这个整数代表的是两个指针之间的元素个数,但是前提条件是两个指针必须指向同一块空间。

5.3 指针的关系运算

指针的关系运算其实就是指针比较大小

运用:访问并输出数组

int main()
{
	int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
	int sz = sizeof(arr) / sizeof(arr[0]);//得到数组元素个数
	int i = 0;
	int* p = arr;//数组名代表数组首元素的地址
	while (p < arr + sz)//p < arr(首元素地址) + sz,首元素地址+整数,得到的仍然是地址,且是数组元素的地址
	{
		printf("%d ", *p);
		p++;//p = p + 1;指针+-整数,一次+1就跳过一个int范围(4个字节)
	}

	return 0;
}

6. 野指针

6.1 野指针的成因

对于野指针的成因,要自己一点点的积累

6.1.1 指针未初始化

未初始化的指针变量,(int * p;)它的值是随机的, 无法进行访问(*p = 100;是非法访问)。

6.1.2 指针越界访问

int main()
{
	int arr[10];
	int i = 0;
	//为数组存入值:1 2 3 4 5 6 7 8 9 10
	for (i = 0; i < 20; i++)
	{
		arr[i] = i + 1;
	}
	//通过指针访问并打印该数组
	int sz = sizeof(arr) / sizeof(arr[0]);//求出元素个数
	int* p = arr;//得到首元素的地址
	for (int i = 0; i < sz; i++)
	{
		printf("%d ", *p);
		p++;//p+1,移动4个字节
	}

	return 0;
}

上面的代码由于p超出了数组的空间范围,就出现了越界访问,此时的p就是一个野指针。

6.1.3 指针指向的空间释放

函数一旦调用结束后,n就被销毁了,此时*p虽然记住了&n,但它没有访问权限了,p就成为了野指针,。若此时*p仍能输出100,则说明n的空间还未完全被覆盖。但如果主函数后面加入printf("hehe\n");此时printf函数的调用占用了这块空间,*p得到的就是随机值了。

6.2 如何规避野指针

6.2.1 指针初始化

如果明确知道指针应该指向哪里,就初始化一个明确的地址;如果还不知道指针应该指向哪里,就初始化NULL。NULL是C语言中定义的一个标识符常量,值为0,0也是一个地址,但这个地址是无法使用的,读写该地址就会报错,相当于程序会提醒你这是个野指针,不要使用。

6.2.2 小心指针越界

一个程序向内存申请了哪些空间,指针也就只能放问哪些空间,不能超出范围访问,超出范围就是越界访问。

6.2.3 对不再使用的指针置NULL,使用前检查有效性

对于暂时不用或者不再使用的指针及时置为NULL,如果误用该指针,系统就会报错,这样就可以将野指针管理起来。有时候我们在写了很多代码后可能会出现无法确定某指针是否为野指针,因此我们在使用该指针前要先检查它的有效性,避免误用野指针。检查有效性可以使用assert断言,下面会涉及。

6.2.4 避免返回局部变量的地址

局部变量在函数执行完,空间所有权就会被释放,一但其他函数执行需要开栈帧,就会占用该空间。

7. assert断言

assert.h头文件定义了宏assert(),用于在运行时确保程序符合指定条件,如果不符合,就会报错终止运行。这个宏被称为“断言”。

assert() 宏接受⼀个表达式作为参数。如果该表达式为真(返回值⾮零), assert() 不会产⽣ 任何作⽤,程序继续运⾏。如果该表达式为假(返回值为零), assert() 就会报错,在标准错误 流 stderr 中写⼊⼀条错误信息,显⽰没有通过的表达式,以及包含这个表达式的⽂件名和⾏号。

使用assert的优点:1 能自动标识文件和出问题的行号;

2 可以在头文件assert.h前面使用#define NDEBUG来关闭assert断言,无需更改代码(debug环境下) 。

此时没有出现由stderr 写⼊的错误信息 。

assert的缺点:1 引入额外的检查,增加了程序的运行时间。

2 assert一般在debug版本中使用,在release版本中是禁用的,因为它被优化掉了。(用debug版本有助于程序员排查问题,在release版本下也不影响用户使用程序的效率)

8. 指针的使用和传址调用

8.1 strlen的模拟实现

库函数strlen的功作用是求字符串长度,长度不可能是负数,因此返回size_t()无符号整型 打印输出用%zd。

要想模拟实现strlen函数,可以写出下面代码

size_t my_strlen(const char * str)//使用const是为了避免传过来的指针所指向的内容被修改
{
	size_t count = 0;
	assert(str != NULL);
	while (*str != '\0')
	{
		count++;
		str++;
	}
	return count;
}
//库函数strlen的功作用是求字符串长度,长度不可能是负数,因此返回size_t()无符号整型 打印输出用%zd
int main()
{
	char arr[] = "abcdef";
	size_t len = my_strlen(arr);//传过去的是数组首元素的地址,该函数的参数要用指针类型来接收
	printf("%zd\n", len);

	return 0;
} 

 8.2 传值调用和传址调用

有些问题的解决必须用到指针,比如说:写一个函数,交换两个整型变量的值,可能会像下面一样写出代码:

根据运行结果可以知道,使用此方法无法实现两个数的交换的,原因是实参传递给行参时,行参是实参的一份临时拷贝,对行参的修改不会影响实参的值。通过调试可以发现,x和y的确收到了a和b传过来的值,但是x和y是自己创建一块空间来存储接收到的值的,行参和实参的地址不一样。在函数中x与y的值确实发生了交换,但是在Swap1函数调用完了以后,a与b的值都没有发生交换,这就是传值调用。

为了使行参被修改时,也可以改变实参,就需要将实参的地址传过去,这就是传址调用。

void Swap2(int * pa,int *pb)
{
	int tmp = 0;
	tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}

int main()
{
	int a, b;
	scanf("%d %d", &a, &b);
	printf("交换前:a = %d b = %d\n", a, b);
	//实现a与b的交换
	Swap2(&a, &b);//传入需要交换的两个整型值
	printf("交换后:a = %d b = %d\n", a, b);

	return 0;
}

传址调用可以让主函数和主调函数之间建立真正的联系,在函数内部可以修改主调函数中的变量;所以未来函数中只是需要主调函数中的变量值来实现计算,就可以使用传值调用。如果函数内部要修改主调函数中的变量值,就需要传址调用。


都看到这儿了,要不点个赞支持一下啊?

  • 43
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 15
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 15
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值