軟件設計本質論(Essential Design) —從鏈表設計說起

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

發佈了9 篇原創文章 · 獲贊 2 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章