C 语言自定义类型:结构体、位段、枚举与联合体

C 语言自定义类型:结构体、位段、枚举与联合体

C 语言的基本类型能够描述一个数字、一个字符或一个地址,但真实程序处理的往往是更完整的对象:学生有姓名和年龄,链表节点有数据和下一个节点,文件状态有一组有限的选项,同一块内存又可能在不同场景下表示不同类型的值。

结构体、位段、枚举和联合体正是用来描述这些场景的工具。它们都属于自定义类型,但解决的问题不同:

  • 结构体把多个成员组合成一个整体。
  • 位段把整数中的若干比特分配给不同字段。
  • 枚举给有限的离散取值起上有意义的名字。
  • 联合体让多个成员共享同一块内存。

理解这四类类型时,要同时关注声明语法、内存布局、生命周期和可移植性。

一、结构体:把相关数据组织起来

结构体是一组成员的集合,每个成员可以使用不同的类型:

c 复制代码
struct Student {
    char name[20];
    int age;
    char gender[8];
    char id[20];
};

上面的声明创建了一个名为 struct Student 的类型。它描述了学生对象的组成方式,但还没有创建具体变量:

c 复制代码
struct Student student;

结构体声明结尾的分号不能省略。也可以在声明类型的同时定义变量:

c 复制代码
struct Point {
    int x;
    int y;
} point1;

struct Point point2;

point1 与 point2 都是 struct Point 类型的对象,只是定义时机不同。

使用 typedef 简化类型名称

如果不希望每次都写 struct Student,可以定义类型别名:

c 复制代码
typedef struct Student {
    char name[20];
    int age;
} Student;

Student student;

这里的 Student 是别名,struct Student 仍然是结构体标签对应的原始类型名称。类型别名能让函数参数和变量声明更加简洁,但不会改变内存布局。

匿名结构体

结构体标签可以省略:

c 复制代码
struct {
    int x;
    int y;
} point;

这种写法适合只使用一次的局部数据类型。但每次单独写出的匿名结构体都是不同的类型,即使成员完全相同,也不能因此认为它们可以相互赋值。如果多个对象需要共享同一种结构体类型,应使用结构体标签或 typedef。

二、结构体初始化和嵌套

结构体变量可以在定义时按成员顺序初始化:

c 复制代码
struct Point point = {10, 20};

也可以使用指定成员初始化:

c 复制代码
struct Point point = {
    .x = 10,
    .y = 20
};

结构体成员还可以是数组、指针或另一个结构体:

c 复制代码
struct Point {
    int x;
    int y;
};

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

struct Node node = {
    .data = 10,
    .position = {.x = 4, .y = 5},
    .next = NULL
};

访问结构体变量的成员使用点运算符,访问结构体指针指向的成员使用箭头运算符:

c 复制代码
node.data = 20;
struct Node *ptr = &node;
ptr->data = 30;

ptr->data 等价于 (*ptr).data。因为点运算符的优先级高于一元 *,手写展开时必须保留括号。

三、结构体自引用与链式数据结构

结构体不能直接包含一个自身类型的成员,否则对象大小会无限递归:

c 复制代码
/* 错误:对象大小无法确定 */
struct Node {
    int data;
    struct Node next;
};

但结构体可以包含指向自身的指针,因为指针大小是确定的:

c 复制代码
struct Node {
    int data;
    struct Node *next;
};

这就是链表节点常用的定义方式。使用 typedef 定义自引用结构体时,必须先写出结构体标签:

c 复制代码
typedef struct Node {
    int data;
    struct Node *next;
} Node;

结构体定义完成后,外部代码可以使用简洁的 Node *。

四、结构体内存对齐

结构体成员在内存中通常按照声明顺序排列,但编译器可能在成员之间插入填充字节,这就是内存对齐。

常见规则可以概括为:

  1. 第一个成员的偏移量为 0。
  2. 后续成员的偏移量需要是其对齐数的整数倍。
  3. 成员的对齐数通常取编译器默认对齐数和成员大小中的较小值。
  4. 结构体总大小需要是结构体最大对齐数的整数倍。
  5. 嵌套结构体要先按照自身的对齐要求放置,外层结构体还要满足整体对齐要求。

以两个成员顺序不同的结构体为例:

c 复制代码
struct WithPadding {
    char c1;
    int value;
    char c2;
};

struct LessPadding {
    char c1;
    char c2;
    int value;
};

在常见编译器和默认对齐设置下,WithPadding 中的 int 前后可能产生填充,而 LessPadding 把两个字符成员放在一起,通常可以减少浪费。成员类型和数量相同,声明顺序不同,结构体大小也可能不同。

可以使用 offsetof 查看成员偏移:

c 复制代码
#include <stddef.h>
#include <stdio.h>

struct Record {
    char flag;
    int value;
    double score;
};

int main(void)
{
    printf("flag: %zu\n", offsetof(struct Record, flag));
    printf("value: %zu\n", offsetof(struct Record, value));
    printf("score: %zu\n", offsetof(struct Record, score));
    printf("size: %zu\n", sizeof(struct Record));
    return 0;
}

内存对齐主要是为了兼容硬件访问规则和提高处理器访问效率,本质上是用空间换时间。设计结构体时,可以把占用空间相近或较小的成员集中放置,但不要只凭经验猜测大小,应通过 sizeof 和 offsetof 验证。

部分编译器支持 pragma pack 修改默认对齐数:

c 复制代码
#pragma pack(push, 1)
struct PackedHeader {
    char type;
    int length;
    short version;
};
#pragma pack(pop)

降低对齐数可以减少结构体大小,但可能导致未对齐访问,影响性能或移植性。它更适合布局明确的文件格式和通信协议,使用时必须结合目标编译器验证。

五、结构体传参

结构体可以按值传递,但这会复制整个对象:

c 复制代码
void print_record(struct Record record)
{
    printf("%d\n", record.value);
}

结构体较小时,按值传递简单直观;如果结构体包含很大的数组或许多成员,复制会增加参数传递的时间和栈空间开销。更常见的方式是传递结构体地址:

c 复制代码
void print_record(const struct Record *record)
{
    printf("%d\n", record->value);
}

调用时传入地址:

c 复制代码
struct Record record = {0};
print_record(&record);

如果函数只读取结构体,建议使用 const;如果函数需要修改调用者的结构体,则使用普通的结构体指针。

六、位段:把整数拆成更细的字段

位段允许在结构体中指定某个整型成员占用的比特数:

c 复制代码
struct Flags {
    unsigned int readable : 1;
    unsigned int writable : 1;
    unsigned int executable : 1;
    unsigned int priority : 3;
};

位宽表示比特数而不是字节数,适合表示权限标志、硬件寄存器字段或取值范围很小的状态。位段通常有这些限制:

  • 成员类型通常是 int、unsigned int、signed int,部分实现也支持 char 或其他整型类型。
  • 位宽不能超过底层分配单元的位数。
  • 位段不能直接取地址。
  • 位段的具体布局与编译器、目标平台和字节序有关。

不同实现可能在有符号位段的解释方式、位段分配方向、跨存储单元时的处理方式上存在差异。因此位段虽然节省空间,但可移植性较弱。跨平台协议应使用明确的整数类型和位运算手动编码。

七、枚举:给有限取值命名

枚举用于表示一组有限且互斥的取值,例如星期、颜色和设备状态:

c 复制代码
enum Day {
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY,
    SUNDAY
};

默认情况下,第一个枚举常量的值为 0,后续成员依次加 1。也可以显式指定值:

c 复制代码
enum Permission {
    PERMISSION_READ = 1,
    PERMISSION_WRITE = 2,
    PERMISSION_EXECUTE = 4
};

定义枚举变量:

c 复制代码
enum Day today = WEDNESDAY;

枚举能提高可读性和可维护性,把相关常量组织在同一个类型中,调试时也更容易理解。枚举常量本质上参与整数表达式,但外部输入仍应检查是否属于有效取值,不能把任意整数直接当成合法状态。

八、联合体:多个成员共享一块内存

联合体又称共用体,它的所有成员从同一个地址开始存放,共享同一块内存:

c 复制代码
union Value {
    int integer;
    float decimal;
    char character;
};

给某个成员赋值后,可以通过该成员读取对应数据;如果随后给另一个成员赋值,原来成员的字节就可能被覆盖:

c 复制代码
union Value value;
value.integer = 42;
value.decimal = 3.14f;

联合体最适合表示同一时刻只使用一种类型的数据,例如变体对象、节省空间的消息载荷或硬件寄存器映射。读取联合体时,必须明确当前哪个成员是有效的。

联合体的大小至少要能够容纳最大成员,并且通常还要满足整体对齐要求:

c 复制代码
union Buffer {
    char bytes[5];
    int number;
};

计算联合体大小时,不能简单地只取成员大小的最大值,还要考虑最大对齐数和尾部填充。与结构体不同,联合体成员的地址通常相同,因为它们共享起始位置。

九、用联合体观察字节序

联合体可以帮助观察一个整数在内存中的第一个字节:

c 复制代码
#include <stdio.h>

union EndianProbe {
    unsigned int value;
    unsigned char first_byte;
};

int main(void)
{
    union EndianProbe probe = {.value = 1};

    if (probe.first_byte == 1) {
        puts("little endian");
    } else {
        puts("big endian");
    }
    return 0;
}

小端模式下,整数最低有效字节位于低地址,first_byte 通常读到 1;大端模式下,低地址保存高位字节。需要强调的是,依赖联合体在不同成员之间重新解释对象表示,涉及实现相关行为;强调标准可移植性时,可以使用 memcpy 把对象的字节复制到 unsigned char 数组后再分析。

十、标签和联合体的组合

常见的安全设计是使用一个枚举标签说明联合体当前的有效成员:

c 复制代码
enum PayloadKind {
    PAYLOAD_INT,
    PAYLOAD_FLOAT
};

union Payload {
    int integer;
    float decimal;
};

struct Message {
    enum PayloadKind kind;
    union Payload payload;
};

使用时先检查标签,再读取对应成员:

c 复制代码
struct Message message = {
    .kind = PAYLOAD_INT,
    .payload.integer = 100
};

if (message.kind == PAYLOAD_INT) {
    printf("%d\n", message.payload.integer);
}

这种"标签加联合体"的模式可以清楚表达变体数据,避免调用者不知道当前联合体成员的问题。

十一、如何选择自定义类型

需求 合适的类型
一个对象同时拥有多个不同属性 结构体
多个小字段需要压缩到若干比特 位段
取值来自有限的命名集合 枚举
同一块空间在不同时间表示不同类型 联合体

结构体强调同时存在,联合体强调共享空间,枚举强调有限取值,位段强调精确控制比特。它们可以组合使用,形成清晰的数据模型。

十二、设计自定义类型时的实用建议

  1. 用结构体表达一个完整对象,不要把无关字段随意堆在一起。
  2. 需要自引用时使用结构体指针,不要直接嵌套同类型对象。
  3. 传递较大的结构体时优先传地址,只读函数参数加上 const。
  4. 设计结构体时考虑对齐和填充,必要时使用 sizeof 与 offsetof 验证实际布局。
  5. 不要把 pragma pack 当成通用优化手段,修改对齐前先确认协议和平台要求。
  6. 位段适合本地实现的紧凑标志,不适合作为跨平台二进制协议的默认方案。
  7. 枚举常量应使用有意义的命名,并对来自文件、网络或用户的值进行范围校验。
  8. 联合体成员共享内存,写入一个成员后只能按当前有效成员读取。
  9. 需要跨平台解释对象字节时,优先使用明确的整数类型、位运算或 memcpy。
  10. 用 sizeof、offsetof 和实际编译结果验证假设,不要只凭成员声明推断布局。

自定义类型的价值不只是减少变量数量,而是把数据的含义、生命周期和内存布局表达清楚。结构体让对象更完整,枚举让状态更明确,联合体让空间得到复用,位段让每个比特都能承担语义。掌握它们的差异之后,链表、状态机、协议解析和设备数据结构都可以建立在更可靠的类型设计之上。

相关推荐
Sarvartha2 小时前
对象创建与引用
java·开发语言
hasty2 小时前
限制写了却没生效:OpenTelemetry Go 的 Unicode 截断边界
开发语言·后端·golang
大侠归来2 小时前
C语言与C++在基础语法上的区别
java·c语言·c++
编码者卢布3 小时前
【App Service 】WebJobs 多实例实验:谁在运行,什么时候运行?
开发语言·python
kaixin_learn_qt_ing3 小时前
Qt抽屉窗体实现demo
开发语言·qt
quantdash_cc3 小时前
Python 获取实时行情后如何进行批量筛选?从全市场快照到策略候选池
开发语言·python·数据分析·量化交易·股票数据·quantdash
cu1433 小时前
细谈GM8229的具体功能与其应用
c语言·c++·人工智能·单片机
论文复现现场3 小时前
论文复现看到“RTX 3090 or higher”:显存、CUDA、batch size 与 OOM 怎么判断?
开发语言·pytorch·batch·cuda·rtx3090
智鸟科技GemeOpen开发者智能设备3 小时前
GemeOpen 智能音箱 GSSM0B - 播放控制(Java示例)
java·开发语言·智能音箱