结构体、表查找与联合

复刻 K&R 第六章:结构体值语义与数组、自引用结构、哈希表查找、typedef、标签联合和位域边界。

学习目标

  • 能解释结构成员访问、赋值、传参和返回的值语义,并区分标准保证与具体实现布局
  • 能实现结构体数组、自引用链表和带碰撞处理的哈希表查找,并写出所有权不变量
  • 能设计用 typedef 分层的标签联合,并判断位域是否适合作为可移植线格式

机制总览

结构体、表查找与联合:机制路径

  1. 1

    从“一条记录的哪些事实必须一起成立”开始

    struct Point translate(struct Point point, int dx, int dy) point.x += dx; point.y += dy; return point;

  2. 2

    结构体基础:标准保证到哪里

    对非位域成员,C 保证后声明成员的地址更高,并保证第一个成员之前没有填充;实现可以在成员之间与结构尾部加入

  3. 3

    结构体与函数:值、借用和修改

    结构可以作为函数参数和返回值,也可以整体赋值;数组不能整体赋值,这正是二者的重要差异。小结构按值传递往往最清楚:输入不会被修改,返回值表达新状态。若函数需要原地更新大型结构,可接收指针,并让 const struct Config 表达只读借用。

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

结构体、表查找与联合:机制与证据

切换《结构体、表查找与联合》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。

选择推理阶段

当前阶段 · 从“一条记录的哪些事实必须一起成立”开始

struct Point translate(struct Point point, int dx, int dy) point.x += dx; point.y += dy; return point;

可核验证据

以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从“一条记录的哪些事实必须一起成立”开始」的实际契约。

学完《结构体、表查找与联合》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。

失效—证据矩阵

结构体、表查找与联合:失效与核验

从“一条记录的哪些事实必须一起成立”开始

典型失效

若把「从“一条记录的哪些事实必须一起成立”开始」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。

核验证据

以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「从“一条记录的哪些事实必须一起成立”开始」的实际契约。

结构体基础:标准保证到哪里

典型失效

若把「结构体基础:标准保证到哪里」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。

核验证据

以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「结构体基础:标准保证到哪里」的实际契约。

结构体与函数:值、借用和修改

典型失效

若把「结构体与函数:值、借用和修改」写法照搬到新输入却不核对类型转换、数组边界和错误返回,程序会在编译器允许的路径上产生错误结果或未定义行为。

核验证据

以严格警告构建本节最小程序,再用边界输入、失败返回和 sanitizer 复核「结构体与函数:值、借用和修改」的实际契约。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

从“一条记录的哪些事实必须一起成立”开始

若一个坐标由 xy 两个数共同表达,把它们放进同一个

, 函数就能接收和返回完整坐标,而不是依赖两个容易错配的平行参数。结构体的首要价值是建立数据关系与不变量,节省几个填充字节只是经过测量后才考虑的实现问题。

struct Point {
    int x;
    int y;
};
 
struct Point translate(struct Point point, int dx, int dy)
{
    point.x += dx;
    point.y += dy;
    return point;
}
 
int point_equal(struct Point left, struct Point right)
{
    return left.x == right.x && left.y == right.y;
}

结构参数按值传递,函数修改的是副本;结构返回值与同类型结构赋值会复制结构的值。C 没有内建结构相等运算,因此应逐成员比较。不能用 memcmp 代替值相等:填充字节可能取不同的未指定值,某些成员类型也可能存在不同对象表示却表示相同值。

结构体基础:标准保证到哪里

对非位域成员,C 保证后声明成员的地址更高,并保证第一个成员之前没有填充;实现可以在成员之间与结构尾部加入

。 标准不保证“偏移是成员大小的整数倍”,也不保证 int 是四字节。对齐要求与类型大小是两个概念,具体数值由实现和 ABI 决定。

应在目标编译器上用 sizeof 检查对象大小,用标准头 <stddef.h>offsetof 检查成员偏移。_Alignof 是 C11 才加入的工具,不属于 K&R 所处的 ANSI C90 边界;若项目使用现代 C,可以额外报告它,但仍不能把一次结果写成跨平台协议。

#include <stddef.h>
#include <stdio.h>
 
struct Record {
    char state;
    long id;
    char grade;
};
 
int main(void)
{
    printf("size=%lu state=%lu id=%lu grade=%lu\n",
           (unsigned long)sizeof(struct Record),
           (unsigned long)offsetof(struct Record, state),
           (unsigned long)offsetof(struct Record, id),
           (unsigned long)offsetof(struct Record, grade));
    return 0;
}

这段程序只报告当前实现,不预言输出。调整成员顺序有时能缩小结构数组,但它也会改变 ABI、调试可读性、缓存访问顺序和外部接口。先确认结构不是公开二进制接口或持久化格式,再用真实对象数量与访问模式测量;“永远按大小降序排列”不是通用规则。

结构体与函数:值、借用和修改

结构可以作为函数参数和返回值,也可以整体赋值;数组不能整体赋值,这正是二者的重要差异。小结构按值传递往往最清楚:输入不会被修改,返回值表达新状态。若函数需要原地更新大型结构,可接收指针,并让 const struct Config * 表达只读借用。

指针参数还必须说明是否可为空、对象由谁拥有、借用持续多久,以及失败时是否保留原值。struct Job * 本身不表达这些协议。调用 worker->state 等价于 (*worker).state,只减少括号,不会自动做空指针检查或生命周期管理。

结构赋值是浅层成员复制:若成员含指针,复制的是地址值而非所指对象。两个副本可能共享同一缓冲,释放与修改责任必须另行定义。若结构拥有动态资源,应提供初始化、复制或转移、销毁操作,并让失败路径恢复明确状态。

先预测:两个结构逐成员都相等时,memcmp 是否一定返回 0?不一定。成员间与尾部填充不属于字段值,结构复制以外的构造路径可能留下不同字节;值比较应按成员定义。

结构体数组与指针步长

适合表达固定数量或连续存储的同类记录。records + 1 前进一个完整 struct Record,不是前进到下一个成员;尾部填充会成为数组元素步长的一部分,使每个后继元素满足实现所需对齐。

#include <stddef.h>
 
struct Score {
    const char *name;
    int value;
};
 
const struct Score *best_score(const struct Score scores[], size_t count)
{
    const struct Score *best;
    size_t index;
 
    if (count == 0)
        return NULL;
    best = &scores[0];
    for (index = 1; index < count; ++index)
        if (scores[index].value > best->value)
            best = &scores[index];
    return best;
}

数组形参仍会调整为指针,所以函数必须另收 count;返回的

借用原数组中的元素,数组失效后也随之失效。若调用者需要独立结果,应返回结构值或执行深复制,不能保存悬空指针。

自引用结构:递归形状必须间接表示

链表节点需要指向同类型的后继。结构标签 struct Entry 在定义完成前已经能标识该类型,因此成员可写 struct Entry *next;指针自身大小已知。直接写 struct Entry child 会要求对象包含一个同样对象、后者又包含同样对象,尺寸无限,因而不成立。

只描述形状,不描述资源责任。链是否允许环、键是否唯一、节点由表拥有还是调用者借出、删除后谁释放字符串,都要成为操作不变量。

表查找:哈希只缩小候选范围

K&R 用自引用节点构造符号表。下面的

采用分离链:相同桶中的节点由 next 串联。查找先计算稳定桶号,再用 strcmp 确认键;哈希相同不表示键相同。

#include <string.h>
 
#define BUCKET_COUNT 17
 
struct Entry {
    const char *key;
    const char *value;
    struct Entry *next;
};
 
static struct Entry *table[BUCKET_COUNT];
 
static unsigned hash_text(const char *text)
{
    unsigned hash = 0;
    while (*text != '\0') {
        hash = 31U * hash + (unsigned char)*text;
        ++text;
    }
    return hash % BUCKET_COUNT;
}
 
const struct Entry *lookup(const char *key)
{
    const struct Entry *entry;
    unsigned bucket = hash_text(key);
 
    for (entry = table[bucket]; entry != NULL; entry = entry->next)
        if (strcmp(entry->key, key) == 0)
            return entry;
    return NULL;
}

这里的

要求 key 是非空、已终止字符串,表中节点和键在调用期间有效。插入还要决定重复键是覆盖、拒绝还是保留多值;若复制键和值,分配任一步失败都要释放已取得资源且不破坏旧表。销毁要遍历每个桶与每条链,恰好释放一次拥有的对象。

哈希函数中的 (unsigned char)*text 避免当普通 char 为有符号且字符值为负时,把符号扩展混进哈希。桶数 17 只是示例,不是性能保证;负载因子、键分布、扩容策略和攻击输入都影响复杂度。正确性检查应覆盖空桶、首节点命中、链尾命中、碰撞未命中和重复插入。

typedef:缩短名字但不隐藏协议

可以把复杂声明分层。例如 typedef struct Entry Entry; 允许后续写 Entry *next,而 typedef int (*Compare)(const void *, const void *); 给函数指针声明命名。它改善可读性,却不会创建运行时对象、不会分配内存,也不会让两个本来相同的整数别名获得类型安全隔离。

是否隐藏 struct 关键字取决于接口风格。公开不透明类型常在头文件只声明 typedef struct Table Table;,把完整成员放进实现文件;调用者只能通过函数维护不变量。若调用者需要栈上大小或直接访问成员,定义就必须可见,也意味着布局成为更强的兼容负担。

不要用别名掩盖指针性质,例如 typedef char *String; 会让 const String 表示“常量指针”,而不是“指向 const char 的指针”,容易误读。对所有权敏感的接口,显式 char *const char * 通常更清楚。

联合:共享存储需要判别标签

的大小足以容纳最大成员并满足各成员对齐,但不保证数值恰好等于最大 sizeof;实现仍可能加入填充。联合适合表示互斥变体,但存储本身不会记录当前写入哪一项,所以可移植程序通常配合枚举形成

enum ValueKind {
    VALUE_INTEGER,
    VALUE_REAL,
    VALUE_TEXT
};
 
struct Value {
    enum ValueKind kind;
    union {
        long integer;
        double real;
        const char *text;
    } data;
};
 
int value_as_real(const struct Value *value, double *result)
{
    if (value == NULL || result == NULL)
        return 0;
    if (value->kind == VALUE_REAL) {
        *result = value->data.real;
        return 1;
    }
    if (value->kind == VALUE_INTEGER) {
        *result = (double)value->data.integer;
        return 1;
    }
    return 0;
}

构造函数应同时设置 kind 和对应成员;读取先检查标签。把一个成员写入后任意读取另一个成员,不应作为通用类型转换、序列化或浮点位检查手段。不同 C 标准与具体实现对跨成员读取有细节规则,线格式仍必须显式编码。

位域:值宽度不是可移植位布局

可写 unsigned int ready : 1;unsigned int mode : 3;,用于表达有限宽度状态。不能取得位域地址,因为它不是可独立寻址的对象;赋入超出可表示范围的值也不能当作统一跨平台截断协议。

位域的高低位方向、是否跨分配单元、单元边界和对齐具有实现相关性,允许的基础类型也受语言版本与实现影响。因此,直接把位域结构覆盖到网络包或硬件寄存器上不具可移植性。可移植边界应读取无符号整数或字节,再用掩码与移位提取协议规定的位。

#define FLAG_READY 0x01U
#define MODE_MASK  0x0eU
#define MODE_SHIFT 1
 
int packet_ready(unsigned char flags)
{
    return (flags & FLAG_READY) != 0;
}
 
unsigned packet_mode(unsigned char flags)
{
    return ((unsigned)flags & MODE_MASK) >> MODE_SHIFT;
}

这段代码把协议位号写在掩码中,不依赖位域排列。若硬件供应商明确规定编译器、ABI 和寄存器头文件,位域可作为受控环境的便捷接口,但应把这种依赖记录并用目标测试验证,而不是宣传为 ISO C 保证。

对外格式:逐字段编码而非转储对象

结构对象表示包含实现选择的大小、填充、对齐、整数宽度和字节序。把 fwrite(&record, sizeof record, 1, file) 或网络 send 当成稳定格式,会把本机 ABI 泄漏到文件。#pragma pack(1) 不是 ISO C 功能,可能产生低效或故障的未对齐访问,也没有解决整数宽度、字节序、版本演进和联合活动成员问题。

稳定格式应逐字段规定:字段编号与含义、固定或变长宽度、字节序、有效范围、字符串编码、缺失字段、版本和校验。解码先检查输入长度再组合数值,遇到未知标签与超范围长度时失败,不让半初始化对象进入业务层。内存结构可以按访问效率演进,编码器保持兼容层。

小结

  • 结构体把相关成员组成值;声明顺序有标准保证,具体偏移、对齐与填充由实现决定
  • 结构可赋值、传参和返回;含指针的结构复制地址而非资源,值相等应逐成员定义
  • 结构体数组以 sizeof 为步长;自引用结构通过指针表达链表、树与哈希碰撞链
  • 哈希只选择候选桶,表查找仍要比较完整键,并维护重复键、所有权与销毁不变量
  • typedef 是别名;联合共享存储需标签约束活动成员;位域布局不能替代可移植协议
  • 文件和网络格式应逐字段编码,不能依赖对象转储或 #pragma pack

练习

问题 1struct Record { char state; long id; char grade; };,列出 ISO C 能保证和不能保证的布局事实,并说明如何在目标实现上验证。

问题 2 为分离链哈希表设计 insertlookupdestroy 的不变量与最小测试集,特别说明碰撞和内存分配失败。

问题 3 为什么 union 与位域都不能直接充当跨平台消息格式?给出标签联合在内存中的正确读取协议。

名词解释

本章出现的专业名词,用大白话再讲一遍。

结构体

由有名字、可具有不同类型的成员组成的聚合类型。成员声明顺序属于语义,具体偏移和对象表示由实现决定。

填充

实现可在结构成员之间或对象尾部保留的未命名字节,用于满足成员访问和结构数组步长等要求,不属于字段值。

结构体数组

以同一结构类型为元素的连续数组,每个元素步长为该结构的 sizeof,包括实现选择的尾部填充。

结构体指针

指向结构对象的位置,可用 -> 访问成员,但不自动携带长度、非空性、所有权或生命周期。

自引用结构

通过指针间接指向同一结构类型的结构,用于链表、树和哈希桶;直接包含自身会要求无限对象大小。

哈希表

用哈希把键映射到桶、再处理碰撞的数据结构。哈希值只缩小候选范围,完整键比较才确认命中。

表查找

计算桶号、遍历候选节点并比较完整键,最终返回匹配节点或空指针的过程。

typedef

为已有类型声明别名的语法,不创建与原类型不兼容的新类型,也不改变对象布局或资源语义。

联合

所有成员共享同一起始存储的类型;对象足以容纳任一成员并满足其对齐,但需要外部协议确定当前可读成员。

标签联合

用枚举等判别字段记录联合活动成员的组合,构造、更新与读取都必须保持标签和成员一致。

位域

带位数声明的成员,可表达有限宽度值;具体位排列和分配单元具有实现相关性,且不能取得其地址。

资料与写作方式声明

本章以The C Programming Language, Second Edition, Chapter 6: Structures合法公开试读核定可见范围,并以目录限定未公开部分,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…