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 */
为什么用数组? 数组实现的栈,访问速度快,内存连续,对缓存友好。而且栈的操作只在栈顶,数组的随机访问优势用不上,但连续内存的优势很明显。我在一个实时控制项目里用数组栈,压栈出栈操作稳定在几十纳秒级别。
通用数据结构的核心设计原则
总结一下,设计这些头文件时,我遵循几个原则:
- 数据隐藏:结构体定义放在头文件里,但内部细节(比如链表节点的prev/next)对使用者透明。使用者只需要知道怎么调用函数,不需要关心内部实现。
- 类型无关:用void*存数据,让一个链表既能存int,也能存结构体。但要注意:使用者要保证数据生命周期正确——别存了一个局部变量的地址,函数返回后地址就失效了。
- 错误处理统一:所有可能失败的操作都返回bool或NULL指针。不要用全局错误码,那玩意儿在多线程环境里就是灾难。
- 提供遍历接口:链表、队列、栈都应该提供遍历回调函数。这样使用者可以方便地打印、查找、修改所有元素。
知识体系结构图
下面这张图展示了本章的核心逻辑:通用数据结构头文件的设计层次。
头文件设计的避坑清单
| 常见问题 | 后果 | 解决方案 |
|---|---|---|
| 结构体定义放在.c文件 | 调用方无法访问成员,无法使用 | 必须放在头文件,但用注释说明哪些是内部字段 |
| 没有考虑线程安全 | 多线程环境下数据竞争 | 头文件里声明加锁/解锁接口,或者用原子操作 |
| void* 滥用,没有类型检查 | 运行时类型错误,难以调试 | 提供类型安全的包装宏(如 LIST_OF(int)) |
| 没有提供遍历接口 | 使用者需要自己操作内部节点,破坏封装 | 提供 foreach 回调函数 |
| 内存管理责任不清 | 谁分配谁释放?容易内存泄漏 | 头文件注释里明确说明:数据由调用方管理 |
我的习惯:每个头文件开头都加一段注释,写明“谁负责分配内存,谁负责释放”。比如链表:节点由模块内部分配,数据由调用方分配和释放。这样团队协作时不会扯皮。
好了,关于链表、队列、栈的头文件设计,就聊这么多。记住:头文件是接口,不是实现。把接口设计得清晰、通用、稳健,你的代码就能被更多人复用。下次你写一个数据结构模块时,不妨回头看看这些原则——嗯,应该能帮你少踩几个坑。