在Scala中实现'yield'的最佳方式是什么?

21 投票
3 回答
5559 浏览
提问于 2025-04-17 01:27

我正在为我的博士研究写代码,最近开始使用Scala。因为我经常需要处理文本,所以我习惯用Python,Python里的'yield'语句非常好用,可以帮助我处理大型、结构不太规则的文本文件,特别是在实现复杂的迭代器时。其他语言(比如C#)也有类似的功能,这也是有原因的。

我知道之前有讨论过这个问题,但那些讨论里的解决方案看起来都不太靠谱,或者解释得很糟糕,效果也不明显,限制条件也不清楚。我想写的代码大概是这样的:

import generator._

def yield_values(file:String) = {
  generate {
    for (x <- Source.fromFile(file).getLines()) {
      # Scala is already using the 'yield' keyword.
      give("something")
      for (field <- ":".r.split(x)) {
        if (field contains "/") {
          for (subfield <- "/".r.split(field)) { give(subfield) }
        } else {
          // Scala has no 'continue'.  IMO that should be considered
          // a bug in Scala.
          // Preferred: if (field.startsWith("#")) continue
          // Actual: Need to indent all following code
          if (!field.startsWith("#")) {
            val some_calculation = { ... do some more stuff here ... }
            if (some_calculation && field.startsWith("r")) {
              give("r")
              give(field.slice(1))
            } else {
              // Typically there will be a good deal more code here to handle different cases
              give(field)
            }
          }
        }
      }
    }
  }
}

我希望能看到实现generate()和give()的代码。顺便说一下,give()应该叫yield(),但Scala已经把这个关键词用了。

我了解到,由于某些我不太明白的原因,Scala的继续(continuations)可能在for语句里不太好用。如果真是这样,generate()应该提供一个尽量接近for语句的功能,因为用yield的迭代器代码几乎总是放在for循环里。

请不要给我以下这些答案:

  1. 'yield'不好,继续更好。(是的,通常来说,继续可以做更多事情。但它们真的很难理解,而且99%的情况下,迭代器就是你想要或需要的。如果Scala提供了很多强大的工具,但用起来太复杂,那这个语言就不会成功。)
  2. 这是重复的问题。(请看我上面的评论。)
  3. 你应该用流、继续、递归等重写你的代码。(请看第1条。我还想补充一点,技术上你也不需要for循环。其实,你可以用SKI组合子做任何你需要的事情。)
  4. 你的函数太长了,拆分成小块就不需要'yield'了。反正你在生产代码里也得这么做。(首先,“你不需要'yield'”在任何情况下都不一定成立。其次,这不是生产代码。第三,对于这种文本处理,通常来说,拆分函数成小块——尤其是在语言强制你这么做,因为它缺少有用的构造——只会让代码变得更难理解。)
  5. 用传入的函数重写你的代码。(技术上,是的,你可以这么做。但结果就不再是迭代器了,链式调用迭代器比链式调用函数要好得多。一般来说,语言不应该强迫我以不自然的方式编写代码——当然,Scala的创造者们也认为这一点,因为他们提供了很多语法糖。)
  6. 用这种、那种或者其他我刚想到的酷炫方法重写你的代码。

3 个回答

8

下面的实现提供了一种类似Python的生成器。

注意,下面的代码中有一个叫做 _yield 的函数,因为在Scala中 yield 已经是一个关键字,顺便说一下,它和你在Python中知道的 yield 没有关系。

import scala.annotation.tailrec
import scala.collection.immutable.Stream
import scala.util.continuations._

object Generators {
  sealed trait Trampoline[+T]

  case object Done extends Trampoline[Nothing]
  case class Continue[T](result: T, next: Unit => Trampoline[T]) extends Trampoline[T]

  class Generator[T](var cont: Unit => Trampoline[T]) extends Iterator[T] {
    def next: T = {
      cont() match {
        case Continue(r, nextCont) => cont = nextCont; r
        case _ => sys.error("Generator exhausted")
      }
    }

    def hasNext = cont() != Done
  }

  type Gen[T] = cps[Trampoline[T]]

  def generator[T](body: => Unit @Gen[T]): Generator[T] = {
    new Generator((Unit) => reset { body; Done })
  }

  def _yield[T](t: T): Unit @Gen[T] =
    shift { (cont: Unit => Trampoline[T]) => Continue(t, cont) }
}


object TestCase {
  import Generators._

  def sectors = generator {
    def tailrec(seq: Seq[String]): Unit @Gen[String] = {
      if (!seq.isEmpty) {
        _yield(seq.head)
        tailrec(seq.tail)
      }
    }

    val list: Seq[String] = List("Financials", "Materials", "Technology", "Utilities")
    tailrec(list)
  }

  def main(args: Array[String]): Unit = {
    for (s <- sectors) { println(s) }
  }
}

这个实现效果很好,包括在常见的for循环中的使用。

需要注意的是:我们要记住Python和Scala在实现继续执行的方式上是不同的。下面我们将看到生成器在Python中的典型用法,并与Scala中的用法进行比较。然后,我们会看到为什么在Scala中需要这样做。

如果你习惯用Python写代码,你可能会这样使用生成器:

// This is Scala code that does not compile :(
// This code naively tries to mimic the way generators are used in Python

def myGenerator = generator {
  val list: Seq[String] = List("Financials", "Materials", "Technology", "Utilities")
  list foreach {s => _yield(s)}
}

上面的代码无法编译。跳过所有复杂的理论,简单来说:它无法编译是因为 "for循环的类型" 和作为继续执行部分的类型不匹配。抱歉,这个解释完全失败了。让我再试一次:

如果你写了下面这样的代码,它就能正常编译:

def myGenerator = generator {
  _yield("Financials")
  _yield("Materials")
  _yield("Technology")
  _yield("Utilities")
}

这段代码能编译通过,因为生成器可以被 分解 成一系列的 yield,在这种情况下,一个 yield 与继续执行的类型匹配。更准确地说,代码可以被分解成链式的块,每个块以 yield 结束。为了更清楚地说明,我们可以认为 yield 的序列可以这样表示:

{ some code here; _yield("Financials")
    { some other code here; _yield("Materials")
        { eventually even some more code here; _yield("Technology")
            { ok, fine, youve got the idea, right?; _yield("Utilities") }}}}

再次强调,不深入复杂的理论,关键是,在一个 yield 之后,你需要提供另一个以 yield 结束的块,或者以其他方式结束这个链。这就是我们在上面的伪代码中所做的:在 yield 之后,我们打开另一个块,这个块又以 yield 结束,接着又是一个 yield,然后又以另一个 yield 结束,依此类推。显然,这个过程必须在某个时刻结束。然后我们唯一能做的就是关闭整个链。

好的。但是...我们如何能 yield 多个信息呢?答案有点模糊,但在你知道答案后会很有道理:我们需要使用尾递归,并且一个块的最后一条语句必须是 yield

  def myGenerator = generator {
    def tailrec(seq: Seq[String]): Unit @Gen[String] = {
      if (!seq.isEmpty) {
        _yield(seq.head)
        tailrec(seq.tail)
      }
    }

    val list = List("Financials", "Materials", "Technology", "Utilities")
    tailrec(list)
  }

让我们分析一下这里发生了什么:

  1. 我们的生成器函数 myGenerator 包含一些逻辑来生成信息。在这个例子中,我们简单地使用了一系列字符串。

  2. 我们的生成器函数 myGenerator 调用一个递归函数,这个函数负责 yield 多个信息,这些信息来自我们的字符串序列。

  3. 递归函数 必须在使用之前声明,否则编译器会崩溃。

  4. 递归函数 tailrec 提供了我们需要的尾递归。

这里的经验法则很简单:用递归函数替代for循环,如上所示。

注意,tailrec 只是我们找到的一个方便的名称,为了更清楚。特别是,tailrec 不一定是我们生成器函数的最后一条语句;不一定。唯一的限制是你必须提供一系列与 yield 类型匹配的块,如下所示:

  def myGenerator = generator {

    def tailrec(seq: Seq[String]): Unit @Gen[String] = {
      if (!seq.isEmpty) {
        _yield(seq.head)
        tailrec(seq.tail)
      }
    }

    _yield("Before the first call")
    _yield("OK... not yet...")
    _yield("Ready... steady... go")

    val list = List("Financials", "Materials", "Technology", "Utilities")
    tailrec(list)

    _yield("done")
    _yield("long life and prosperity")
  }

进一步说,你一定在想实际应用是怎样的,特别是如果你使用多个生成器的话。如果你能找到一种方法来 标准化 你的生成器,使其围绕一个单一的模式,这在大多数情况下会很方便,那将是个好主意。

让我们看看下面的例子。我们有三个生成器: sectorsindustriescompanies。为了简洁起见,只有 sectors 完整显示。这个生成器使用了一个 tailrec 函数,如上面已经演示过的。这里的窍门是,其他生成器也使用相同的 tailrec 函数。我们所要做的就是提供一个不同的 body 函数。

type GenP = (NodeSeq, NodeSeq, NodeSeq)
type GenR = immutable.Map[String, String]

def tailrec(p: GenP)(body: GenP => GenR): Unit @Gen[GenR] = {
  val (stats, rows, header)  = p
  if (!stats.isEmpty && !rows.isEmpty) {
    val heads: GenP = (stats.head, rows.head, header)
    val tails: GenP = (stats.tail, rows.tail, header)
    _yield(body(heads))
    // tail recursion
    tailrec(tails)(body)
  }
}

def sectors = generator[GenR] {
  def body(p: GenP): GenR = {
      // unpack arguments
      val stat, row, header = p
      // obtain name and url
      val name = (row \ "a").text
      val url  = (row \ "a" \ "@href").text
      // create map and populate fields: name and url
      var m = new scala.collection.mutable.HashMap[String, String]
      m.put("name", name)
      m.put("url",  url)
      // populate other fields
      (header, stat).zipped.foreach { (k, v) => m.put(k.text, v.text) }
      // returns a map
      m
  }

  val root  : scala.xml.NodeSeq = cache.loadHTML5(urlSectors) // obtain entire page
  val header: scala.xml.NodeSeq = ... // code is omitted
  val stats : scala.xml.NodeSeq = ... // code is omitted
  val rows  : scala.xml.NodeSeq = ... // code is omitted
  // tail recursion
  tailrec((stats, rows, header))(body)
} 

def industries(sector: String) = generator[GenR] {
  def body(p: GenP): GenR = {
      //++ similar to 'body' demonstrated in "sectors"
      // returns a map
      m
  }

  //++ obtain NodeSeq variables, like demonstrated in "sectors" 
  // tail recursion
  tailrec((stats, rows, header))(body)
} 

def companies(sector: String) = generator[GenR] {
  def body(p: GenP): GenR = {
      //++ similar to 'body' demonstrated in "sectors"
      // returns a map
      m
  }

  //++ obtain NodeSeq variables, like demonstrated in "sectors" 
  // tail recursion
  tailrec((stats, rows, header))(body)
} 
17

'yield' 不好用,继续执行的方式更好

其实,Python 的 yield 实际上就是一种继续执行的方式。

什么是继续执行?简单来说,就是保存当前执行的位置和所有相关的状态,这样你就可以稍后从这个位置继续执行。这正是 Python 的 yield 所做的,也是它的实现方式。

不过,我了解到 Python 的继续执行并不是有“界限”的。我对这个了解不多,可能我说错了。而且我也不清楚这会有什么影响。

Scala 的继续执行在运行时是不能用的——实际上,有一个 Java 的继续执行库,它通过在运行时处理字节码来实现,这样就不受 Scala 的继续执行限制。

Scala 的继续执行完全是在编译时完成的,这需要做很多工作。而且需要编译器准备好将要“继续”的代码。

这就是为什么 for-comprehensions 不好用的原因。像这样的语句:

for { x <- xs } proc(x)

如果翻译成

xs.foreach(x => proc(x))

其中 foreachxs 类中的一个方法。不幸的是,xs 类早已被编译,所以无法修改以支持继续执行。顺便提一下,这也是 Scala 没有 continue 的原因。

除此之外,是的,这个问题是重复的,没错,你应该找到其他方式来写你的代码。

30

你的问题似乎是想要完全一样的Python的yield功能,而不想听其他在Scala中实现相似功能的合理建议。如果真是这样,而且这对你来说非常重要,那为什么不直接用Python呢?Python是一门很不错的语言。除非你的博士学位是计算机科学,并且使用Scala是你论文的重要部分,否则如果你已经熟悉Python,并且真的喜欢它的一些特性和设计选择,那为什么不直接用它呢?

不过,如果你真的想学会如何在Scala中解决你的问题,实际上,对于你写的代码来说,使用分隔的继续(delimited continuations)是过于复杂的。你只需要使用flatMapped迭代器就可以了。

下面是怎么做的。

// You want to write
for (x <- xs) { /* complex yield in here */ }
// Instead you write
xs.iterator.flatMap { /* Produce iterators in here */ }

// You want to write
yield(a)
yield(b)
// Instead you write
Iterator(a,b)

// You want to write
yield(a)
/* complex set of yields in here */
// Instead you write
Iterator(a) ++ /* produce complex iterator here */

就这样!你所有的情况都可以简化为这三种之一。

在你的例子中,代码看起来会像这样:

Source.fromFile(file).getLines().flatMap(x =>
  Iterator("something") ++
  ":".r.split(x).iterator.flatMap(field =>
    if (field contains "/") "/".r.split(field).iterator
    else {
      if (!field.startsWith("#")) {
        /* vals, whatever */
        if (some_calculation && field.startsWith("r")) Iterator("r",field.slice(1))
        else Iterator(field)
      }
      else Iterator.empty
    }
  )
)

顺便说一下,Scala确实有continue;它是这样实现的(通过抛出无栈(轻量级)异常):

import scala.util.control.Breaks._
for (blah) { breakable { ... break ... } }

但是这并不能满足你的需求,因为Scala没有你想要的yield功能。

撰写回答