【C语言】结构体

摘要:结构体把不同类型的数据组织成一条记录。本文从三个平行数组讲起,覆盖结构体类型的声明、变量定义与初始化、成员访问和传参;再讲内存对齐、嵌套与自引用、结构体数组、位段和柔性数组。重点放在两件事:结构体在内存里怎样排,以及传值时到底复制了什么。

文章目录

  • [1. 三个平行数组](#1. 三个平行数组)
  • [2. 声明、定义与初始化](#2. 声明、定义与初始化)
  • [3. 成员访问](#3. 成员访问)
  • [4. 结构体传参](#4. 结构体传参)
  • [5. 内存对齐](#5. 内存对齐)
  • [6. 嵌套与自引用](#6. 嵌套与自引用)
  • [7. 结构体数组](#7. 结构体数组)
  • [8. 位段](#8. 位段)
  • [9. 柔性数组](#9. 柔性数组)
  • [10. 小结](#10. 小结)

1. 三个平行数组

要存三个学生的姓名、年龄和成绩,最容易想到的是三个数组:

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

int main(void)
{
    char names[3][20] = {"张三", "李四", "王五"};
    int ages[3] = {20, 18, 22};
    int scores[3] = {90, 85, 88};

    for (int i = 0; i < 3; i++)
    {
        printf("%s %d %d\n", names[i], ages[i], scores[i]);
    }
    return 0;
}

输出:

text 复制代码
张三 20 90
李四 18 85
王五 22 88

三个数组靠相同的下标关联,names[1]、ages[1]、scores[1] 必须始终指向同一个学生。增加一个字段就要再加一个数组,插入或删除一个学生时,三个数组都要同步移动;只要有一处漏改,数据就对不上。

结构体(structure)把同一条记录的多个字段放进一个类型里,每个字段叫成员(member)。成员可以是不同类型,这一点和数组不同:数组要求所有元素同类型,结构体允许 char、int、数组、指针,甚至另一个结构体。

2. 声明、定义与初始化

结构体类型的一般写法是:

c 复制代码
struct tag
{
    member-list;
} variable-list;

struct 是关键字,tag 是结构体标签,member-list 是成员列表,variable-list 是声明类型时顺带定义的变量,平时可以省略。描述一个学生:

c 复制代码
struct Stu
{
    char name[20];
    int age;
    char sex[5];
    char id[20];
};   // 分号不能丢

struct Stu 才是类型名,单独写 Stu s; 不合法;想省掉 struct 就得用 typedef。右花括号后面的分号也不能漏,漏了会把后面的代码一起带进声明里。

定义变量有两种方式。第一种是在声明类型的同时定义,第二种是先声明类型,再单独定义变量:

c 复制代码
struct Stu
{
    char name[20];
    int age;
    char sex[5];
    char id[20];
};

struct Stu s1;   // 全局变量

int main(void)
{
    struct Stu s2;   // 局部变量
    return 0;
}

第二种更常用:类型只声明一次,变量可以在需要的地方定义,作用域和生命周期也更清楚。

初始化用 {} 把值括起来,可以按成员顺序写,也可以用 C99 的指定初始化器:

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

struct Stu
{
    char name[20];
    int age;
    char sex[5];
    char id[20];
};

int main(void)
{
    struct Stu s1 = {"张三", 20, "男", "202818001"};
    struct Stu s2 = {.age = 18, .name = "lisi", .id = "202818002", .sex = "女"};
    struct Stu s3 = {"lisi", 18};

    printf("[%s] [%d] [%s] [%s]\n", s1.name, s1.age, s1.sex, s1.id);
    printf("[%s] [%d] [%s] [%s]\n", s2.name, s2.age, s2.sex, s2.id);
    printf("[%s] [%d] [%s] [%s]\n", s3.name, s3.age, s3.sex, s3.id);
    return 0;
}

输出:

text 复制代码
[张三] [20] [男] [202818001]
[lisi] [18] [女] [202818002]
[lisi] [18] [] []

指定初始化器用 .成员名 = 值,顺序可以打乱,没写到的成员补 0。s3 只初始化了前两个成员,后面的 sex 和 id 都是空字符串。

「不完全初始化」和「完全不初始化」不是一回事。写了初始化器但没写全,剩下的成员按 0 补齐;局部变量如果连初始化器都没有,里面是不确定的值,用之前要逐个成员赋值。

结构体变量可以整体赋值,数组不行:

c 复制代码
struct Stu s1 = {"张三", 20, "男", "202818001"};
struct Stu s2 = {0};
s2 = s1;             // 逐成员复制

整体赋值复制的是每个成员的值,不是让两个变量指向同一块内存。改 s2 不会影响 s1。但如果成员是指针,复制的是指针本身,两个结构体会指向同一块内存,改一边两边都会变。

结构体里的数组成员也不能单独赋值,s1.name = "c"; 不合法,改字符串要逐个字符复制,或者调用字符串拷贝函数。结构体也不能直接用 == 比较,要比就逐个成员比。

用 typedef 可以省掉 struct 关键字:

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

typedef struct
{
    int x;
    int y;
} Point;

int main(void)
{
    Point p = {3, 4};
    printf("(%d, %d)\n", p.x, p.y);
    return 0;
}

输出:

text 复制代码
(3, 4)

这里声明的是一个匿名结构体(anonymous structure)类型,同时用 typedef 把类型名定为 Point。匿名结构体没有标签,通常只能配合 typedef 使用;即使两个匿名结构体的成员完全一样,编译器也会把它们当成不同的类型,不能互相赋值。

3. 成员访问

有结构体变量时,用点操作符 . 访问成员:

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

struct Stu
{
    char name[20];
    int age;
};

int main(void)
{
    struct Stu s = {"张三", 20};
    struct Stu *ps = &s;

    printf("%s %d\n", s.name, s.age);
    printf("%s %d\n", ps->name, ps->age);
    printf("%s %d\n", (*ps).name, (*ps).age);

    ps->age = 18;
    printf("%s %d\n", s.name, s.age);
    return 0;
}

输出:

text 复制代码
张三 20
张三 20
张三 20
张三 18

有结构体指针时,用箭头操作符 -> 访问成员。ps->age 等价于 (*ps).age,-> 只是语法糖。

不能写成 *ps.age。. 的优先级高于 *,*ps.age 会先算 ps.age,而 ps 是指针,没有成员 age。想先解引用再加点,括号不能省。

写法 适用对象 等价写法
s.age 结构体变量 无
ps->age 结构体指针 (*ps).age
(*ps).age 结构体指针 ps->age

结构体指针加减整数时,步长是整个结构体的大小,不是 1 个字节。ps + 1 跳过一整个 struct Stu,这一点和数组指针类似。

4. 结构体传参

结构体可以按值传给函数,也可以传地址:

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

typedef struct
{
    char name[20];
    int age;
} Student;

void ByValue(Student s)
{
    printf("函数内 sizeof(s) = %zu\n", sizeof(s));
    s.age = 100;
}

void ByAddress(Student *ps)
{
    printf("函数内 sizeof(ps) = %zu\n", sizeof(ps));
    ps->age = 200;
}

int main(void)
{
    Student stu = {"zhangsan", 20};

    printf("sizeof(stu) = %zu\n", sizeof(stu));
    ByValue(stu);
    printf("值传递后 age = %d\n", stu.age);
    ByAddress(&stu);
    printf("地址传递后 age = %d\n", stu.age);
    return 0;
}

输出:

text 复制代码
sizeof(stu) = 24
函数内 sizeof(s) = 24
值传递后 age = 20
函数内 sizeof(ps) = 8
地址传递后 age = 200

按值传参时,形参 s 是实参的一份副本,sizeof(s) 仍是整个结构体的 24 字节。函数里改 s.age 改的是副本,main 里的 stu.age 不受影响。

按地址传参时,形参 ps 是一个 8 字节的指针,函数里通过 ps->age 改的是原对象。结构体越大,两种方式的差距越明显:传值要复制整个结构体,传地址只传一个地址。

对比项 传值 Student s 传址 Student *ps
函数内 sizeof 整个结构体大小 指针大小
操作对象 实参的副本 实参本身
改成员是否影响调用方 否 是
传递开销 随结构体大小增长 固定为指针大小

结构体传参优先传地址。只读时形参加 const,写成 void Print(const Student *ps),既防止函数误改,也让调用方一眼看出这是只读接口。结构体很小时传值也无妨,但接口统一传地址更稳妥,结构体以后变大时性能不会悄悄变差。

5. 内存对齐

结构体的大小不等于成员大小之和。看两个成员相同、顺序不同的结构体:

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

struct S1
{
    char c1;
    char c2;
    int i;
};

struct S2
{
    char c1;
    int i;
    char c2;
};

#pragma pack(1)
struct S3
{
    char c1;
    int i;
    char c2;
};
#pragma pack()

int main(void)
{
    printf("sizeof(S1) = %zu, sizeof(S2) = %zu, sizeof(S3) = %zu\n",
           sizeof(struct S1), sizeof(struct S2), sizeof(struct S3));

    printf("S1: c1=%zu c2=%zu i=%zu\n",
           offsetof(struct S1, c1), offsetof(struct S1, c2),
           offsetof(struct S1, i));
    printf("S2: c1=%zu i=%zu c2=%zu\n",
           offsetof(struct S2, c1), offsetof(struct S2, i),
           offsetof(struct S2, c2));
    return 0;
}

输出:

text 复制代码
sizeof(S1) = 8, sizeof(S2) = 12, sizeof(S3) = 6
S1: c1=0 c2=1 i=4
S2: c1=0 i=4 c2=8

S1 和 S2 的成员都是 char、char、int,只是顺序不同,大小一个是 8 字节,一个是 12 字节。多出来的字节是填充字节(padding),由内存对齐(memory alignment)规则插入。

对齐规则有四条:

  1. 第一个成员放在偏移 0 处。
  2. 从第二个成员开始,每个成员放到「对齐数」的整数倍偏移处。对齐数取编译器默认对齐数和成员自身大小中的较小者;数组成员按元素类型的大小算,char[20] 的对齐数是 1,int[3] 的对齐数是 4。
  3. 结构体总大小必须是最大对齐数的整数倍,不够就在末尾补填充字节。
  4. 嵌套结构体时,内层结构体按自己的最大对齐数对齐,整体大小仍要取所有最大对齐数的整数倍。

S1 的排布是:c1 在 0,c2 在 1,i 的对齐数是 4,所以从 4 开始,2 到 3 填 2 字节;总大小 8,正好是 4 的倍数。S2 的排布是:c1 在 0,i 从 4 开始,1 到 3 填 3 字节;c2 在 8,末尾再补 3 字节凑到 12。

offsetof 定义在 <stddef.h>,用来查成员离结构体起始地址的偏移。算 sizeof 不要靠猜,用 offsetof 和 sizeof 读出来最可靠。

对齐的原因是性能和平台限制。很多硬件平台只能从特定地址读取特定类型的数据;现代 CPU 按对齐块批量读内存,一个 4 字节整数如果跨了两个对齐块,就要读两次再拼接。对齐用少量填充字节换更少的内存访问,是拿空间换时间。

成员顺序会影响填充量。把小成员集中在一起,通常能少一些填充:

c 复制代码
struct A
{
    char c1;
    int i;
    char c2;
};   // 12 字节

struct B
{
    char c1;
    char c2;
    int i;
};   // 8 字节

成员一样,B 比 A 少 4 字节。设计结构体时,可以按成员大小从大到小排,或者把小成员放在一起。

#pragma pack(n) 可以修改默认对齐数,#pragma pack() 恢复默认。上面的 S3 用 #pragma pack(1) 后没有填充,大小是 6。

这个指令常用于网络协议、文件格式这类要求字节布局精确可控的场景。代价是成员可能落在非对齐地址上,某些平台访问会变慢甚至触发异常,日常代码不要为了省几个字节随手 pack(1)。

不同平台和编译器的默认对齐数可能不同,同一个结构体的 sizeof 也可能不同。不要写死结构体大小,也不要用 memcmp 整体比较两个结构体,填充字节里的值是不确定的。

6. 嵌套与自引用

结构体的成员可以是另一个结构体。描述学生时,生日本身有年、月、日三个字段,可以先定义一个日期结构体:

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

struct Date
{
    int year;
    int month;
    int day;
};

struct Student
{
    char name[20];
    struct Date birthday;
    char id[20];
};

int main(void)
{
    struct Student s = {"aoyun", {2008, 8, 8}, "2026010102"};

    printf("%s %d-%d-%d %s\n", s.name, s.birthday.year,
           s.birthday.month, s.birthday.day, s.id);
    return 0;
}

输出:

text 复制代码
aoyun 2008-8-8 2026010102

struct Student 里有一个 struct Date 成员,初始化时用嵌套的大括号:外层对应 Student 的成员,内层的 {2008, 8, 8} 对应 birthday 的三个成员。访问嵌套成员用连续的 .,写成 s.birthday.year。

嵌套结构体在内存里是实实在在占空间的。birthday 按 struct Date 的大小和对齐数放在 Student 内部,sizeof(struct Student) 包含它,不是存一个指针。

结构体还可以引用自己,用来构造链表、树这样的结构,这叫自引用(self-referential structure)。自引用必须用指针:

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

typedef struct Node
{
    int data;
    struct Node *next;
} Node;

int main(void)
{
    Node n2 = {2, NULL};
    Node n1 = {1, &n2};

    for (Node *p = &n1; p != NULL; p = p->next)
    {
        printf("%d ", p->data);
    }
    printf("\n");
    return 0;
}

输出:

text 复制代码
1 2 

next 是指向 struct Node 的指针,大小固定为 8 字节(64 位平台)。每个节点保存下一个节点的地址,最后一个节点的 next 是 NULL,遍历到 NULL 就结束。

如果写成 struct Node next;,结构体里包含一个和自己同样大的成员,大小会无穷大,编译器直接报错。自引用只能通过指针,指针的大小是固定的,结构体的大小才能确定。

typedef 和自引用一起用时,结构体内部还没完成 typedef,所以要写 struct Node *next,不能写 Node *next;typedef 结束后,外部就可以用 Node 了。

7. 结构体数组

一个学生用一个结构体变量,多个学生就用结构体数组(array of structures):

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

typedef struct
{
    char name[20];
    int age;
} Stu;

int main(void)
{
    Stu s[3] = {{"张三", 20}, {"李四", 18}, {"王五", 22}};

    for (int i = 0; i < 3; i++)
    {
        printf("%s %d\n", s[i].name, s[i].age);
    }
    return 0;
}

输出:

text 复制代码
张三 20
李四 18
王五 22

结构体数组的元素在内存里连续存放,每个元素是一个完整的结构体。s[i] 是第 i 个结构体变量,s[i].name 是它的成员。数组长度可以用 sizeof(s) / sizeof(s[0]) 算出来,前提是 s 还在数组的作用域里,没有被传参退化。

结构体数组适合存表格型数据。要排序时,可以直接用《指针》一篇里的 qsort,比较函数按某个成员比较即可。

8. 位段

有时一个字段只需要几个 bit,用 int 存会浪费空间。位段(bit-field)把成员声明成「类型 成员名 : 位数」:

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

struct Flags
{
    unsigned int read : 1;
    unsigned int write : 1;
    unsigned int mode : 3;
};

int main(void)
{
    struct Flags f = {0};

    f.read = 1;
    f.write = 0;
    f.mode = 5;

    printf("sizeof(struct Flags) = %zu\n", sizeof(struct Flags));
    printf("read = %u, write = %u, mode = %u\n", f.read, f.write, f.mode);
    return 0;
}

输出:

text 复制代码
sizeof(struct Flags) = 4
read = 1, write = 0, mode = 5

read 占 1 bit,write 占 1 bit,mode 占 3 bit,三个字段一共 5 bit,但它们按 unsigned int 的存储单元分配,所以整个结构体占 4 字节,而不是 1 字节。

位段有几个限制:

  1. 标准规定成员类型是 int、unsigned int、signed int,C99 还允许 _Bool;现代编译器通常还支持 char、short 等类型,但这是编译器扩展。
  2. 位段成员不能取地址,因为一个字节内部的 bit 没有独立地址;也不能对单个位段成员用 sizeof。
  3. 不能用 scanf 直接给位段成员输入值,因为 scanf 需要地址。先用一个普通变量接住输入,再赋值给位段成员。
  4. 位段的内存分配方向、跨存储单元的方式由编译器实现决定,不同编译器可能不同,不要依赖字节序列。

无符号位段能表示的范围是 0 到 2 n − 1 2^n - 1 2n−1。把 10 赋给 3 位的 unsigned int 位段,存进去的是 10 的低 3 位,也就是 2,编译器通常会给出截断警告。赋值前要确认范围。

位段适合表示标志位和权限组合。比如一个权限字段只需要读、写、执行三个 bit,用位段可以塞进一个整数里;网络协议里的 IP 头部也大量使用位段来描述版本号、首部长度等小字段。但跨平台传输时,位段的布局不可移植,通常改为按字节手动拼装。

9. 柔性数组

结构体的最后一个成员可以写成未知大小的数组,叫柔性数组(flexible array member):

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

struct Flex
{
    int n;
    int a[];
};

int main(void)
{
    int n = 5;
    struct Flex *pf = (struct Flex *)malloc(sizeof(struct Flex) + (size_t)n * sizeof(int));
    if (pf == NULL)
    {
        return 1;
    }

    pf->n = n;
    for (int i = 0; i < n; i++)
    {
        pf->a[i] = i * i;
    }

    printf("sizeof(struct Flex) = %zu\n", sizeof(struct Flex));
    printf("pf->a[4] = %d\n", pf->a[4]);

    free(pf);
    pf = NULL;
    return 0;
}

输出:

text 复制代码
sizeof(struct Flex) = 4
pf->a[4] = 16

a[] 不占结构体本身的空间,sizeof(struct Flex) 只有 n 的 4 字节。申请时写 sizeof(struct Flex) + n * sizeof(int),一次拿到「头部 + 数组」的连续内存;pf->a[i] 就落在这块内存的数组部分。

柔性数组必须满足两个语言层面的条件:必须是最后一个成员,前面至少有一个其他成员。使用时通常用 malloc 按「结构体大小 + 数组长度」分配。老代码里常见的 int a[0]; 是编译器扩展,C99 的标准写法是 int a[];。

也可以把结构体设计成「成员指针」的版本:

c 复制代码
struct Flex2
{
    int n;
    int *a;
};

这种版本要申请两次:先 malloc 结构体,再 malloc 数组,让 a 指向它。释放时也要两次,先释放 a,再释放结构体。柔性数组版本只需要一次 malloc 和一次 free,数据连续,少一次间接访问,也不容易漏释放。

对比项 柔性数组 成员指针
申请次数 1 次 2 次
释放次数 1 次 2 次
内存是否连续 连续 不连续
扩容 realloc 整个结构体 单独 realloc 数组成员
使用方是否需要知道内部指针 不需要 需要

柔性数组不能单独扩容,要调整元素个数时,用 realloc 重新调整整个结构体的空间,并同步更新记录元素个数的字段。动态内存的申请、释放和 realloc 的用法,在《指针》一篇有完整说明。

10. 小结

  • 结构体把不同类型的数据组织成一条记录;struct Stu 才是类型名,右花括号后的分号不能丢。
  • 初始化支持按顺序、指定初始化器和完全初始化;不完全初始化时缺的成员补 0;结构体能整体赋值,但不能直接用 == 比较。
  • 成员访问用 .,结构体指针用 ->;结构体传参优先传地址,只读接口用 const 指针。
  • 内存对齐用填充字节换访问效率;对齐数取默认对齐数和成员大小的较小者,总大小补到最大对齐数的整数倍,调整成员顺序和使用 #pragma pack 都会改变大小。
  • 嵌套结构体初始化用嵌套大括号,访问用连续的 .;自引用必须用指针;结构体数组的元素连续存放,适合存表格型数据。
  • 位段用「类型 成员名 : 位数」声明,能省空间,但布局由编译器实现决定,不能取地址,也不能单独 sizeof,跨平台性差。
  • 柔性数组必须是最后一个成员,sizeof 不含它;一次 malloc 分配头部和数组,一次 free 释放全部,数据连续。
相关推荐
2601_966949651 小时前
如何封装 qd.klines.batch(),让批量 K 线查询函数在多个策略中复用?
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
神仙别闹2 小时前
基于Python实现英词语自动对齐系统
开发语言·python
唐青枫2 小时前
C 语言 malloc() 详解与实战 Demo
c语言
akinggw2 小时前
libagentcore - 一个基于c++的AI多agent系统升级到2.0版本
开发语言·jvm·c++
老歌老听老掉牙3 小时前
圆柱端面四平面交线及其在yoz面投影的几何解析与 Python 实现
开发语言·python·平面
旖旎夜光3 小时前
【C++ 基础入门】命名空间、输入输出、缺省参数与函数重载
开发语言·c++·笔记·学习
俩个逗号。。4 小时前
Kotlin flow 详解
android·开发语言·kotlin
程序猿乐锅4 小时前
万字详解Single-flight(单飞模式)
java·开发语言·分布式·后端·学习·single-flight
揽星河..6 小时前
【C语言】指针
c语言·开发语言