软件设计本质论(Essential Design) —从链表设计说起
转载时请注明出处:http://blog.csdn.net/absurd/
大师说,软件设计不过是在适当的时候做出适当的决策罢了。对此我深以为然,好的设计就是做出了正确决策。然而,在多种互相竞争的因素下,要好做出正确的决策可不是件容易的事!本文以一个双向链表的设计为例,阐述一下软件设计为什么这样困难。
双向链表无疑是最简单的数据结构之一。即使没有系统的学习过《数据结构》的程序员,可能不知道AVL或者红黑(RB)树,但决不会不知道双向链表。他们会说双向链表是下面这样的数据结构:
Struct _DLIST { struct _DLIST* prev; struct _DLIST* next; TYPE value; }; |
如果用形象一点的图形来表示,可以表示为:
再辅以文字说明:像链子一样一环套一环,除第一个结点外的所有链表结点都有一个指向前一个结点的指针,除最后一个结点外的所有链表结点有一个指向后一个结点的指针,所有链表结点还有一个用于保存数据的域。
看来双向链表很简单,似乎没有什么好说的,那么本文是不是应该到此结束呢?当然不是,尽管在真正做软件设计时,双向链表是一个基本组件,所以很少有人去考虑它。但是如果要实现一套容器/算法的基本程序库,对双向链表的考虑就要纳入设计范畴了,这正是本文要说的。
思想与语言无关,而设计是与语言相关的。在《说说动态语言》中曾说“不少设计模式,在动态语言里根本就是多此一举,一切来得直截了当,根本不用如此拐弯抹角。”。我想并非是这些设计的思想在动态语言中就没有了,相反可能这些思想太重要了,以至于在语言层面已经有了支持,程序员不需要付出任何努力就可以享受这些思想的好处,所以说思想是与语言无关的。同样,如果语言对这种思想有了支持,程序员在设计时就不必考虑了,在使用不同的语言,在设计时考虑的内容有所不同,所以说设计是与语言相关的。
在本系列的序言中,我曾经说过,我们提到的方法同样适用于所有语言,但我们是基于C语言来讲解的。双向链表库设计中,有的决策正是与语言有关的,下面我们来看看用C实现双向链表遇到的问题:
1. 专用还是通用。我们面临的第一个决策可能是:设计一个专用链表还是通用链表?从现有的代码来,两者都有大量的应用。按存在即合理的原则来看,它们都是合理的。专用还是通用,这是一个两难的选择,在做出选择之前弄清它们各自的特点是有必要的。特点也就决定了它们的适用条件,这可以帮助我们做出决策。
专用链表至少具有以下优点:
类型安全:既然是专用的,前面所说节点数据的TYPE是特定的,即类型是确定的。编译器可以进行类型检查,所以专用链表是类型安全的。
性能更高。专用链表往往是给实现者自己用的,无需要对外提供干净的接口,常常把对链表的操作和对链表中数据的操作揉合在一起,省去一些函数的调用,在性能上可能会有更好的表现。
实现简单。由于是给自己用的,可以只是实现暂时用得着的函数,其它函数完全不用理会。给自己用,很多情况都在控制范围内,一些边界条件没有必要考虑,实现进一步简化。
通用链表至少具有以下优点:
代码重用。既然是通用的,也就是说一次编写到处使用,这有效的避免了代码重复。同时由于使用的地方多,测试更加严谨,代码会越来越稳定。这正是设计通用链表最重要的原因。
便于测试。通用链表要求接口函数设计得比较合理,也比较全面。可以基于契约式设计,对它进行全面测试。另外,把对链表的操作和对链表中数据的操作分开了,对链表测试更加容易。
事实上,专用链表和通用链表各自的优点正是对方的缺点,这里对它们的缺点不再多说。至于如何选择,您老自己做主,只有你自己才知道你想要什么。
2. 存值还是存指针。双向链表归根结底只是一个容器,我们的目的是用它存放我们数据。那么,我们应该在里面存放指向数据的指针,还是存放数据本身呢?为了做出这个决定,我们先研究一下两种方式各自的特点:
存放指向数据的指针的特点:
性能更高。在链表中获取、插入或删除数据,不需要拷贝数据本身,速度会更快。
更容易造成内存碎片。因为存放的是指针,数据和链表结点在两块不同的内存上,要分两次分配。链表本身就容易造成内存碎片了,若存放数据指针让内存分配次数加倍,这会加剧内存碎片倾向。
数据的生命周期难以管理。要保证数据的生命周期大于等于链表的生命期,否则会造成野指针问题。数据由链表释放还是由调用者释放呢?通常用链表释放比较简单一些,这需要调用者提供一个释放函数。也可以由链表提供一个遍历函数,由调用者遍历所有结点,在遍历时释放它们。
存放数据本身的特点:
效率较低。因为涉及到数据的拷贝,所以效率较低。
数据拷贝复杂。如果数据是基本数据类型或者普通结构,问题不大,但如果结构中包含了指其数据的指针,怎么办?这时数据要提供深拷贝函数。
同样,至于如何选择,要看情况和你当时的心情。
3. 要封装还是要性能。
封装的目的是想把变化隔离开来,也就是说如果链表的实现变化了,不会影响链表的使用者。链表的实现比较简单,你可能会认为它不会有什么变化。这可很难说,比如,现在多核CPU风行,大家都在打多线程的主意,假设你想把链表改为线程安全的,那麻烦就来了。
要达到线程安全的目的,要对所有对链表的操作进行同步处理,也就是要加锁。如果你的运气比较好,调用者都通过链表函数来访问链表,加锁操作在链表函数内部实现,这对调用者没有什么影响。但是如果调用者直接访问了链表的数据成员,这种改造一定会影响调用者。
封装隔离了变化,特别是对于那些易变的实现来说,封装会带来极大价值。当然,凡事有利必有弊,封装也会带来一点点性能上的损失。在大多数情况下,这种损失微不足道,而在一些关键的地方,也可能成性能的瓶颈。
至于选择封装还是性能,要看具体情况而定。也可以采用一种折中的方法:通过宏或者inline函数去访问数据成员。
4. 是否支持遍历,支持何种遍历。
很多人认为遍历链表很简单,不就是用一个foreach函数把链表中的元素都访问一遍吗?没错,如果是只读遍历(即不增加/删除链表的结点),那确实如此。但实际情况并非那样简单,考虑下面几种情况(为了便于说明,我们把遍历函数称为foreach,访问元素的函数称为visit):
visit函数的上下文(context)。遍历链表的主要目的是要对链表中的元素进行操作,而对元素操作并非是孤立的,可能要依赖于某个上下文(context)。比如,要把链表中的元素写入文件,此时文件句柄就是一个上下文(context)。visit函数中如何得到这个上下文呢,这是foreach函数要考虑的。
遍历时增加/删除链表的结点。要做到这一点并不是件容易的事,通常visit拿到的只是数据本身,它对数据在链表中的位置一无所知,所以它还需要额外的信息。即使它有了需要的信息,仍然不是那么简单,foreach里面还要做特殊处理,以防止引用被删除的结点。
遍历的终止条件。多数情况下,我们可能希望遍历链表中的所有结点。而有时当我们找到了需要的结点时,我们可能希望中止遍历,避免不必要的时间浪费。这是foreach要考虑的,当然实现很简单,可以根据visit的返回值来决定继续还是中止。
由此可见,遍历并非那么简单,至于是否要实现遍历,实现到何种程度,完全看你需要而定。如果你不嫌麻烦,可以使用后面的介绍的迭代器,它使遍历的实现大大简化。
5. 谁来管理内存。
链表是最容易产生内存碎片的容器,它往往有大量的结点,每个结点都占一块内存,每个内存块的大小很小。链表的优点是插入和删除操作非常迅捷,调用者既然选择了链表,也味着调用者可能频繁的做插入和删除操作,这种操作伴随着频繁分配/释放小块内存,所以会带内存碎片问题。
要对付内存碎片,通常采用专用的内存分配算法,比如固定大小分配。这种分配算法简单,问题是应该由谁来实现呢?由链表来实现吗?如果是,那么如果在平衡二叉树或者哈希表中要用到,是不是也要自己实现一套呢?所以,显然不应该由链表使用,链表只是使用者。
当然,如果底层的内存管理算法做得非常好,你可以不必考虑这一点。
6. 算法与容器是否分开。
链表只是一个容器,它的目的是方便我们对容器里的数据元素操作。对数据元素的操作即算法,是与容器合二为一,还是独立存在呢?我们当然会选择后者,原因是容器中的元素是不确定的,操作它们的算法也是不确定的。如果这里算法与链表放在一起,每次增加或者修改算法,都要修改链表,变化没有隔离开来。
算法与链表分开了,但算法可能要调用链表的函数,才能存取或者遍历链表中的元素,也就是说算法与链表的耦合仍然很紧密。这些算法本来可能也适用于哈希表或者二叉树的,现在与链表的耦合起来了,我们不得不为其它容器各写一套。
要彻底分离算法与容器,我们希望一种不暴露容器的实现,又能对容器用元素进行操作的方法。这就是迭代器模式,迭代器并非是C++的专利,在C语言里也可以使用。这里我们介绍一下C语言实现迭代器的方法(下面代码仅作演示所用,未经验证):
定义抽象的迭代器:
struct _iterator; typedef struct _iterator iterator; typedef BOOL (iterator_prev)(iterator* iter); typedef BOOL (iterator_next)(iterator* iter); typedef BOOL (iterator_advance)(iterator* iter, int offset); typedef BOOL (iterator_is_valid)(iterator* iter); typedef void* (iterator_get_data)(iterator* iter); typedef void (iterator_destroy)(iterator* iter); struct _iterator { iterator_prev prev; iterator_next next; iterator_advance advance; iterator_get_data get_data; iterator_is_valid is_valid; iterator_destroy destroy; char priv[1]; }; |
实现具体的迭代器:
typedef struct _ListIterData { List* list; ListNode* current; }ListIterData; iterator* list_begin(List* list) { ListIterData* data = NULL; iterator* iter = (iterator*)malloc(sizeof(iterator) + sizeof(ListIterData)); iter->prev = list_iter_prev; iter->next = list_iter_next; iter->advance = list_iter_advance; iter->get_data = list_iter_get_data; iter->is_valid = list_iter_is_valid; iter->destroy = list_iter_destroy; data = (ListIterData*)iter->priv; data->list = list; data->current = list->first; return iter; } |
使用迭代器:
iterator* iter = list_begin(list); for(; iter->is_valid(iter); iter->next(iter)) { data = iter->get_data(iter); ... } iter->destroy(iter); |
7. 抽象还是硬编码。
每种容器都有自己的优缺点。链表的优点是插入/删除比较快捷,因它只需要修改结点的指针,而不需要移动数据。缺点是排序不方便,它不能采用像快速排序或堆排序这样的高级排序方法,查找也不能采用像二分(折半)查找那样的高级查找方法。而数组恰恰相反,插入/删除非常慢,而排序和查找非常方便。
同一个软件,在有的条件下,可能主要是对数据进行插入/删除,很少去查找,这时用链表比较合适。在另外一种条件下,很少对数据进行插入/删除,多数情况下是查找,这时用数组比较合适。我们能不能动态的切换容器,自适应的各种情况呢?
当然可以,那就是抽象一个容器接口。所有容器都实现这个接口,调用者使用抽象的容器接口,而不是具体的容器。
这样抽象的后果是,实现复杂了,同时限制了容器的功能。因为容器接口只能提供所有容器都能实现的函数,不能支持各种容器的专用函数了。
下面我们看一下用C语言如何实现(下面代码仅作演示所用,未经验证)::
定义抽象的容器:
struct _container; typedef struct _container container; typedef iterator* (container_begin)(container* thiz); typedef BOOL (container_insert)(container* thiz, void* data); typedef BOOL (container_erase)(container* thiz, void* data); typedef BOOL (container_remove)(container* thiz, void* data); typedef void (container_destroy)(container* thiz); struct _container { container_begin begin; container_insert insert; container_erase erase; container_remove remove; container_destroy destroy; char priv[1]; }; |
实现具体的容器:
typedef struct _ListData { ListNode* first; }ListData; container* list_container_create(void) { ListData* data = NULL; container* thiz = (container*)malloc(sizeof(container) + sizeof(ListData)); thiz->begin = list_begin; thiz->insert = list_insert; thiz->erase = list_erase; thiz->remove = list_remove; thiz->destroy = list_destroy; data = (ListData*)thiz->priv; data->first = NULL; return thiz; } ///////////////////////////////////////////////////// typedef struct _VectorData { VectorNode* first; }VectorData; container* vector_container_create(void) { VectorData* data = NULL; container* thiz = (container*)malloc(sizeof(container) + sizeof(VectorData)); thiz->begin = vector_begin; thiz->insert = vector_insert; thiz->erase = vector_erase; thiz->remove = vector_remove; thiz->destroy = vector_destroy; data = (VectorData*)thiz->priv; data->first = NULL; return thiz; } |
8. 支持多线程。
要不要支持多线程?尽管我认为考虑多线程,应该从架构着手,减少同步点,最大化并发。否则各个线程之间的关系耦合太紧,同步点太多,不但于提升性能无益,反而对程序的稳定性造成伤害。但这种同步点毕竟无法避免,假设多线程都要对链表进行访问,对链表加锁就是必需的了。
由调用者加锁,还是由容器加锁呢。由调用者加锁,则使用麻烦一点,也容易出错一些,好处是链表的实现简单。由链表加锁,会简化调用者的任务,但链表实现比较复杂,有时可能要考虑实现递归锁。比如在foreach里加锁了,而在visit里又要调用删除函数,删除函数里又加锁了,直接使用pthread的函数可能会造成死锁,这时你不得不自己实现递归锁。
一个小小链表的设计,竟然要面临如此之多设计决策,其它设计是不是更复杂呢? 当然不用太悲观,孙子说,多算胜少算,而况于不算乎。考虑总比不考虑好,多考虑比少考虑好,考虑得多对问题把握得更全面。关于设计,一位同事总结得非常精辟,多想少做。考虑了不去做,和不考虑不去做,两者是不可同日而语的。
~~end~~