本文意在介绍C语言里的常规自定义类型,它是C语言里最重要的概念之一,是我们从简单使用C语言到综合运用必不可少的知识之一,在C语言中具有重要的地位和作用,掌握自定义类型的使用方法和技巧对于写出高质量的C程序是非常重要的。
本章重点
结构体
结构体类型的声明
结构的自引用
结构体变量的定义和初始化
结构体内存对齐
结构体传参
结构体实现位段(位段的填充&可移植性)
枚举
枚举类型的定义
枚举的优点
枚举的使用
联合
联合类型的定义
联合的特点
联合大小的计算
C语言里已经内含了一些基本的数据类型(整型,字符型等),但在实际编程中,我们会碰到一些复杂的数据类型,例如描述一个学生,或者是一辆汽车等一些实际事物光靠基础的类型是不能简单描述的,这时候结构体就派上了用场。
结构体说白了,就是数据的集合,里面的成员可以有多种类型,例如描述一个学生,得有名字,性别,年龄,学号等一些信息,这时就可以用结构体来进行定义。
结构体定义如下
定义一个学生:
struct Stu
{
char name[20];//名字
int age;//年龄
char sex[5];//性别
char id[20];//学号
}; //分号不能丢
上面是常规的声明,缺点是每次定义时都要将struct关键字写入,影响编写效率,下面有一种特殊的声明,此时省略了结构体标签(匿名结构体类型,只能使用一次)
//匿名结构体类型
struct
{
int a;
char b;
float c;
}x;
struct
{
int a;
char b;
float c;
}a[20], *p;
要注意,这时候定义的结构体变量(x,a【20】,*p)都是全局变量,而在主函数里进行定义则是局部变量(对主函数全局)。
那么问题来了,如果我此时再加上p = &x这一行代码,阁下又该如何应对呢?
可以试着编译一下,运行是没有问题的,但编译器会报警告,尽管两个结构体组成是一样的,但编译器会把它们当作不同的类型进行编译,这种做法不建议。
既然结构体能存放不同的类型,那能不能存放结构体类型呢?
答案是可以的。
//代码1
struct Node
{
int data;
struct Node next;
};
//可行否?
如果可以,那sizeof(struct Node)是多少?
编译一下你会发现,甚至都无法编译,这相当于一个结构体里存放一个自己的结构体,同时这个结构体也可能会存放和自己的结构体,大小根本计算不了,那该如何引用呢?
那就引用地址嘛,通过地址就可以找到该结构体并进行引用,而同时存放地址的指针在编译器里的大小是确定的,这样一来也能计算该结构体的大小。
//代码2
struct Node
{
int data;
struct Node* next;
};
讲到这里我们再看看下面一段代码
//代码3
typedef struct
{
int data;
Node* next;
}Node;
//这样写代码,可行否?
答案显然是不行的,虽然是匿名结构体,但体内已经有了Node类型的指针,后面才生成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 = {x, y};
struct Stu //类型声明
{
char name[15];//名字
int age; //年龄
};
struct Stu s = {"zhangsan", 20};//初始化
struct Node
{
int data;
struct Point p;
struct Node* next;
}n1 = {10, {4,5}, NULL}; //结构体嵌套初始化
struct Node n2 = {20, {5, 6}, NULL};//结构体嵌套初始化
前面我们留下了一个问题,就是关于结构体的大小应该如何计算,同时这也是一些大厂笔试特别热门的考点:结构体内存对齐
下面介绍一下结构体的对齐规则:
1.第一个成员在与结构体变量偏移量为0的地址处(偏移量就是地址相较于起始地址的差值)
2.其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处,对齐数=编译器默认的一个对齐数与该成员大小的较小值。
3.结构体总大小为最大对齐数(每一个成员变量都有一个对齐数)的整数倍。
4.如果嵌套了结构体的情况,嵌套的结构对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
看到这里,你可能还有一点懵,我们来个例子解释一下:
struct S1
{
char c1;
int i;
char c2;
};
printf("%d\n", sizeof(struct S1));
我们来分析一下:(在vs环境里默认对齐数是8)
char类型在内存里占1个字节,由于起始地址是0,与8比较起来1较小,所以对齐数是1,而内存起始地址我们设为0,可以看出,结构体的第一个成员永远放在内存的起始地址。
接下来是int类型,在内存中占4个字节,但0的下一位就是1,不是4的整数倍,根据对齐规则,就得对齐到4的位置进行存放4个字节。
最后是char,和第一个一样,直接存放下一个(8)即可。
现在结构体占的大小是0~8,一共九个字节,而结构体成员的最大对齐数是4,还得对齐到4的整数倍上才能算结构体的大小,就是12.
很多人会有疑问了,为什么会存在内存对齐这种说法呢?
结构体内存对齐是为了使结构体的访问更加高效。当结构体中的字段内存对齐后,CPU 可以更快地访问字段所对应的内存地址,因为它们与 CPU 的缓存结构更加匹配。如果结构体的字段没有进行内存对齐,则会导致 CPU 访问内存的效率较低,这会影响程序的性能。
此外,一些计算机体系结构需要结构体内存对齐才能正确工作。例如,一些处理器需要对 4 字节或 8 字节的内存地址进行访问,这意味着结构体中的字段必须按照 4 字节或 8 字节的边界进行对齐才能被正确访问。
因此,结构体内存对齐是为了提高程序的性能和可靠性,确保结构体中的字段可以被正确访问。
简单的来说就是:内存对齐是一种舍弃空间换取时间的方法。
不同的编译器默认的对齐数是不一样的,但可以通过 #pragma 这个预处理指令,改变我们的默认对齐数。
#include
#pragma pack(8)//设置默认对齐数为8
struct S1
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
#pragma pack(1)//设置默认对齐数为1
struct S2
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
int main()
{
//输出的结果是什么?
printf("%d\n", sizeof(struct S1));
printf("%d\n", sizeof(struct S2));
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;
}
传结构体和传地址都能实现传参的功能,但那一种会比较好呢?
函数传参传地址和传变量是两种不同的方式。
当使用传地址方式时,函数的参数将是指向变量内存地址的指针。这意味着函数将直接访问变量的内存地址,对变量的操作将在原始地址上进行。这种方式通常用于需要在函数内部修改变量的情况。这种方式可以避免在函数内部对变量进行拷贝,从而提高性能和效率。
当使用传变量方式时,参数是变量本身。这意味着函数将使用变量的副本进行操作,并不会直接改变原始变量。这种方式通常用于不需要修改变量的情况,或者对变量进行操作时不需要改变原始值的情况。
总的来说,传地址方式更加灵活,可以实现更复杂的操作,但需要注意避免因为指针操作不当而导致的错误。传变量方式相对简单,使用起来更为直观,但不能直接在函数内部修改变量的值。
位段是一种数据结构,它允许程序员在内存中为字段指定特定数量的位数,而不是以字节为单位。这样做有时可以节省内存空间。
在C语言中,可以使用位段来定义一个包含多个字段的结构体。例如,假设我们要定义一个结构体来存储一个16位的数据包,其中包含4个不同的字段,每个字段分别占用4位,可以使用位段来定义这个结构体。
需要注意的是,使用位段可能会导致一些不便之处。例如,不能使用 sizeof
运算符来计算结构体的大小,因为它计算的是按字节对齐的大小。而且不同编译器可能会对位段的实现有所不同,导致可移植性问题。因此,使用位段时需要仔细考虑其适用性和安全性。
位段的声明
struct A
{
int _a:2;
int _b:5;
int _c:10;
int _d:30;
};
位段的内存分配
首先要知道的就是位段是比特为单位进行分配空间的
举个例子
如下图,a是char类型,占1个字节(8比特)在主函数里,给a赋值10,但位段要求,只能保留3为比特位,所以要进行截取保留3位,以此类推,当存放的位数已满足一个字节或剩余的比特位空间不够,此时就得再开辟一个字节进行存储。
位段在不同编译器和不同平台上的实现是有所不同的,这可能会导致跨平台问题。
最常见的问题之一是,如何对位段进行按位运算。在一些平台上,位段是定义为无符号整数,可以直接进行按位运算;但在另一些平台上,则需要将位段转换为整数类型,才能进行按位运算。
此外,位段的顺序和字节对齐方式也可能会发生变化。例如,在某些平台上,位段的顺序是从左向右,而在其他平台上,顺序是从右向左。同时,一些平台可能会对位段进行字节对齐,而其他平台则不会。
为了避免位段的跨平台问题,可以采取以下措施:
1. 避免在位段中使用多个类型。
2. 明确指定位段的顺序和字节对齐方式。
3. 避免使用位段进行按位运算,或者使用平台无关的按位运算规则。
4. 在不同平台上进行测试和调试,确保代码的可移植性和正确性。
总之,位段虽然能够节省内存空间,但也需要考虑其在不同平台上的实现和兼容性,以保证代码的正确性和可移植性。
枚举顾名思义就是一一枚举
enum Day//星期
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
enum Sex//性别
{
MALE,
FEMALE,
SECRET
};
enum Color//颜色
{
RED,
GREEN,
BLUE
};
上面的Day、Sex、Color、都是枚举类型,括号里的叫做枚举常量,这些常量都是有值的,默认从0开始,一次递增1,也可以在定义的时候进行赋值。
enum Color//颜色
{
RED=1,
GREEN=2,
BLUE=4
};
程序可读性强。枚举类型可以使用具有意义的符号名称来表示常量,使得程序的可读性更高,增加代码的可维护性。
减少代码中的魔数。枚举类型可以减少代码中出现的“魔数”(没有明确含义的数字),从而提高代码的可读性和可维护性。
编译器提供类型检查。枚举类型被视为一种类型,因此编译器可以进行类型检查,从而避免一些常见的错误,例如将一个枚举类型的值赋给另一个类型的变量。
枚举类型可以实现类型安全的类型别名。实际上,枚举类型可以用来实现一些类型安全的类型别名,例如使用枚举类型来定义一个有限的整数集合。
enum Color//颜色
{
RED=1,
GREEN=2,
BLUE=4
};
enum Color clr = GREEN;//只能拿枚举常量给枚举变量赋值,才不会出现类型的差异。
clr = 5; //ok??
联合体是一种特殊的自定义类型,这种类型定义的变量也包含一系列成员,特征是这些成员公用一块空间。
//联合类型的声明
union Un
{
char c;
int i;
};
//联合变量的定义
union Un un;
//计算连个变量的大小
printf("%d\n", sizeof(un));
联合体的成员是共用一块内存空间的,所以一个联合变量的大小,至少是最大成员的大小。
要注意的问题:
1.联合的大小至少是最大成员的大小
2.当最大成员大小不是最大对齐数的整数倍时,就要对齐到最大对齐数的整数倍。