16、链表模块的封装:单向链表、双向链表、循环链表的通用接口设计

链表这东西,说实话,是嵌入式开发里绕不开的数据结构。我早年做项目时,总觉得链表就是课本上的概念,直到有一次做动态内存管理模块,才发现没有一套好用的链表库,代码会写得又臭又长。

今天我们就来聊聊,怎么把单向链表、双向链表、循环链表这些“亲戚”统一封装起来。嗯,这里要注意:不是让你写三个独立的库,而是设计一套通用接口,让调用者感觉不到底层是哪种链表。

为什么需要通用接口?

你想想看,如果每个链表类型都单独写一套API,那代码里就会出现 list_add_headdlist_add_headclist_add_head 这种命名。调用方换个链表类型,就得改一堆函数名。我在项目中遇到过这种“命名灾难”,后来重构时花了整整两天才改完。

通用接口的核心思想是:接口与实现分离。调用者只关心“插入”、“删除”、“遍历”这些操作,不关心底层是单向还是双向。

核心原则:链表库只操作节点指针,不关心节点内部的数据结构。数据由调用者自己管理。

单向链表:最基础的实现

单向链表的结构很简单,每个节点只有一个 next 指针。我习惯这样定义:

typedef struct slist_node {
    struct slist_node *next;
} slist_node_t;

typedef struct {
    slist_node_t *head;
    int size;
} slist_t;

注意,节点里没有数据字段。为什么?因为数据是调用者自己定义的。调用者可以把 slist_node_t 嵌入到自己的结构体里:

typedef struct {
    slist_node_t node;  // 链表节点
    int id;
    char name[32];
} my_data_t;

这种做法,说白了就是“侵入式链表”。节点和数据绑定在一起,而不是用 void* 去指向数据。我在项目中用过 void* 的方式,后来发现内存碎片和类型转换问题很头疼。侵入式链表虽然看起来“脏”一点,但性能更好,也更适合嵌入式环境。

双向链表:多一个指针,多一份灵活

双向链表比单向多了一个 prev 指针。删除操作时不需要遍历找前驱节点,这是它最大的优势。

typedef struct dlist_node {
    struct dlist_node *prev;
    struct dlist_node *next;
} dlist_node_t;

typedef struct {
    dlist_node_t *head;
    dlist_node_t *tail;
    int size;
} dlist_t;

我曾经在一个实时任务调度器里用了双向链表。任务需要频繁地插入和删除,单向链表每次删除都要从头遍历,导致调度延迟不稳定。换成双向链表后,延迟从平均 50μs 降到了 8μs。嗯,这个优化效果很明显。

小技巧:双向链表的 tail 指针可以让你在尾部插入时达到 O(1) 复杂度。单向链表如果要保持 O(1) 尾部插入,需要额外维护一个尾指针。

循环链表:让遍历回到起点

循环链表的特点是最后一个节点的 next 指向头节点。双向循环链表则更灵活,头节点的 prev 指向尾节点。

typedef struct clist_node {
    struct clist_node *next;
    struct clist_node *prev;  // 双向循环
} clist_node_t;

typedef struct {
    clist_node_t *head;
    int size;
} clist_t;

循环链表最适合“轮询”场景。比如一个传感器数据采集系统,需要循环遍历所有传感器节点。用循环链表,遍历到尾部后自动回到头部,不需要额外判断。

通用接口设计:统一操作,隐藏差异

现在我们来设计一套通用接口。不管底层是单向、双向还是循环,调用者看到的都是这些函数:

接口函数 功能描述 复杂度
list_init 初始化链表 O(1)
list_add_head 在头部插入节点 O(1)
list_add_tail 在尾部插入节点 O(1) 或 O(n)
list_remove 删除指定节点 O(1) 或 O(n)
list_foreach 遍历所有节点 O(n)
list_is_empty 判断链表是否为空 O(1)

注意,list_add_taillist_remove 的复杂度取决于底层实现。单向链表如果没有尾指针,尾部插入就是 O(n)。双向链表和循环链表则可以是 O(1)。

我建议在接口文档里明确标注每种实现的复杂度,让调用者自己权衡。我曾经见过一个同事,在单向链表上频繁调用 list_add_tail,结果性能惨不忍睹。后来我帮他改成双向链表,问题就解决了。

实战:实现一个通用的链表库

下面我们动手实现一个通用链表库。为了简化,我们以双向链表为基础,因为它的功能最全。单向链表和循环链表可以在此基础上裁剪。

// list.h
#ifndef LIST_H
#define LIST_H

typedef struct list_node {
    struct list_node *prev;
    struct list_node *next;
} list_node_t;

typedef struct {
    list_node_t *head;
    list_node_t *tail;
    int size;
} list_t;

// 初始化
void list_init(list_t *list);

// 头部插入
void list_add_head(list_t *list, list_node_t *node);

// 尾部插入
void list_add_tail(list_t *list, list_node_t *node);

// 删除节点
void list_remove(list_t *list, list_node_t *node);

// 遍历
#define list_foreach(list, node) \
    for (node = (list)->head; node != NULL; node = node->next)

// 判空
int list_is_empty(list_t *list);

#endif

实现文件:

// list.c
#include "list.h"

void list_init(list_t *list) {
    list->head = NULL;
    list->tail = NULL;
    list->size = 0;
}

void list_add_head(list_t *list, list_node_t *node) {
    node->prev = NULL;
    node->next = list->head;
    if (list->head) {
        list->head->prev = node;
    } else {
        list->tail = node;  // 空链表时,尾指针也指向新节点
    }
    list->head = node;
    list->size++;
}

void list_add_tail(list_t *list, list_node_t *node) {
    node->next = NULL;
    node->prev = list->tail;
    if (list->tail) {
        list->tail->next = node;
    } else {
        list->head = node;  // 空链表时,头指针也指向新节点
    }
    list->tail = node;
    list->size++;
}

void list_remove(list_t *list, list_node_t *node) {
    if (node->prev) {
        node->prev->next = node->next;
    } else {
        list->head = node->next;  // 删除的是头节点
    }
    if (node->next) {
        node->next->prev = node->prev;
    } else {
        list->tail = node->prev;  // 删除的是尾节点
    }
    node->prev = NULL;
    node->next = NULL;
    list->size--;
}

int list_is_empty(list_t *list) {
    return list->size == 0;
}

使用示例:

typedef struct {
    list_node_t node;
    int value;
} my_item_t;

int main() {
    list_t list;
    list_init(&list);

    my_item_t item1 = { .value = 10 };
    my_item_t item2 = { .value = 20 };

    list_add_tail(&list, &item1.node);
    list_add_tail(&list, &item2.node);

    list_node_t *pos;
    list_foreach(&list, pos) {
        my_item_t *item = (my_item_t *)pos;
        printf("value = %d\n", item->value);
    }

    return 0;
}

注意:使用 list_foreach 遍历时,不要在循环体内删除当前节点。如果需要删除,请使用 list_foreach_safe 宏(保存下一个节点的指针)。

SVG 结构图:链表库的层次设计

通用链表库层次结构 应用层(调用者) 使用 list_add_head、list_remove、list_foreach 等接口 通用接口层 list_init / list_add_head / list_add_tail / list_remove / list_foreach 实现层(三种链表) 单向链表 / 双向链表 / 循环链表 节点层(侵入式设计) list_node_t 嵌入到用户结构体中,不管理数据内存

避坑指南:我踩过的那些坑

我曾经在一个项目里,把链表节点和数据分开管理,用 void* 指向数据。结果每次插入都要 malloc 一个节点,再 malloc 一份数据。内存碎片严重,最后系统跑了两天就挂了。后来改成侵入式链表,节点和数据一起分配,问题就解决了。

还有一次,我在中断服务函数里调用了 list_remove,结果和主循环的遍历冲突了。嗯,这里要注意:链表操作不是线程安全的。如果要在中断里操作链表,要么关中断,要么用无锁队列。

我的建议:在嵌入式系统中,尽量使用静态分配的节点池,避免动态内存分配。链表节点可以从池中取出和归还,这样既避免了内存碎片,也提高了确定性。

最后,别忘了测试边界情况:空链表删除、单节点链表删除、头尾节点操作。我每次写完链表库,都会写一个单元测试用例,专门测试这些边界。别问我为什么,问就是吃过亏。

好了,链表模块的封装就聊到这里。这套通用接口设计,我在多个项目中复用,效果不错。你可以在自己的项目里试试,根据实际需求裁剪实现。


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