軟件設計本質論(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~~