关于C语言的类型
喜欢的可以收藏转发加关注
在一切的开始,内存只是一片荒芜,后修真者编译天地,便有了今天的锦绣山河。
一块没有使用的内存就像是一片荒凉的大地,为了更方便管理,人们进行区域划分,便有了良田千顷,房屋万座,为了更方便的管理内存,几乎在每一门编程语言中都有类型这个概念,每一种类型都有特定的大小,内存在被使用时就被不同的类型分割成了大大小小的不同的块。C语言中基本数据类型有int、char等,在基本数据类型的基础上又可以任意组合生成结构体类型。很多人在第一次接触编程时,往往很难理解类型是一个怎样的概念,我个人觉得称之为类型不是很合适,我更喜欢称之为格式,本文会从格式的角度尝试理解类型,看看会发生怎样的结果。
在不同的平台上,同一种类型也会有不同的大小,所以抛开平台谈类型无异于耍流氓,本文使用的环境为windows 64位。int格式占用空间为4个字节,char格式占用空间为1个字节,所以int格式和char格式不是同一种格式,float格式占用空间为4个字节,那int格式和float格式就是同一种格式吗?显然这是不合逻辑的,格式的定义还是太过于模糊了,那就细分为存储格式和读取格式,存储格式描述了数据存储所占用的空间,读取格式描述了存储空间中的数据已何种方式组装,这两种格式决定了一种“类型“,int和float有用相同的存储格式,但是没有相同的读取格式,所以它们不是一种格式。
当我们定义一个变量的时候都发生了什么呢?先看一个很复杂的表达式:
int x = 5;
之所以说这个表达式很复杂,是因为编译器帮我们处理了太多的事,我们尝试用格式的概念分析下这个表达式背后故事,语法分词后编译器首先接收到int关键字,int格式在编译器中对应着类似这种结构:
{ 存储格式, // 存储空间大小 读取格式, // 假设不同类型对应着不同的读取器 }
然后编译器接收到x,根据前面获取到的存储格式分配存储空间,绑定x和这块存储空间,接着将5存入该空间中,此时编译器中应该有了这样一个结构:
{ 存储格式, 读取格式, 变量名, 存储空间指针 }
程序中使用x时,会根据变量名调用读取格式对应的读取器,去处理存储空间指针指向的区域,读取大小为存储格式规定的。截止到现在,格式的基本概念已经基本清楚了,后文会继续使用格式去理解几个特定的问题。
为了行文方便,伪造了一些编译器的概念,真实的编译过程并不是上面说的那样,感兴趣的可以学一学编译器原理,真的很浪漫。
1. 类型转换
C语言中有两种类型转换:自动(隐式)类型转换和强制(显式)类型转换,自动类型转换由编译器完成,例如:
char c = 'a'; int x = c; // 等价于 int x = (int)c;
自动类型转换一般发生在较低类型转换为较高类型,此处的高低指存储空间占用的大小,这样可以保证数据完整性,如果从较高类型向较低类型转换,多出的数据将会丢弃,这样会导致数据不完整,所以需要强制类型转换(显示的调用,要清楚自己在做什么)。
而这一切如果用格式的概念理解,就变得简单起来,上文代码中的c被转换为int格式,对应的操作为:先复制一份c,保证原有的数据不受影响,然后将复制出的c的存储格式改为int的存储格式,读取格式变成int的读取器,变量名变为x,存储空间指针不变(此处的指针并不指向c,而是指向c的复制体),这样就完成了一次类型转换。强制类型转换发生的数据丢失该怎么理解呢?注意到一点,读取器读取的空间大小是存储格式指定的,类型转换的时候存储格式已经被改变了,所以读取器读取的时候会读取新的大小。
还有一个有趣的问题,假设有如下代码:
float f = 1000.1; int x = (int)f; float f1 = (float)x;
int和float存储空间都为4个字节,x是类型转换后的f,此时并没有发生数据丢失,那f1在赋值类型转换后的x,也没有发生数据丢失,是不是f1就是1000.1呢?然而f1实际的结果是1000.0,看着好像发生了数据丢失,也确实是这样,只不过并不是因为类型转换导致的,编译器在处理x的赋值的时候,自动舍弃了小数点之后的数据取整,所以再转换为float的时候有部分数据就丢失了。
2. 指针
指针作为C语言的灵魂,有着很多无法替代的作用。指针本身有两种类型,一种是指针类型,一种是指针指向的类型,很难理解?试试格式的概念,指针的格式如下:
{ 存储格式, 读取格式, 变量名, 存储空间指针, // 这里面存储的是目标的地址 目标格式 }
指针都有一个共同的特点,就是它们的存储格式都保持一致,均为地址存储格式,读取格式也保持一致,决定它们不同的是目标格式,比如下面这段代码:
int x = 5; int * p = &x; printf("%d", *p); // 5
变量x的定义不在赘述了,后面的指针初始化流程为:存储格式为int *,读取格式为int *的读取器,变量名为p,分配8个字节的内存空间,存储空间指针指向该块内存,目标格式为int,取出x的存储空间指针,赋值到p的存储空间中,然后以p的目标格式的读取器(int的读取器)读取p存储空间中存储的x的存储空间指针指向的空间,其中涉及到了两个操作符:& 返回变量的存储空间指针,* 表示调用目标格式的读取器读取目标的地址。伪代码表示如下:
x: { 存储格式: 4, 读取格式: int, 变量名: x, 存储空间指针: 0 // [0 - 3]存储着5 } p: { 存储格式: 8, 读取格式: 地址读取器 变量名: p 存储空间指针: 4 // [4 - 11] 目标格式: int } // 即内存[4 - 11]存储了数字0 p.存储空间[4 - 11] = x.存储空间指针0 p.目标格式 = int // *p p.目标格式.读取格式(p.存储空间[4 - 11]) // 因为p.存储空间[4 - 11]存储内容为0,x.存储空间指针也为0,所以上式等价于 p.目标格式.读取格式(x.存储空间指针) // p.目标类型就是int,x.目标类型也是int,所以上式等价于 x.读取格式(x.存储空间指针)
是不是发现访问*p和访问x是完全一致的呢?
3. 结构体和结构体指针
结构体是一种复合类型,通过它可以衍生出无数的类型,格式的概念用来解释结构体再合适不过了。先来看一个简单的结构体:
typedef struct{ int a; char b; double c; }Example;
*注: 编译器在处理结构体时,为了加快访问速度会执行内存对齐,例如上面的结构体实际所占内存为 4 + 1 + 3 + 8 = 16。
这个结构体格式在编译器中对应着类似这种结构(数字表示相对于结构体首地址的偏移量):
Example { a[0 - 3]: int格式 b[4 - 4]: char格式 [5 - 7]: 执行对齐浪费的内存 c[8 - 15]: double格式 }
初始化一个结构体变量:
Example example; example.a = 5; example.b = 'c'; example.c = 12.1;
对属性进行赋值时经过的过程如下:
example.a[0 - 3].int存储格式 = 5; example.b[4 - 4].char存储格式 = 'c'; example.a[8 - 15].double存储格式 = 12.1; // 读取属性值时是类似的操作,不再赘述
接下来看一下结构体指针怎么理解,这里是重中之重:
Example * p = (Example *)malloc(sizeof(Example));
先来说一下malloc,这是C语言中在堆中分配内存使用的API,栈内存是由系统进行统一分配回收的,而堆内存是由开发者自主维护,malloc有唯一参数表示要分配内存的大小,单位字节,返回值类型为void *,void *可以指向任意类型,也可以转换为任意类型,后文会提到。上面的代码将一块内存初始化为了Example格式,即将这块内存按照Example的存储格式进行分块,分块的格式为: [0 - 3][4 - 4][5 - 7][8 - 15]。这里就是把结构体的指针类型看成格式刷对目标内存进行格式化。下面接着看一个有趣的问题:
Example * p = (Example *)malloc(sizeof(Example)); p->a = 5; p->b = 'M'; p->c = 12.1; int * pNum = (int *)p; printf("%d ", *pNum); // 5 p->a = 7; printf("%d ", *pNum); // 7
第六行将p指针强制转换成了int *格式,此时发生了什么呢?
// 创建int *指针 pNum { 存储格式; // 8 读取格式, // 地址读取器 变量名, // pNum 存储空间指针,// 分配的内存地址 目标格式 // int格式 } // 赋值操作 pNum->存储空间指针 = p->存储空间指针 // 属性a的首地址相对于结构体的首地址偏移量为0,所以结构体的首地址就是属性a的首地址,上式等价于 pNum->存储空间指针 = p->a.存储空间指针
那从Example *转换为int *存在数据的丢失吗?回想上节说明的,所有的指针存储格式和读取格式保持一致,唯一的区分在于目标格式,类型转换时,相当于改变存储格式和读取格式,从一个指针类型转换为另一个指针类型,它的存储格式和读取格式改变后还是原来的样子,所以就不存在一种指针转换成另一种指针发生数据丢失的情况,但是在指针的类型转换中,目标格式发生了变化,所以在读取的时候会调用不同的读取器,例如:
printf("%d ", *pNum); // 回顾前文,* 表示调用目标格式的读取器读取目标的地址 pNum->目标格式(int).读取器(目标地址) // 读取器读取的时候会根据存储格式来决定读取长度,假设结构体p内存为 [0 - 3][4 - 4][5 - 7][8 - 15] // 那pNum中存的目标地址就为0,读取长度为4,即读取内容为[0 - 3],所以等价于 printf("%d ", p->a);
应用这个特点,可以实现结构体的继承,前提条件是子结构体要将继承的父结构体放在属性的第一位。
4. 函数指针
很多教材将变量和函数分开来讲,以至于在后面遇到函数指针和回调的时候,往往一时间不能接受,所以我个人认为学会使用函数指针和回调的关键是要打破固有思维,将函数看成一种特殊的变量,对于这一点我更喜欢JavaScript对待函数的态度:
function test() {} // 等价于 let test = function() {};
function作为js的一等公民,可以出现在任何变量可以出现的地方,变量可以当做函数参数,那函数也可以当做参数使用,这就是回调。
使用类型的概念我们很难去描述一个函数,但是用格式的概念就很简单了,试想一下区分两个函数的标准是什么?
1. 返回值 2. 参数表 3. 函数名
所以可以把函数类型看成一个结构体:
{ 返回格式; 参数表; }
定义一个函数的时候,实际上是:
int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } // 等价于 add.返回格式 = int; add.参数表 = (int a, int b); add.函数名 = add; add.函数体 = { return a + b; }; sub.返回格式 = int; sub.参数表 = (int a, int b); sub.函数名 = add; sub.函数体 = { return a - b; };
看到这你也许会发现,add和sub的返回格式和参数表相同,所以add和sub是同一种函数格式。可以尝试定义一个这种格式的函数指针:
// 定义Func类型 typedef int(*Func)(int a, int b); // 等价于 Func.返回格式 = int格式 Func.参数表 = (int a, int b) int add(int a, int b) { return a + b; } // 等价于 add: { 存储格式: 函数格式 读取格式: 函数调用读取器 变量名: add 存储空间指针: add的地址 } Func func = add; // 等价于 func: { 存储格式: 8, // 指针类型 读取格式: 地址读取器 变量名: func 存储空间指针: 存储地址 目标格式: Func } func.存储空间指针的存储内容赋值为add.存储空间指针 func(8, 6); // 这里和调用普通指针完全一样 // 等价于 func->目标格式(Func).读取器(目标地址).函数调用器(8, 6) // 目标地址就是add的地址,所以上式等价于 add(8, 6);
从格式的概念看,函数和普通的变量没有任何的区别,普通的变量可以进行类型转换,那函数类型肯定也可以类型转换:
int add(int a, int b) { return a + b; } float sub(float a, float b) { return a - b; } typedef int(*Func)(); Func func = NULL; func = add; printf("%d ", func(5, 2)); // 7 func = sub; // 此处发生了类型转换 printf("%f ", func(5, 2)); // 0.000000
可以看到,当函数指针强制转换(回顾前文指针类型转换一定不会发生数据丢失)时,参数表和返回值发生了变化,导致最终的结果不可预知,所以在日常开发中要尽量避免函数指针发生类型转换。
文中定义了格式的概念只是为了帮助理解,真实的编译过程并不是文中所述。
学习C/C++的伙伴可以私信回复小编“学习”领取全套免费C/C++学习资料、视频