C语言-深入理解指针(1)

学习到指针部分开始内容可能对有些人不好理解,但是指针又是c语言中不可或缺、十分重要的一个部分。所以我将指针拆接成多个篇章,编写尽量详尽,让读者们更好理解,更容易听懂。

目录

1.如何理解指针?

1.1内存与地址

1.2计算机常用单位换算

1.3如何理解硬件编址

2.指针变量和地址

2.1取地址操作符(&)

2.2指针变量

2.3解引用操作符(*)

2.4指针变量的大小总是一致

3.指针变量类型的意义

3.1决定指针权限

3.2决定指针增量

3.3void*指针

4.const修饰指针

4.1const修饰变量

4.2const修饰指针变量

4.3.1const int* p或int const* p

4.3.2int* const p

4.3.3int const* const p或const int* const p

5.指针运算

5.1指针+-整数

5.2指针-指针

5.3指针的关系运算

6.野指针

6.1为什么会造成野指针?

6.1.1指针未初始化

6.2.2指针越界访问

6.3.3指针指向的空间已释放

6.2如何规避野指针

6.2.1指针初始化

6.2.2小心指针越界

6.2.3指针不再使用,及时置NULL 

7.assert断言

7.1宏assert

7.2assert开启与关闭

8.如何正确使用指针

8.1对5.2中的模拟strlen函数进行优化

8.2指针的传值调用、传址调用

8.2.1函数实现交换两个数的值


1.如何理解指针?

1.1内存与地址

我们能理解生活中的一个案例:

刚开学时,你在宿舍中如果要去找另外一个同学,但是你不知道他在哪一间,你就需要一间一间地去寻找。但是如果他提前告诉了你他在608,那么你就可以直接定位到编号为608的房间直接找到他。

      

我们将电脑的内存划分成一个个大小为一个字节(8个bit位)的内存单元,我们可以将地址理解成宿舍的门牌号,那么内存单元就是宿舍内部的空间和内容,就好比如是一个8人间的宿舍,每个人的大小为1bit位,8个人就是8个bit位,也就是一个字节。

在现实生活中,我们将宿舍、房门的门牌号称作是地址,那么相同地,在c语言中,我们将内存单元的编号称作为是地址,也叫做指针,这就是对指针通俗易懂的理解。

所以,我们可以说在c语言中:内存单元的编号 = 地址 = 指针

1.2计算机常用单位换算

1 Byte = 8 Bits

1 KB = 1024 Bytes                                       1 MB = 1024 KB

1 GB = 1024 MB                                          1 TB = 1024 GB

1 PB = 1024 TB                                           1 EB = 1024 PB

1 ZB = 1024 EB                                           1 YB = 1024 ZB

1.3如何理解硬件编址

        我们看上面的一张图。CPU和内存之间存在着大量的数据交互,如地址总线,数据总线和控制总线。比方CPU想要读取内存中的某个字节空间,那么控制总线就会发出R(read)信号,CPU会通过地址总线进行找到内存中的字节空间,最后内存将数据通过数据总线传回CPU。

那么计算机中内存的字节空间非常多,所以我们必须要对内存进行编址,从而让CPU能够更容易找到。但是每个字节的地址是不需要存起来的,它是通过硬件完成的。

在32位系统下,计算机有32根地址总线(同理在64位系统下就有64根地址总线),每根地址线根据电脉冲的有无(类似于交流电)分为0、1两种状态。那么每一根线都有两种含义,两根线就有四种含义,32根线就会有2的32次方种含义,也就是说,32位系统下一共有2的32次方个地址。

CPU通过地址线,就可以在内存上找到对应的数据,将其通过数据线传入CPU的寄存器。

2.指针变量和地址

2.1取地址操作符(&)

在c语言中,创建变量的本质是向内存申请一块空间。例如int a=10,本质就是为a申请4个字节的空间:

#include<stdio.h>
int main()
{
	int a = 10;

	return 0;
}

我们可以看到,创建变量a本质是向内存申请了空间,并且每个空间都有自己的编号。

当我们要得到a的地址时,我们就要用到取地址操作符&,它属于单目操作符。

#include<stdio.h>
int main()
{
	int a = 10;
	printf("%p\n", &a);

	return 0;
}

我们还要注意,a的空间有四个字节,每个字节都有自己的地址,我们利用取地址操作符取出的是四个字节中较小的那个地址

2.2指针变量

我们已经在2.1中知道了如何得到一个变量的地址,那我们怎么样将它存储起来呢?比如若将&a存储到p中,那么必须有一块空间可以存放,此时的p是一个变量,是用于存放地址的变量,称为指针变量

#include<stdio.h>
int main()
{
	int a = 10;
	int* p = &a;//p是指针变量

	return 0;
}

此时指针变量的类型是int*。对于指针变量类型,我们需要拆分进行理解。其中*表示p是一个指针变量,而前面的int表示p指向的对象是int类型。那如果我们输出p,就会得到a的地址。

同样,如果我们需要将一个char类型变量的地址存储起来,那么指针变量p的类型就是char*

#include<stdio.h>
int main()
{
	char ch = 'v';
	char* p = &ch;

	return 0;
}

2.3解引用操作符(*)

依然以这个代码举例:

#include<stdio.h>
int main()
{
	int a = 10;
	int* p = &a;//p是指针变量

	return 0;
}

我们现在已经得到了a的地址。我们得到了一个变量的地址,就可以通过这个地址去访问这个变量,或者说找到该地址所指向的对象。在c语言中要实现这个功能,需要通过解引用操作符*。

#include<stdio.h>
int main()
{
	int a = 10;
	int* p = &a;
	int b = *p;
	printf("%d\n", b);

	return 0;
}

在main函数里第三行,我们定义了一个变量b,它的值为*p,它的意思就是找到该指针所指向的对象。a的值为10,p是a的地址,*p是p所指向的内容,也就是a,所以我们最后打印出的结果b就等于a等于10。也就是说,想找到一个指针变量指向的内容,就在这个指针变量前面加上解引用操作符*就可以了。

2.4指针变量的大小总是一致

在1.3中,大家已经了解到了再不同平台下地址线数量是不同的。假设在32位平台下,地址线有32根,那么就会有2的32次方个地址,每个地址是由32个bit位构成,也就是4个字节。所以在32位平台下,虽然说int是四个字节,char是一个字节,double是8个字节,但是他们的指针类型都是4个字节;在64位平台下,地址线有64根,就会有2的64次方个地址,每个地址由64个bit位构成,也就是8个字节,所以他们的指针类型都是8个字节。我们可以利用sizeof()看看32位平台下各个指针类型的字节大小:

#include <stdio.h>
//32位平台下 - 4个字节
//64位平台下 - 8个字节
int main()
{
	 printf("%zd\n", sizeof(int*));
	 printf("%zd\n", sizeof(char*));
	 printf("%zd\n", sizeof(double*));
	 printf("%zd\n", sizeof(float*));
	 printf("%zd\n", sizeof(short*));
	 printf("%zd\n", sizeof(long*));
	 printf("%zd\n", sizeof(long double*));

	 return 0;
}

那么显然结果都应该为4。

由此我们可以得到结论:指针变量的大小和类型无关,与环境有关

3.指针变量类型的意义

那既然无论是int*、char*、double*、float*都是4或8个字节,那他们有什么区别?为什么还要分成这么几个?int*类型的地址不是照样能存放char类型的变量?我直接自创一个类型unit*把他们统一起来不就好了吗?

如果你也困惑与上面的问题,下面就以几个案例说明指针变量类型是有意义的,是不能统一的。

3.1决定指针权限

我们先来看一下下面这段代码:

//代码A
#include <stdio.h>
int main()
{
	int n = 0x0012ff40;
	int* p = &n;
	*p = 0;

	return 0;
}

在这段代码中,p是指向n的指针变量,在32位平台下大小为4个字节。我们来看看它在内存中的存储

当我们将p指向的内容改为0时,我们可以看到指针变量四个字节的内存全部改为了0

我们再看另外一段代码:

//代码B
#include <stdio.h>
int main()
{
	int n = 0x0012ff40;
	char* p = (char*)&n;
	*p = 0;

	return 0;
}

可以看到,区别与代码A,我们将&n的类型强制转化为了char*类型,那么我们来看一下n在内存中的存储 

可以发现,代码A和代码B中n都是占四个字节,原因就是因为相同平台下各个指针类型大小相等。此时我们将p指向的内容改为0,发现四个字节中只有地址较小的那个字节内容被改为了0。

这是为什么呢?首先第一点,指针变量大小是一样的,大家都是四个四节,char类型的变量也可以存储在int*类型的地址中。但是由于指针指向的内容类型的不同,在指针进行解引用操作的时候所访问或改变的大小就会不同。例如代码A中指针p指向的是int类型的变量,那么解引用时就会一次访问四个字节,所以四个字节的内容都被改为了0;而代码B中指针p指向的是char类型的变量,那么解引用一次只访问一个字节。所以我们可以得到结论:

conclusion:指针类型决定了指针解引用操作时访问几个字节,即决定指针权限

3.2决定指针增量

我们接着来看下面这段代码:

#include<stdio.h>
int main()
{
    int a = 10;
    int c = 0;
    int* pa = &a;
    char* pc = &c;
    printf("pa     = %p\n", pa);
    printf("pa + 1 = %p\n", pa + 1);
    printf("pc     = %p\n", pc);
    printf("pc + 1 = %p\n", pc + 1);

    return 0;
}

我们来看一下这段代码的运行结果:

首先看到我们可以看到pa是指向int类型变量a的指针,pc是指向char类型变量c的指针。其次,pa+1比pa多了4个字节,因为a是int类型的变量,大小为4个字节;同理pc+1比pc多1个字节,因为c是char类型的变量,大小为1个字节。所以我们能得到结论:

conclusion:指针类型决定了指针一次走的距离,即决定指针增量

3.3void*指针

在指针中,有一类特殊的指针叫做void*指针,也叫做泛型指针。这种指针的优点是它可以接受任何类型的地址,也就是说void* p,此时p指向的可以int类型、char类型等等类型的变量。但是它的缺点也很明显,那就是它没有办法进行指针的解引用操作和指针+-整数的运算。

#include<stdio.h>
int main()
{
    int a = 10;
    char* pc = &a;

    return 0;
}

在上面这段代码中,a是int类型的变量,将a的地址存在char*类型的指针变量中,编译器会报警(如下面这张图),而如果我们使用void*编译器就不会报警告。

若使用void*类型的指针接收: 

#include<stdio.h>
int main()
{
    int a = 10;
    void* pc = &a;

    return 0;
}

但我们要注意,void*没法解引用,也不能进行指针+-整数的指针运算。

4.const修饰指针

在之前的学习中,我们了解过关键字const。const在英文中翻译为常量,也就是说,如果一个变量被const修饰,那么这个变量就不能被修改。但是我们注意,它虽然无法修改,但是它依然是变量,只是这个变量具有了常属性。而在C++中,被const修饰的变量就是常量。我们看下面这段代码:

int main()
{
    //int a = 10;
    const int a = 10;//a具有了常属性(不能被修改)
    int arr[a];//a不是常量
    a = 20;
    printf("%d\n", a);

    return 0;
}

我们知道在VS中,数组arr[ ]中括号内必须是常量。如果arr[a]没有报错,那么就证明a是常量,反之则为变量。

由此大家一定记住,const修饰的变量依然是变量,只是具有了常属性,没有办法被修改。 

4.1const修饰变量

我们说,被const修饰的变量没有办法被修改。但是我们如果绕一个弯,若我们得到了这个变量的地址,利用指针的解引用操作修改可不可行呢?我们来试试看

#include<stdio.h>
int main()
{
    const int a = 10;
    //a=20;//error
    int* p = &a;
    *p = 20;
    printf("%d\n", a);

    return 0;
}

我们来运行一下看看结果:

能看到,a的值被改变成为了20,说明通过这种方式来改变被const修饰的常变量的值是可行的。

4.2const修饰指针变量

我们前面了解了const修饰变量,那如果这个变量是指针变量呢?再抛出一个问题,我们说const的作用是使得变量具有常属性,没有办法修改,但是通过指针的解引用却可以修改,这不太合理,我们应该要的是就算指针变量p得到了a的地址依然没办法修改a的值,那这应该怎么实现呢?

4.3.1const int* p或int const* p

我们来看看下面这段代码:

int main()
{
    int a = 10;//&a - 0x0012ff40
    int b = 20;//&b - 0x0012ff44
    int const* p = &a;//0x0012ff40
     p = &b;//ok
    *p = 100;//error
    printf("%d\n", a);

    return 0;
}

在这段代码中,假设a的地址为0x0012ff40,b的地址为0x0012ff44,又定义了指向a的指针变量p。但是注意,在定义指针变量p的时候,在int后面加了关键字const,它起到了什么作用呢?我们继续看后面的代码,p原先定义为指向a的指针,当我们将p改变为指向b的指针时,编译器并没有报错;而我们将p指向的内容改为100时,编译器报错。也就是说,这个const加在int后面使得指针p指向的内容不能被改变,而其本身可以被改变。同理,我们测试一下const放在最前面的时候:

int main()
{
    int a = 10;
    int b = 20;
    const int* p = &a;
     p = &b;//ok
    *p = 100;//error
    printf("%d\n", a);

    return 0;
}

依然是指针p不能改变,但其指向的内容可以改变。所以我们得到结论:

conclusion:const修饰指针变量时,放在*的左边,限制的是指针指向的内容,不能通过指针来修改,但是可以修改指针变量本身的值

4.3.2int* const p

接下来我们看另外一段代码:

int main()
{
    int a = 10;//&a - 0x0012ff40
    int b = 20;//&b - 0x0012ff44
    int* const p = &a;//0x0012ff40
     p = &b;//error
    *p = 100;//ok
    printf("%d\n", a);

    return 0;
}

和4.3.1中的代码很像,但是我们将int const* p改为了int* const p,也就是const在*右边。那在这种情况下,我们发现在修改指针p的时候编译器报错,而修改指针p指向的内容a时却没有报错。 所以我们可以得出结论:

conclusion:const修饰指针变量时,放在*的右边,限制的是指针变量本身,指针变量不能再指向其他变量,但是可以通过指针变量修改指针变量指向的内容。 

4.3.3int const* const p或const int* const p

有了上面的基础,再来看这个就很简单了,无非就是const放*左边限制指针变量指向的内容,放在*右边限制指针变量本身。那如果左边和右边都放上了const,自然就是指针本身和其指向的内容都没办法修改。当然,const在左侧的位置可以是int const*也可以是const int*。

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

    return 0;
}

运行时发现,当我们改变指针变量p或者改变p指向的内容a时,编译器都会报错。

5.指针运算

指针有三种基本运算,分别是指针+-整数、指针-指针、指针的关系运算三种,我们现在来逐个了解。

5.1指针+-整数

我们知道,数组在内存中是连续存放的。所以如果我们得到了第一个元素的地址,那么就可以得到整个数组的地址和元素。

int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };

我们再学习数组的时候已经明白了一维数组如何输出,我们来复习一下:

#include<stdio.h>
int main()
{
    int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
    int sz = sizeof(arr) / sizeof(arr[0]);
    for (int i = 0; i < sz; i++)
    {
        printf("%d ", arr[i]);
    }

    return 0;
}

那我们知道,地址实际上就是指针。那我们能否利用指针的方式来写呢?答案是可以的,如下:

#include<stdio.h>
int main()
{
    int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
    int* p = arr;
    int sz = sizeof(arr) / sizeof(arr[0]);
    for (int i = 0; i < sz; i++)
    {
        printf("%d ", *(p + i));//指针+整数
    }

    return 0;
}

因为数组名是数组首元素的地址,所以我们直接让它等于p,那么p+1,p+2……就分别代表arr[1]、arr[2]……的地址,解引用就可以访问数组的元素了。那么同理,我们也可以用减法:

#include<stdio.h>
int main()
{
    int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
    int sz = sizeof(arr) / sizeof(arr[0]);
    int* p = &arr[sz];
    for (int i = sz; i > 0; i--)
    {
        printf("%d ", *(p - i));//指针-整数
    }

    return 0;
}

或者,我们也可以这样 :

#include<stdio.h>
int main()
{
    int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
    int sz = sizeof(arr) / sizeof(arr[0]);
    int* p = arr;
    for (int i = 0; i < sz; i++)
    {
        printf("%d ", *(p++));
    }

    return 0;
}

当然,上述只是为了让大家理解指针+-整数的基本运算,并不是在炫技,大家熟悉哪种写哪种即可。 

5.2指针-指针

有些人看见这个小标题可能有个疑惑:为什么没有指针+指针?我们可以把指针理解成我们现在的日期。大家想想,我写这篇文章的时间是2024年2月22日,我写上一篇文章(函数递推)的时间是2024年1月24日,那么将这两个日期相减表示我写这篇文章距离写上一篇文章过了多少时间,但是两个日期相加有什么意义?显然是没有意义的。同样的道理,指针-指针(的绝对值)表示两个指针之间的元素个数,但前提是这两个指针是指向同一块空间的

我们来举个例子:

已知:字符串strlen统计的是字符串中\0之前的字符个数(使用需包含头文件),例如:

#include<stdio.h>
#include<string.h>
int main()
{
    char arr[] = "Chinese Zodiac";
    int length = strlen(arr);
    printf("%d\n", length);//14

    return 0;
}

上面 strlen(arr)的值就为14。那么现在要求编写一个函数mystrlen,模拟实现函数strlen得到字符串长度。

首先第一种,我们可以:

int my_strlen(char arr[])
{
    int count = 0;
    char* p = arr;
    while (*p != '\0')
    {
        p++;
        count++;
    }
    return count;
}

 如果我们用指针-指针的方法:

int my_strlen(char arr[])
{
    char* start = arr;
    while (*arr != '\0')
    {
        arr++;
    }
    return arr-start;
}

5.3指针的关系运算

指针和指针之间是可以比较大小的,其实就是地址和地址比较大小。例如,数组存储总是从低地址到高地址,所以解引用访问数组元素时,若arr[0]用指针变量p来表示,那么arr[1]就是用p+1来表示而不是p-1。

前面我们利用指针+-整数的方法打印了一维数组的元素,那我们现在利用指针的关系运算来打印一维数组的元素:

#include<stdio.h>
int main()
{
	int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
	int sz = sizeof(arr) / sizeof(arr[0]);
	int* p = arr;//&arr[0]
	while (p < arr + sz)//指针比较大小
	{
		printf("%d ", *p++);
	}

	return 0;
}

6.野指针

野指针的概念:指针的指向未知或不明确(可能是随机的、不正确的或没有明确的限制)

6.1为什么会造成野指针?

6.1.1指针未初始化

指针如果没有初始化,默认是随机值,就没有办法知道指针指向的具体内容,该指针就是野指针。

//1.指针未初始化
int main()
{
    int* p;//p是一个局部变量,不初始化默认为随机值
    //int* p = NULL;//初始化空指针
    *p = 20;

    return 0;
}

6.2.2指针越界访问

用指针指向数组元素,若指针指向的范围超出数组的范围时,称指针越界访问,该指针就是野指针。

//2.指针越界访问
int main()
{
    int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
    int* p = arr;//此时p不是野指针
    int i = 0;
    for (i = 0; i <= 10; i++)
    {
        printf("%d ", *p++);
    }

    return 0;
}

6.3.3指针指向的空间已释放

若指针指向的是一个局部变量,该局部变量若被销毁,则称该指针指向的空间已释放,该指针就是野指针。

//3.指针指向的空间释放
int* test()
{
    int a = 10;
    return &a;
}
int main()
{
    int* p = test();
    printf("%d\n", *p);

    return 0;
}

6.2如何规避野指针

我们已经知道了野指针的成因,那我们只要特别注意这些原因即可。

6.2.1指针初始化

我们再定义每一个指针,都要对它进行初始化。如果说该指针暂时不明确指向的对象,就定义为空指针,如下:

int* p = NULL;

6.2.2小心指针越界

在对数组进行循环时,特别注意指针的范围一定要小于数组的范围。

int main()
{
    int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
    int* p = arr;//此时p不是野指针
    int i = 0;
    for (i = 0; i < 10; i++)//数组范围从0~10,不包括是10,否则造成野指针
    {
        printf("%d ", *p++);
    }

    return 0;
}

6.2.3指针不再使用,及时置NULL 

同6.2.1,指针不再使用,及时置NULL。野指针很危险,我们可以将野指针类比为野狗,它是很危险的,但如果我们将它同绳子捆在树旁边,它就不会危害到范围之外的人。同理,我们一定要及时将指针置NULL,将指针暂时管理起来。

7.assert断言

7.1宏assert

assert和if有些许类似,是用来确保满足某些特定的条件,如果满足条件就往下执行,但如果不满足条件程序就会报错,返回值为0,。我们将它称为“断言”。当然,要使用宏assert(),我们要使用头文件#include<assert.h>,例如我们常见用来断言指针是否为空:

//assert断言
#include<assert.h>
int main()
{
    int* p = NULL;
    assert(p != NULL);

    return 0;
}

7.2assert开启与关闭

assert对程序员非常的友好,他可以自动识别出错的行号,最重要的是我们在无需更改代码的情况下就可以控制它的开启与关闭。如果已经确认程序没有问题不需要断言,我们只需要在头文件上方定义一个宏NDEBUG即可

#define NDEBUG
#include <assert.h>

那为什么assert如此重要呢?通过之前的章节(VS中的调试功能)我们知道,程序可以分为debug版本和release版本,前者称为调试版本,在这个版本中我们需要对程序不断进行测试,这个时候就需要频繁地用到assert断言;后者称为发布版本,在这个版本中我们只需将assert优化即可。

8.如何正确使用指针

8.1对5.2中的模拟strlen函数进行优化

我们可以对5.2的代码进行优化:

size_t mystrlen(const char* p)
{
    assert(p != NULL);
    char* s = p;
    while (*p != '\0')
    p++;
    return p - s;
}

首先,我们传入的是一个数组,可以将它写成指针的形式,而且是const在*左边的指针,也就是指针指向的内容不能改变; 其次我们用assert断言来保证传入的指针不是空指针;最后strlen的返回值标准来说应该和sizeof一样是size_t类型,可以理解为unsigned int或unsigned long等类型。

8.2指针的传值调用、传址调用

我们思考一下,数组名本质上是首元素的地址,所以我们可以用指针来表达、访问数组元素,那么有什么问题是非用指针不可呢?

8.2.1函数实现交换两个数的值

如果在没有学习指针之前,我们可能会写出以下代码:

void swap1(int x, int y)
{
	int temp = x;
	 x = y;
	 y = temp;
}

这种直接将值传入函数形参部分的函数调用称为传值调用,实际运行中是没有进行交换的。具体是为什么已经在函数章节(函数基础知识)进行了阐述,总的来说是因为形参只是实参的一份临时拷贝,对形参的修改并不会影响实参。所以我们就必须利用函数指针进行传址调用。

void swap2(int* x, int* y)
{
    int temp = *x;
    *x = *y;
    *y = temp;
}

当然如果对位操作符了解的,也可以这么写:

void swap3(int* x, int* y)
{
    *x = *x ^ *y;
    *y = *x ^ *y;
    *x = *x ^ *y;
}

总之,想要函数实现交换两数,必须利用指针进行传址调用。

以上就是这篇文章的内容了,望大家认真学习,才能理解下一章节~

  • 21
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值