【问题标题】:How to implement memoization in Scala without mutability?如何在没有可变性的情况下在 Scala 中实现记忆?
【发布时间】:2023-01-10 14:51:39
【问题描述】:

我最近正在阅读《程序员范畴论》,在其中一项挑战中,Bartosz 建议编写一个名为记忆它以一个函数作为参数并返回相同的函数,不同之处在于,第一次调用这个新函数时,它存储参数的结果,然后在每次再次调用时返回这个结果。

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

问题是,如果不求助于可变性,我想不出任何方法来实现这个功能。此外,我看到的实现使用可变数据结构来完成任务。

我的问题是,是否有一种纯粹的功能性方法来实现这一点?也许没有可变性或使用一些功能技巧?

感谢您阅读我的问题和任何未来的帮助。祝你今天过得愉快!

【问题讨论】:

  • 没有可变性 AFAIK 就无法实现这一点——这并不会降低它的功能。

标签: scala thread-safety immutability memoization category-theory


【解决方案1】:

有没有一种纯粹的功能性方法来实现这一目标?

不。不是在最狭义的纯函数和使用给定签名的意义上。

TLDR:使用可变集合,没关系!

g杂质

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

您希望电话g(a) 发生什么情况?

如果 g(a) 记住了结果,(内部)状态必须改变,所以调用 g(a) 之后的状态与之前不同。 由于这可以从外部观察到,对 g 的调用有副作用,这会使您的程序不纯。

从您引用的书中,2.5 Pure and Dirty Functions

[...] 函数

  • 给定相同的输入总是产生相同的结果
  • 没有副作用

被称为纯函数.

这真的是副作用吗?

通常,至少在 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的结果。

从这个意义上说,副作用只是打印输出、写入公共变量、文件等。

因此,这个实现被认为纯的(至少在斯卡拉)。

避免可变集合

如果你真的想要避免 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)
// ...

f(a1) 的结果将缓存在g 中,但不会缓存其他内容。然后,您可以链接它并始终获得新功能。

如果您对它的更快版本感兴趣,请参阅@esse 的答案,它的作用相同,但效率更高(使用不可变映射,所以 O(log(n)) 而不是上面的函数链接列表,O(n))。

【讨论】:

  • 我真的很喜欢有人花时间和精力写出像这样格式正确、漂亮和专注的答案,所以非常感谢!另外,我真的希望可以完成类似的事情:/顺便说一句,懒惰的评估让我知道如何完成这件事,所以也谢谢你!
【解决方案2】:

让我们try(笔记:我已经更改了 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)

【讨论】:

    【解决方案3】:

    是的,有纯函数式的方法来实现多态函数记忆。这个主题出奇地深奥,甚至召唤了Yoneda Lemma,这很可能是 Bartosz 在这个练习中想到的。

    博客文章 Memoization in Haskell 通过稍微简化问题给出了一个很好的介绍:它不是查看任意函数,而是将问题限制为整数函数。

    以下 memoize 函数采用类型为 Int -> a 的函数,并且 返回同一函数的记忆版本。诀窍是转 一个函数变成一个值,因为在 Haskell 中,函数不是 记忆但价值是。 memoize 转换函数 f :: Int -> a 到无限列表 [a] 中,其第 n 个元素包含 f n 的值。 因此,列表的每个元素在首次访问时都会被评估 并由 Haskell 运行时自动缓存,这要归功于 lazy 评估。

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

    显然,该方法可以推广到任意域的功能。诀窍是想出一种方法,将域的类型用作用于“存储”先前值的惰性数据结构的索引。这是where the Yoneda Lemma comes in,我自己对该主题的理解变得脆弱。

    【讨论】:

    • 我实际上在考虑这个问题,我什至在 Stack Overflow 中有另一个问题询问如何将函数的所有输出存储在惰性列表中。但是在 Scala 中,这似乎真的很难实现:/无论如何,感谢您的回答!我希望有这样的东西存在。
    • Bartosz 在有关可表示仿函数的章节中谈到了函数记忆:bartoszmilewski.com/2015/07/29/representable-functors
    【解决方案4】:

    我发现了一个技巧,使用 Scala 3 中的多态函数类型来记忆一元函数,同时仍然在其类型参数中保持输出函数的多态性:

    import scala.collection.mutable
    
    trait Eq[A]:
        def eqv(a: A, b: A): Boolean
    
    def memoizePoly1[I[_], O[_]](
        f: [A] => Eq[I[A]] ?=> I[A] => O[A]
    ): [A] => Eq[I[A]] ?=> I[A] => O[A] =
      var memo: mutable.ArrayBuffer[(I[Any], O[Any])] = mutable.ArrayBuffer()
      {
         [A] =>
          (eq: Eq[I[A]]) ?=>
            (a: I[A]) =>
              var m = memo.asInstanceOf[mutable.ArrayBuffer[(I[A], O[A])]]
              synchronized {
                m.find((i, _) => eq.eqv(i, a))
                  .fold {
                    val r = f(a)
                    m.append((a, r))
                    r
                  }(_._2)
              }
      }
    

    函数 memoizePoly1 的类型签名被设置为这样它可以接受类型参数 A 上的任何多态函数,前提是该函数的输入可以使用应用于AI[_]类型的类型函数来计算参数类型它的输出可以使用应用于 AO[_] 类型函数来计算参数类型。如果您决定使用基于 Hash 的记忆策略,则还考虑了相等类型类 Eq 要求,您可以忽略它。

    现在展示一个函数的例子:

    def expensive[A](a: List[A]): Result[Computed[A]] = ???
    
    val memoized = memoizePoly1[List, [x] =>> Result[Computed[x]]](
        [A] => (eq: Eq[List[A]]) ?=> (in: List[A]) => expensive[A](in)
    )
    
    memoized(List(1,2,3)) // : Result[Computed[Int]] (compiles!)
    memoized(List('a', 'b', 'c')) // : Result[Computed[Char]] (compiles!)
    

    您仍然可以使用 memoizePoly1 实现非多态版本 memoize1(类似于其他解决方案建议的其他方法),如下所示:

    def memoize1[A, B](f: A => B)(using eq: Eq[A]): A => B =
      val g = memoizePoly1[[x] =>> A, [x] =>> B]([X] => (eq: Eq[A]) ?=> (a: A) => f(a))
      ((a: A) => g(using eq)(a)) 
    

    【讨论】:

      猜你喜欢
      • 2015-08-06
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2010-09-10
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多