【问题标题】:Scala IO monad: what's the point?Scala IO monad:有什么意义?
【发布时间】:2013-10-30 15:44:50
【问题描述】:

我最近看了一个关于如何提出 IO monad 的视频,演讲是在 scala 中进行的。我实际上想知道让函数返回 IO[A] 有什么意义。包裹在 IO 对象中的 lambda 表达式就是突变,在某个更高的点上,它们必须被观察到,我的意思是被执行,以便发生一些事情。你不只是把问题推到树上更高的地方吗?

我能看到的唯一好处是它允许延迟评估,即如果您不调用 unsafePerformIO 操作,则不会出现副作用。另外我猜程序的其他部分可以使用/共享代码并决定何时发生副作用。

我想知道这就是全部吗?可测试性有什么优势吗?我假设不是因为您必须观察否定这一点的效果。如果您使用特征/接口,您可以控制依赖关系,但不能控制对这些依赖关系产生影响的时间。

我在代码中整理了以下示例。

case class IO[+A](val ra: () => A){
  def unsafePerformIO() : A = ra();
  def map[B](f: A => B) : IO[B] = IO[B]( () => f(unsafePerformIO()))
  def flatMap[B](f: A => IO[B]) : IO[B] = {
    IO( () =>  f(ra()).unsafePerformIO())
  }
}



case class Person(age: Int, name: String)

object Runner {

  def getOlderPerson(p1: Person,p2:Person) : Person = 
    if(p1.age > p2.age) 
        p1
      else
        p2

  def printOlder(p1: Person, p2: Person): IO[Unit] = {
    IO( () => println(getOlderPerson(p1,p2)) ).map( x => println("Next") )
  }

  def printPerson(p:Person) = IO(() => {
    println(p)
    p
  })

  def main(args: Array[String]): Unit = {

    val result = printPerson(Person(31,"Blair")).flatMap(a => printPerson(Person(23,"Tom"))
                                   .flatMap(b => printOlder(a,b)))

   result.unsafePerformIO()
  }

}

您可以看到效果是如何延迟到 main 的,我认为这很酷。我是在从视频中了解到这一点后想到的。

我的实现是否正确,我的理解是否正确。

我也想知道是否要获得里程,它应该与 ValidationMonad 结合,如 ValidationMonad[IO[Person]] 这样我们可以在发生异常时短路?请思考。

布莱尔

【问题讨论】:

    标签: scala dependency-injection functional-programming monads


    【解决方案1】:

    函数的类型签名记录它是否有副作用是很有价值的。您的 IO 实现很有价值,因为它确实完成了这么多。它使您的代码更好地记录在案;如果您重构代码以尽可能多地将涉及 IO 的逻辑与不涉及 IO 的逻辑分开,那么您就使不涉及 IO 的函数更具可组合性和可测试性。您可以在没有显式 IO 类型的情况下进行相同的重构;但是使用显式类型意味着编译器可以帮助您进行分离。

    但这仅仅是开始。在您问题的代码中,IO 操作被编码为 lambda,因此是不透明的;除了运行 IO 操作之外,您无能为力,并且它在运行时的效果是硬编码的。

    这不是实现 IO monad 的唯一可能方式。

    例如,我可能会将我的 IO 操作作为扩展通用特征的案例类。然后,例如,我可以编写一个运行函数并查看它是否返回正确的 IO 操作种类的测试。

    在那些代表不同类型 IO 操作的案例类中,我可能不包括在我运行时操作的硬编码实现。相反,我可以使用 typeclass 模式将其解耦。这将允许交换 IO 操作的不同实现。例如,我可能有一组实现与生产数据库通信,而另一组实现与模拟内存数据库通信以用于测试目的。

    Bjarnason 和 Chiusano 的Scala 中的函数式编程一书的第 13 章(“外部效果和 I/O”)很好地处理了这些问题。尤其参见第 13.2.2 节,“简单 IO 类型的优点和缺点”。

    更新(2015 年 12 月):重新“交换 IO 操作的不同实现”,现在越来越多的人使用“免费单子”来做这种事情;参见例如John De Goes 的博文“A Modern Architecture for FP”。

    【讨论】:

    • 谢谢。很好的答案我今晚会看看这些想法。
    • 你有一个包含子类和类型类的代码 sn-p 吗?
    • 查看 Drexin 链接到的 Runar 的幻灯片,特别是 ConsoleIO 的内容。它演示了存在哪些 IO 操作的声明(case object GetLine ...case class PutLine ...)与运行这些操作时可能发生的情况的定义(implicit object ConsoleEffect ...)的分离。但请注意那里还有其他东西; 我所说的不是最小的代码。
    【解决方案2】:

    使用 IO monad 的好处是拥有纯程序。您不会将副作用推向更高的链条,而是消除它们。如果你有如下不纯函数:

    def greet {
      println("What is your name?")
      val name = readLine
      println(s"Hello, $name!")
    }
    

    您可以通过将其重写为来消除副作用:

    def greet: IO[Unit] = for {
      _ <- putStrLn("What is your name?")
      name <- readLn
      _ <- putStrLn(s"Hello, $name!")
    } yield ()
    

    第二个函数是引用透明的。

    可以在 scala.io 的 in Rúnar Bjarnason's slides 找到一个很好的解释为什么使用 IO monads 会导致纯程序(视频可以在 here 找到)。

    【讨论】:

    • 这里是非 FP 程序员。为什么第二个引用透明?打印将取决于包含在 greet 函数中的用户输入,不是吗?
    • @nawfal 它是引用透明的,因为它对外部世界没有影响,也不依赖于外部状态。每次运行它都会得到相同的结果:运行时会打印一些文本并要求输入的操作。如果您想执行两次,您可以调用该函数两次以获得两个相同的操作,或者您可以调用该函数一次并重用它返回的操作,您的程序在语义上将是相同的。你也可以调用它并且从不使用在语义上与从不调用函数相同的结果。
    • @puhlen 我明白了,但是当你调用返回的操作时,你有副作用,对吧?基本上你正在将副作用部分转移到其他地方?
    • 是的。本质上,代码被移动到了包含它的副作用的地方。并且每次返回 IO[Unit] 时,无论 monad 中发生了什么, IO.这与之前的 greet 方法不同,在该方法中您可能会遇到 IO 异常。想象一下 println 正在写入磁盘 IO
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2012-03-03
    • 2018-05-06
    • 2018-05-05
    • 1970-01-01
    • 2011-09-29
    • 1970-01-01
    • 2017-04-27
    相关资源
    最近更新 更多