10.1.2 使用记忆化缓存结果
记忆化(Memoization)功能,可以描述为缓存函数调用的结果,听起来可能很复杂,但是,技术非常简单。正如我们前面提到的那样,在函数式编程中的大多数函数都没有副作用。这意味着,如果我们用相同的参数值,两次调用一个函数,得到的结果相同。
如果我们要得到相同的结果,与上一次得到的相同,为什么还要麻烦去再次执行这个函数呢?因此,我们可以缓存这个结果。如果我们把第一次调用的结果存储在字典中,就不必要重新计算第二次调用的值。我们可以从字典中读取结果,并马上返回。清单 10.3 显示了一个函数,求两个整数的和。
Listing 10.3 Adding numbers with memoization (F# Interactive)
> open System.Collections.Generic;;
> let addSimple(a, b) =
printfn "adding %d + %d" a b
a + b
;;
val addSimple : int * int �C> int
> let add =
let cache = new Dictionary<_, _>()
(fun x �C>
match cache.TryGetValue(x) with
| true, v �C> v
| _ -> let v = addSimple(x)
cache.Add(x, v)
v)
;;
val add : (int * int -> int)
> add(2,3);;
adding 2 + 3
val it : int = 5
> add(2,3);;
val it : int = 5
清单 10.3 的第一部分是一个正常的加法函数,有一点稍许的变化是,记录其运行到控制台。如果没有这一点,我们就不会看到任何明显的差异,在原来的版本和记忆化的版本之间,因为效率的变化在这个例子中实在是太小了。
实现带缓存功能的函数被称为 add。它使用 .NET 中的字典(Dictionary)类型来存储缓存的结果。缓存被声明为一个本地值,用 lambda 表达式给 add 赋值。我们使用在第八章中,讨论的使用闭包捕获可变状态类似的模式。在这里,cache 值也是可变的(因为,字典是一个可变的哈希表),也由闭包捕获。问题是,对于所有调用 add 函数的,我们需要使用相同的 cache 值,所以,我们必须在函数之前声明它,但我们不希望使该它成为全局值。
该函数的最后一部分是 lambda 函数。当结果还没有缓存时,它才使用 addSimple 函数。 正如你可以从F#Interactive 会话中看到的,
做计算的函数,只在第一次才运行。
这种技术比尾递归的应用更广泛。它可应用于任何没有任何副作用[1]的函数。这意味着,我们也可以把它成功用到 C# 3.0。在下一小节中,我们将使用 C# 3.0 把这段代码写成更通用的版本。
在 C# 和 F# 可重用的记忆化
如果你看一下清单 10.3 中建立 add 值的代码,你可以看到它并没有真正了解加法。它使用了 addSimple 函数,但它也可以处理其他任何函数。为了使代码更通用,我们可以把这个函数变成一个参数。
我们要写一个函数(或 C# 中的方法),取一个函数作为参数,返回这个函数的记忆化版本。参数值是完成实际工作的函数,返回的函数增加了缓存功能。 你可以在清单 10.4 中看到代码的 C# 版本。
Listing 10.4 Generic memoization method (C#)
Func<T, R> Memoize<T, R>(Func<T, R> func) {
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);
return val;
} };
}
这个代码类似于清单 10.3 中的特定的加法函数。另外,我们首先创建一个缓存,然后,返回一个 lambda 函数,捕捉在闭包中的缓存。
这意味着,对于每个返回的函数,将会有一个缓存,这正是我们想要的。
方法签名表明,它取一个函数 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)));
Console.Write("{0}; ", addMem(Tuple.Create(19, 23)));
Console.Write("{0}; ", addMem(Tuple.Create(18, 24)));
如果运行这段代码,你会看到第二个代码块只打印“adding 19+23” 一次,第三块打印“adding 18 + 24”。这意味着,第一个只执行一次,因为,缓存比较两个元组值,当它们的元素都是相等时,会找到一个匹配。这不会处理元组的第一个实现,因为,它没有任何相等的值实现。在 .NET 4.0 中的元组类型,以及在本章的源代码版本覆盖了 Equals 方法,来比较组件值。这就是所谓的结构比较,在第十一章,你会更多地了解它。另一个选择,用 Memoize 方法处理有多个参数的函数,重载 Func<T1, T2, R>、Func<T1, T2, T3, R>,等等。我们仍然使用元组作为缓存中的键,但它对于这个方法的用户,是隐藏的。
在 F# 中,相同的代码显示了它是多么容易使代码泛型。我们将采取在清单 10.3 中写的加法的代码,使计算的函数成为记忆化的函数的参数。你可以在清单 10.5 中看到 F# 的版本。
Listing 10.5 Generic memoization function (F# Interactive)
> let memoize(f) =
let cache = new Dictionary<_, _>()
(fun x �C>
match cache.TryGetValue(x) with
| true, v �C> v
| _ -> let v = f(x)
cache.Add(x, v)
v);;
val memoize : ('a -> 'b) -> ('a -> 'b)
在 F# 版本中,可以推断出类型签名,所以,我们不必要手工使函数成为泛型。F# 编译器使用泛型化为我们做到了,推断出的签名与 C# 代码中显式签名的对应。
这一次,我们将使用一个更有趣的例子来演示如何有效的记忆化。我们会回到全世界最喜欢的递归的例子:阶乘函数。清单 10.6 试图记忆化这个,但它并不完全按照计划......
Listing 10.6 Difficulties with memoizing recursive function (F# Interactive)
> let rec factorial(x) =
printf "factorial(%d); " x
if (x <= 0) then 1 else x * factorial(x - 1);;
val factorial : int �C> int
> let factorialMem = memoize factorial
val factorial : (int -> int)
> factorialMem(2);;
factorial(2); factorial(1); factorial(0);
val it : int = 1
> factorialMem(2);;
val it : int = 1
> factorialMem(3);;
factorial(3); factorial(2); factorial(1); factorial(0)
val it : int = 2
乍一看,代码似乎是正确的。它首先实现阶乘计算,作为一个简单的递归函数,然后,创建一个优化的版本,使用记忆化的函数。当我们在后面,测试运行相同的调用两次,似乎仍然工作。第一次调用后,结果被缓存,因此,可以重复使用。
最后一个调用不正确,或更确切地说,它不没有完成我们希望它外地人的。问题是记忆化只覆盖了第一次调用,这是 factorialMem(3)。阶乘函数在随后进行的递归计算基部的调用,直接调用了原来的函数,而不是调用记忆化的版本。要改正这个问题,我们需要改变完成递归调用的这一行代码,使用记忆化的版本(factorialMem)。这个函数在后面的代码中声明,所以,我们可以使用 let rec ... and ... 语法来声明两个相互递归的函数。
一个简单的选择是使用 lambda 函数,只暴露记忆化的版本,作为一个可重用的函数。清单 10.7 显示我们如何能够用短短几行代码实现这个。
Listing 10.7 Correctly memoized factorial function (F# Interactive)
> let rec factorial = memoize(fun x ->
printfn "Calculating factorial(%d)" x
if (x <= 0) then 1 else x * factorial(x - 1));;
warning FS0040: This and other recursive references to the
object(s) being defined will be checked for initializationsoundness
at runtime through the use of a delayed reference...
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 的引用出现在 lambda 函数的内部,在初始化期间被调用,当 initialize 函数被调用时。如果我们运行这个代码,会得到一个运行时错误,在 num 被声明处。当使用递归函数时,该函数将总是在我们将执行递归调用的时候,才被定义。代码可能永远保持循环,但是,这是一个不同的问题。
在 factorial 声明中,对 factorial 值的引用出现在 lambda 函数中,它并不在初始化期间调用,所以,它是一个有效的声明。F# 编译器在编译时无法区分这两种情况,所以,它会发出一个警告,并增加了运行时检查。不要过于害怕这个!只要确保 lambda 函数包含的这个引用不在初始化过程中进行计算。
由于 factorial 声明使用记忆化版本,成为递归调用,它现在可以读取缓存中任何步骤的计算值。例如,当我们计算 2 的阶乘后,再计算 4 的阶乘,只需要计算剩下的两个值。
注意
到目前为止,我们已经看到了两个优化技术用于函数编程。我们可以使用尾递归,避免栈溢出,写出更好的递归函数。记忆化,可用于优化任何无副作用的函数。
这两种技术都非常完美地适合迭代开发的风格,这被认为是 F# 编程的一个重要方面。我们可以从一个简单的实现开始,往往是一个函数,可能是的递归的,无副作用。在后来的过程中,确定代码需要优化的地方。正如我们早先看到的,演变代码的结构很容易,添加面向对象方面,需要优化的改变都相当简单。迭代过程可以帮助我们在复杂的领域花很小的代价,只在利益真正显著的地方。
到目前为止,我们已经看到写高效函数的通用技巧。还有一个数据结构,适合于非常具体的优化:集合类型。在下一节中,我们要讨论函数式列表,也要看一下我们如何以函数方式使用 .NET 数组。
-----------
[1] 这可能有些令人困惑,因为,在前面清单中的函数有一个副作用(打印到屏幕)。这是一种“软副作用”(soft side effect),我们可以放心地忽略。核心要求是,结果应该仅依赖于传递给函数的参数值。