一、基礎研究
我們在這里要理解和實現一種最基本的數據結構:鏈表。首先看看實現的程序代碼:
List .h:
事實上我們觀察list.h發現前面一部分是數據結構的定義和函數的聲明,后面一部分是函數的實現。我們僅僅觀察前面一部分就可以知道這個鏈表的結構是怎么實現的了。
程序將處理的對象分成了三類:線性表、結點和元素,分別定義了它們的數據類型和操作函數,對線性表有創建、撤銷、清空操作,對元素有追加、加入、刪除、取操作,對結點有取、遍歷、創建操作,每一個操作都用一個子函數來實現。它們全部被封裝進了頭文件list.h,這是對共性的封裝。
我們用m.c對list.h進行測試:
執行結果如下:
m.c首先創建了一個字符數組來裝載要存入線性表中的元素,再定義了顯示線性表的函數showlist和顯示單個元素的函數putelement。在主函數中首先調用CreateList函數創建一個線性表,如果創建失敗會提示錯誤並返回,如果成功則調用ListAppend函數將字符數組里的內容放進線性表中,再調用showlist函數顯示字符串。之后我們調用ListInsert函數向鏈表中插入一個元素結點並顯示,再調用ListDelete函數刪除之前插入的元素,並顯示字符串。其中CreateList函數、ListInsert函數、ListDelete函數都是在list.h中的函數,是有關鏈表本身的操作,是共性,而showlist函數和putelement函數是在c文件中實現的,它們的功能是個性,是需求。showlist函數是調用TraverseList函數遍歷鏈表,並對每個元素用putelement函數進行處理,而putelement函數是將該元素打印出來。為什么在TraverseList函數里要將遍歷鏈表和處理函數分開呢?這里也是將共性和個性分離開,很多時候我們都需要遍歷鏈表,但是不一定每一次都要用同一個函數來處理。那么就把個性也用函數封裝起來。
將list.h的第一個語句typedef char EleType改為typedef int EleType,再用m1.c測試:
運行結果為:
這里把鏈表元素由字符型改成整形,只需要再在m.c里進行極小的改動,就可以實現相關功能。
再將list.h的第一個語句typedef char EleType改為typedef struct{char a;int b;} EleType,再用m2.c測試:
執行結果為:
這里要處理的鏈表元素為結構體,所以我們要定義一個結構體變量,並進行初始化,之后再插入鏈表中,然后做一些修改,則可以實現相關功能。
我們可以發現List里只有一個數據項“ChainNode *head”,為什么還要定義這個數據類型?同樣地,我們用typedef char EleType定義了線性表存儲的元素類型,其實只是將char取名為EleType而已,為什么要取這個別名而不是直接用char呢?我們在編寫程序的過程中,需要一些符號來幫助我們認識、理解、記憶變量的名字,這些符號最好是有特殊含義、能讓我們聯想起它的功能的,如果元素的類型就用char表示,那么在定義和使用元素時很容易把它與別的變量弄混,會造成程序的可讀性降低。而且如果鏈表的元素變成了int型,我們只需要將typedef char EleType改成typedef int EleType就可以了,這樣使程序易於修改和擴展。同樣地,List里只有一個數據項“ChainNode *head”,但是我們還要將它封裝在一個List數據類型中,也是考慮到了程序的擴展性和可讀性。而且如果我們在這里只定義一個頭指針的話,表達不出定義線性表的意思,是線性表里面包括頭結點,這個結點可以用一個頭指針指向,所以頭指針可以代表一個線性表,但是它們不是一個層次的東西,我們要將線性表的屬性都封裝起來才能更好的對它進行操作,這個屬性是我們抽象出來的,我們同樣可以抽象出更多的線性表的屬性添加進來以方便實現更多功能。現在我們向線性表中添加一個tail指針,使它指向鏈表的最后一個結點,那么首先要修改線性表的定義:
修改創建線性表的函數CreateList,因為創建線性表后只有一個頭結點,所以head和tail指針都指向這個結點:
撤銷線性表時要將頭尾兩個指針都釋放:
因為我們要提高ListAppend的速度,而加入元素是在線性表尾端加入的,所以我們用tail指針加入會更快:
這樣我們不用改動線性表的程序m.c就可以實現了,因為這里我們把共性和個性分離開了,使每一個函數的功能單一,獨立性高,與外部的隔絕性好。也就是我們從外部看,不用管一個函數的功能是怎么實現的,而只需要知道它的參數是什么,功能是什么,返回值是什么,這樣就保證了我們要改動程序只需要改動較小的部分。
為什么要使用一個頭結點呢?因為線性表有為空的情況,這時如果沒有頭結點,我們加入元素就沒有地方存放結點的地址,而且我們寫函數時還要專門對第一個元素進行處理。這樣容易出錯,也會使程序變得更加復雜。
程序中實現的鏈表里的元素類型都是固定的,怎么實現一個鏈表使它的元素類型為任意類型呢?要在鏈表里結點的數據空間存放任意類型的數據是不可能的,因為每個節點定義時的大小都是固定的。我們可以這樣實現:在結點里的數據空間存放指針,指針指向每一個元素處的空間,這個空間的大小可以是任意的,根據我們定義的數據類型而改變,用malloc函數動態分配內存。
但是現在的問題是我們不知道用戶傳入的數據大小是多少,像printf函數一樣用類型說明符只能實現基本數據類型而不能實現用戶自定義類型,而用戶用結構體定義的自定義大小可以為任意大小,甚至理論上是無窮大的。之前我以為要實現鏈表的每一個元素的類型都可以是不一樣的,但是后來發現應該實現的是元素類型都是一樣的,但是這個類型是由用戶決定的而不是提前先規定好的。
因為不知道數據大小,所以我們要在線性表中加入一個數據項int datasize以表示數據大小,並在main函數中創建線性表時用sizeof計算數據大小並傳給datasize;
我們將ListAppend函數、ListInsert函數實現為不定函數,這樣它們接受的參數類型就沒有限制了:
因為我們傳入ListAppend函數的鏈表數據是一個局部變量,保存在棧段中,並且在函數返回后會被釋放,所以要另外開辟空間來存儲它。這里&lp表示傳入的線性表lp在棧中的地址,&lp+1表示下一個參數,即我們要添加的數據在棧中的地址。我們用malloc函數創建一個傳入數據大小的空間並將它的地址賦給指針target。然后用memcpy函數將數據從戰中轉移到target指向的我們動態開辟的空間中。Memcpy函數的原型為:void *memcpy( void *dest, const void *src, size_t count);即從指針src指向的空間拷貝count個字節到指針dest指向的空間里。
之后修改NewChainCode函數、GetElement函數、CreateList函數就可以了,這也體現了各個函數的獨立性,否則我們可能就要修改整個程序了。
這里一定要注意的是,我們在一個指針進行賦值之后,一定要對它進行判斷,如果是0則返回,這樣可以使程序更安全、更容易調試。
現在我們就可以在c文件里定義數據結構而不用更改頭文件的內容了。我們用m1.c進行測試:
結果是正確的,注意在用CreateList創建線性表時一定要先用sizeof計算傳入的數據大小。
二、擴展研究
1、這個程序有什么特色?表現了一種什么樣的程序設計思想?
答:這個程序將共性抽象開並封裝到頭文件里,我們可以很清楚地看到頭文件list.h里封裝的都是鏈表的數據結構和方法,我們在c文件里只需要將數據傳入並用自定義的方法(比如輸出)來進行操作就可以了。這個程序的結構非常清楚:共性的抽象、個性的實現,每一個函數實現一個功能,函數與函數之間沒有聯系,這樣就可以保證一個函數出問題不會影響到其它函數。這個list.h頭文件完全可以看成一個模塊,調用它就能實現鏈表的相關功能,這是結構化的思想。
三、研究總結
程序設計需要綜合的能力和視野。這個程序頭文件里的函數其實和java里的類很像,每一個需求都是由專門的函數來實現的,函數與函數之間沒有聯系,只與調用的函數傳遞數據,這樣我們只需要考慮單個函數的功能怎么實現就夠了。而這樣首先要把問題細化為一個個小需求來實現,這需要我們在程序設計時先對問題有清楚的認識和深度的思考分析。確定每一個函數的功能、參數、返回值,然后再來實現函數,這時就是編程的細節問題了,相對程序設計要簡單得多。我們要更多地思考怎么來進行程序設計,而不是具體的技術細節。