在前面2篇文章,我们简单介绍了单链表的基本运算及其实现。同时,我们还介绍了回调函数。并将公共接口函数抽象出来,具体应用由用户提供回调函数来实现。此外,我们也注意到,采用上节提到的回调机制,当要释放链表所有元素的内存空间时,将带来些不方便或者无法直接使用。
有没有办法让这种回调机制进行更大一步的改善呢?当然有。本文参考了李先静前辈的《系统程序员成长计划》一书,并加入些自己的见解。与该书一致,本文也以双向链表的实现为载体进行深入探讨和分析。
本文引用的C语言代码采用了封装机制。为什么要封装?总体来说,封装主要有以下两大好处(具体影响后面再说):
隔离变化。程序的隐私通常是程序最容易变化的部分,比如内部数据结构,内部使用的函数和全局变量等等,把这些代码封装起来,它们的变化不会影响系统的其它部分。
降低复杂度。接口最小化是软件设计的基本原则之一,最小化接口容易被理解和使用。封装内部实现细节,只暴露最小的接口,会让系统变得简单明了,在一定程度上降低了系统的复杂度。
如何封装?
隐藏数据结构
暴露内部数据结构,会使头文件看起来杂乱无章,让调用者发蒙。其次是如果调用者图方便,直接访问这些数据结构的成员,会造成模块之间紧密耦合,给以后的修改带来困难。隐藏数据结构的方法很简单,如果是内部数据结构,外面完全不会引用,则直接放在C文件中就好了,千万不要放在头文件里。如果该数据结构在内外都要使用,则可以对外暴露结构的名字,而封装结构的实现细节,做法如下:
在头文件中声明该数据结构。
如:
struct _LrcPool;
typedef struct _LrcPool LrcPool;
在C文件中定义该数据结构。
struct _LrcPool
{
size_t unit_size;
size_t n_prealloc_units;
};
提供操作该数据结构的函数,哪怕只是存取数据结构的成员,也要包装成相应的函数。
如:
void* lrc_pool_alloc(LrcPool* thiz);
void lrc_pool_free(LrcPool* thiz, void* p);
提供创建和销毁函数。因为只是暴露了结构的名字,编译器不知道它的大小(所占内存空间),外部可以访问结构的指针(指针的大小的固定的),但不能直接声明结构的变量,所以有必要提供创建和销毁函数。
如:
这样是非法的:LrcPool lrc_pool;
应该对外提供创建和销毁函数。
LrcPool* lrc_pool_new(size_t unit_size, size_t n_prealloc_units);
void lrc_pool_destroy(LrcPool* thiz);
任何规则都有例外。有些数据结构纯粹是社交型的,为了提高性能和方便起见,常常不需要对它们进行封装,比如点(Point)和矩形(Rect)等。当然封装也不是坏事,MFC就对它们作了封装,是否需要封装要根据具体情况而定。
隐藏内部函数
内部函数通常实现一些特定的算法(如果具有通用性,应该放到一个公共函数库里),对调用者没有多大用处,但它的暴露会干扰调用者的思路,让系统看起来比实际的复杂。函数名也会污染全局名字空间,造成重名问题。它还会诱导调用者绕过正规接口走捷径,造成不必要的耦合。隐藏内部函数的做法很简单:
在头文件中,只放最小接口函数的声明。
在C文件上,所有内部函数都加上static关键字。
禁止全局变量
除了为使用单件模式(只允许一个实例存在)的情况外,任何时候都要禁止使用全局变量。这一点我反复的强调,但发现初学者还是屡禁不止,为了贪图方便而使用全局变量。请读者从现在开始就记住这一准则。
全局变量始终都会占用内存空间,共享库的全局变量是按页分配的,那怕只有一个字节的全局变量也占用一个page,所以这会造成不必要空间浪费。全局变量也会给程序并发造成困难,想把程序从单线程改为多线程将会遇到麻烦。重要的是,如果调用者直接访问这些全局变量,会造成调用者和实现者之间的耦合。
在整个系统程序员成长计划中,我们都是以面向对象的方式来设计和实现的(封装就是面向对象的主要特点之一。
下面,我们具体进行双向链表的实现进行分析.在头文件中,李先静老师是这么做的:
#include
#ifndef DLIST_H
#define DLIST_H
#ifdef __cplusplus
extern "C" {
#endif/*__cplusplus*/
typedef enum _DListRet
{
DLIST_RET_OK,
DLIST_RET_OOM,
DLIST_RET_STOP,
DLIST_RET_PARAMS,
DLIST_RET_FAIL
}DListRet;
struct _DList;
typedef struct _DList DList;
typedef void (*DListDataDestroyFunc)(void* ctx, void* data);
。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。
#ifdef __cplusplus
}
#endif/*__cplusplus*/
#endif/*DLIST*/
可见,在头文件中定义了双向链表的基本操作。包括链表结点的创建、链表结点的插入、删除、查找以及链表的建立和销毁。
我们先看下双向链表的结构:
typedef struct _DListNode
{
struct _DListNode* prev;
struct _DListNode* next;
void* data;
}DListNode;
struct _DList
{
DListNode* first;
DListDataDestroyFunc data_destroy;
void* data_destroy_ctx;
};
1. 双向链表中结点的插入
设P指向双向链表中某结点, s指向待插入的值为X的新结点,将*s插入到*p的前面:
步骤如下:
(1)s->prev = p->prev
(2)p->prev->next =s
(3)s->next = p
(4)p->prev = s
指针操作的顺序不是唯一的。但是第一步必须放到第四部前完成,否者p的前驱结点的指针就丢掉了。
下面我们按照上述原理分析李先静前辈的代码:
DListRet dlist_insert(DList* thiz, size_t index, void* data)
{
DListNode* NodeToInsert = NULL; //定义待插入结点
DListNode* NodeToInsertBefore= NULL; //在该指定结点之前插入,也就是待插入结点的后继结点
if( ( NodeToInsert= dlist_create_node(thiz, data ) ) == NULL ) //根据传入数据,创建待插入结点
{
return DLIST_RET_OOM; //创建待插入结点失败!
}
if(thiz->first == NULL)
{
thiz->first = NodeToInsert; //如果第一个结点为空,把该结点插入到第一个结点
return DLIST_RET_OK;
}
NodeToInsertBefore= dlist_get_node(thiz, index, 1); //查找要插入的结点的指针位置,也就是说在该结点指针之前插入
if(index < dlist_length(thiz)) //如果索引序号值小于链表长度,则是在链表中插入
{
if(thiz->first == NodeToInsertBefore) //如果第一个结点就是插入位置
{
thiz->first = NodeToInsert; //则把第一个结点替换为待插入结点
}
else
{
NodeToInsertBefore->prev->next = NodeToInsert; //前面讲述规则的第二步,使得指定结点的前驱的后继指向待插入结点
NodeToInsert->prev = NodeToInsertBefore->prev; //前面讲述规则的第一步,使得待插入结点的前驱指向指定结点的前驱
}
NodeToInsert->next = NodeToInsertBefore; //前面讲述规则的第三步,使得待插入结点的后继指向指定结点
NodeToInsertBefore->prev = NodeToInsert; //前面讲述规则的第四步,使得指定结点的前驱指向待插入结点
}
else //如果索引序号值大于或等于链表长度,说明是在链表末尾插入
{
NodeToInsertBefore->next = NodeToInsert;
NodeToInsert->prev = cursor;
}
return DLIST_RET_OK;
}
1. 双向链表中结点的删除
设P指向双向链表中某结点, 将P从链表从删除:
步骤如下:
(1)p->prev->next = p->next, 修改前驱结点的后继结点
(2)p->next->prev =p->prev,修改后继结点的前驱结点
(3) free(P) 释放内存空间
下面我们按照上述原理分析李先静前辈的代码:
DListRet dlist_delete(DList* thiz, size_t index)
{
DListNode* NodeToDel= dlist_get_node(thiz, index, 0); //查找待删除结点
if( NodeToDel!= NULL )
{
if( NodeToDel == thiz->first ) //如果待删除结点是第一个结点
{
thiz->first = NodeToDel->next; //那么直接使得第一个结点指针指向待删除结点
}
if( NodeToDel->next != NULL ) //如果待删除结点有后继结点
{
NodeToDel->next->prev = NodeToDel->prev; //前面所述规则的第二步,修改后继结点的前驱结点
}
if( NodeToDel->prev != NULL ) //如果待删除结点有前驱结点
{
cursor->prev->next = cursor->next; //前面所述规则的第一步,修改前驱结点的后继结点
}
dlist_destroy_node(thiz, cursor); //释放内存空间, 前面所述规则的第三步
}
return DLIST_RET_OK;
}