引言:
链表是数据结构中的重要成员之中的一个。因为其结构简单且动态插入、删除节点用时少的长处,链表在开发中的应用场景许多。仅次于数组(越简单应用越广)。
可是。正如其长处一样,链表的缺点也是显而易见的。这里当然不是指随机存取那些东西,而是因为链表的构造方法(在一个结构体中套入其同类型指针)使得链表本身的逻辑操作(如添加结点,删除结点,查询结点等),往往与其应用场景中的业务数据相互混杂。这导致我们每次使用链表都要进行手工打造,做过链表的人肯定对此深有了解。
是否能将链表从变换莫測的业务数据中抽象出来呢?答案是肯定的。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 ",pTD->n_i); printf("%lf ",pTD->d_j); printf("%s ",pTD->ch_arr_k); } //销毁链表 List_Destroy(&pHead); system("pause"); return 0; }
在主函数文件里先是构建了一个測试用的业务模型,然后将其几个对象增加到了我们制作的链表其中。注意增加时要转换成void*以匹配链表底层实现,然后在查询结果上进行反向转换,得到我们想要的类型。然后再对其进行对应操作。也就是业务层与底层之间传递的是void*指针,这在底层库的设计中经经常使用到。应提起注意。
事情并没有到此结束,让我们再来分析一下指针链表的两部分,两个指针:一个是链接域表明其是一个链表结点,还有一个是数据指针域,用来实现其与业务数据数据的联系。
也就是说。给我们一个结点,我们既能够顺藤摸瓜找到下一个结点,又能够通过一个指针联系到业务数据。
这不是在说绕口令。是想让大家重视这个逻辑关系。
接下来我们要考虑这样一件事情:是否能用一个指针来完毕两个目标呢。既能够找到下一个结点,又能够联系到业务数据?既然仅仅有两个。那我们就轮流删去看看吧。
假设删去的是链接域指针。那么问题显然就来了,怎么查找下一个结点呢?假设我们站在内存旁边,里面放了一个指针。顺着这个指针找过去得到的是一个业务模型。假设这个模型里正好有一个指针指向下一个结点...这种是传统链表模型。如今业务模型里没有下一个结点信息了,我们仅仅好再回到指针旁边,去哪儿找呢?上看看。下看看...哎,对了,我们仅仅能以这个指针的地址为基础,往上找找或往下找找。
假设以下正好就是下一个结点的指针,那就太好了。
没错,因为去掉了链接域,我们仅仅能用顺序存储方式来存放各个结点指针了。
当然这也就不叫链表了,它有一个固定的名字,也许你已经认出来了,它就是指针数组。
假设删去的是数据指针域。那么问题又来了,怎么去找这个结点相应的业务数据呢?我们不得不重新来的内存的世界,站到存放指针的内存块儿前,去哪儿找呢?上看看,下看看...哎。假设我们足够幸运,结点旁边正好是业务数据该多好啊。然而Linux内核设计者告诉我们,成功,是没有半点侥幸的。
内核链表:
问题非常easy,我们仅仅要把业务数据放到指针旁边就好了。而放到旁边的方法就是将指针作为业务数据的一个成员变量(精髓部分),同其它数据一同分配空间。这也就形成了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 ",pTD->n_i); printf("%lf ",pTD->d_j); printf("%s ",pTD->ch_arr_k); } //销毁链表 List_Destroy(&pHead); system("pause"); return 0; }
实现链表模型易犯错误之中的一个是试图释放其结点内存。从形式上来说,一套函数库中的malloc/new和free/delete数量应该是相等的。
我们在插入结点时并没有分配内存,所以删除结点时就不应该是否内存。否则肯定会出错。另外多说一句,本模块内申请的内存最好在本模块内部释放,否则easy出错。
对这个问题本身来说。从结点的连接示意图还有前面的叙述中我们能够看出。结点是和业务数据一起分配在栈上的,我们仅仅是定义了一个头结点。并用函数将其连接起来而已。释放栈上的内存当然是错误的。
写在最后:
关于链表的讨论到此告一段落。从传统链表到指针链表再到最后的内核链表,带大家走了一遍从繁杂业务逻辑中抽象模型的过程。当中主要用到了指针带来的透明性以及变量在内存中靠偏移量索引等思想。这些思想比較偏向底层。一些相关书籍中多少有些介绍,比方《深度探索C++对象模型》中各种对象模型中指针的引用,以及多态实现过程中的函数指针。都是这样的透明性的思想。
而变量存放方式的知识非常多书中都要涉及,《深度理解计算机系统》一书中讲的甚是具体。
有兴趣的筒子们能够去看看。最后,个人能力有限,有误人子弟的地方还请大家批评指正。
PS:文中代码所有在VS2010中測试通过