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