如何在Scala中实现无需可变性的记忆化?

3

我最近在阅读《程序员的范畴论》,其中Bartosz在挑战中提出编写一个名为 memoize 的函数,该函数将一个函数作为参数并返回相同的函数。不同之处在于,这个新函数第一次被调用时,它会存储参数的结果,然后每次再次调用时都会返回此结果。

def memoize[A, B](f: A => B): A => B = ???

问题在于,我想不出任何实现此功能的方法而不借助可变性。此外,我见过的实现使用可变数据结构来完成任务。
我的问题是,是否有一种纯函数式的方法来完成这个任务?也许可以避免可变性或使用一些函数式技巧?
感谢阅读我的问题和未来的任何帮助。祝您愉快!

1
据我所知,没有可变性就无法完成这个任务 - 但这并不会使其功能更少。 - Luis Miguel Mejía Suárez
3个回答

3

有没有一种纯函数式的方法来完成这个任务?

没有。就使用给定的签名和最狭义的纯函数而言,没有。

简而言之:使用可变集合即可!

g的不纯性

val g = memoize(f)
// state 1
g(a)
// state 2

你会期望调用 g(a) 会发生什么?
如果 g(a) 记忆了结果,则(内部)状态必须更改,这样在调用 g(a) 后状态与之前不同。由于这可以从外部观察到,因此对 g 的调用具有副作用,这使得您的程序是不纯的。
从你提到的书籍中,2.5节是关于"纯函数和非纯函数": > [...] 函数应该满足以下两个条件: > > - 给定相同的输入,总是产生相同的输出 > - **没有副作用** > > 被称为纯函数。
这真的算是一个副作用吗?
通常情况下,至少在Scala中,内部状态的更改不被认为是副作用。
查看Scala Book中的定义: > 纯函数是一种只依赖于其声明的输入和其内部算法来生成输出的函数。它不会从“函数范围之外”的任何其他值中读取数据,并且不会修改外部世界中的任何值。
以下这些懒计算的例子都更改了它们的内部状态,但通常仍被认为是纯函数,因为它们总是产生相同的结果,并且除了内部状态之外没有副作用:
lazy val x = 1
// state 1: x is not computed
x
// state 2: x is 1

val ll = LazyList.continually(0)
// state 1: ll = LazyList(<not computed>)
ll(0)
// state 2: ll = LazyList(0, <not computed>)

在你的情况下,等价的做法是使用一个私有的可变 Map(就像你可能找到的实现一样),例如:
def memoize[A, B](f: A => B): A => B = {
  val cache = mutable.Map.empty[A, B]
  (a: A) => cache.getOrElseUpdate(a, f(a))
}

请注意,缓存不是公开的。因此,对于纯函数f而言,从外部无法根据内存消耗、计时、反射或其他恶意行为等来判断函数f是否被调用了两次,或者g是否缓存了f的结果。
在这个意义上,副作用只指像输出打印、写入公共变量、文件等等这些东西。
因此,至少在Scala中,这个实现被认为是纯的。
避免可变集合
如果您真的想避免var和可变集合,您需要改变memoize方法的签名。这是因为,如果g不能改变内部状态,它将无法在初始化后记忆任何新信息。
(一个低效但简单的)示例如下:
def memoizeOneValue[A, B](f: A => B)(a: A): (B, A => B) = {
  val b = f(a)
  val g = (v: A) => if (v == a) b else f(v)
  (b, g)
}

val (b1, g) = memoizeOneValue(f, a1)
val (b2, h) = memoizeOneValue(g, a2)
// ...
< p > f(a1) 的结果将被缓存在g中,但没有其他的。然后,您可以链接此并始终获得一个新函数。

如果您对更快的版本感兴趣,请参见@esse的答案,它执行相同的操作,但更有效率(使用不可变映射,因此为O (log(n))而不是上面的函数链表,O(n))。


我非常喜欢有人花时间和精力撰写格式良好、优美且专注的答案,所以非常感谢你!另外,我真的很希望能够做到这样的事情 :/ 顺便说一下,惰性求值给了我一个思路,所以也感谢你! - Gabriel Santana Paredes

1

让我们尝试(注意:我已更改memoize的返回类型以存储缓存数据):

import scala.language.existentials

type M[A, B] = A => T forSome { type T <: (B, A => T) }

def memoize[A, B](f: A => B): M[A, B] = {
  import scala.collection.immutable
  
  def withCache(cache: immutable.Map[A, B]): M[A, B] = a => cache.get(a) match {
    case Some(b) => (b, withCache(cache))
    case None    =>
      val b = f(a)
      (b, withCache(cache + (a -> b)))
  }
  withCache(immutable.Map.empty)
}


def f(i: Int): Int = { print(s"Invoke f($i)"); i }


val (i0, m0) = memoize(f)(1)    // f only invoked at first time
val (i1, m1) = m0(1)
val (i2, m2) = m1(1)

1

有一种纯函数式的方法来实现多态函数记忆化。这个主题非常深奥,甚至召唤了Yoneda引理,这可能是Bartosz在这个练习中所考虑的。

博客文章Memoization in Haskell通过简化问题提供了一个不错的入门:将问题限制为从整数到任意类型的函数。

下面的memoize函数接受一个类型为Int -> a的函数,并返回相同函数的记忆化版本。技巧在于将函数转换为值,因为在Haskell中,函数不能被记忆化,但是值可以被记忆化。memoize将一个函数f :: Int -> a转换成一个无限列表[a],其中第n个元素包含f n的值。因此,当第一次访问列表的每个元素时,都会对其进行求值,并且由于惰性求值,该值会自动地被Haskell运行时缓存。

memoize :: (Int -> a) -> (Int -> a)
memoize f = (map f [0 ..] !!)

显然,这种方法可以推广到任意域的函数。关键是想出一种方法,利用域的类型作为索引,将其用于“存储”先前的值的惰性数据结构中。而这就是Yoneda引理的应用,也是我自己对这个主题的理解变得薄弱的地方。


1
我其实一直在考虑这个问题,我甚至在 Stack Overflow 上发了另一个问题,询问如何将函数的所有输出存储在 Lazy List 中。但是在 Scala 中,这似乎很难实现 :/ 无论如何,感谢您的回答!我一直希望有类似这样的东西存在。 - Gabriel Santana Paredes
1
Bartosz在关于可表示函子的章节中谈到了函数记忆化的一些内容:https://bartoszmilewski.com/2015/07/29/representable-functors/ - michid

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接