数据结构接口:链表、队列、哈希表的通用接口设计,如何做到类型无关

说实话,嵌入式开发里最让我头疼的,不是算法有多复杂,而是每次换一个数据类型,就得把链表、队列重写一遍。你想想看,今天用 int,明天用 struct,后天又来一个 char*,每个都得重新实现一套增删改查。这活儿干多了,谁受得了?

我早年在一家物联网公司做网关设备时,就踩过这个坑。当时项目赶得急,我图省事,直接给每种数据类型都写了一套链表操作。结果呢?代码量翻了三倍,bug 还特别多。后来我痛定思痛,开始研究怎么让数据结构接口做到类型无关。今天就把这些经验分享给你。

核心思路:用 void* 打通任督二脉

类型无关的核心,说白了就是 void*。C 语言里,void* 可以指向任何类型的数据。我们只需要在接口里统一用 void* 来传递数据指针,就能做到一套接口,通吃所有类型。

但这里有个坑——void* 只是指针,它不携带任何类型信息。所以你需要额外告诉接口:这个数据有多大?怎么比较?怎么拷贝?

我一般会在初始化时,让用户传入一个「类型描述结构体」,里面包含:

  • 元素大小(size_t elem_size)
  • 比较函数指针(int (*compare)(const void*, const void*))
  • 拷贝函数指针(可选,void (*copy)(void* dest, const void* src))
  • 销毁函数指针(可选,void (*destroy)(void* data))

这样,接口内部就知道怎么操作数据了。嗯,这个设计模式我用了快十年,基本没出过问题。

链表接口设计:最基础的通用容器

链表是很多数据结构的基础。我们先看一个类型无关的链表接口长什么样:

// list.h
#ifndef LIST_H
#define LIST_H

#include <stddef.h>

// 类型描述结构体
typedef struct {
    size_t elem_size;
    int (*compare)(const void* a, const void* b);
    void (*copy)(void* dest, const void* src);
    void (*destroy)(void* data);
} type_info_t;

// 链表节点(对外不可见)
typedef struct list_node list_node_t;

// 链表句柄
typedef struct {
    list_node_t* head;
    list_node_t* tail;
    size_t size;
    type_info_t type;
} list_t;

// 接口声明
void list_init(list_t* list, type_info_t type);
void list_destroy(list_t* list);
int list_insert(list_t* list, size_t index, const void* data);
int list_append(list_t* list, const void* data);
int list_remove(list_t* list, size_t index);
int list_get(list_t* list, size_t index, void* out);
size_t list_size(const list_t* list);

#endif

你看,所有数据操作都通过 const void* data 传入,内部用 type.elem_size 来分配内存和拷贝数据。这样不管你是存 int 还是存 struct sensor_data,接口完全一样。

我曾经在一个项目里,用这个链表同时管理了三种不同的结构体:传感器配置、网络包缓存、日志记录。代码复用率极高,维护起来也轻松。

队列接口设计:先进先出的通用封装

队列其实可以基于链表来实现。但为了接口清晰,我习惯单独设计一套队列接口:

// queue.h
#ifndef QUEUE_H
#define QUEUE_H

#include "list.h"

typedef struct {
    list_t list;
} queue_t;

void queue_init(queue_t* queue, type_info_t type);
void queue_destroy(queue_t* queue);
int queue_enqueue(queue_t* queue, const void* data);
int queue_dequeue(queue_t* queue, void* out);
int queue_peek(queue_t* queue, void* out);
int queue_is_empty(const queue_t* queue);
size_t queue_size(const queue_t* queue);

#endif

这里有个小技巧:队列内部直接复用链表,但只暴露入队、出队、查看队首这三个操作。这样既保证了类型无关,又限制了操作范围,防止用户误用。

我记得有一次调试一个串口数据接收模块,数据包乱序了。后来发现是有人直接操作了队列内部的链表节点,跳过了出队逻辑。从那以后,我坚持把队列的底层实现完全隐藏,只暴露队列语义的接口。

哈希表接口设计:键值对的通用存储

哈希表比链表和队列复杂一些,因为它涉及键和值两个类型。但类型无关的思路是一样的:

// hashtable.h
#ifndef HASHTABLE_H
#define HASHTABLE_H

#include <stddef.h>

typedef struct hashtable hashtable_t;

// 创建哈希表
hashtable_t* hashtable_create(
    size_t key_size,
    size_t value_size,
    size_t capacity,
    int (*key_compare)(const void* a, const void* b),
    size_t (*key_hash)(const void* key)
);

// 销毁哈希表
void hashtable_destroy(hashtable_t* ht);

// 插入/更新键值对
int hashtable_put(hashtable_t* ht, const void* key, const void* value);

// 获取值
int hashtable_get(hashtable_t* ht, const void* key, void* out_value);

// 删除键值对
int hashtable_remove(hashtable_t* ht, const void* key);

// 判断键是否存在
int hashtable_contains(hashtable_t* ht, const void* key);

// 获取元素个数
size_t hashtable_size(const hashtable_t* ht);

#endif

这里的关键是 key_hashkey_compare 两个函数指针。哈希函数决定了键怎么映射到桶,比较函数决定了怎么判断两个键相等。这两个函数完全由用户提供,所以任何类型的键都能用。

我做过一个嵌入式数据库模块,键是 32 位整数,值是自定义的日志结构体。哈希表接口完全不用改,只需要传入对应的比较和哈希函数就行。说实话,这种「写一次,到处用」的感觉,真的很爽。

类型无关设计的避坑指南

讲了这么多好处,我也得说说坑。毕竟我在这些坑里摔过不少次。

我曾经犯过的错:
  • 忘记处理对齐问题:有些 MCU 对数据对齐有要求。你用 void* 拷贝数据时,如果源地址和目标地址不对齐,可能会触发硬件异常。我的做法是:在分配节点内存时,用 mallocaligned_alloc 保证对齐。
  • 忽略生命周期管理:如果数据里包含指针(比如字符串),单纯用 memcpy 拷贝只会拷贝指针值,不会拷贝指针指向的内容。这时候必须用用户提供的 copy 函数来做深拷贝。
  • 比较函数返回值的歧义:我习惯让比较函数返回 -1、0、1,分别表示小于、等于、大于。但有些同事写的比较函数只返回 0 或 1。后来我在文档里明确规定了返回值语义,并在接口内部做了校验。
我的个人习惯:
  • 所有数据结构接口都返回 int 错误码,0 表示成功,负数表示具体错误类型。这样调用方可以统一处理错误。
  • 在调试版本里,我会在接口内部加断言,检查传入的指针是否为空、索引是否越界。发布版本再关掉断言,保证性能。
  • 对于嵌入式环境,我建议把 type_info_t 定义成 const 全局变量,避免每次初始化都传一遍。

知识体系总览

下面这张图,帮你理清类型无关数据结构接口的整体脉络:

类型无关数据结构接口设计 void* 核心指针 类型描述结构体 type_info_t elem_size | compare | copy | destroy 链表 (list_t) 插入/删除/遍历 队列 (queue_t) 入队/出队/查看 哈希表 (hashtable_t) 键值对/哈希/比较 底层实现:memcpy 数据拷贝 | 函数指针回调 | 错误码返回

总结一下

类型无关的数据结构接口,核心就是 void* + 类型描述结构体 + 函数指针。这套组合拳打好了,你的代码复用率能提升一个数量级。

我个人建议,在项目初期就把这些通用接口封装好,放到一个公共库中。后续所有模块都基于这套接口来开发。这样不仅减少了重复劳动,还能保证整个项目的数据结构操作风格统一。

嗯,关于类型无关接口设计,今天就聊到这里。记住:好的接口设计,是让调用方感觉不到底层实现的存在。你做到了这一点,代码质量自然就上去了。


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