关于C语言的类型

喜欢的可以收藏转发加关注

在一切的开始,内存只是一片荒芜,后修真者编译天地,便有了今天的锦绣山河。


一块没有使用的内存就像是一片荒凉的大地,为了更方便管理,人们进行区域划分,便有了良田千顷,房屋万座,为了更方便的管理内存,几乎在每一门编程语言中都有类型这个概念,每一种类型都有特定的大小,内存在被使用时就被不同的类型分割成了大大小小的不同的块。C语言中基本数据类型有int、char等,在基本数据类型的基础上又可以任意组合生成结构体类型。很多人在第一次接触编程时,往往很难理解类型是一个怎样的概念,我个人觉得称之为类型不是很合适,我更喜欢称之为格式,本文会从格式的角度尝试理解类型,看看会发生怎样的结果。

关于C语言的类型

在不同的平台上,同一种类型也会有不同的大小,所以抛开平台谈类型无异于耍流氓,本文使用的环境为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的复制体),这样就完成了一次类型转换。强制类型转换发生的数据丢失该怎么理解呢?注意到一点,读取器读取的空间大小是存储格式指定的,类型转换的时候存储格式已经被改变了,所以读取器读取的时候会读取新的大小。

关于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++学习资料、视频

关于C语言的类型

相关推荐