前面学到了C语言中的内置类型:char,int…
C语言中还有一种类型是自定义类型,其中包括了结构体,联合体和枚举。
那么今天来学习自定义类型中的结构体类型。
本章重点
- 结构体类型的声明
- 结构体的自引用
- 结构体变量的定义和初始化
- 结构体的内存对齐
- 结构体传参
- 结构体实现位段(位段的填充&可移植性)
目录
结构体
1.结构体的声明
1.1 结构基础知识
结构是一些值的集合,这些值称为成员变量,结构的每个成员可以是不同类型的变量。
1.2 结构的声明
下里面来定义一个描述学生的类型:
struct stu
{
//学生的相关属性
char name[20];//成员name
int age;//成员age
} s1,s2;
结构体标签一般是关于结构体是用来干什么的,s1和s2是 struct stu 类型的变量。
注意:结构体只是创造出来的一中类型,不用初始化。
上面的代码也可以这样写,省去变量列表,也是没问题的。
struct stu
{
//学生的相关属性
char name[20];//成员name
int age;//成员age
};
若此时结构体在main函数里面,s1和s2是局部变量;若在main外面是全局变量。
#include<stdio.h>
int main()
{
struct stu
{
//学生的相关属性
char name[20];//成员name
int age;//成员age
} s1, s2;//局部变量
return 0;
}
#include<stdio.h>
struct stu
{
//学生的相关属性
char name[20];//成员name
int age;//成员age
} s1, s2;//全局变量
int main()
{
return 0;
}
struct stu 是声明的一个结构体类型,我们还可以拿他创建一个s3变量。所创建出来的s3是局部变量。
#include<stdio.h>
struct stu
{
//学生的相关属性
char name[20];//成员name
int age;//成员age
} s1, s2;//全局变量
int main()
{
struct stu s3;//局部变量
return 0;
}
外面可以在创建结构体的同时创建变量。也可以在main里利用结构体创建变量。但是一定要注意的是结构体的分号不能丢。
1.3 特殊的声明
在声明的时候,可以不完全声明
结构体的名字可以省略,变成 匿名结构体类型
//匿名结构体类型
struct
{
//学生的相关属性
char name[20];//成员name
int age;//成员age
} s1, s2;
匿名结构体的使用
//匿名结构体类型
struct
{
//学生的相关属性
char name[20];//成员name
int age;//成员age
} s1;
int main()
{
return 0;
}
s1是用匿名结构体创建的变量。
注意:这个变量只能使用一次 - 声明类型的时候所创建的变量s1以后就用不了。
struct
{
int a;
char b;
float c;
} x;
struct
{
int a;
char b;
float c;
} a[20], *p;
上面的两个结构体在声明的时候省略掉了结构体标签
那么问题来了?
在上面代码的基础上,下面的代码合法吗?
p = &x;
代码演示:
struct
{
int a;
char b;
float c;
} x;
struct
{
int a;
char b;
float c;
} a[20], *p;
int main()
{
p = &x;
return 0;
}
代码虽然跑过了,但是会报警告。
原因:因为编译器会把上面的两个声明当做是两个完全不同的类型。
1.4 结构的自引用
在结构中包含是一个类型为该结构本身的成员
数据结构是数据在内存中的存储结构,有线性,和树形(二叉树)。
其中线性又分为顺序表和链表。
其中顺序表是数据在内存中连续存放的,通过找到第一个数据就可以找到后面的数据。
但是链表的数据不是连续存放的,那么就需要通过上一个数据能找到下一个数据,那么这是后可以通过结构体的自引用
但是,下列结构体的自引用是错误的
错误写法:
struct Dode
{
int data;
struct Dode next;//自引用
};
int main()
{
sizeof(struct Dode);
return 0;
}
未来定义一个结点的时候,这个结点既可以放一个数值,又可以放下一个结点。这样就可以做到1结点找到2结点,2结点找到3结点,直到找到5结点,但是这样写存在问题。
结构体里面有一个date,还有一个next;而next又是struct Dode类型的,所以next也有有个next,在这里会一直套下去,sizeof无法计算大小,程序会报错。
正确写法:
可以在一个结点中存放数据和下一个结点的地址
struct Dode
{
int data;
struct Dode* next;
};
int main()
{
sizeof(struct Dode);
return 0;
}
这个时候结点中存放的是一个数据和下一个结点的地址,用来存放数据的叫数据域,存放下一个结点的地址的叫指针域。
这个时候我们就可以把一个一个的数据串联起来,形成一个链表了。
总结:
结构体里面包含一个同类型的结构体是不行的,要包含一个的结构体指针。
1.5 结构体变量的定义和初始化
有了结构体类型,那该如何定义变量呢?
struct point
{
int x;
int y;
}p1 = { 2, 3 };
用结构体定义变量就像是用图纸盖房子,即使是有了图纸也不能直接盖房子,还需要有砖头、水泥等材料和工具才行。结构体就相当于是图纸,所创建的成员变量就相当于是砖头和水泥。利用这几样定义了一个p1变量,这叫定义;在定义变量的时候赋值,这叫初始化。上面2,3就是给p1赋的值,可以把p1看成一座房子,而2,3则是房子里的家具。
定义:
struct point
{
int x;
int y;
}p1;//声明结构体的同时定义p1变量
int main()
{
struct point p2;//定义结构体变量p2
}
可以在声明结构体类型的时候定义变量,也可以用所声明的类型定义变量。
初始化:
struct stu
{
char name[20];
int age;
} p3 = { 1,2,3 };
int main()
{
struct stu s = { "lisi", 20 };
return 0;
}
在定义变量之后紧跟着赋值。
1.6 结构体内存对齐
结构体的大小如何计算?
代码结果:
这两段代码只是成员变量的顺序变了其他的都一样,那为什么结果不一样呢?
结构体的对齐规则
- 第一个成员在与结构体变童偏移量为0的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。对齐数=编译器默认的-一个对齐数与该成员大小的较小值。(VS中默认的值为8)
- 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
代码1:
struct S1
{
char c1;
int i;
char c2;
};
int main()
{
printf("%d\n", sizeof(struct S1));
return 0;
}
代码结果:
- 根据对起规则知S1中的c1存放在偏移量为0处。占用一个字节即0.
- 因为int类型为4个字节小于8,那么该成员S1中的 i 的对齐数是4,即 i 要存放在4的倍数的偏移量处,即存放在偏移量为4处。占用了四个字节即4~7
- 因为char类型为1个字节小于8,那么该成员i的对齐数是1,即i要存放在1的倍数的偏移量处,即存放在偏移量为8处。占用一个字节即8。
- 计算上面的字节数为9,不是该结构体总大小为最大对齐数4的倍数,那么要继续增加到12,那么结构体的大小就是12。
对齐规则很重要!很重要!很重要!
代码2:
struct S2
{
char c1;
char c2;
int i;
};
int main()
{
printf("%d\n", sizeof(struct S2));
return 0;
}
代码结果:
- 根据对起规则知c1存放在偏移量为0处。占用一个字节即0.
- 因为char类型为1个字节小于8,那么该成员i的对齐数是1,即i要存放在1的倍数的偏移量处,即存放在偏移量1处。占用一个字节即1。
- 因为int类型为4个字节小于8,那么该成员i的对齐数是4,即i要存放在4的倍数的偏移量处,即存放在偏移量为4处。占用了四个字节即4~7
- 计算上面的字节数为8,是该结构体总大小为最大对齐数4的倍数,结构体的大小就是8。
下面给出两个练习题,来练习巩固前面讲的对齐规则。要注意的是练习2中的S4有一个成员是结构体(S3),S4的大小就包括了S3的大小。
练习1:
struct S3
{
double d;
char c;
int i;
};
int main()
{
printf("%d\n", sizeof(struct S3));
return 0;
}
代码结果是,16
练习2:
struct S3
{
double d;
char c;
int i;
};
struct S4
{
char c1;
struct S3 s3;
double d;
};
int main()
{
printf("%d\n", sizeof(struct S3));
printf("%d\n", sizeof(struct S4));
return 0;
}
代码结果是,32
为什么存在内存对齐
1.平台原因(移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取
某些特定类型的数据,否则抛出硬件异常。
2.性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。
总结:结构体的内存对齐是拿空间来换取时间的做法。
在设计结构体的时候,尽量让占用空间小的成员尽量集中在一起,因为这样既可以满足对齐,又可以节省空间。
在使用的时候,尽量用S2来代替S1的写法,因为可以节省更多的空间。
1.7 修改默认对齐数
#pragma 这个预处理指令,这里我们再次使用,可以改变我们的默认对齐数。
一般修改默认对齐数都为2的倍数。
struct S
{
int i;
double d;
};
int main()
{
printf("%d\n", sizeof(struct S));
return 0;
}
代码结果:
默认对齐数是8,所以代码结果是16
#pragma pack(4)//将对齐数改为4
struct S
{
int i;
double d;
};
#pragma pack()//将对齐数改为默认值8
int main()
{
printf("%d\n", sizeof(struct S));
return 0;
}
代码结果:
修改对齐数后,代码输出12.
1.8 结构体传参
struct S
{
int date[1000];
int num;
};
void print1(struct S ss)
{
int i = 0;
for (i = 0; i < 3; i++)
{
printf("%d ", ss.date[i]);
}
printf("%d\n", ss.num);
}
void print2(struct S* ps)
{
int i = 0;
for (i = 0; i < 3; i++)
{
printf("%d ", ps -> date[i]);
}
printf("%d\n", ps -> num);
}
int main()
{
struct S s = { {1,2,3}, 100 };
print1(s);
print2(&s);
return 0;
}
代码结果:
print1 和 print2 哪个函数的效果更好呢?
答案是:print1
函数传参的时候。参数是需要压栈的,会有时间和空间上的系统开销。如果传递一个结构体对象的时候,结构体过大,参数压栈的系统开销比较大,所以会导致性能的下降。
结论:结构体传参的时候,要传结构体的地址。
参数具体是怎样压栈的,我在其它的文章中有做详细讲解,下面是链接。
函数的栈帧的开辟与销毁:http://t.csdn.cn/2HSWx
2.位段
位段是有结构体实现的
1.什么是位段
位段的声明和结构是类似的,有两个不同:
- 位段的成员必须是 int unsigned int 或signed int。
- 位段的成员名后面有一个冒号的数字。
注意:成员后面的数字不能超过类型的大小。若a是 int 类型,数字最大是32。
实际上 char 也可以是位段的成员,因为字符在底层存储的是 ASCII 码,并且只要是整形家族就都可以。
例1:
struct A
{
int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
位段的位其实是比特位的意思。
a后面的数字表示,a只需要2个比特位。
a后面的数字表示,b只需要5个比特位。
a后面的数字表示,c只需要10比特位。
a后面的数字表示,d只需要30个比特位。
4个成员明明是 int 类型,应该是32比特位,那2、5、10、30又是什么意思呢?
比如说_a不需要那么多的空间,所以只分配了2个比特位的空间,也就是用多少取多少。
这里也就可以看出,位段其实是用来节省空间的。
例2:
#include<stdio.h>
struct A
{
int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
int main()
{
printf("%d\n", sizeof(struct A));
return 0;
}
代码结果:
1个整形4个字节,4个整形应该是16个字节,那结果为什么会是8呢?4个成员一共是47个比特位,那给6个字节,就够用了,应该输出的是6,但是结果也不对。
注意:所谓的节省空间并不是极致的节省空间,而是适当的节省空间。如果不使用位段的话,那将会开辟16个字节的空间。
具体是什么原因。在下面的位段的内存分配有做详细介绍。
2.位段的内存分配
- 位段的成员可以是int unsigned int signed int 或者是char (属于整形家族) 类型
- 位段的空间上是按照需要以4个字节( int)或者1个字节( char)的方式来开辟的。
- 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
前面例2的解释:因为先开辟4个字节的空间,一共是32个比特位。4个成员一共有47个比特位,分配完之后还剩下15个比特位没有空间存放了,所以在开辟4个字节给剩下的15个比特位使用,所以才是8个字节。
一般都是位段的类型都会是同一种类型,要不然的话,这个位段会非常复杂。
位段是如何分配空间的
例3:
struct S
{
char a : 3;
char b : 4;
char c : 5;
char d : 4;
};
int main()
{
printf("%d\n", sizeof(struct S));
return 0;
}
情况1:
- 看见是char,所以先开辟1个字节,a占3个比特位,还剩下5个比特位。
- b有4个比特位,给b分配后还剩1个比特位,不够c分配了,所以把剩下的1个比特位舍弃。
- 再开辟1个字节,给c分配完之后还剩下3个,不够d分配了,还要再开辟1个字节给d。
如果输出3则说明是情况1,空间不够的话会直接开辟新的空间,多余出来的比特位会直接浪费掉。
情况2:
- 先开辟1个字节,给a分配完以后,还剩5个比特位。
- 给b分配完后,还剩1个比特位,再开辟1个字节,加上前面剩下的就还有9个比特位。
- 给c用完后还剩4个比特位,这个时候可以正好给d分配。
如果输出2则说明是情况2,空间不够的话会直接再开辟1个字节的空间,之前剩下的空间也会继续参与分配。
代码结果:
例4:
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;
}
存储前的内存:
存储后的内存:
- a的二进制序列是1010,但是a只有3个比特位的空间,所以只会存放010。
- 这个字节还剩下5个比特位,而b二进制序列是1100,剩下的空间可以放得下。
- 这个时候不够存c了,需要再开辟一个空间。
- c的二进制是011,但是不够5个比特位所以要补0,变成00011。
- 因为只剩下3个比特位的空间,不够d使用了,所以要再开辟1个空间
- 4的二级制序列是0100,空间正好够用。
3.位段的跨平台问题
- int位段被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目不能确定。(1 6位机器最大16, 32位机器最大32,写成27, 在16位机器会出问题。
- 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时, 是舍弃剩余的位还是利用,这是不确定的。
总结:
跟结构相比, 位段可以达到同样的效果,但是可以很好的节省空间,但是有跨平台的问题存在。