26、头文件中的数据结构:链表、队列、栈等通用数据结构的头文件设计

数据结构这东西,说白了就是组织数据的方式。我在嵌入式项目里摸爬滚打这么多年,发现一个规律:代码写得好不好,一半看数据结构设计得巧不巧。而头文件,就是数据结构的“门面”——别人看你代码,第一眼就是头文件。

今天咱们聊聊链表、队列、栈这些通用数据结构,在头文件里该怎么设计。嗯,这里要注意:通用两个字是关键。不是让你写死一个int链表,而是要设计成能复用的、可移植的模块。

为什么要在头文件里定义数据结构?

你想想看,一个链表节点长什么样?它需要存数据,还需要指向下一个节点。这些信息如果不放在头文件里,那使用这个模块的人根本不知道该怎么操作。我见过有人把结构体定义藏在.c文件里,结果调用方还得自己再定义一遍——这不扯吗?

所以,数据结构的定义必须放在头文件里。这是接口的一部分,就像函数的声明一样重要。

链表头文件设计

先看一个我常用的链表头文件模板。我个人习惯用双向链表,因为嵌入式里经常需要删除操作,双向链表比单向灵活得多。

/* list.h - 通用双向链表模块 */
#ifndef LIST_H
#define LIST_H

#include <stdint.h>
#include <stdbool.h>

/* 链表节点结构体 */
typedef struct list_node {
    struct list_node *prev;   /* 前驱指针 */
    struct list_node *next;   /* 后继指针 */
    void *data;               /* 数据指针,支持任意类型 */
} list_node_t;

/* 链表控制块 */
typedef struct {
    list_node_t *head;        /* 头节点 */
    list_node_t *tail;        /* 尾节点 */
    uint32_t size;            /* 节点数量 */
    void (*free_data)(void *); /* 数据释放回调,可选 */
} list_t;

/* 创建/销毁 */
list_t* list_create(void);
void list_destroy(list_t *list);

/* 插入操作 */
bool list_push_front(list_t *list, void *data);
bool list_push_back(list_t *list, void *data);
bool list_insert_at(list_t *list, uint32_t index, void *data);

/* 删除操作 */
void* list_pop_front(list_t *list);
void* list_pop_back(list_t *list);
void* list_remove_at(list_t *list, uint32_t index);

/* 查询操作 */
void* list_get_at(list_t *list, uint32_t index);
uint32_t list_size(const list_t *list);
bool list_is_empty(const list_t *list);

/* 遍历操作 */
void list_foreach(list_t *list, void (*callback)(void *data));

#endif /* LIST_H */

设计要点:

  • void* 数据指针:这是通用的关键。不管你是存int、float还是结构体,都能往里塞。
  • free_data 回调:如果数据是动态分配的,销毁链表时需要释放它们。这个回调让使用者自己决定怎么释放。
  • 返回值统一用bool:插入失败返回false,成功返回true。调用方一看就明白。

避坑指南:我曾经在项目里没加free_data回调,结果链表销毁时只释放了节点,没释放数据。内存泄漏查了三天才找到原因。从那以后,我每个链表头文件都加上这个回调。

队列头文件设计

队列说白了就是“先进先出”。嵌入式里经常用队列做消息缓冲,比如串口接收数据、任务调度等。我建议队列基于链表实现,这样长度可以动态扩展。

/* queue.h - 通用队列模块 */
#ifndef QUEUE_H
#define QUEUE_H

#include <stdint.h>
#include <stdbool.h>

/* 队列节点 */
typedef struct queue_node {
    struct queue_node *next;
    void *data;
} queue_node_t;

/* 队列控制块 */
typedef struct {
    queue_node_t *front;      /* 队首 */
    queue_node_t *rear;       /* 队尾 */
    uint32_t size;            /* 当前元素个数 */
    uint32_t max_size;        /* 最大容量,0表示无限制 */
} queue_t;

/* 创建/销毁 */
queue_t* queue_create(uint32_t max_size);
void queue_destroy(queue_t *queue);

/* 入队/出队 */
bool enqueue(queue_t *queue, void *data);
void* dequeue(queue_t *queue);

/* 查看队首(不出队) */
void* queue_peek(const queue_t *queue);

/* 状态查询 */
uint32_t queue_size(const queue_t *queue);
bool queue_is_full(const queue_t *queue);
bool queue_is_empty(const queue_t *queue);

#endif /* QUEUE_H */

注意:队列的max_size字段很重要。在资源受限的嵌入式系统里,队列不能无限增长。我见过一个同事没设上限,结果消息堆积把内存吃光了——系统直接挂掉。设个上限,满了就丢弃或阻塞,这才是稳健的做法。

栈头文件设计

栈是“后进先出”。函数调用、表达式求值、回溯算法都离不开它。栈的实现可以用数组,也可以用链表。我个人习惯用数组实现,因为嵌入式里栈的大小通常可以预估,数组更省内存(不需要额外的指针开销)。

/* stack.h - 通用栈模块 */
#ifndef STACK_H
#define STACK_H

#include <stdint.h>
#include <stdbool.h>

/* 栈控制块 */
typedef struct {
    void **data;              /* 数据数组,动态分配 */
    uint32_t top;             /* 栈顶索引 */
    uint32_t capacity;        /* 栈容量 */
} stack_t;

/* 创建/销毁 */
stack_t* stack_create(uint32_t capacity);
void stack_destroy(stack_t *stack);

/* 压栈/出栈 */
bool stack_push(stack_t *stack, void *data);
void* stack_pop(stack_t *stack);

/* 查看栈顶(不出栈) */
void* stack_peek(const stack_t *stack);

/* 状态查询 */
uint32_t stack_size(const stack_t *stack);
bool stack_is_empty(const stack_t *stack);
bool stack_is_full(const stack_t *stack);

#endif /* STACK_H */

为什么用数组? 数组实现的栈,访问速度快,内存连续,对缓存友好。而且栈的操作只在栈顶,数组的随机访问优势用不上,但连续内存的优势很明显。我在一个实时控制项目里用数组栈,压栈出栈操作稳定在几十纳秒级别。

通用数据结构的核心设计原则

总结一下,设计这些头文件时,我遵循几个原则:

  1. 数据隐藏:结构体定义放在头文件里,但内部细节(比如链表节点的prev/next)对使用者透明。使用者只需要知道怎么调用函数,不需要关心内部实现。
  2. 类型无关:用void*存数据,让一个链表既能存int,也能存结构体。但要注意:使用者要保证数据生命周期正确——别存了一个局部变量的地址,函数返回后地址就失效了。
  3. 错误处理统一:所有可能失败的操作都返回bool或NULL指针。不要用全局错误码,那玩意儿在多线程环境里就是灾难。
  4. 提供遍历接口:链表、队列、栈都应该提供遍历回调函数。这样使用者可以方便地打印、查找、修改所有元素。

知识体系结构图

下面这张图展示了本章的核心逻辑:通用数据结构头文件的设计层次。

通用数据结构头文件设计层次 头文件接口层 链表 (List) 队列 (Queue) 栈 (Stack) 链表核心要素 • 双向/单向节点 • void* 数据指针 • free_data 回调 队列核心要素 • 队首/队尾指针 • max_size 上限 • 入队/出队操作 栈核心要素 • 数组/链表实现 • 栈顶索引 • 容量限制 通用设计原则:数据隐藏 · 类型无关 · 统一错误处理 · 遍历接口

头文件设计的避坑清单

常见问题 后果 解决方案
结构体定义放在.c文件 调用方无法访问成员,无法使用 必须放在头文件,但用注释说明哪些是内部字段
没有考虑线程安全 多线程环境下数据竞争 头文件里声明加锁/解锁接口,或者用原子操作
void* 滥用,没有类型检查 运行时类型错误,难以调试 提供类型安全的包装宏(如 LIST_OF(int))
没有提供遍历接口 使用者需要自己操作内部节点,破坏封装 提供 foreach 回调函数
内存管理责任不清 谁分配谁释放?容易内存泄漏 头文件注释里明确说明:数据由调用方管理

我的习惯:每个头文件开头都加一段注释,写明“谁负责分配内存,谁负责释放”。比如链表:节点由模块内部分配,数据由调用方分配和释放。这样团队协作时不会扯皮。

好了,关于链表、队列、栈的头文件设计,就聊这么多。记住:头文件是接口,不是实现。把接口设计得清晰、通用、稳健,你的代码就能被更多人复用。下次你写一个数据结构模块时,不妨回头看看这些原则——嗯,应该能帮你少踩几个坑。


公众号:蓝海资料掘金营,微信deep3321