链表的艺术——Linux内核链表分析

引言:

链表是数据结构中的重要成员之一。由于其结构简单且动态插入、删除节点用时少的优点,链表在开发中的应用场景非常多,仅次于数组(越简单应用越广)。

但是,正如其优点一样,链表的缺点也是显而易见的。这里当然不是指随机存取那些东西,而是由于链表的构造方法(在一个结构体中套入其同类型指针)使得链表本身的逻辑操作(如增加结点,删除结点,查询结点等),往往与其应用场景中的业务数据相互混杂。这导致我们每次使用链表都要进行手工打造,做过链表的人肯定对此深有了解。

能否将链表从变换莫测的业务数据中抽象出来呢?答案是肯定的。Linux内核中使用的链表就是这样一个东西,接下来本文将会带领大家一步一步从传统链表出发,分析其抽象的过程,最终得到为什么要这么做以及为什么可以这样做的结论。再强调一下,是分析其思维过程,所以本文给出的代码大都是验证性的,不是很完备,没有太多应用价值,大家不用纠结于此。

传统链表:

这个大家应该很熟悉了,就不多说了,下面是其一种结点定义方式和接口实现:

/*
*
*传统链表的结点与实现接口
*/
typedef structNode_Trad{      //传统链表结点
          int n_i;
          double d_j;
          char ch_arr_k[32];
          //...     变化莫测的业务数据
          struct Node_Trad* next;
}NT;
 
int List_Create(NT**p_head);                                          //构造一个传统链表
int List_Insert(NT*p_head, int pos, int i, double j, char *k/*...*/); //插入一个结点
int List_Delete(NT*p_head, int pos);                        //删除一个结点
int List_Entry(NT*p_head, int pos, NT *dest);     //查询结点
int List_Destroy(NT**p_head);                                //销毁一个链表</span>


由于篇幅原因,这里就不给出实现代码了。从中我们可以出传统链表的缺点,尤其是在插入接口函数上面。更严重的是我们每次更换业务数据都要重新构代码,根本没有复用性可言!

那么是什么原因阻止了链表的复用呢?显然,因为每一种业务类型都对应不同的业务结点结构体,它们在内存中形态各异:这个业务可能只需要一个char作为数据成员,而那个业务可能需要上百个字符串...我们的链表指针深陷其中,根本无法进行统一操作。所以,要想将链表抽象出来,必须统一结点口径,即无论什么业务模型,我们的结点都是一样的。这种间接性显然是指针的菜。(如果不能顺利过渡,说明对指针的理解还比较欠缺,建议继续修行,最后我也会给大家推荐几本相关书籍)

只要有了指针这个念头,那就攻克了一个重大的思想难关。具体怎么做呢,我们的结点该怎么构建呢?直接看下面的代码吧:

指针链表:

<spanstyle="font-size:14px;">/*
*
*指针链表的结点定义
*/
typedef structNode_Ptr{
          struct Node_Ptr *next;         //链接指针
          void *data;                    //业务数据指针
}NP;


正如你已经看到的,为了叙述方便,我们给这种链表起一个名字叫指针链表,以说明其内包含的是一个指针。

和传统的链表结点一样,指针链表的结点内也包含了链接域即next指针,但不同的是我们在这里不再把各种各样的业务数据包含到结点里,而是留了一个指针作为业务数据”挂载“到结点上的接口。这样一来,无论业务模型是什么样子,我们只要用一个指针指向它就好了。下面是这两种链表模型的结点连接示意图:


图2中上面那部分就是我们的链表模型了,无论下面的业务数据怎么变化,我们的链表结构都不用做任何改变。相应的增删查操作也不用改变,在链表中我们面对的数据就是一个指针。用专业的话说,业务数据对我们的链表操作是透明的。

好了,天花乱坠说了半天,到底怎么实现呢?到底能不能实现呢?还是用代码说话吧:

链表的头文件:

//ptrList.h文件
#ifndef _PTRLIST_H
#define _PTRLIST_H
#include"string.h"
#include"stdlib.h"
 
/*
*
*指针链表的结点定义
*/
typedef structNode_Ptr{
          struct Node_Ptr *next;         //链接指针
          void *data;                                        //业务数据指针
}NP;
 
int List_Create(NP**p_head);                                          //新建一个链表
int List_Insert(NP*p_head, void *p_data);                   //插入数据至链表尾
int List_Delete(NP*p_head, void *p_data);                   //删除链表指定项结点
void* List_Entry(NP*p_head, int pos);                       //返回指定位置结点地址
int List_Destroy(NP**p_head);                                         //销毁链表
 
#endif
 

链表实现文件:

//ptrList.c文件
 
#include"ptrList.h"
 
int List_Create(NP**p_head){
          //功能:新建一个链表,返回其头结点地址赋给*p_head
          NP *head = NULL;
          //构建头结点并初始化
          head = (NP*)malloc(sizeof(NP));
          if(head == NULL){
                    return -1;
          }
          head->next = NULL;
          head->data = NULL;
          //将头结点用参数返回
          *p_head = head;
          return 0;
}
int List_Insert(NP*p_head, void *p_data){
          //功能:将传入的指针参数插入到链表最后
          //说明:p_data为NULL不做特殊处理
          NP *pCur = NULL, *pM = NULL;
          if(p_head == NULL){
                    return -1;
          }
          //构建要插入的结点
          pM = (NP*)malloc(sizeof(NP));
          if(pM == NULL){
                    return -1;
          }
          pM->data = p_data;
          pM->next = NULL;
          //寻找最后一个结点
          pCur = p_head;
          while(pCur->next != NULL){
                    pCur = pCur->next;
          }
          //将结点插入
          pCur->next = pM;
          return 0;
}
int List_Delete(NP*p_head, void *p_data){        
          //功能:删除p_head指向的链表中第一个数据项地址为p_data的结点
          //说明:p_data为NULL不做特殊处理
          NP *pCur = NULL;    //指向要删除的结点
          NP *pPre = NULL;    //保存pCur前一个结点
          if(p_head == NULL){
                    return -1;
          }
          //搜索场景初始化
          pPre = p_head;
          pCur = p_head->next;
          //循环搜索
          while(pCur!=NULL &&pCur->data!=p_data){
                    pPre = pCur;
                    pCur = pCur->next;
          }
          //执行删除动作
          if(pCur != NULL){//找到了
                    pPre->next =pCur->next;
                    free(pCur);
          }
          return 0;
}
void* List_Entry(NP*p_head, int pos){            
          //功能:返回p_head指向链表的第pos个结点的数据项指针
          //说明:如pos(最小为1)超出索引范围则返回NULL
          NP *pCur = NULL;               //指向目标结点
          int index = 1;                           //结点计数
          if(p_head==NULL || pos<=0){
                    return NULL;
          }
          //搜索场景初始化
          pCur = p_head->next;
          //循环搜索
          while(index!=pos &&pCur!=NULL){
                    pCur = pCur->next;
                    index++;
          }
          //返回结果
          if(pCur == NULL){ //没找到
                    return NULL;
          }
          return pCur->data;
}
int List_Destroy(NP**p_head){                    
          //功能:销毁一个链表
          //说明:将其头指针置NULL
          NP *pCur = NULL;               //指向要销毁的结点
          NP *pNxt = NULL;               //要销毁的下一个结点
          if(p_head == NULL){
                    return -1;
          }
          //场景初始化
          pCur = *p_head;
          pNxt = (*p_head)->next;
          //循环删除结点
          while(pNxt != NULL){
                    free(pCur);
                    pCur = pNxt;
                    pNxt = pNxt->next;
          }
          //删除最后一个结点
          free(pCur);
          (*p_head) = NULL;
          return 0;
}

在链表实现文件中,我们用void*来封装真实业务模型的指针,即在链表看来,我们只是对void*指针进行操作,然后在业务层进行相应的类型转换,得到我们想要的指针类型。

主函数内测试文件

//main.c 文件
#include"stdio.h"
#include"string.h"
#include"stdlib.h"
#include"ptrList.h"
 
typedef structTestData{
          int n_i;
          double d_j;
          char ch_arr_k[32];
 
 
}TD;
int main(){
          //构建List_ptr的测试用例
          int i = 0;
          NP *pHead = NULL;
          TD td1, td2, td3, td4;
          td1.n_i = 1;
          td1.d_j = 1.1;
          strcpy(td1.ch_arr_k, "Hi, I amtd1!");
          td2.n_i = 2;
          td2.d_j = 2.2;
          strcpy(td2.ch_arr_k, "Hi, I amtd2!");
          td3.n_i = 3;
          td3.d_j = 3.3;
          strcpy(td3.ch_arr_k, "Hi, I amtd3!");
          td4.n_i = 4;
          td4.d_j = 4.4;
          strcpy(td4.ch_arr_k, "Hi, I amtd4!");
 
 
          //新建一个List_ptr链表
          List_Create(&pHead);
          //将用例加入链表
          List_Insert(pHead, (void*)&td1);
          List_Insert(pHead, (void*)&td2);
          List_Insert(pHead, (void*)&td3);
          List_Insert(pHead, (void*)&td4);
          //删除第二个元素
          List_Delete(pHead, (void*)&td2);
          //用查找遍历链表并打印
          for(i=0; i<3; i++){
                    TD *pTD =(TD*)List_Entry(pHead, i+1);
                    printf("%d\n",pTD->n_i);
                    printf("%lf\n",pTD->d_j);
                    printf("%s\n",pTD->ch_arr_k);
          }
          //销毁链表
          List_Destroy(&pHead);
          system("pause");
          return 0;
}

在主函数文件中先是构建了一个测试用的业务模型,然后将其几个对象加入到了我们制作的链表当中,注意加入时要转换成void*以匹配链表底层实现,然后在查询结果上进行反向转换,得到我们想要的类型,然后再对其进行相应操作。也就是业务层与底层之间传递的是void*指针,这在底层库的设计中经常用到,应提起注意。

事情并没有到此结束,让我们再来分析一下指针链表的两部分,两个指针:一个是链接域表明其是一个链表结点,另一个是数据指针域,用来实现其与业务数据数据的联系。也就是说,给我们一个结点,我们既可以顺藤摸瓜找到下一个结点,又可以通过一个指针联系到业务数据。

这不是在说绕口令,是想让大家重视这个逻辑关系。接下来我们要考虑这样一件事情:能否用一个指针来完成两个目标呢,既可以找到下一个结点,又可以联系到业务数据?既然只有两个,那我们就轮流删去看看吧。

如果删去的是链接域指针,那么问题显然就来了,怎么查找下一个结点呢?假设我们站在内存旁边,里面放了一个指针,顺着这个指针找过去得到的是一个业务模型,如果这个模型里正好有一个指针指向下一个结点...这样的是传统链表模型。现在业务模型里没有下一个结点信息了,我们只好再回到指针旁边,去哪儿找呢?上看看,下看看...哎,对了,我们只能以这个指针的地址为基础,往上找找或往下找找。如果下面正好就是下一个结点的指针,那就太好了。没错,由于去掉了链接域,我们只能用顺序存储方式来存放各个结点指针了。当然这也就不叫链表了,它有一个固定的名字,或许你已经认出来了,它就是指针数组。

如果删去的是数据指针域,那么问题又来了,怎么去找这个结点对应的业务数据呢?我们不得不又一次来的内存的世界,站到存放指针的内存块儿前,去哪儿找呢?上看看,下看看...哎,如果我们足够幸运,结点旁边正好是业务数据该多好啊。然而Linux内核设计者告诉我们,成功,是没有半点侥幸的。

内核链表:

问题很简单,我们只要把业务数据放到指针旁边就好了,而放到旁边的方法就是将指针作为业务数据的一个成员变量(精髓部分),同其他数据一同分配空间。这也就形成了Linux内核链表的构造方法。每个业务结点里放一个指针,但这个指针不像传统链表一样指向下一个结点地址,而是指向下一结点的指针。其连接模型如下:


从图中看,这个好像和传统的连接模型没有太多区别,但二者却有完全不同的操作逻辑。主要是我们可以将其内部链接域从业务数据中抽离出来,进行独立的链表操作。相对于第二种指针链表来说,它省去了结点的构造过程(在构建业务数据时构建),而链表操作只是对一个个现有指针结点的操作。(注意这一点)

让我们回到绕口令那部分,我们能否从指针结点还原出业务结点的数据呢?这里用到了一个比较底层的知识,即结构体在内存中存放方法的问题。既然说到了,就多说几句吧,我们对结构体内数据访问的过程全部是由偏移量来控制的,当C或者C++程序最终转化汇编代码时,所有变量信息都不存在了。里面不再有你定义的int n, double f, char c。取而代之的是不同的偏移量和不同的内存大小。所以当我们在写下p->a时,(假设p指向一个结构体,里面含有一个名为a的变量)编译器理解的实际是*(p+x),其中x为a的偏移量。让我们再回到*next结点,我们找到这个结点后,相当于知道了关于业务结点的一个地址,如果指针放在第一个位置,那这个位置就是业务结点的地址。用(Data*)(next)->a的形式就可以访问业务结点中对应的数据了。如果放的不是第一个结点,那还要加上一个偏移量来确定业务结点的首地址,真正的Linux内核链表就是这么做的,它定义的链表结点为双向的,并且可以将链表结点放在业务模型的任何位置。求偏移量用的是宏定义传类型的方式,属于很底层的知识的应用了,有兴趣的可以去查看源码。

由于各种原因,这里就不给出内核链表的源代码了,给出一份类似的简化代码吧:

内核链表头文件:

//kernelList.h文件
#ifndef _KERNELLIST_H
#define _KERNELLIST_H
 
typedef structNode_Kernel{
          struct Node_Kernel *next;
}NK;
 
int List_Create(NK**p_head);                                //构造一个链表
int List_Insert(NK*p_head, NK *p_insert); //在链表末尾插入一个新节点
int List_Delete(NK*p_head, NK *p_delete);         //删除指定元素
void* List_Entry(NK*p_head, int pos);             //返回指定位置的链表结点地址
int List_Destroy(NK**p_head);                               //销毁链表
 
#endif

内核链表实现文件:

//kernelList.c
#include"kernelList.h"
#include"string.h"
#include"stdio.h"
#include"stdlib.h"
 
int List_Create(NK**p_head){  //构造一个链表
          //功能:构造一个链表
          NK *pM = NULL;
          if(p_head == NULL){
                    return -1;
          }
          pM = (NK*)malloc(sizeof(NK));
          if(pM == NULL){
                    return -1;
          }
          pM->next = NULL;
          *p_head = pM;
          return 0;
}
int List_Insert(NK*p_head, NK *p_insert){
          //功能:在链表末尾插入一个新节点
          NK *pCur = NULL;//指向末尾结点
          if(p_head==NULL || p_insert==NULL){
                    return -1;
          }
          //场景初始化
          pCur = p_head;
          //循环查找
          while(pCur->next != NULL){
                    pCur = pCur->next;
          }
          //插入
          pCur->next = p_insert;
          return 0;
}
int List_Delete(NK*p_head, NK *p_delete){        
          //功能:删除指定元素
          NK *pCur = NULL;    //指向被删除元素
          NK *pPre  = NULL;   //指向被删除元素前一个
          if(p_head==NULL || p_delete==NULL){
                    return -1;
          }
          //场景初始化
          pPre = p_head;
          pCur = p_head->next;
          //循环查找
          while(pCur!=p_delete &&pCur!=NULL){
                    pPre = pCur;
                    pCur=pCur->next;
          }
          //删除
          if(pCur == NULL){//没找到,直接返回
                    return 0;
          }
          pPre->next = pCur->next;
          return 0;
 
}
void* List_Entry(NK*p_head, int pos){            
          //功能:返回指定位置的链表结点地址
          //说明:   如pos超出索引范围(最小为1),则返回NULL
          NK *pCur = NULL; //指向要查找的结点
          int index = 1; //结点计数
          if(p_head == NULL){
                    return NULL;
          }
          //场景初始化
          pCur = p_head->next;
          //循环查找
          while(pCur != NULL && index !=pos){
                    pCur = pCur->next;
                    index++;
          }
          //返回
          return pCur; //没找到则pCur为NULL
}
int List_Destroy(NK**p_head){                    
          //功能:销毁链表
          //说明:注意只需释放头结点即可,不要释放后面的结点
          free(*p_head);
          *p_head = NULL;
          return 0;
}

主函数测试文件:

#include"stdio.h"
#include"string.h"
#include"stdlib.h"
#include"kernelList.h"
 
/*
*链表格式如下
*
* typedef structKernel_Node{
*         struct Kernel_Node *next;
* }KN;
* int List_Create(KN**p_head);                                        //构造一个链表
* int List_Insert(KN*p_head, KN *p_insert); //在链表末尾插入一个新节点
* int List_Delete(KN*p_head, KN *p_delete);       //删除指定元素
* void* List_Entry(KN*p_head, int pos);           //返回指定位置的链表结点地址
*
**/
 
typedef structTestData{
          NK nk;<spanstyle="white-space:pre">                         </span>//放在第一个位置,省去计算偏移量的过程
          int n_i;
          double d_j;
          char ch_arr_k[32];
}TD;
int main(){
          //构建List_ptr的测试用例
          int i = 0;
          NK *pHead = NULL;
          TD td1, td2, td3, td4;
          td1.nk.next = NULL;
          td1.n_i = 1;
          td1.d_j = 1.1;
          strcpy(td1.ch_arr_k, "Hi, I amtd1!");
          td2.nk.next = NULL;
          td2.n_i = 2;
          td2.d_j = 2.2;
          strcpy(td2.ch_arr_k, "Hi, I amtd2!");
          td3.nk.next = NULL;
          td3.n_i = 3;
          td3.d_j = 3.3;
          strcpy(td3.ch_arr_k, "Hi, I amtd3!");
          td4.nk.next = NULL;
          td4.n_i = 4;
          td4.d_j = 4.4;
          strcpy(td4.ch_arr_k, "Hi, I amtd4!");
 
          //构造一个链表
          List_Create(&pHead);
          //插入元素
          List_Insert(pHead, (NK*)&td1);
          List_Insert(pHead, (NK*)&td2);
          List_Insert(pHead, (NK*)&td3);
          List_Insert(pHead, (NK*)&td4);
          //删除第二个元素
          List_Delete(pHead, (NK*)&td2); //删除指定元素
          //循环输出
          for(i=0; i<3; i++){
                    TD *pTD =(TD*)List_Entry(pHead, i+1);
                    printf("%d\n",pTD->n_i);
                    printf("%lf\n",pTD->d_j);
                    printf("%s\n",pTD->ch_arr_k);
          }
          //销毁链表
          List_Destroy(&pHead);
          system("pause");
          return 0;
}


实现链表模型易犯错误之一是试图释放其结点内存。从形式上来说,一套函数库中的malloc/new和free/delete数量应该是相等的。我们在插入结点时并没有分配内存,所以删除结点时就不应该是否内存,否则肯定会出错。另外多说一句,本模块内申请的内存最好在本模块内部释放,否则容易出错。对这个问题本身来说,从结点的连接示意图还有前面的叙述中我们可以看出,结点是和业务数据一起分配在栈上的,我们只是定义了一个头结点,并用函数将其连接起来而已。释放栈上的内存当然是错误的。

写在最后:

关于链表的讨论到此告一段落。从传统链表到指针链表再到最后的内核链表,带大家走了一遍从繁杂业务逻辑中抽象模型的过程。其中主要用到了指针带来的透明性以及变量在内存中靠偏移量索引等思想。这些思想比较偏向底层,一些相关书籍中多少有些介绍,比如《深度探索C++对象模型》中各种对象模型中指针的引用,以及多态实现过程中的函数指针,都是这种透明性的思想。而变量存放方式的知识很多书中都要涉及,《深度理解计算机系统》一书中讲的甚是详细。有兴趣的筒子们可以去看看。最后,个人能力有限,有误人子弟的地方还请大家批评指正。

PS:文中代码全部在VS2010中测试通过

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值