定义struct出错指针不允许指向不完整类型_从内存的角度读懂C指针

8e13bb2f9459db95ad87a1375b9e8e0e.png

一、前言

C语言是一门比较偏底层的语言,为什么这样说呢?因为C语言可以直接操作内存,而直接操作内存是通过指针来实现的。指针是C语言的精华,功能强大,可以灵活地操作内存,也是C语言区别于其他编程语言的一大特点。但是要深入理解指针、熟练地运用指针却不是一件容易的事。

本文会从计算机的内存说起,尝试从内存对象模型的角度,一步步来解读指针相关的知识,希望能让读者深入理解指针的本质:

  1. 计算机的内存与地址
  2. 变量的读写过程
  3. 什么是指针变量与指针操作?
  4. 为什么需要指针变量?
  5. 为什么指针变量需要类型?
  6. 为什么需要二重指针?

二、计算机的内存与地址

为了更好地理解指针,必须先了解计算机中内存与编址相关的知识。

假如,我们的计算机是32位的机器,拥有4G(2^32字节)的物理内存,那么计算机是如何运用这一块内存的呢?为了方便读写数据,计算机会以字节为单位,从0开始,对内存进行编号,所以编号的范围为0到2^32-1。编号的值即为我们通常所说的地址,如下图所示:

07ba0a44ce57cd87aefbea6c5bc815d6.png

注:在现代的计算机中,使用的都是虚拟地址,在64位的计算机中,4G的物理内存,对应的虚拟地址值可以大于2^32,但是虚拟地址不是本文要讲述的对象,为了方便理解,本文在讨论时,忽略虚拟地址这一概念。

三、普通变量的存储与读写过程

下面从内存的角度,讨论一个int类型变量的存储与读写过程,代码如下:

int num = 10;
printf("%dn", num);

从代码的层面上看,上面两行代码的作用是:定义并初始化了一个变量值为10的变量num,并使用标签输出stdout,打印出num的值。

但是从内存的层面来看num这个变量,在计算机内存中,究竟做了什么呢?其实在计算机的内存中,已经完成了一次写操作和一次读操作。

  1. int num = 10:在内存中分配一个sizeof(int)大小的内存,然后让num指向这片空间,即num就有了自己的合法的内存空间,最后修改内存中的数据为10
  2. printf("%dn", num):获取num在内存中的地址(即下图中的10000),根据内存中的地址找到变量num对应的值,读取num的值,并输出

其内存分布示意图如下所示,图中左边为地址编号,方框内为内存,方框内的数据为以二进制表示的数据,由于int占4个字节,而数据只有10,所以只有一个字节有值,其他三个字节为0:

40ce9c4092815043c9f4e855d4c61b30.png

四、什么是指针变量与指针操作

用来存储整型int的变量,是整型变量,即int变量,同理,用于存储地址的变量,就是指针变量,而通过上面的描述可知,地址本质上就是一个整数。例如对于下面的代码

int num = 10;
int *p = #

变量p,即为指针变量,由于它是指向int的变量num的,所以它的类型是整型指针变量,即int*,下图所求了p与num的关系:

c45dd0567a42f269944be64c0ba46d91.png

指针的操作,我认为主要可以分为三类,下面逐一说明

1)指针的解引用(*p)

p的值为10000,即num在内存中的地址,也就是说p指向了num所在的内存。在C中,我们要对num读写,如修改num的值为11,可以通过两个方法:

  • 第一个是直接通过num来修改,如:num = 11
  • 另一个方法,就是通过指针p来修改,如:*p = 11,*p表示解引用,它的意思就是找到p指向的内存空间,然后进行相应的读写操作,*p=11的意思就是找到p指向的num的4个字节的内存空间(10000~10003),然后把11写到这4个字节内。由于num对应的内存空间,已经被修改,所以再能读取num的值时,其值已经变为了11。

2)指针移动(p++, p--,p+n)

指针移动一般用于随机访问数组,如遍历和直接跳转到数组的某个位置等,如:

int nums[10];
int *p = nums;
for i = 0; i < 10; i++
{
    p++; // 从第0个元素开始,每次移动一个位置
}

p = nums;
p += 5;  // 直接跳到数组的第五个位置

3)指针相减(p2 - p1)

int nums[10];
int *p1 = nums + 2;
int *p2 = nums + 5;
int diff = p2 - p1;

指针相减得到的是一个偏移量,如在上面的例子中,diff的值为3,表示p1+3就能得到p2。注意这个偏移量的单位并不是字节,而是跟指向变量指向的类型的size相关的,所以,p2与p1相差的字节数应该为diff*sizeof(int)。

五、为什么需要指针变量

在开发过程中,我们为什么需要指针变量,笔者认为,主要因为下面的三类原因:

1)参数传递与效率

因为C语言中的参数传递本质上,都是值复制,在函数调用时,形参会复制实参的值来创建一个新的变量。

为了能在函数中修改实参的值,我们就需要传递实参的地址,即需要使用指针变量来传参,然后在函数中,通过指针来修改实参的值。

再者,对于数组、struct等,占用空间较大的结构,如果不使用指针传递的话,还会由于值复制产生大量的内存复制,降低程序的运行效率。

2)使用堆内存

C语言可以通过malloc等函数来申请内存,返回一个指针变量,指针申请的可用的内存。而这一内存是在堆中的,需要开发者自己管理,并不会由于指针变量的生命周期结束而销毁,需要开发者调用free函数来释放。

由于堆内存数量大、生命周期可以由开发者控制等特点,所以,C语言的开发中,使用堆内存是很常见的,如链表、树等数据结构的实现,大多都是使用堆内存。

3)设计更通用的代码

C语言是一门面向过程的语言,但是我们会发现,C的项目在开发时,是可以使用面向对象的设计方法进行架构和设计的。面向对象三大特性中的多态,就可以通过函数指针的方式来实现。框架可以过函数指针,把接口定义好,然后实现通用的框架逻辑,最后通过传入不同的函数指针来执行差异的功能。

例如,设计一个通用的sort函数,对任意类型的数组进行排序,可以通过函数指针的方式,传入compare函数,用于比较数组的两个元素,而具体的排序逻辑,则由sort函数实现。当涉及比较时,则调用compare方法来完成。

// len,数组长度
// elemSize,元素的size大小,如int数组,则为sizeof(int)
void sort(void *elems, int len, int elemSize, compare CmpFunc);

在业界,Linux内核、Redis等C语言的项目,都是使用面向对象的方法实现的。

六、为什么指针变量需要类型

指向int的指针的定义是int*,指向结构体Student的指针的定义是struct Student*,虽然都是指针变量,但是即有着不同的类型。

但是反过来想,指针变量是用来存储地址的,而地址本质上是一个整数的编号,使用一个能保存整数的变量来存储地址,其实也就可以了。而且C语言中也确实有一个能表示任意类型指针的void*,用于表示和存储任意类型的指针变量。而且无论是什么类型的指针变量,占用的内存空间都是一样的,在32位系统中是4个字节,在64位系统中是8个字节。

更进一步说,在计算机的硬件设计与运行中,也不存在类型的概念,那为什么C语言的指针变量需要类型呢?指针的类型,究竟是定义给谁看的呢?

我们知道,计算机在硬件层面上看,都是非0即1的二进制数据,而类型的作用就是让程序知道如何解释这一块内存的数据,准确点来说,类型是写给编译器看的,是为了让编译器知道如何把我们写的代码编译成机器代码。

例如对于0xFFFFFFFF,下面的代码,即有不同的结果:

int x = 0xFFFFFFFF;
unsigned int y = 0xFFFFFFFF;
if (x > 0){} // x为-1,条件不成立
if (y > 0){} // y为2^32-1,条件成立 

从内存的角度来看,x和y的值应该是一样的,都是占4个字节,且所有的bit都是1,但是由于类型的不同,却导致了不一样的结果。如果当int型解释,则为-1;如果当作无符号数解释,当是2^32-1,必然大于0。

同样的道理,指针变量保存的是一个地址值,但是该如何使用这个地址,却要看指针的类型。

例如,对于int*p,p代表的是它的地址值指向的内存及其后的4个字节的内容,如p的值是10000,则*p代表的是从10000到10003这4个字节的内存,并把它解释成int。如果p是char**p代表的是10000这一个字节的内存,并把它解释成char。

那么指向struct的指针呢?例如:

struct Student
{
    char name[36]; // 36字节
    int age; // 4字节
};
struct Student s;
struct Student *p = &s;
p->age = 10;

p表示把它指向的内存及其后40个字节解释为Student,p->age则表示,根据Student的定义,偏移36个字节,找到age,再把下面的4个字节的内存解释为int,再赋值数据10。

还有一个就是,对于指针的移动操作,如p+1,需要移动多少个字节,也是需要根据指针的类型来决定的,如果是int*则移动4个字节,如果是char*则移动1个字节,如果是struct Student*,则移动40个字节。

总的来说,指针变量及其类型表示了地址的起始位置和长度,决定了如何解释这一块内存,并用于指针的偏移计算,主要的作用是方便开发者和编译器解释和使用内存。可以想像一下,如果没有指针类型,如所有的指针类型都是char*,则相应的偏移计算和数据解释,都需要开发者来实现,则开发效率和代码可读性必然大大降低。

七、为什么需要二重指针

二重指针表示指向指针的指针,这个解释听起来很绕,但是只要理解了指针的本质,其实是非常简单的。因为指针变量也是一个变量,这个变量也是有地址的,而记录这个指针的指针就是二重指针。可以使用代入法来理解这个概念

typedef int* IntPtr;
int n = 10;
IntPtr p = &n;
IntPtr *pp = &p;

pp就是二重指针,n、p、pp的关系如下:

19ad7428982253c403ff4c0876eb4c4a.png

那么为什么需要二重指针呢?其实只一重指针的概念是一样的,当我们需要修改一个普通变量的值时,可以通过指针实现;而当我们需要修改一个指针变量值(修改指针的指向)时,则需要二重指针。经典的例子就是交换两个指针的指向的函数

void swap(int **x, int**y)
{
    int *tmp = *x;
    *x = *y;
    *y = tmp;
}

由此,我们可以看到C语言的参数传递,本质上都是值传递,包括指针传递也是值传递,只不过传递的是地址的值。所以,当我们需要修改指针变量的值,而不是其指向的内存的值时,就需要二重指针。

本文主要从内存的角度来解读指针相关的知识,并给出自己对于C语言的指针及其设计的理解。如有不对,或者有不同的见解,欢迎讨论。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值