结构的基础知识:
结构是一些值的集合,这些值称为成员变量,结构的每个成员可以是不同类型的变量。
结构的声明:
基本格式是这样的
struct tag //tag是结构体标签,可随意起
{
member-list; //member-list是成员列表
}variable-list; //variable-list是变量列表
现在我们要声明一个结构体类型结构体的名称是Stu
请看举例(描述一个学生):
struct Stu
{
//结构体的成员变量
char name[20];//名字
char tele[12];//电话
char sex[10];//性别
int age;//年龄
};
当我们有了struct Stu
这个类型之后我们就可以通过这个类型创建结构体变量,请看:
struct Stu
{
//结构体的成员变量
char name[20];//名字
char tele[12];//电话
char sex[10];//性别
int age;//年龄
};
int main()
{
//创建结构体变量
struct Stu s1;
struct Stu s2;
return 0;
}
我们也可以在main
函数外部创建结构体变量,区别是此时的结构体变量是一个全局变量,请看:
struct Stu
{
//结构体的成员变量
char name[20];//名字
char tele[12];//电话
char sex[10];//性别
int age;//年龄
}s4,s5,s6;//s4,s5,s6均为全局变量
struct Stu s3;//s3为全局变量
int main()
{
//创建结构体变量
struct Stu s1;//局部变量
struct Stu s2;//局部变量
return 0;
}
特殊的声明
在声明结构的时候,可以不完全的声明(但是不建议这样写)。
比如:
//匿名结构体类型
struct
{
int a;
char b;
float c;
}x;
匿名结构体类型,在创建类型的同时后面必须直接跟着名字。
那我们可不可以这样呢?请看:
#include
//匿名结构体类型
struct
{
int a;
char b;
}sa;
struct
{
int a;
char b;
}* psa;//结构体指针
int main()
{
psa = &sa;
return 0;
}
我们发现这两个结构体的成员一模一样,只不过下面这个结构体是拿类型创建了一个指针psa
而已
我们编译一下代码看会不会出现警告再说:
很可惜,这里弹出来一个警告⚠警告 C4133 “=”: 从“*”到“*”的类型不兼容 上面两个结构在声明的时候省略了结构体标签(tag)。
什么意思呢?匿名结构体当然可以存在当两个结构体各自的结构体成员一模一样的时候时,我们感觉它两好像是一回事,但实际上编译器在处理时会把它两当作各自不同的类型。也就是说虽然这两个结构体各自的结构体成员一模一样,但是编译器认为这是两个各自独立的类型。所以psa = &sa;
这种说法是错误的❌
警告:编译器会把上面两个声明当作完全不同的两个类型,所以是非法的。
那有没有可能会出现匿名结构体的使用呢?这是由可能的。匿名结构体的一个特点是它只能用一次,以后就再也不能用了。因为这个类型是没有结构体标签的,所以下一次想使用时是不能使用的。
在结构中包含一个类型为结构本身的成员是否也可以呢?答案是肯定的。
大家看下面这段代码是否正确呢?
#include
struct Node
{
int data;
struct Node n;
};
int main()
{
sizeof(struct Node);
return 0;
}
这样会导致程序报错:
注意结构体类型定义的时候,自己的结构体内部可以包含各种各样类型的成员,但是要包含一个自己类型的这种成员变量是不可以的,这就给人一种死递归的现象(自己里边包含一个自己)。这个时候我们算这样一个结构体大小是没法算的。未来的时候我们用这个类型创建结构体变量的时候也是不能创建的。(假设要创建,要创建一块多大的空间才合适呢?)
我们可以这样来写:
#include
//结构体的自引用
struct Node
{
int data;//4个字节
struct Node* next;//struct Node*是一个指针类型,占4/8个字节
};
int main()
{
return 0;
}
以上是结构体的自引用。
关于结构体我们经常会遇到typedef
的这种写法,即我们可以使用typedeg
来对结构体进行重命名请看:
typedef struct Node
{
int data;
struct Node* next;
}Node;
这个时候我们就把类型名struct Node
简化成Node
了。我们以后就可以通过类型名Node
来创建结构体变量,请看:
#include
typedef struct Node
{
int data;
struct Node* next;
}Node;
int main()
{
struct Node n1;//struct Node本来就是一种类型,通过struct Node来创建结构体变量当然没有问题
Node n2;//我们已经给struct Node重新取名为Node,这两种类型名其实都存在,
return 0;
}
那假设我们把Node省略掉,省略掉之后就相当于给这个匿名结构体取一个名字叫Node。即:
#include
typedef struct
{
int data;
struct Node* next;
}Node;
int main()
{
struct Node n1;
Node n2;
return 0;
}
没有了Node之后,我们还需要把struct Node* next;
改为Node* next;
这样,代码就变成了这样:
#include
typedef struct
{
int data;
Node* next;
}Node;
int main()
{
struct Node n1;
Node n2;
}
如果程序运行之后就会出现一系列的警告:
编译器此时是不认识Node
的,为什么呢?是因为我们这个结构体类型有重命名才能产生Node,而在这个结构体中,我们还没有定义出Node却想使用Node,可以把它想象成先有鸡还是先有蛋的问题。所以上面这种写法是错误的。建议是即使我们要重命名,也不要把Node
省略掉,即要写成typedef struct Node
,这样才可以有struct Node* next
这种写法,所以最终的写法是这样的:
#include
typedef struct Node
{
int data;
struct Node* next;
}Node;
int main()
{
struct Node n1;
Node n2;
return 0;
}
因为只有struct Node
这个类型存在,才会有下面的
有了结构体类型,那如何定义结构体变量,其实很简单。
比如:
#include
struct T
{
double weight;
short age;
};
struct S
{
char c;
struct T st;
int a;
double d;
char arr[20];
};
int main()
{
struct S s = { 'c',{55.6,50},100,3.14,"hello world" };
printf("%c %d %lf %s\n", s.c, s.a, s.d, s.arr);
printf("%lf\n", s.st.weight);
return 0;
}
现在我们深入讨论一个问题:计算结构体的大小。
注意这是一个热门考点:结构体内存对齐。
#include
struct S1
{
char c1;
int a;
char c2;
};
struct S2
{
char c1;
char c2;
int a;
};
int main()
{
struct S1 s1 = { 0 };
printf("%d\n", sizeof(s1));//?
struct S2 s2 = { 0 };
printf("%d\n", sizeof(s2));//?
return 0;
}
大家可以猜一下上述代码的运行结果是什么?
为什么算出来的结果是12和8呢?这就涉及到结构体的内存对齐的知识点了。它在对齐的时候有一套对齐的规则。
**如何计算?**首先得掌握结构体的对齐规则:
1.第一个成员在与结构体变量偏移量为0的地址处。
2.其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。
vs中对齐数默认的值为8
gcc无默认对齐数,成员的大小就是对齐数。
3.结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
4.如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数值。
下面这张图是结构体变量s1、s2的大小。
以上就是结构体默认对齐数的规则。
struct S3
{
double d;
char c;
int i;
};
printf("%d\n", sizeof(struct S3));
最终结果为16。
struct S3
{
double d;
char c;
int i;
};
struct S4
{
char c1;
struct S3 s3;
double d;
};
printf("%d\n", sizeof(struct S4));
大部分参考资料是这样说的:
1.平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的数据的;某些硬件平台只能在某些地址处取得某些特定类型的数据,否则抛出硬件异常。
2.性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
对于第二点的性能问题,我们举一个例子:
总体来说:
结构体的内存对齐是拿空间换时间的说法。
那在设计结构体的时候,我们既要满足对方,既要节省空间,如何做到:
让占用空间小的成员尽量集中在一起。
例如:
struct S1
{
char c1;
int i;
char c2;
};
struct S2
{
char c1;//c1和c2放在一起的话就不会浪费很多空间
char c2;
int i;
};
S1和S2类型的成员一模一样,但是S1和S2所占空间的大小有了一些区别。
在C语言中有#pragma
这个预处理指令,这里我们再次使用,可以改变我们的默认对齐数。
#pragma pack(4)//设置默认对齐数位4
#include
struct S
{
char c1;
//由原来的浪费7个字节变为只需要浪费3个字节
double d;
};
#pragma pack()//取消设置的默认对齐数
int main()
{
struct S s;
printf("%d\n", sizeof(s));//结果为12
return 0;
}
倘若我们设置默认对齐数为1的话,上述代码的结果就是9。
然后我们一般设计默认对齐数的时候不会设置3、5、7等这样的数,一般都会设置2、4、8、16等2的次方数这样的数字。
结论:
结构在对齐方式不合适的时候,我们可以自己更改默认对齐数。
#include
struct S
{
char c;
int i;
double d;
};
int main()
{
return 0;
}
如果我们想知道结构体成员c i d
相对于起始位置的偏移量是几的话,我们就需要offsetof
,在这里offset是偏移量的意思
,而of是谁的偏移量的意思
。它是用来计算结构体成员相对于结构体起始位置的偏移量是几。
注意offsetof不是函数,而是一个宏
。
注意offsetof
头文件是
。
offsetof:size_t offsetof(structName,memberName);
structName中传的不是结构体变量名,而是结构体类型名。
这里拿上述的代码进行举例:
#include
#include
struct S
{
char c;
int i;
double d;
};
int main()
{
printf("%d\n", offsetof(struct S, c));
printf("%d\n", offsetof(struct S, i));
printf("%d\n", offsetof(struct S, d));
return 0;
}
写一个宏,计算结构体中某变量相对于首地址的迁移,并给出说明。
考察:offsetof宏的实现
#include
struct S
{
int a;
char c;
double d;
};
void Init(struct S tmp)//值传递时,tmp是s的一份临时拷贝,改变tmp不会影响s
{
tmp.a = 100;
tmp.c = 'w';
tmp.d = 3.14;
}
int main()
{
struct S s = { 0 };
Init(s);//值传递
return 0;
}
#include
struct S
{
int a;
char c;
double d;
};
void Init(struct S* ps)//传址
{
ps->a = 100;
ps->c = 'w';
ps->d = 3.14;
}
void Print1(struct S tmp)//传值
{
printf("%d %c %lf\n", tmp.a, tmp.c, tmp.d);
}
void Print2(const struct S* ps)//加了const会让ps指向的内容相对比较安全。
{
printf("%d %c %lf\n", ps->a, ps->c, ps->d);
}
int main()
{
struct S s = { 0 };
Init(&s);//传址
Print1(s);
Print2(&s);
return 0;
}
那我们来比较一下是Print1
好一些呢?还是Print2
好一些呢?
事实上,Print2
好一些,传过去的仅仅是一个地址的而已(4个字节),即不管我们的结构体有多大,我们只传4个字节(64位传的是8个字节)过去。
所以说print1和print2函数哪一个好些,答案:首选print2函数。原因:
函数传参的时候,参数是需要压栈的,会有时间和空间的系统开销。
如果传递一个结构体对象的,结构体过大的时候,函数压栈的系统开销较大,所以会导致性能的的下降。
结论:结构体传参的时候,参数是需要压栈的,会有时间和空间上的系统开销。
如果传递一个结构体对象的时候,结构体过大,参数压栈的系统开销比较大,所以会导致性能的下降。
结构体讲完,就得讲讲结构体实现位段
的能力。
首先位段的声明与结构是类似的,但是有两个不同:
1.位段的成员必须是int,unsigned int,或signed int。(但实际上经过大量的测试,包括阅读一些源码的时候会发现,位段的成员不仅仅是这三个,只要是整型就可以了,还可以是short,char,但一般情况是:如果是int,就全是int;如果是char就全是char。不要int和char相互交错。位段的成员类型通常都是相同的类型。当然也有可能有不同的类型,但是它们的大小还是比较相似的。不会有float,double这样的类型出现。)
2.位段的成员名后面有一个冒号和一个数字。
比如:
struct S
{
int a : 2;
int b : 5;
int c : 10;
int d : 30;
};
A就是一个位段类型。
那位段S的大小是多少?看这段代码:
#include
struct S
{
int a : 2;
int b : 5;
int c : 10;
int d : 30;
};
int main()
{
struct S s;
printf("%d\n", sizeof(s));
return 0;
}
大家猜一下程序的运行结果是什么呢?
为什么计算出来的s
的大小是8个字节呢?下面我们逐步地去进行分析:
所谓位段,位段中的位是二进制位的意思。
倘若a
只有四种状态,那么a
占2两个比特位就够了,即两个比特位就能表达4种状态;对于b
来说,5个比特位就够了。所以:
struct S
{
int a : 2;//a只需要占2个比特位
int b : 5;//b只需要占5个比特位
int c : 10;//c只需要占10个比特位
int d : 30;//d只需要占30个比特位
//计算一下总共需要47个比特位,6个字节(48个比特位)就够了,但程序运行的结果是8,为什么呢?
};
要想解决上面的问题,我们需要知道位段的内存分配
。
1.位段的成员可以是
int、unsigned int、signed int或者是char(属于整型家族)
类型。
2.位段的空间是按照需要以4个字节(int
)或者1个字节(char
)的方式来开辟的。
3.位段涉及到很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用段位。
我们还是拿下面这段代码进行举例:
struct S
{
int a : 2;
int b : 5;
int c : 10;
int d : 30;
};
//还有一个就是冒号后面的那个数不能>32的。
我们假设用struct S s;
来开辟空间,每个成员都是整型,其在开辟空间的时候是按int
(即整型)的方式来开辟空间的。一次开辟一个整型空间,一次开辟一个整型空间。总共占了4个字节+4个字节共8个字节大小。
说白了位段就是可以节省空间的。
现在我们来看下面这段代码:
#include
struct S
{
char a : 3;
char b : 4;
char c : 5;
char d : 6;
};
int main()
{
struct S s = {0};
{
s.a = 10;
s.b = 20;
s.c = 3;
s.d = 4;
}
return 0;
}
问题来了:struct S s = { 0 };
中的s
是怎么分配空间的呢?
首先我们发现s
内的元素都是char
类型,那这个地方我们就一个一个字节的开辟空间,一个字节是8个比特位,那我一次就开辟8个比特位的空间。
我们在来看一下struct S s = {0};
中s
的内存空间分配:
那就说明我们刚刚的分析方式是正确的,我们重新整理一下放进去的思路:我们开辟空间的时候因为类型为char
类型,所以一次开辟一个字节大小的空间这个字节呢,是先使用地位再使用高位,我由右向左使用我们的比特位,而我们发现当前开辟空间剩下的那个比特位不能满足我们即将新下来的那个比特位时,我们就把它浪费掉重新开辟新的空间然后去使用,下一个新的空间的使用依然是由低位向高位使用,知道我们把所有的数据都放进去,这个时候就算开辟空间完了。以上就是具体思路。
如果理解了,我们就会明白:原来我们当前编译器是这样开辟空间的。
同时我们也会发现位段的确会节省空间,如果没有位段的话,上述代码中我们至少需要4个字节,而位段的存在可以让我们只需要3个字节。
当然位段虽然可以节省空间,但是也会带来一些问题,上面已经提到过了:位段涉及到很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用段位。
1.int位段被当作有符号数还是无符号数是不确定的。(即最高位是否会被当成符号位是不确定的,C语言也没有给出规定)
2.位段中最大位的数目不能确定。(16位机器最大16,32位机器最大是32,写成27的时候就会在16位机器上出现问题)。
3.位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
4。当一个结构包括两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还时利用剩余的位,这是不确定的。
跟结构相比,位段可以达到同样的效果,同时位段可以很好的节省空间,但是有跨平台的问题存在。
枚举顾名思义就是一一列举。
把可能的取值一一列举。
比如我们日常生活中:
一周的星期一到星期日是有限的7天,可以一一列举。
性别有:男、女、保密,也可以一一列举。
月份有12个月,也可以一一列举。
所以我们发现生活中有一些值的确可以一一列举出来,而这些值它对应的类型就可以称为枚举类型。枚举是一种类型。
enum Day//声明
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
enum Sex
{
//枚举的可能取值
MALE,
FEMALE,
SECRET
};
enum Color
{
RED,
GREEN,
BLUE
};
#include
enum Color
{
RED,//0
GREEN,//1
BLUE//2
};
int main()
{
enum Color c = BLUE;
printf("%d %d %d\n", RED, GREEN, BLUE);
return 0;
}
我们也可以这样:
注意上述代码并不是改枚举常量,而是赋一个初值,即给常量赋一个初始值。
那我们能不能这样来写代码:
**当我们将该程序运行之后并没有报错什么的?那是不是就意味着我们写的代码是正确的呢?
很可惜不是这样的。不报错并不意味着输入的是代码是正确的。编译器之所以没有报错是因为编译器对这里的语法检测不够严格。**那到底是哪里出错了呢?
错误点在这里:enum Color c = 2;
这里我们要清楚的知道enum Color c = 2;
中的2
是int类型
,而enum Color c = 2;
中的c
是枚举类型
。**所以这里左右两端的类型是有差异的。**而如果我们这么写的话,编译器只是没有检测出我们的错误来。但是我举个例子来演示另一种写法:我们把.c
文件改成.cpp
文件。这里的cpp
就是C++
的意思。即:
我们发现当我们把.c文件改为.cpp文件之后编译器就报错了
。因为C++
的语法点更加的严格,所以这里就直接报错了。所以.c
文件中不报错并不代表着语法点是正确的。
所以在未来我们给枚举变量赋值的时候,要赋它的可能取值,这也是最合理最标准的一种写法。
那我们在来重新回顾一下:枚举是什么呢?枚举是一个类型,可以通过枚举类型创建枚举变量,而枚举创建这个变量的可能取值,我们之前在{}中就已经规定好了;{}中放的就是枚举的可能取值。枚举的应用场景一般就是把那些可以把未来的一些取值可以一一列举出来的这些值定义成枚举类型,然后有创建枚举变量,这就是枚举。
enum Color
{
RED,//0
GREEN,//1
BLUE//2
};
有人说定义这个枚举类型,无非就是代码里的0、1、2
吗?,感觉枚举类型没啥必要吗,我们直接这样做不行吗?请看:
#include
#define RED 0
#define GREEN 1
#define BLUE 2
int main()
{
int color = RED;
return 0;
}
但是我们还是推荐使用枚举这种写法,那使用枚举有那些好处呢?
我们来看一下枚举的优点
为什么使用枚举?
我们既然可以使用#define定义变量
,那为什么非要使用枚举?枚举的优点:
1.增加代码的可读性和科维护性。
2.和#define
定义的标识符比较枚举有类型检查,更加严谨。
3.防止命名污染(封装)。
4.便于调试
5.使用方便,一次性可以定义多个变量。
我们先来简单说一下一个源文件变成可执行程序的过程中经过了什么:
C语言的源代码—预处理(也叫预编译)—>编译—>链接—>可执行程序
而预编译就是来处理刚刚代码中的这一部分的,即:
#define RED 0
#define GREEN 1
#define BLUE 2
即未来代码扫描的时候把RED全部改为0,把GREEN全部改为1,把BLUE全部改为2
,这就是预处理阶段要做的事情,当然预处理阶段不仅仅只是做这一件事,比如说注释删除(倘若我们的写了一些注释,当预编译的时候会把所有的注释都删掉)让我们把这些冗余的东西都做完之后,就会进入编译这一阶段。
所以预编译就是做了刚刚的替换工作,那替换的时候我们压根就不知道什么类型,压根就不知道类型的概念在里头,当替换了之后我们才知道说刚刚替换的RED
原来是0
,而0
是整型,知道这里才有类型的概念,即替换之前是没有类型的概念的。没有类型其实就不够严谨。
而如果我们用枚举的方式去走的话Color
就是一种类型,RED、GREEN、BLUE
也是有类型的,有类型就有限制,有限制就会更加的严谨。
对于枚举优点的第4点,你看到的就是你调试的,你调试的就是你看到。它不会像define
那样会做一些替换。在调试的时候已经不再是我们看到的那些符号了,而是替换之后的内容。而#define定义的符号是直接替换的,我们调试的内容、执行的内容,和我们看到的执行的代码可能是有区别的,此时不易于我们发现代码中的问题。
对于第五点:#define
定义的时候需要写多个#define
,而枚举可以一口气定义多个。
以上就是枚举的优点。我们在写代码的时候可以慢慢去领悟感悟这些优点。
#include
enum Sex
{
MALE,
FEMALE,
SECRET
};
int main()
{
enum Sex s = MALE;
printf("%d\n", sizeof(s));
return 0;
}
联合既可以叫联合体,也可以叫共用体。
联合也是一种特殊的自定义类型,这种类型定义的变量也包含一系列的成员,特征是这些成员公用一块空间(所以联合也叫共用体)。
#include
#include
//联合类型的声明
union Un
{
char c;
int i;
};
int main()
{
//联合定义的变量
union Un u;
printf("%d\n", sizeof(u));
return 0;
}
可以发现程序运行结果为4
。那为什么呢?我们再来看一段代码:
#include
union Un
{
char c;
int i;
};
int main()
{
union Un u;
printf("%d\n", sizeof(u));
printf("%p\n", &u);
printf("%p\n", &(u.c));
printf("%p\n", &(u.i));
return 0;
}
联合体中是这样的,里面确实是有几个成员但是这些成员一定会共同同一块空间。
联合的成员是公用一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联合至少得有能力保存最大的那个成员)。
那还有一个点:既然联合体会把它的成员共用同一块空间,那我们能不能i成员
和c成员
同时使用呢?显然是不可能的,因为改c
的时候i
就改了,改i
的时候c
就改了。所以在同一时刻,i和c是不能同时使用的。
首先我们要知道低位低地址是小端,高位低地址是大端。
#include
int main()
{
int a = 1;
if (*(char*)&a == 1)
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
上述判断的方式比较挫,那我们现在来分装一个函数来判断大小端:
#include
int check_sys()
{
int a = 1;
return *(char*)&a;
}
int main()
{
int ret = check_sys();
if (ret == 1)
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
现在我们通过联合来判断大小端:
#include
int check_sys()
{
union Un {
char c;
int i;
}u;
u.i = 1;
//返回1是小端
//返回0则是大端
return u.c;
}
int main()
{
int ret = check_sys();
if (ret == 1)
{
printf("小端\n");
}
else
{
printf("大端\n");
}
return 0;
}
这里我们巧妙的运用了联合的特点。
1.联合的大小至少是最大成员的大小。
2.当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。
比如:
#include
union Un
{
int a;
char arr[5];
};
int main()
{
union Un u;
printf("%d\n", sizeof(u));
return 0;
}
现在我们在来回忆一下:结构体存在对齐;位段是不存在对齐的;枚举也是不存在对齐这个概念的;而联合体也是存在对齐的。
现在,结构体、枚举、联合三大部分到这里就彻底结束了。
本文至此结束!!!
感谢!!!