Scala IO单子:有什么用处?

26

我最近观看了一个介绍如何使用IO单子的视频,这个演讲是用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]],这样当出现异常时就可以快速中断了。你怎么看?

Blair

2个回答

30

函数的类型签名记录其是否具有副作用是有价值的。您对IO的实现很有价值,因为它确实实现了这一点。它使您的代码更易于文档化;如果您重构代码,尽可能地将涉及IO的逻辑与不涉及IO的逻辑分开,那么非IO相关的函数将变得更加可组合和可测试。您可以在没有明确的IO类型的情况下进行相同的重构;但使用明确的类型意味着编译器可以帮助您进行分离。

但这仅仅是个开始。在您问题中的代码中,IO操作被编码为lambda函数,因此它们是不透明的;除了运行它以外,您无法对IO操作执行任何操作,并且其运行时的效果是硬编码的。

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

例如,我可以将我的IO操作定义为扩展了一个共同特征的案例类。然后,我可以编写一个测试,运行一个函数并查看它是否返回了正确的IO操作类型。

在表示不同种类的IO操作的这些案例类中,我可能不会包含运行时操作的硬编码实现。相反,我可以使用类型类模式来解耦。这将允许替换不同的IO操作实现。例如,我可以有一组实现与生产数据库通信的实现,以及另一组实现与内存中的模拟数据库进行测试的实现。
有关这些问题的很好的处理方法可以在Bjarnason和Chiusano的书《Functional Programming in Scala》的第13章("External Effects and I/O")中找到。特别是13.2.2,“简单IO类型的优点和缺点”。
更新:关于“替换IO操作的不同实现”,您可以查找“自由单子”(free monad),这是一种实现该目标的方法之一。还有相关的“无标签最终”(tagless final)风格,在这种风格中,您可以独立于像IO这样的具体类型编写单子代码。

谢谢。非常好的答案,我今晚会仔细研究这些想法。 - Blair Davidson
你有考虑子类和类型类的代码片段吗? - Blair Davidson
2
请查看Drexin链接的Runar的幻灯片,特别是ConsoleIO部分。它演示了IO操作存在的声明(case object GetLine ...case class PutLine ...)与如果运行这些操作可能发生的定义(implicit object ConsoleEffect ...)之间的分离。但请注意,其中还有其他内容;它不是仅演示我所说的最小代码。 - Seth Tisue

20
使用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 ()

第二个函数是引用透明的。
使用IO单子导致纯程序的很好的解释可以在Rúnar Bjarnason的幻灯片中找到(视频可以在这里找到)。

1
这里是一个非函数式编程员。为什么第二个引用透明?打印输出将取决于包含在问候函数中的用户输入,不是吗? - nawfal
@nawfal它是引用透明的,因为它对外部世界没有影响,也不依赖于外部状态。每次运行它,你都会得到同样的东西:一个当运行时将打印一些文本并要求输入的操作。如果你想做两次,你可以调用函数两次来获得两个相同的操作,或者你可以调用函数一次并重用它返回的操作,你的程序将在语义上相同。你也可以调用它并从未使用结果,这与根本不调用函数在语义上是相同的。 - puhlen
@puhlen 我理解你的意思,但是当你调用返回的操作时,会产生副作用,对吧?基本上你是将具有副作用的部分移到其他地方了? - nawfal
是的。本质上,代码被移动到了一个包含其副作用的位置。每次返回IO[Unit],无论在monad IO中发生了什么,都会这样做。这与以前的greet方法不同,您可能会遇到IO异常。想象一下println正在写入磁盘IO。 - thlim

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