標籤:
10.1.2.1 C# 和 F# 中可重用的記憶化
如果看一下清單 10.3 中建立 add 值的代碼,可以發現,它並不真正知道加法,只是使用了 addSimple 函數,因此,也可以處理其他任何函數。為了使代碼更通用,我們可以把這個函數改成參數。
我們要寫一個函數(C# 中叫方法),參數為函數,返回這個函數的記憶化版本。參數值是做實際工作的函數,返回的函數增加了緩衝功能。清單 10.4 是C# 版本的代碼。
清單 10.4 泛型記憶化方法 (C#)
Func<T, R> Memoize<T,R>(Func<T, R> func) { [1]
var cache = new Dictionary<T,R>(); <-- 由閉包捕獲緩衝
return arg => {
R val;
if(cache.TryGetValue(arg, out val)) return val; <-- 返回緩衝值
else {
val =func(arg); | 計算值,
cache.Add(arg, val); | 加到緩衝中
returnval; |
} };
}
代碼類似於清單 10.3 中的專用加法函數;另外,我們首先建立一個緩衝,然後,返回在閉包中捕捉緩衝的匿名函式。這樣,對於每個返回的函數,將只有一個緩衝,這正是我們想要的。
方法簽名[1]表明,參數為函數 Func<T, R>,返回相同類型的函數。這樣,不改變函數的結構,只是把它封裝成另一個實現緩衝的函數。簽名是泛型的,所以,它可以使用任何接受一個參數的函數;用元組就能突破這個限制。下面的代碼實現了兩個數字相加的記憶化函數的 C# 版本:
var addMem = Memoize((Tuple<int, int>arg) => {
Console.Write("adding {0} +{1}; ", arg.Item1, arg.Item2);
return arg.Item1 + arg.Item2; });
Console.Write("{0}; ",addMem(Tuple.Create(19, 23))); |[1]
Console.Write("{0}; ",addMem(Tuple.Create(19, 23))); |
Console.Write("{0}; ", addMem(Tuple.Create(18,24))); [2]
運行這段代碼,會發現第二段代碼只輸出“adding 19+23”一次,第三塊輸出“adding 18 + 24”。這就是說,前面的加法只執行一次,因為,緩衝比較兩個元組值,當它們的元素都相等時,就找到了一個匹配。這不會處理元組的第一個實現,因為,它沒有任何相等的值實現;在 .NET 4.0 中的元群組類型,以及本章原始碼都是重寫(override)了Equals 方法,進行組件值的比較,這稱為結構比較(structural comparison),我們會在第十一章詳細介紹。要使 Memoize 方法能夠處理有多個參數的函數,還有一種選擇,重載 Func<T1, T2, R>、Func<T1, T2, T3, R>,等等。在緩衝中,仍然把元組作為鍵,但對於方法的使用者來說,它是隱藏的。
[
overload和override的區別:
override(重寫)
1、方法名、參數、返回值相同。
2、子類方法不能縮小父類方法的存取權限。
3、子類方法不能拋出比父類方法更多的異常(但子類方法可以不拋出異常)。
4、存在於父類和子類之間。
5、方法被定義為final不能被重寫。
overload(重載)
1、參數類型、個數、順序至少有一個不相同。
2、不能重載只有返回值不同的方法名。
3、存在於父類和子類、同類中。
]
在 F# 中,實現的泛型同樣代碼更加容易。我們將取清單 10.3 中寫的加法代碼,把計算的函數作為記憶化函數的參數。可以在清單 10.5 中看到 F# 版本。
清單10.5 泛型記憶化方法(F# Interactive)
> let memoize(f) =
let cache = newDictionary<_, _>() <-- 初始化由閉包捕獲的緩衝
(fun x –>
matchcache.TryGetValue(x) with
|true, v –> v
| _-> let v = f(x)
cache.Add(x, v)
v);;
val memoize : (‘a -> ‘b) -> (‘a ->‘b) [1]
在 F# 版本中,可以推斷出類型簽名[1],所以,我們不必要手工使函數成為泛型。F# 編譯器使用泛型化(generalization)就能做到,推斷出的簽名相當於 C# 代碼中顯式簽名。
這一次,我們將使用更有意義的例子來示範記憶化的有效。我們會回到全世界最喜歡的遞迴樣本:階乘函數。清單 10.6 試圖對這個函數進行記憶化(memoize),但並不完全按照計劃......
清單10.6 用實現記憶化的遞迴函式的困難 (F# Interactive)
> let rec factorial(x) = [1]
printf"factorial(%d); " x
if (x <= 0)then 1 else x * factorial(x - 1);; [2]
val factorial : int –> int
> let factorialMem = memoizefactorial [3]
val factorial : (int -> int)
> factorialMem(2);;
factorial(2); factorial(1);factorial(0); <-- 第一次計算 2!
val it : int = 1
> factorialMem(2);;
val it : int = 1 <-- 使用緩衝的值
> factorialMem(3);;
factorial(3); factorial(2); factorial(1);factorial(0) [4] 為什麼這個 2! 會重新計算
val it : int = 2
乍一看,代碼似乎是正確的。首先把階乘計算實現為簡單的遞迴函式[1],然後,使用memoize(記憶化)函數,建立函數的最佳化版本[3]。我們在後面進行測試,運行相同的調用兩次,看上去正常。第一次調用後,結果被緩衝,因此,可以重複使用。
最後一個調用[4]就不正確了,或更確切地說,沒有按照我們所希望的。問題出在記憶化只覆蓋了第一次調用,即 factorialMem(3)。factorial 函數在隨後的遞迴計算期間,直接調用了原始的函數,沒有調用記憶化的版本。要解決這個問題,需要把進行遞迴調用的這一行代碼[2],改成使用記憶化的版本(factorialMem)。這個函數在後面的代碼中聲明,所以,可以使用 let rec ... and ... 文法來聲明兩個相互遞迴的函數。
還有一個簡單的方法,就是使用 匿名函式,只把記憶化的版本公開為可重用的函數。清單 10.7 只用幾行代碼實現這個函數。
清單10.7改正後可記憶化的 factorial 函數 (F# Interactive)
> let rec factorial = memoize(fun x->
printfn"Calculating factorial(%d)" x
if (x <= 0)then 1 else x * factorial(x - 1));; [1]
warning FS0040: This and other recursivereferences to the |
object(s) being defined will be checked forinitializationsoundness | [2]
at runtime through the use of a delayedreference... |
val factorial : (int -> int)
> factorial(2);;
factorial(2); factorial(1);factorial(0); <-- 先計算幾個值
val it : int = 2
> factorial(4);;
factorial(4); factorial(3); <-- 只計算沒有的值
val it : int = 24
在這個例子中的 factorial 表示值,它不是文法意義上定義為有參數的函數,相反,它就是值(恰好是函數類型),由 memoize 函數返回的。這就是說,我們並沒有聲明遞迴的函數,而是聲明遞迴的值。我們在第八章建立決策樹時,用 let rec聲明遞迴值,但是,我們只用它來以更自然的方式寫節點,在代碼中並沒有任何的遞迴調用。
這一次,我們建立了真正遞迴的值,因為,值 factorial 在聲明它自己的時候就用到了。遞迴值的困難在於,如果我們不小心,寫出引用某個值的代碼,可能會在這個值的初始化期間,是無效的操作。下面是不正確初始化的例子:
let initialize(f) = f()
let rec num = initialize (fun _ -> num +1)
在這裡,對值 num 的引用出現在 匿名函式的內部,在初始化期間被調用,當 initialize 函數被調用時,匿名函式被調用。如果運行這個代碼,在 num 被聲明的地方,會出現執行階段錯誤。使用遞迴函式時,函數總是在執行遞迴調用的時候,才定義;代碼可能永遠保持迴圈,但是,這是不同的問題。
我們聲明的 factorial,對 factorial 值的引用出現在 匿名函式中,並不在初始化期間調用,所以,是有效聲明。而F# 編譯器在編譯時間無法區分這兩種情況,所以,會發出警告[2],並增加了運行時檢查。不要過於害怕這個!只要確保包含引用的 匿名函式不在初始化過程中進行計算,就行了。
由於 factorial 聲明進行遞迴調用時,使用記憶化版本,現在可以在任意計算步驟中,讀緩衝的值。例如,當我們計算 2 的階乘後,再計算 4 的階乘,只需要計算剩下的兩個值。
注意
到目前為止,我們已經看到了函數編程中的兩種最佳化方法。使用尾遞迴,要吧避免棧溢出,寫出更好的遞迴函式;記憶化,能夠最佳化任何無副作用的函數。
這兩種方法都非常完美地適合反覆式開發法的風格,被認為是 F# 編程的重要方面。我們可以從簡單的實現開始,往往是一個函數,可能是遞迴的,無副作用。在隨後的過程中,對代碼中需要最佳化的地方進行確認。正如我們前面看到的,演變代碼的結構,添加物件導向方面,都很容易,最佳化所需要的改變也相當簡單。迭代過程能夠在複雜的領域花很小的代價,效益顯著。
到目前為止,我們已經看到寫高效函數的通用技巧;還有一種資料結構,本身適合於非常具體的最佳化:集合類型。在下一節,我們要討論函數式列表,以及以函數方式使用 .NET 數組。
10.1.2.1 C# 和 F# 中可重用的記憶化