類型的本質和函數(shù)式實現(xiàn)
在上一篇文章《二叉樹迭代器算法》中,我介紹了一種基于棧的二叉樹迭代器實現(xiàn)。程序設計語言和Haskell大牛@九瓜 在看過之后評論到:
這里用了 stack 來做,有點偷懶,所以錯失了一個抽象思考機會。如果我們能夠理解二叉樹到線性表的轉換過程,完全可以把 Iterator 當作抽象的線性表來看,只要定義了關于 Iterator 的 empty, singleton, 還有 append 操作,實現(xiàn)二叉樹的 Iterator 就變得非常直觀。
“錯失了一個抽象思考機會”是什么意思呢?我理解九瓜的意思是基于棧的實現(xiàn)雖然是正確的,但它缺乏對于迭代器類型本質的理解,不具有通用性。如果能 對迭代器進行合適地抽象就可以像二叉樹遞歸遍歷一樣自然地得出二叉樹迭代器,甚至其他更復雜的數(shù)據(jù)結構,只要我們能寫出它的遍歷算法,迭代器算法都可以自 然推出。
類型的本質
九瓜提到了通過empty, singleton和append操作對Iterator進行抽象,我本來打算直接根據(jù)這個思路介紹函數(shù)式的二叉樹迭代器實現(xiàn),但是考慮到其實首要的問題 在于理解類型的本質,而并不是所有人都具備這個基礎,不如先普及一下類型基礎再進入具體實現(xiàn)。那么下面我們就先來認識一下類型到底是什么?我們先以來看看 表示元素對的Pair類型,可能有人一提到Pair類型馬上就會在腦海中浮現(xiàn)出下面的結構:
- struct Pair {
- int left;
- int right;
- }
其實,這種理解是非本質的,Pair完全可以用2個元素的數(shù)組來表示,第一個元素表示left,第二個元素表示right:
- struct Pair {
- int elements[2];
- }
上面的兩種不同表示是類型的不同實現(xiàn),而類型的本質是由操作(Operation)和操作間的關系或不變式(Invariant)所定義的,我們稱之為類型規(guī)范(Type Specification)。比如,Pair類型是這樣定義的:
- Type Pair:
- Operations:
- Pair make_pair(int x, int y)
- int get_left(Pair pair)
- int get_right(Pair pair)
- Invariants:
- get_left(make_pair(x, y)) == x //對x, y構造的Pair取左元素等于x
- get_right(make_pair(x, y)) == y //對x, y構造的Pair取右元素等于y
也就是說只要是滿足Pair類型規(guī)范,即定義了make_pair,get_left, get_right這3種操作,并且這些操作滿足上面兩個不變式,那么它這就是Pair類型。我們再來看看稍微復雜一點的Stack類型:
- Type Stack:
- Operations:
- Stack make_stack() //構造空棧
- Stack push(Stack stack, int x) //將元素x壓棧,返回新棧
- int top(stack) //獲取棧頂元素
- Stack pop(Stack stack) //將棧頂元素彈棧,返回新棧
- Invariants:
- top(push(stack, x)) == x //棧頂元素為最后一次壓棧值
- pop(push(stack, x)) == stack //對stack壓棧后再彈棧等于原來的stack
Stack類型規(guī)范簡言之就是FILO(先入后出),如果要形式化就是上面的不變式。為了加深理解,我們現(xiàn)在切換到測試視角來看一看,如果請你來編 寫一個Stack類的單元測試用例,你應該怎么寫呢?許多朋友都不清楚單元測試到底測什么?怎么測?我見過不少人用一個測試用例單獨對push做測試,用 另一個測試用例對pop單獨做測試,其主要原因就在于缺乏對類型本質的理解。其實,只要理解了類型的本質我們就知道孤立地看push或pop是沒有任何意 義的,它們的意義是在FILO關系下相互解釋的,所以測試當然是基于類型規(guī)范來測試FILO不變式!這種基于類型規(guī)范的測試是一種黑盒測試,與類型的內部 實現(xiàn)細節(jié)無關,只要單元測試覆蓋了類型所定義的所有操作和不變式,那么不管內部怎么實現(xiàn)或優(yōu)化,測試用例都不需要調整。反之,如果深入到了類型的內部實現(xiàn) 做白盒測試,那這樣的測試用例實際上就不再是反映其類型規(guī)范了,它會隨著實現(xiàn)細節(jié)的調整而失效。
更深一層來看,不僅是在Pair,Stack這樣的微觀層面,在一個系統(tǒng)的宏觀層面同樣可以采用類型視角,即考察系統(tǒng)定義了哪些操作?這些操作之間 有什么樣的關系或不變式?比如,你如何從類型的角度來看待MySQL這樣一套數(shù)據(jù)庫系統(tǒng)?MySQL系統(tǒng)定義了哪些操作?這些操作之間必須滿足怎樣的關系 和不變式?不僅如此,類型視角除了可以應用于計算機系統(tǒng),甚至還可以應用于生活中的事物,比如,你到超市購物可以寄存自己的包,在寄包的時候會獲得一張密 碼條,取包時可以通過密碼條打開箱子。你能從超市寄包這個例子中看出類型來嗎?如果你看出來了,說明你對類型的理解真正融會貫通了!
類型的函數(shù)式實現(xiàn)
上面我們介紹了類型的本質在于操作和操作間的關系,下面我們要關注的是類型的實現(xiàn)。在上面的例子中,Pair的內部結構到底是什么,是一個left 和一個right成員?還是一個兩元素的數(shù)組?沒有講,也沒關系,就好像Windows的Handle和Linux的FileDescriptor一樣, 它們都是一個標識,你并不需要關心它的值本身,你只需要用幾個相關的函數(shù)創(chuàng)建和操作它就行了(上面超市寄包例子中的密碼條和Windows中的 Handle是什么關系,你看出來了嗎?你需要理解密碼條的內容嗎?)。換句話說,只要滿足類型規(guī)范,具體實現(xiàn)是可變的,使用者只依賴于類型規(guī)范而不依賴于其具體實現(xiàn)。這在面向對象語言中意味著接口保持不變而具體實現(xiàn)可以變化(這里把public方法視為一種廣義的接口)。
下面,我們還會看到的是不僅類型的內部實現(xiàn)可以變化,而且可以根本沒有什么所謂的內部實現(xiàn)。這是什么意思呢?讓我們來思考一下,是不是Pair內部一定要有什么東西來保存構造函數(shù)傳入的left和right?我們能跳出這個定勢嗎?在函數(shù)式編程中,我們能做到:
- //Javascript
- function make_pair(x, y) {
- // 返回一個支持get_left和get_right操作的閉包(Closure)
- return {
- get_left : function() { return x },
- get_right : function() { return y }
- }
- }
- function get_left(pair) {
- return pair.get_left();
- }
- function get_right(pair) {
- return pair.get_right();
- }
- // Test case
- console.log(get_left(make_pair(1, 2))) //1
- console.log(get_right(make_pair(1, 2))) //2
#p#
上面的關鍵代碼在于make_pair的內部返回的不是一種具體的數(shù)據(jù)結構,而是一個支持get_left和get_right操作的閉包 (Closure),將來可以通過get_left和get_right來提取x, y。這種基于閉包的實現(xiàn)和我們通常采用的基于數(shù)據(jù)結構的實現(xiàn)的本質區(qū)別在哪里呢?不難發(fā)現(xiàn),基于閉包的實現(xiàn)和類型規(guī)范是完全對應的, 它并沒有引入類型規(guī)范之外的東西,而基于數(shù)據(jù)結構的實現(xiàn)則隱藏了實現(xiàn)的細節(jié)。換句話說,如果要驗證實現(xiàn)代碼的正確性,對于前者只需要比對著類型規(guī)范,對于 后者我們可能需要去仔細理解推敲其所采用的數(shù)據(jù)結構。對于Pair這樣簡單的結構二者差別不大,甚至基于數(shù)據(jù)結構的實現(xiàn)更簡單,但是對于復雜的類型就容易 體現(xiàn)出閉包實現(xiàn)的優(yōu)勢了。為了加深理解,我們再來看一個Stack的函數(shù)式實現(xiàn):
- //Javascript
- function make_stack() {
- return null
- }
- function push(stack, x) {
- return {
- top : function() { return x },
- pop : function() { return stack }
- }
- }
- function top(stack) {
- return stack.top()
- }
- function pop(stack) {
- return stack.pop()
- }
- // Test case
- var stack = make_stack()
- stack = push(stack, 1)
- stack = push(stack, 2)
- stack = push(stack, 3)
- console.log(top(stack)) //3
- stack = pop(stack)
- console.log(top(stack)) //2
- stack = push(stack, 4)
- console.log(top(stack)) //4
上面的所有函數(shù)都是采用了無副作用的純函數(shù)式設計,可能習慣面向對象編程的朋友不是很習慣,不過這不影響我們對類型的討論,而且它也很容易改造成面向對象的風格,感興趣的朋友可以自己嘗試對上面的代碼進行簡單的包裝讓它看起來像面向對象的樣子。
函數(shù)式二叉樹迭代器
上面我們介紹了類型的本質和函數(shù)式實現(xiàn),下面我們再來看看Iterator類型又該如何定義和實現(xiàn)呢? 思路當然還是從操作入手,考慮Iterator類型對應了哪些操作,它們的關系是什么?上面九瓜提示了Iterator類型可以抽象為線性表List類 型,或者說Iterator本質上是一個List。為什么呢?其實,只要跳出“如何表示數(shù)據(jù)結構”的思維,從類型角度思考就很容易理解,因為 Iterator和List都定義了相同的操作,Iterator的使用者完全不關心也不知道它背后到底是鏈表還是二叉樹,你對Iterator的操作和 一個List的操作完全一樣。正是這個原因,STL等范型庫才能通過Iterator將算法和數(shù)據(jù)結構解耦。
怎么定義一個List類型呢?九瓜提到的empty(), singleton()和append()實際上就是和List打交道最多的Lisp語言的經(jīng)典定義方式。Lisp是基于s-expression 的,s-expression既可以視為線性表又可以視為樹,本質上Lisp為List類型了構造、取首元素和取剩余元素等幾種操作:
- Type List:
- Operations:
- List empty() //構造空表,通常由()這個文字量表示
- List singleton(Element e) //構造包含一個元素的表,通常由(e)這個文字量表示
- Element first(List list) //取list的第一個元素,通常又叫car操作
- List rest(List list) //取list除第一個元素外的剩余部分,通常又叫cdr操作
- List append(List list1, List list2) //連接兩個表
- Invariants:
- append(empty(), list) == list //空表和表list連接后等于表list
- append(list, empty()) == list //空表和表list連接后等于表list
- first(singleton(e)) == e //對singleton(e)取首元素等于e
- rest(singleton(e)) == empty() //對singleton(e)取首元素外的剩余部分的結果為空表
- append(first(list), rest(list)) == list //對list的首尾兩部分進行連接等于list本身
- if list1 is not empty then
- first(append(list1, list2)) == first(list1) //對非空表list1于表list2的連接取首元素等于對非空表list1取首元素
- if list1 is not empty then
- rest(append(list1, list2)) == append(rest(list1), list2) //對非空表list1于表list2的連接取首元素等于對非空表list1取首元素
有了上面的分析,我們相應地寫出下面的List實現(xiàn):
- //Javascript
- function empty() {
- return null
- }
- function singleton(e) {
- return {
- first: function() { return e },
- rest: function() { return null }
- }
- }
- function first(list) {
- return list.first()
- }
- function rest(list) {
- return list.rest()
- }
- function append(list1, list2) {
- if (null == list1) return list2
- if (null == list2) return list1
- return {
- first : function() { return first(list1) },
- rest : function() { return append(rest(list1), list2) }
- }
- }
#p#
在此基礎上可以進一步實現(xiàn)二叉樹迭代器:
- function make_binary_tree_iterator(node) {
- return {
- first : function() {
- return null != node.left ? first(make_binary_tree_iterator(node.left)) : node
- },
- rest : function() {
- var left_it = (null == node.left ? null : make_binary_tree_iterator(node.left))
- var root_it = singleton(node)
- var right_it = (null == node.right ? null : make_binary_tree_iterator(node.right))
- var it = append(append(left_it, root_it), right_it)
- return rest(it)
- }
- }
- }
- //======== Test case ========
- var tree = {
- value : 1,
- left : {
- value : 2,
- left : { value : 4, left : null, right : null },
- right : null
- },
- right : {
- value : 3,
- left : null,
- right : { value : 7, left : null, right : null }
- }
- }
- for (var it = make_binary_tree_iterator(tree); null != it; it = rest(it)) {
- console.log(first(it).value)
- }
上面的make_binary_tree_iterator在List類型的基礎上按照二叉樹遍歷過程構造了一個List。不知道你是否注意到了,為什么它不像下面這個例子一樣直接返回一個List,而要構造一個閉包呢?
- function make_binary_tree_iterator(node) {
- var left_it = (null == node.left ? null : make_binary_tree_iterator(node.left))
- var root_it = singleton(node)
- var right_it = (null == node.right ? null : make_binary_tree_iterator(node.right))
- return append(append(left_it, root_it), right_it)
- }
這里關鍵的區(qū)別在于閉包是惰性求值的,也就是說只有當真正開始迭代遍歷的時候才會逐漸對各個函數(shù)進行求值,而上面的函數(shù)遞歸調用是非惰性的,會從一開始就把所有結點展開成線性表。如果你對這一點還不能很好地理解,可以嘗試在各個函數(shù)中加log跟蹤函數(shù)的調用過程。
總結
本文介紹了類型的本質在于它所定義的操作以及操作之間的關系和不變式。類型的實現(xiàn)關鍵在于滿足類型規(guī)范的要求,而具體實現(xiàn)是可以變化的,使用者和測 試用例都應該只依賴于類型規(guī)范而不依賴于具體實現(xiàn)。函數(shù)式的類型實現(xiàn)往往和類型規(guī)范是直接對應的,簡單通用,但可能有性能問題,而命令式的類型實現(xiàn)往往會 引入復雜的內部數(shù)據(jù)結構,但是其優(yōu)點是高效。這兩種實現(xiàn)并不是完全互斥的,有時候可以將二者相結合達到簡單與高效的結合。