目录
注:
本笔记参考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
此时内存中有:
为什么存在内存对齐?
||| 大部分资料认为:
- 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址取某些特定类型的数据(比如只能读取特定偏移量的数据),否则抛出硬件异常。
- 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存仅需要一次访问。
认为上图假设的计算机一次只能读取偏移量为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 的实现效率更高。另一方面,通过指针可以轻松地改变变量本身而不是变量的拷贝。
也就是:
- 函数传参时,参数是需要压栈的,会有时间和空间上的开销。
- 如果传递一个结构体对象的时候,结构体过大,参数压栈的系统开销比较大,会导致性能的下降。
结论:
||| 结构体传参,一般选择传入结构体的地址。
位段
结构体实现 位段 的能力。
什么是位段?
位段的声明和结构是类似的,但有两个不同:
- 位段的成员必须是 int、unsigned int 或 signed int 。(char类型也可以)
- 位段的成员名后边有一个冒号和一个数字。
比如:
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 内到底是如何分配空间的呢?
位段的内存分配
- 位段的成员可以是 int unsigned int signed int 或者 char(属于整型家族)类型。
- 位段的空间上是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的。
- 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
再看上面的例子
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空间就被舍弃了:
如果真的按照这种方式进行,接下来就要放入数据:
- s.a 内存放一个10,10的二进制是1010,但是 a 的大小只有3bit,不够,进行截断:存放010;
- s.b 内存放一个12,12的二进制是01100,b 内能存放4bit,故 b 内存放:1100;
- s.c 内存放一个3,3的二进制是0011,c 内能存放5bit,故 c 内存放:00011;
- s.d 内存放一个4,4的二进制是0100,d 内能存放4bit,故 d 内存放:0100。
现在再通过内存观察一下是否符合推测:
先观察结构体s的初始化:
一共是3个字节。
然后放入数据:
和推测相符,得出初步结论:
VS编辑器下①字节使用是自低位向高位使用;②当目前使用空间的大小无法满足变量的需求时,这块空间就会被浪费掉。
注:大小端讨论的是字节序,并不包含一个字节内部的排序。
位段的跨平台问题
- int 位段被当成有符号数还是无符号数是不确定的。
- 位段中最大的数目不能确定。(16位机器最大16,32位机器最大32。如果变量设定过大,如27bit,在16位机器上就会出问题。)
- 位段的成员在内存中从左向右分配,还是从右向左分配尚未定义。
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余位还是利用,这是不确定的。
总结:
||| 跟结构相比,位段可以达到同样的效果,但是可以很好的节省空间,但是存在跨平台的问题。
位段的应用
举例:数据在网络上的封装。
上面就是一个数据包。如果一个数据包过大,需要的空间过大,就会使网络变得比较“拥挤”。所以数据包如果“小一点”,就可以较好的解决这个问题。这就是通过位段完成的一种优化。
枚举
枚举,顾名思义就是——列举。
把可能的取值一一列举。
比如:
一周的星期一到星期日是有限的7天,可以一一列举;
性别:男、女、保密,也可以一一列举。
月份等等
这些都可以使用枚举。
枚举类型的定义
//声明枚举类型
enum Color//颜色
{
RED,
GREEN,
BLUE,
};
int main()
{
enum Color c = BLUE;
//变量c的可能取值就是 BLUE
return 0;
}
以上定义的 enum RED,enum GREEN,enum 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
为什么还有枚举类型呢?
枚举的优点:
- 增加了代码的可读性和可维护性;
一个例子:实现计算器
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; }
这就增加了代码的可读性。
- 和 #define 定义的标识符相比,枚举类型有类型检查,更加严格;
- 防止了命名污染(封装);
- 便于调试;
#define 在预编译阶段完成替换,就比如:int a = RED; 如果定义RED = 5,则在预编译阶段后,变成 int a = 5; 但是此时还未调试,所以如果调试是看不到 替换 这个操作的。而枚举就不是替换,可以通过调试看到过程。
- 使用方便,一次可以定义多个变量。
ps:枚举类型就是一种类型,和整形类型等一样
而如果是C++环境,要限定枚举常量来自特定的枚举类型,可以写成:
enum Color c = Color::BLUE;