一种通用的链表结构(来自linux源代码)

Posted on 2018-07-06 12:05  2422  阅读(130)  评论(0编辑  收藏  举报

这里以双向循环链表为例。一般定义的链表结构,如整数的链表,会用到如下结构:

struct list_int {
int n;
struct list_int* next;
struct list_int* prev;
};

那么这里会有一个很明显的缺点,如果把int换成别的数据类型,又想定义一个类似的链表,那么又得定义另一个list_xxx结构,然后把各种链表操作再重写一遍。

实际上,这个问题有一个解决方法,在《Linux内核设计与实现》这本书上有它的描述。

struct list_t {
struct list_t* next;
struct list_t* prev;
};

注意到在这个结构中并没有那个int类型的数据。那么定义一个int链表的时候应该怎么定义呢?答案是按如下结构。

struct list_int {
struct list_t list;
int n;
};

于是,在链表中,每一个list_int结构就被连接起来了。插入、删除等操作与普通的链表是相同的,这些都很容易。

举个例子,已知一个list_int结构数据,它的地址是x。我想访问它的下一项,我需要用到像x -> list . next这样的代码。乍一看,这样好像只能访问到下一项的list的地址,可是我要的是整个下一项的地址啊?在这个情景下,其实很好办。不过这个需要从内存布局说起。

来说说数据结构在内存中的布局,假设是32位计算机,内存地址p处开始,存了一个list_int类型的数据。在p处开始,首先是一个完整的list_t结构,它包含两个地址:next和prev,每个占4字节(因为是32位系统,32位=4字节),总共8字节,也就是一个list_t结构占8字节。之后则是int数据,占4字节。所以整个list_int结构占12个字节,最前面4字节是next指针,之后4字节是prev指针,最后4字节是int数据n。

x -> list . next这个语句得到的是什么呢,是x的下一项的list_t结构的地址,就是next和prev组成的结构,它位于整个list_int结构的最前面,所以x -> list . next就已经得到了x的下一项的其实地址了!这个地址是整个list_int结构的地址,也是其中的list_t结构的地址,同时还是其中的next指针的地址。在C语言中,用一个强制类型转换就可以把list_t指针转化成list_int指针,虽然看起来好像多了一步类型转换,但是在最终生成的汇编语言代码中,这一步并没有开销,毕竟值都没变。

上一个例子中,之所以能如此方便地由“下一项”中某个成员的地址直接得到“下一项”的地址,本质上是因为这个成员位于开头,导致这个成员的地址等于整个结构的地址。那么是不是任何时候都能让这个成员位于整个结构的开头呢?这个真不一定。甚至有时候,一个结构会同时位于两个链表中,例如可能有如下结构:

struct list_int {
struct list_t list1;
struct list_t list2;
int n;
};

那么这两个list结构就不可能同时位于开头了,这里的list2就不在开头了。那我通过2号链表找x的下一项的时候,x -> list2 . next就不能直接得到下一项的地址了。那么这时候得再研究一下内存布局的问题。

现在一个list_int占20个字节了,从前往后依次是4字节的list1 . next、4字节的list1 . prev、4字节的list2 . next、4字节的list2 . prev、4字节的n。如果整个list_int结构的其实地址是p的话,这些成员的地址分别是p、p+4、p+8、p+12、p+16,并且list1的地址是p,list2的地址是p+8。

x -> list2 . next得到的,其实不就是这个“p+8”吗?想得到list_int,把它减去8就可以了。然后再来一个无开销的强制类型转换就可以了。

不过在C语言里有一点要注意,例如我得到了某一项的list2的地址是q,那我想得到这一项的起始地址,我写(struct list_int*) (q - 8),那就大错特错了,因为q是一个list_t类型的地址,而一个list_t地址占8字节,你写了一个q-8,实际上它给你减了多少呢,给你减了64。看起来写q-1就完美了?那也仅限这个情景下。正确的做法是,先把q进行一个强制类型转换,成为某单字节类型的指针,例如char。所以正确的写法是(struct list_int*) ((char*) x - 8)。

不过问题还是没有完全解决。这样写的话,每次使用都要亲自判断这个成员到底在第几个字节的位置,这肯定不能接受。其实,完全可以把这个任务交给编译器。

按上面的例子,我想知道list2的地址相对整个list_int结构的地址在哪里。考虑这么一句:(struct list_int*) 0。没错,把0转化成list_int型的地址了。再看看((struct list_int*) 0) -> list2,一看就知道,非法访问,直接就会报错的语句。但是如果改一下,改成&(((struct list_int*) 0) -> list2),就是外面加了一层括号,前面加了一个取地址符。这个语句是不会报错的,因为这里只算了一下这个地址是多少,并没有真的用这个地址去访问内存。这个地址其实是很有意义的,容易看出,它就是我们要的,list2相对整个list_int结构的地址,这是因为一个假想的整个list_int的起始地址位于0,这个结构里面的list2自然就位于地址8了。

所以,我们可以写一个宏:#define OFFSET(type, member) (&(((type*) 0) -> member))。不,这么写是错的,检查一下后面的整个语句,它是什么类型的?它是一个地址,对谁取的地址?对member取的。放到我们的例子里就是对list2取的地址,而list2是list_t类型的,因此实际得到的是一个list_t型地址,可是我们要的是一个数,表示这个成员到底比整个结构的起始地址靠后几个字节,因为我们最终是要用一个地址和这个数做减法的。所以外面还需要一次强制类型转换,把它转化成unsigned int型(因为是32位系统,地址可以用unsigned int表示,如果是64位就要转化成long long类型。不过貌似有一个标准库定义了一个size_t类型可以做到在32位系统下占4字节在64位系统下占8字节),于是这个宏应该是这样的:#define OFFSET(type, member) ((unsigned) (&(((type*) 0) -> member)))(唉,括号真多。。。),想知道list2相对整个list_int的地址,调用OFFSET(struct list_int, list2)就可以了,之后怎么访问,上面已经说过了。或者也可以再写一个宏来做剩下的。