Haskell中短暂的记忆?

时间:2012-02-24 20:46:27

标签: haskell memoization

在面向对象的语言中,当我需要在已知的生命周期中缓存/记忆函数的结果时,我通常会遵循这种模式:

  1. 创建新课程
  2. 为我想要缓存的每个功能结果添加数据成员和方法
  3. 实施方法首先检查结果是否已存储在数据成员中。如果是,则返回该值; else调用函数(使用适当的参数)并将返回的结果存储在数据成员中。
  4. 此类的对象将使用各种函数调用所需的值进行初始化。
  5. 这种基于对象的方法与此处描述的基于函数的memoization模式非常相似:http://www.bardiak.com/2012/01/javascript-memoization-pattern.html

    这种方法的主要好处是结果只保留缓存对象的生命周期。常见的用例是处理工作项列表。对于每个工作项,一个为该项创建缓存对象,使用该缓存对象处理工作项,然后在继续下一个工作项之前丢弃工作项和缓存对象。

    在Haskell中实现短暂的memoization的好方法是什么?问题的答案取决于要缓存的函数是纯粹的还是涉及IO?

    重申一下 - 看到涉及IO的功能的解决方案会很好。

4 个答案:

答案 0 :(得分:12)

让我们使用Luke Palmer的记忆库:Data.MemoCombinators

import qualified Data.MemoCombinators as Memo
import Data.Function (fix) -- we'll need this too

我要定义的东西与他的库的方式略有不同,但它基本相同(而且兼容)。 “可记忆”的东西将自己作为输入,并产生“真实”的东西。

type Memoizable a = a -> a

“memoizer”接受一个函数并生成它的memoized版本。

type Memoizer a b = (a -> b) -> a -> b

让我们写一点函数将这两件事放在一起。给定Memoizable函数和Memoizer,我们需要生成的memoized函数。

runMemo :: Memoizer a b -> Memoizable (a -> b) -> a -> b
runMemo memo f = fix (f . memo)

使用fixpoint combinator(fix)这有点神奇。不要管那个;如果你有兴趣,你可以谷歌。

所以让我们写一个经典的fib例子的Memoizable版本:

fib :: Memoizable (Integer -> Integer)
fib self = go
  where go 0 = 1
        go 1 = 1
        go n = self (n-1) + self (n-2)

使用self约定使代码简单明了。请记住,self是我们期望的这个函数的memoized版本,因此递归调用应该在self上。现在点起ghci。

ghci> let fib' = runMemo Memo.integral fib
ghci> fib' 10000
WALL OF NUMBERS CRANKED OUT RIDICULOUSLY FAST

现在,关于runMemo的一件很酷的事情是,您可以创建多个相同功能的新记忆版本,并且它们共享内存库。这意味着我可以编写一个本地创建并使用fib'的函数,但是只要fib'超出范围(或更早,取决于编译器的智能),它可以垃圾收集不必在顶层记忆。这可能会或可能不会与依赖unsafePerformIO的记忆技术很好地配合。 Data.MemoCombinators使用纯粹的,懒惰的Trie,与runMemo完美契合。您可以根据需要简单地创建memoized函数,而不是创建一个本质上成为memoization manager的对象。问题是如果你的函数是递归的,那么它必须写成Memoizable。好消息是你可以插入你想要的任何Memoizer。你甚至可以使用:

noMemo :: Memoizer a b
noMemo f = f

ghci> let fib' = runMemo noMemo fib
ghci> fib' 30 -- wait a while; it's computing stupidly
1346269

答案 1 :(得分:4)

Lazy-Haskell编程在某种程度上是一种极端的记忆范式。此外,无论你在命令式语言中做什么都可以在Haskell中使用IO monad,ST monad,monad变换器,箭头,或者你的名字。

唯一的问题是这些抽象设备比你提到的命令式等效设备要复杂得多,而且它们需要一个非常深刻的思维 - 重新布线。

答案 2 :(得分:2)

我相信上述答案都比必要的复杂得多,尽管它们可能比我要描述的更容易携带。

据我了解,ghc中有一条规则,即当输入包含lambda表达式时,每个值只计算一次。因此,您可以按如下方式创建完全短暂的memoization对象。

import qualified Data.Vector as V
indexerVector :: (t -> Int) -> V.Vector t -> Int -> [t]
indexerVector idx vec = \e -> tbl ! e
  where m = maximum $ map idx $ V.toList vec
        tbl = V.accumulate (flip (:)) (V.replicate m []) 
                 (V.map (\v -> (idx v, v)) vec)

这是做什么的?它根据第一个参数Data.Vector t计算的整数将vec中传递的所有元素作为第二个参数idx分组,并将其分组保留为Data.Vector [t]。它返回类型为Int -> [t]的函数,该函数通过此预先计算的索引值查找此分组。

我们的编译器ghc已承诺tbl只有在我们调用indexerVector时才会被淹没一次。因此,我们可能会将\e -> tbl ! e返回的lambda表达式indexVector分配给另一个值,我们可能会反复使用这些值,而不必担心tbl会被重新计算。您可以在trace上插入tbl来验证这一点。

简而言之,您的缓存对象就是这个lambda表达式。

我发现通过返回像这样的lambda表达式,几乎可以用短期对象完成的任何事情都可以完成。

答案 3 :(得分:1)

你也可以在haskell中使用相同的模式。延迟评估将负责检查是否已经评估了值。已经提到过多次,但代码示例可能很有用。在下面的示例中,memoedValue只会在需要时计算一次。

data Memoed = Memoed
  { value       :: Int
  , memoedValue :: Int
  }

memo :: Int -> Memoed
memo i = Memoed
  { value       = i
  , memoedValue = expensiveComputation i
  }

更好的是,你可以记住依赖于其他记忆值的值。你应该避免依赖循环。他们可以导致不确定

data Memoed = Memoed
  { value        :: Int
  , memoedValue1 :: Int
  , memoedValue2 :: Int
  }

memo :: Int -> Memoed
memo i = r
  where
  r = Memoed
    { value        = i
    , memoedValue1 = expensiveComputation i
    , memoedValue2 = anotherComputation (memoedValue1 r)
    }