笔记21-1(C语言进阶 自定义类型)

本文详细讲解了C语言中结构体的声明、内存对齐规则,位段的使用技巧以及枚举类型的特点。通过实例和练习揭示内存对齐的重要性,并讨论了如何通过调整对齐数来优化内存空间。此外,本文还介绍了位段在节省空间方面的优势及跨平台问题,以及枚举在代码可读性和维护性提升中的作用。
摘要由CSDN通过智能技术生成

目录

注:

结构体的声明

结构体的基础知识

实例

特殊的声明

结构体的自引用

结构体变量的定义和初始化

结构体内存对齐

引例

内存对齐的规则

练习

为什么存在内存对齐?

修改默认对齐数

扩展

结构体传参

位段

什么是位段?

位段的内存分配

例子

位段的跨平台问题

位段的应用

枚举

枚举类型的定义

枚举的优点


注:

 本笔记参考B站up鹏哥C语言的视频


结构体的声明

结构体的基础知识

||| 结构体是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。

struct tag

{

        member-list;

}variable-list;

实例

struct Book
{
	char name[20];
	int price;
	char id[12];
}b4, b5, b6;//b4,b5,b6是全局的

int main()
{
	//b1,b2,b3是局部变量
	struct Book b1;
	struct Book b2;
	struct Book b3;

	return 0;
}

特殊的声明

在声明结构的时候,可以不完全的声明。

比如:

匿名结构体类型

struct
{
	int a;
	char b;
	float c;
}x;

其中x就是一个结构体变量。

如果使用以下写法:

struct
{
	int a;
	char b;
	float c;
}x;
struct
{
	int a;
	char b;
	float c;
}a[20], *ps;

int main()
{
	ps = &x;
	return 0;
}

发现编译器报了一个警告

对于编译器而言,上述的两个匿名结构体是两个不同的类型。既然如此,产生的也就是两个不同的变量,自然无法进行地址的存储。


结构体的自引用

问:是否可以在结构中包含一个类型为该结构本身的成员?

struct Node
{
	int data;
	struct Node next;
};

可行吗?
如果可以,那sizeof(struct Node)是多少?

回答:不行。如果以这种方式进行包含,那么反复的引用会导致数据空间不断增大,一开始的int类型加4,然后结构体next内部又有一个int类型,再加4,之后又是结构体next……

知识点

对于链表的每一个节点,设置

其中:

数据域:存储数据(如:1);

指针域:存储下一个节点的地址,直到最后一个节点不再存入地址。(应该是一个结构体指针。)

正确的自引用方式:

struct Node
{
	int data;
	struct Node* next;
};

注意:

//代码1
struct
{
	int data;
	struct Node* next;
};

 这种写法是有问题的:

这个匿名结构体内部的结构体指针不知道是指向什么。

---

//代码2
typedef struct
{
	int data;
	Node* next;
}Node;

这种代码也存在问题:

这种写法,是重新定义一种struct类型。在这个struct类型中,定义一个Node类型的指针,需要:

①创建struct类型;

②有了struct类型内部的所有类型,才可以创建Node;

Node是在struct类型存在后才可以创建,但是struct内部又包含了一个Node类型的指针,这就产生了矛盾。

进行更改:

typedef struct Node
{
	int data;
	struct Node* next;
}Node;

结构体变量的定义和初始化

struct Point
{
	int x;
	int y;
}p1;				//声明类型的同时定义变量p1
struct Point p2;	//定义结构体变量p2

//初始化:定义的同时赋初值
struct Point p3 = { 1,2 };

struct Stu			//类型声明
{
	char name;	//名字
	int age;		//年龄
};

struct Stu s = { 'a', 15 };

struct Node
{
	int data;
	struct Point p;
	struct Node* next;
}n1 = {10, {4, 5}, NULL};				//结构体嵌套初始化

struct Node n2 = { 20, {5, 6}, NULL };	//结构体嵌套初始化

struct De
{
	double d;
	struct Stu s;
	char c;
};

int main()
{
	struct De b = { 3.14, {'w', 100}, 'q'};
	printf("%lf %c %d %c\n", b.d, b.s.name, b.s.age, b.c);
	return 0;
}

结构体内存对齐

引例

#include<stdio.h>

struct S
{
	int i;
	char c;
};

int main()
{
	struct S s = { 0 };
	printf("%d\n", sizeof(s));

	return 0;
}

如果只就结构体S内部的变量讨论,int类型是4个字节,char类型是1个字节,那么s的大小就应该是5个字节,但是在实际打印中会发现:

真正的打印结果是8。

分析:

再创建一个结构体S2,计算大小:

struct S2
{
	char c2;
	int i;
	char c;
};

int main()
{
	struct S2 s2 = { 0 };
	printf("%d\n", sizeof(s2));

	return 0;
}

发现打印结果是:

S2的大小原本应该是6个字节,但是打印结果却告诉我们S2的真正大小应该是12个字节,这就是结构体内存对齐的问题

内存对齐的规则

     1.第一个成员在与结构体变量偏移量为0的地址处。

     2.其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。(相当于偏移量为0的地址而言。)

  • 对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值 。譬如在VS系统中,一个不是第一个成员的int类型是4个字节,与默认对齐数8相比,4小,取4为对齐数。
  • VS中默认对齐数是8 (ps:Linux系统没有默认对齐数的概念。)

     3.结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。

     4.如果是嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

struct S
{
	char c1;
	int i;
	char c2;
};

int main()
{
	struct S s = { 0 };
	printf("%d\n", sizeof(s));

	return 0;
}

其中:

练习

//练习1
struct S2
{
	char c1;
	char c2;
	int i;
};

int main()
{
	struct S2 s2 = { 0 };
	printf("%d\n", sizeof(s2));
	return 0;
}

打印结果:8

分析:

struct S2
{
	char c1;
	char c2;
	int i;
};

内存中存在:


//练习2
struct S3
{
	double d;
	char c;
	int i;
};

int main()
{
	struct S3 s3 = { 0 };
	printf("%d\n", sizeof(s3));
}

打印结果:16

内存中存在:


//练习4
struct S3
{
	double d;
	char c;
	int i;
};

struct S4
{
	char c1;
	struct S3 s3;
	double d
};

int main()
{
	struct S4 s4 = { 0 };
	printf("%d\n", sizeof(s4));
}

打印结果:32

此时内存中有:

为什么存在内存对齐?

||| 大部分资料认为:

  1. 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址取某些特定类型的数据(比如只能读取特定偏移量的数据),否则抛出硬件异常。
  2. 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存仅需要一次访问。

认为上图假设的计算机一次只能读取偏移量为4的倍数的数据。

总体来说:

||| 结构体的内存对齐是拿空间来换取时间的做法。

那在设计结构体时,我们既要满足对齐,又要节省空间,如何做到:

||| 让占用空间小的变量尽量集中在一起。

//例如
struct S1
{
	char c1;
	int i;
	char c2;
};
struct S2
{
	char c1;
	char c2;
	int i;
};

 S1和S2类型的成员相同,但是S1和S2所占空间的大小存在一定的区别。

修改默认对齐数

通过 #pragma 这个预处理指令,可以改变默认对齐数。

#include<stdio.h>

struct S
{
	char c1;//偏移量为0
	//开辟未使用内存,偏移量为1-3
	int i;//偏移量为4-7
	char c2;//偏移量为8
	//开辟未使用内存,偏移量为9-11
};

int main()
{
	printf("%d\n", sizeof(struct S));
	return 0;
}

此时默认对齐数是8,接下来改变默认对齐数

#include<stdio.h>

#pragma pack(2)//把默认对齐数改为2
struct S
{
	char c1;
        int i;
	char c2;
};
#pragma pack()//将默认对齐数还原为原始值

int main()
{
	printf("%d\n", sizeof(struct S));
	return 0;
}

此时打印结果为:8

分析:

  • i 是int类型,4个字节,默认偏移量是2,2<4,所以 i 的偏移量就是2;
  • c2 是char类型,1个字节,1<2,所以 c2 的偏移量就是1。

此时内存中:

注意,这里开辟到 c2 的空间偏移量只有6,空间总大小只有7,7不是最大偏移量 2 的倍数,所以再往下开辟一个字节的空间,得到struct S的空间大小就是8。

再问,下面代码的打印结果是多少?

#include<stdio.h>

#pragma pack(1)//把默认对齐数改为2
struct S
{
	char c1;
	int i;
	char c2;
};
#pragma pack()

int main()
{
	printf("%d\n", sizeof(struct S));
	return 0;
}

打印结果:6

分析:

此时无论如何对齐数都取1,几个成员之间没有间隔。

结论:

||| 结构在对齐方式不合适的时候,可以修改默认对齐数。

扩展

offsetof 宏的实现)

通过 offsetof 宏可以计算结构体成员相较于起始位置的偏移量的大小。

使用例

#include<stdio.h>
#include<stddef.h>

struct S
{
	char c1;
	int i;
	char c2;
};

int main()
{
	printf("%d\n", offsetof(struct S, c1));
	printf("%d\n", offsetof(struct S, i));
	printf("%d\n", offsetof(struct S, c2));
	return 0;
}

打印结果:

结构体传参

struct S
{
	int data[1000];
	int num;
};

struct S s = { {1, 2, 3, 4}, 1000 };
//结构体传参
void print1(struct S s)
{
	printf("%d\n", s.num);
}
//结构体地址传参
void print2(struct S* ps)
{
	printf("%d\n", ps->num);
}

int main()
{
	print1(s);	//传结构体
	print2(&s);	//传地址
	return 0;
}

在上述代码进行结构体传参时,如果使用 print1 ,注意,此时结构体S的大小至少是1004个字节,而 print1 内部的 参数s 结构体s 的一份临时拷贝,如果直接拷贝一份一样大小的空间,不仅浪费了空间,而且会浪费时间。

相反,如果传入的是指针,只需要一个指针变量,就不会出现这种情况。故 print2 的实现效率更高。另一方面,通过指针可以轻松地改变变量本身而不是变量的拷贝。

也就是:

  • 函数传参时,参数是需要压栈的,会有时间和空间上的开销。
  • 如果传递一个结构体对象的时候,结构体过大,参数压栈的系统开销比较大,会导致性能的下降。

结论:

||| 结构体传参,一般选择传入结构体的地址

位段

结构体实现 位段 的能力。

什么是位段?

位段的声明和结构是类似的,但有两个不同:

  1. 位段的成员必须是 int、unsigned intsigned int 。(char类型也可以)
  2. 位段的成员名后边有一个冒号和一个数字。

比如:

struct A
{
	int _a : 2;
	int _b : 5;
	int _c : 10;
	int _d : 30;
};

A就是一个位段类型。

那位段A的大小是多少呢?通过 printf("%d\n", sizeof(struct A)) 可以看见:

但是存在问题:

  • int _a : 2; 表示_a成员占2个bit位;
  • int _b : 5; 表示_b成员占5个bit位;
  • int _c : 10; 表示_c成员占10个bit位;
  • int _d : 30; 表示_d成员占30个bit位。

总共有47个bit位。而8个字节是64个字节,如果这样,64 > 47,空间大小明显不匹配。那么此时 A 内到底是如何分配空间的呢?

位段的内存分配

  1. 位段的成员可以是 int unsigned int signed int 或者 char(属于整型家族)类型。
  2. 位段的空间上是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的。
  3. 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。

再看上面的例子

struct A
{
	int _a : 2;
	int _b : 5;
	int _c : 10;
	int _d : 30;
};
  • 首先执行 int _a : 2;此时开辟一个4个字节的整型(即32个bit)的空间, 但是 _a 只用了2bit的空间,开辟的空间中还剩下30bit的空间没有被使用。
  • 所以接下来的 _b 使用的也是上面已经开辟出来的整型空间,_b 使用了5bit的空间。此时还剩下25个bit的空间。
  • _c 同样使用开辟好的整型空间,还剩下15bit的空间。
  • 但是接下来到 _b_b 需要30bit的空间,原本剩下的整型空间已经不够用了。所以再开辟一个整型(32bit)的空间。

(同时,此处 _b 到底是先使用先开辟的整型空间剩下的15bit的空间,还是完全使用新开辟的32bit的空间是没有明确的标准的,这是一个不确定因素,使用位段时应该避免跨平台。)

总共开辟了64bit —— 8个字节的空间。

(此处的整型指代int类型。)

ps:如对于int _d,最大可以设置的空间大小就是32bit(一个整型的大小),不可以超过类型本身的大小。

这种指定空间的类型出现,为空间的节省提供了一个条件。

例子

struct S
{
	char a : 3;
	char b : 4;
	char c : 5;
	char d : 4;
};
int main()
{
	struct S s = { 0 };
	s.a = 10;
	s.b = 12;
	s.c = 3;
	s.d = 4;

	return 0;
}

分析:

首先是 char a : 3; 开辟了一个字节(8bit)的空间,初始化全部变为0。然后 a 只使用 3bit 的空间,这就存在问题,开辟好的这块一个字节的空间应该从低位向高位使用,还是从高位向低位使用?

1. 假设从低位向高位使用:

此时还剩下5bit,b 使用4bit,所以:

还剩下1bit,c 使用5bit,不够,需要再申请1一个字节(8bit)的空间,此时问题又来了:c 会不会使用原本剩下的1bit的空间?

假设只使用新开辟的8bit空间而不使用原本剩下的:

此时还有3bit空间可以被使用,但是 b 需要4bit空间,不够,再开辟。按照之前的说法原本的3bit空间就被舍弃了:

如果真的按照这种方式进行,接下来就要放入数据:

  1. s.a 内存放一个10,10的二进制是1010,但是 a 的大小只有3bit,不够,进行截断:存放010;
  2. s.b 内存放一个12,12的二进制是01100,b 内能存放4bit,故 b 内存放:1100;
  3. s.c 内存放一个3,3的二进制是0011,c 内能存放5bit,故 c 内存放:00011;
  4. s.d 内存放一个4,4的二进制是0100,d 内能存放4bit,故 d 内存放:0100。

现在再通过内存观察一下是否符合推测:

先观察结构体s的初始化:

一共是3个字节。

然后放入数据:

和推测相符,得出初步结论:

VS编辑器下①字节使用是自低位向高位使用;②当目前使用空间的大小无法满足变量的需求时,这块空间就会被浪费掉。

注:大小端讨论的是字节序,并不包含一个字节内部的排序。

位段的跨平台问题

  1. int 位段被当成有符号数还是无符号数是不确定的。
  2. 位段中最大的数目不能确定。(16位机器最大16,32位机器最大32。如果变量设定过大,如27bit,在16位机器上就会出问题。)
  3. 位段的成员在内存中从左向右分配,还是从右向左分配尚未定义。
  4. 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余位还是利用,这是不确定的。

总结:

||| 跟结构相比,位段可以达到同样的效果,但是可以很好的节省空间,但是存在跨平台的问题。

位段的应用

举例:数据在网络上的封装。

上面就是一个数据包。如果一个数据包过大,需要的空间过大,就会使网络变得比较“拥挤”。所以数据包如果“小一点”,就可以较好的解决这个问题。这就是通过位段完成的一种优化。

枚举

枚举,顾名思义就是——列举。

把可能的取值一一列举。

比如:

一周的星期一到星期日是有限的7天,可以一一列举;

性别:男、女、保密,也可以一一列举。

月份等等

这些都可以使用枚举。

枚举类型的定义

//声明枚举类型
enum Color//颜色
{
	RED,
	GREEN,
	BLUE,
};

int main()
{
	enum Color c = BLUE;
	//变量c的可能取值就是 BLUE
	return 0;
}

以上定义的 enum REDenum GREENenum BLUE 都是枚举类型。{}中的内容是枚举类型的可能取值,也叫 枚举常量 。

与结构体进行对比:

成员常量是不一样的。

接下来尝试打印代码:

int main()
{
	printf("%d\n", RED);
	printf("%d\n", GREEN);
	printf("%d\n", BLUE);

	return 0;
}

打印结果为:

可以看出,在 enum Color 内部,

  • RED 的默认值是 0
  • GREEN 的默认值是 1
  • BIUE 的默认值是 2

这些值在以递增 1 的形式往下走。

现在我们知道 BLUE 的默认值是 2,那可不可以通过这个 默认值2 进行赋值呢?

int main()
{
	enum Color c = 2;

	printf("%d\n", RED);
	printf("%d\n", GREEN);
	printf("%d\n", BLUE);
	return 0;
}

程序运行,似乎没什么问题。但是现在换到C++环境下进行执行,发现:

运行代码,发生报错:

2是个int类型,不兼容Color类型。发现:C++语法检查更加严格,在使用时需要注意。

int main()
{
    enum Color c = BLUE;
    printf("%d\n", c);

    return 0;
}

另一方面,主动给枚举变量赋值是可以的:

enum Color
{
    RED = 5,
    GREEN,
    BLUE,
};

打印结果:

---

enum Color
{
    RED = 5,
    GREEN = 8,
    BLUE,
};

打印结果:

疑问

在上面的解释中,枚举类型内部包含的是常量,为什么常量可以被改动?

注意:

即使是常量,最开始也需要一个值,也就是说在enum内部赋初值是可行的。但是如果在enum外部对枚举常量的值进行改动,则是不行的

枚举的优点

但是目前来看,枚举的效果都可以用 #define 来实现:

#define RED 5
#define GREEN 8 
#define BLUE 9

为什么还有枚举类型呢?

枚举的优点:

  1. 增加了代码的可读性和可维护性;

    一个例子:实现计算器

    void menu()
    {
    	printf("************************\n");
    	printf("****  1.add    2.sub****\n");
    	printf("****  1.mul    2.div****\n");
    	printf("****     0.exit     ****\n");
    	printf("************************\n");
    }
    
    int main()
    {
    	int input = 0;
    	do
    	{
    		menu();
    		printf("请选择:");
    		scanf("%D", &input);
    		switch (input)
    		{
    		case 1:
    			break;
    		case 2:
    			break;
    		case 3:
    			break;
    		case 4:
    			break;
    		case 0:
    			break;
    		default:
    			break;
    		}
    	} while (input);
    
    	return 0;
    }

    在switch语句中,case 0 ~ case 4 ,如果需要理解代码就较为困难,因为 数字 和 加减乘除 之间并没有什么实际的联系,也不方便联想。这时候使用枚举类型:

    enum Option
    {
    	EXIT,//0
    	ADD,//1
    	SUB,//2
    	MUL,//3
    	DIV//4
    };

    以后switch语句就可以写成:

    		switch (input)
    		{
    		case ADD:
    			break;
    		case SUB:
    			break;
    		case MUL:
    			break;
    		case DIV:
    			break;
    		case EXIT:
    			break;
    		default:
    			break;
    		}

    这就增加了代码的可读性。

  2. 和 #define 定义的标识符相比,枚举类型有类型检查,更加严格;
  3. 防止了命名污染(封装);
  4. 便于调试;

    #define 在预编译阶段完成替换,就比如:int a = RED; 如果定义RED = 5,则在预编译阶段后,变成 int a = 5; 但是此时还未调试,所以如果调试是看不到 替换 这个操作的。而枚举就不是替换,可以通过调试看到过程。

  5. 使用方便,一次可以定义多个变量。

ps:枚举类型就是一种类型,和整形类型等一样

       而如果是C++环境,要限定枚举常量来自特定的枚举类型,可以写成:

enum Color c = Color::BLUE;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值