C语言链表节点实现技巧--struct的妙用

Nov 8, 2021

目录


作者能力有限, 如果您在阅读过程中发现任何错误, 还请您务必联系本人,指出错误, 避免后来读者再学习错误的知识.谢谢!


废话


C 语言虽然只提供了非常简单的语法,但是丝毫不影响 C 语言程序员使用 C 来实现很多让人叹为观止的高级功能.

本文介绍一项在 C 语言中非常常见的链表节点实现的一个技巧.

也许你看过了好几本 C 语言的书籍,也看到过相关的介绍,但是你却没有很在意,那么这里我们来详细的学习一下.

接下来,我们将描述一个链表节点的实现,先不要失望,它的实现可能并不像所想的那么简单.


节点的定义


1typedef struct _LIST_ENTRY {
2  struct _LIST_ENTRY *Next;
3} LIST_ENTRY, *PLIST_ENTRY;
4

LIST_ENTRY 代表双向链表的一个节点. Next 是指向下一个节点的指针.

但是对于上述节点,我们没法使用它, 因为它除了能表示一个节点之外, 无法包含其他任何额外的信息.

好,这里我们假设我们想创建一个表示学生的链表,我们先定义一下学生结构吧.

1typedef struct _STUDENT {
2  char name[64];
3  int  age;
4} STUDENT, *PSTUDENT;

我们随手就写出来一个表示学生的结构体,它很简单,是因为这里我们只是用它来说明我们如果使用 LIST_ENTRY, 而并不想讲解如果构建一个学生管理系统.

为了让 STUDENT 结构可以成为链表的一个节点,我们需要将他们合并一下. 然后我们的 STUDENT 结构就变成了这样:

1typedef struct _STUDENT {
2  LIST_ENTRY list_entry;
3  char name[64];
4  int  age;
5} STUDENT, *PSTUDENT;

注意,我们将 LIST_ENTRY 结构嵌套在 STUDENT 结构的开始位置,这将使得后续的实现简单很多. 放在其他位置当然也是可以的,但是却会把事情搞得复杂起来.


使用


既然结构体定义好了,下面我们就来看看,我们如何使用这个结构体,以及这个结构体体的巧妙之处,这也是本文想要表达的东西.

再次重申一下,本文是想描述这个结构体用法的妙处,无意于实现一个完整的链表. 因此只给出了最简陋的版本.

 1#define GET_STUDENT(address, type, field) ((type *)( \
 2  (char *)(address) - \
 3  (char *)(&((type *)0)->field)))
 4
 5PLIST_ENTRY list_header = NULL; // 链表头
 6
 7// 在链表的尾部添加一个新的节点
 8int add_student(char* name, int age) {
 9  // create a student with the given parameters
10  PSTUDENT student = malloc(sizeof(STUDENT));
11  if (student == NULL)
12    return -1;
13  memset(student, 0, sizeof(STUDENT));
14
15  strcpy(student->name, name);
16  student->age = age;
17
18  if (list_header == NULL) {
19    list_header = &student->list_entry;
20  } else {
21    PLIST_ENTRY p = list_header;
22    while (p->Next) {
23      p = p->Next;
24    }
25    p->Next = &student->list_entry;
26    // student->list_entry.Next is NULL
27  }
28}
29
30int main() {
31
32  // 添加两个节点
33  add_student("student abc", 22);
34  add_student("student ijk", 25);
35
36  // 遍历整个链表
37  请注意这里!!!!
38  /////////////////////////////////////////////////////
39  for (p = list_header; p != NULL; p = p->Next) {
40    // get the student struct
41    PSTUDENT student = GET_STUDENT(p, STUDENT, list_entry);
42    // PSTUDENT student = (PSTUDENT)(((char*)p - (char*)(&((PSTUDENT)0)->list_entry)));
43    printf("student name: %s, student age: %d\n", student->name, student->age);
44  }
45  /////////////////////////////////////////////////////
46
47  // 省略释放内存的代码
48  return 0;
49}

解析


如果至此,你已经看到了它的巧妙之处,就不需要再浪费时间看接下来的部分了.

上述代码的重点在哪儿呢?

重点就是 GET_STUDENT 那个宏.

为了方便调试,我们在提供了 42 行的宏展开之后的形式以方便调试.

  1. 首先需要注意的时,我们的链表中每一个节点的类型 STUDENT,而不是 LIST_ENTRY.
  2. 但是需要注意,我们的 STUDENT 结构中第一个字段是 LIST_ENTRY,这是我们 GET_STUDETN 正常工作的前提.
  3. 那么为什么这样子就能工作呢?

首先,在 42 行加个断点调试一下,我们得到了如下结果:

在这里插入图片描述

请注意,我们此时 p 的地址和 student 的地址是一样的. 这是因为我们 LIST_ENTRY 放在 STUDENT 结构体的第一个位置,而我们在往链表中添加新节点的时候添加的都是 STUDETN 结构,在这种情况下,我们可以将一个 STUDENT 结构的指针赋值给一个 LIST_ENTRY 的指针.

这里我们在看一样整个 student 结构的内部布局:

在这里插入图片描述

这里首先看到我们 student 的内存开始地址为 0x00000000600049fb0, 这个值和我们上图中显示的一致的,因为我的计算机是 64 位机器,因此地址占用 8 的字节.

这里我们详细解析一下这些字节的意义:

(1) 因为我们 student 的第一个字段是 LIST_ENTRY,而 LIST_ENTRY 中仅包含一个指向链表下一个节点的 Next 指针. 因此毫无疑问前八个字 10a00400 06000000表示当前字节的下一个节点的首地址. 注意这里是小段表示,因此和第一张图片中看到的地址字节序列正好相反, 最高有效位在最后.

(2) 解析来的字节值止倒数第三个字节的内存都是用来保存 student->name

(3) 最后两个字节表示 student->age,它的值是小段编码的 16.

  1. 接着,我们让循环继续,定位链表的第二个节点,看一下内存布局:

在这里插入图片描述

验证一下我们上面说的对不对. 第二个节点的地址为 0x0000000060004a010, 它正好与 10a00400 06000000 吻合,因为我们知道第一个节点的 list_entry->Next 指向的正是当前节点. 它的前两个字节为 0,是因为当前节点的 Next 是空. 接下来的字段与 (2)(3) 小结相同,这里我们不再解释.

总结, LIST_ENTRY 与 STUDENT 的结构体巧妙的使用了 C 语言结构体内存布局的特点, 将 STUDENT 结构体置入一个 LIST_ENTRY 的链表. 它的优点是什么呢?

这就使得我们可以将任何结构放入我们使用 LIST_ENTRY 定义的链表中,而我们不必为每一个需要放入链表的结构单独定义相关的字段使得他们得以互联. 这样做之后我们等于将链表相关的逻辑从真正用来保存信息的结构体中抽离出来,我们在编写操作链表的方法时几乎可以不关注存入链表中的真正的 student 类型.

欢迎交流任何想法.

End…


Tags