【问题标题】:Scala Streams: how to avoid to keeping a reference to the head (and other elements)Scala Streams:如何避免保持对头部(和其他元素)的引用
【发布时间】:2025-12-18 09:35:02
【问题描述】:

假设我有一个循环遍历Stream 元素的尾递归方法,如下所示(简化代码,未经测试):

@tailrec
def loop(s: Stream[X], acc: Y): Y = 
  s.headOption match {
    case None => acc
    case Some(x) => loop(s.tail, accumulate(x, acc))
  }

我是否在迭代时保留对流的头部(和所有其他元素)的引用,我知道应该避免这种情况? 如果是这样,那么实现相同目标的更好方法是什么?

调用它的代码(我希望)不保留引用。假设listList[X],那么代码正在调用

loop(list.sliding(n).toStream, initialY)

编辑: 我知道这可以在没有尾递归的情况下轻松完成(例如,使用foldLeft),但非简化代码一次不会只循环一个元素(有时使用s 而不是s.tail,有时使用s.tail.dropWhile(...)使用过。所以我正在寻找如何正确使用Stream

【问题讨论】:

  • accumulate 在这里做什么?从代码中,我认为loop 在这里可能是一个错误的名称。看起来Y 是这里的一个集合,如果是这样,那么你所做的一切都很好。
  • @Jatin accumulate 做什么以及Y 是什么(虚构类型)在这里并不重要,只是想表明正在对当前元素和累积结果进行某些操作。我认为loop 很好,因为我正在循环流的所有元素。
  • @herman:谢谢!这是我回答过的最好的问题!
  • 我很好奇——你为什么让函数采用 Stream 而不是 Iterator? list.sliding(n) 已经是一个迭代器,所以代码会更简单。使用迭代器可能会更有效。此外,Iterator 是一个更通用的接口,并且(从我在这里看到的)你所需要的一切。通过避免使用 Streams,您将消除您所说的内存问题。如果你真的想用 Stream 来调用它,没问题——Streams 可以给你迭代器。
  • @AmigoNico: Iterator 是可变的 - 它可能导致 strange errors

标签: scala recursion stream


【解决方案1】:

tl;dr:您的方法loop 是正确的,不会引用流的头部。您可以在无限 Stream 上对其进行测试。

让我们将您的代码示例简化到极限:

class Test {
  private[this] var next: Test = _

  final def fold(): Int = {
    next = new Test
    next.fold()
  }
}

请注意,您的 loop 方法也是某个对象的方法。

方法是final(就像Stream#foldLeft)——非常重要。

在尾递归优化后使用scalac -Xprint:all test.scala,您将得到:

final def fold(): Int = {
  <synthetic> val _$this: Test = Test.this;
  _fold(_$this: Test){
    ({
      Test.this.next = new Test();
      _fold(Test.this.next)
    }: Int)
  }
};

而且这段代码不会帮助你理解发生了什么。

通往理解的神奇之地的唯一途径是java字节码。

但是你应该记住一件事:没有method of object 这样的东西。所有方法都是“静态的”。而this 只是方法的第一个参数。如果方法是virtual,会有vtable这样的东西,但是我们的方法是final的,所以这种情况下不会有动态dispatch。

还要注意没有参数这样的东西:所有参数都只是变量,在方法执行之前初始化。

所以this 只是方法的第一个变量(索引 0)。

我们来看看字节码(javap -c Test.class):

public final int fold();
  Code:
     0: aload_0       
     1: new           #2                  // class Test
     4: dup           
     5: invokespecial #16                 // Method "<init>":()V
     8: putfield      #18                 // Field next:LTest;
    11: aload_0       
    12: getfield      #18                 // Field next:LTest;
    15: astore_0      
    16: goto          0

让我们用类似scala的伪代码编写这个方法:

static foo(var this: Test): Int {
  :start // label for goto jump

  // place variable `this` onto the stack:
  //   0: aload_0       

  // create new `Test`
  //   1: new           #2                  // class Test
  // invoke `Test` constructor
  //   4: dup           
  //   5: invokespecial #16                 // Method "<init>":()V

  // assign `this.next` field value
  //   8: putfield      #18                 // Field next:LTest;

  this.next = new Test

  // place `this.next` onto the stack
  //  11: aload_0       
  //  12: getfield      #18                 // Field next:LTest;

  // assign `this.next` to variable `this`!
  //  15: astore_0      
  this = this.next // we have no reference to the previous `this`!

  //  16: goto          0
  goto :start
}

this = this.next 之后,我们没有在堆栈或第一个变量中引用之前的this。而之前的this可以被GC去掉!

所以Stream#foldLeft 中的tail.foldLeft(...) 将替换为this = this.tail, ...; goto :start。由于this 只是@tailrec 方法的第一个参数,foldLeft 声明才有意义。

现在我们终于可以理解scalac -Xprint:all test.scala的结果了:

final def method(a: A, b: B, ...): Res = {
  <synthetic> val _$this: ThisType = ThisType.this;
  _method(_$this: Test, a: A, b: B, ...){
    ({
      // body
      _method(nextThis, nextA, nextB, ...)
    }: Res)
  }
};

意思是:

final def method(var this: ThisType, var a: A, var b: B, ...): Res = {
  // _method(_$this: Test, a: A, b: B, ...){
  :start

  // body

  //   _method(nextThis, nextA, nextB, ...)
  this = nextThis
  a = nextA
  b = nextB
  ...
  goto :start
};

这正是您在 scalac -Xprint:all 方法上使用 loop 之后将得到的结果,但 body 将是巨大的。所以在你的情况下:

...
case Some(x) =>
  this = this
  s = s.tail
  acc = accumulate(x, acc)
  goto :start
...

s = s.tail 之后,您没有引用流的头部。

【讨论】:

  • 这看起来不错。在我的情况下,该方法不是final,而是在另一个同样有效的方法中。感谢您的研究!也许我应该问“尾调用优化如何工作”。我知道不要在调用代码中保留引用,但现在我确信将它作为参数传递不会造成问题。
  • @herman: private 方法也是不可覆盖的,就像final 一样。还要注意object的所有方法都是final
【解决方案2】:

在出现这个问题的大多数情况下,更重要的问题是“调用代码是否挂在流的头部?”

真正重要的是您将另一个方法的输出直接传递给loop,而不是先将其分配给val

也就是说,我会通过使用更简单的方法来避免所有可能的混淆:

list.sliding(n).foldLeft(initialY)(accumulate)

【讨论】:

  • Stream 不是无限的。但它足够大,我不想缓存这些值。我已经用类似于实际调用代码的代码更新了这个问题。基本上使用可能是数百个元素的窗口大小n 滑动超过一百万个元素。所以我不想缓存n * 1000000 元素。
  • @Senia - 我从来没有...我想这就是我只使用迭代器的原因。
  • @senia 嗯,我想你是对的:@tailrec 让我很困惑,它似乎对不是真正递归的调用有效,我想这有助于堆栈溢出。我仍然对最初调用 foldLeftStream 对象如何即使在 foldLeft` 仍在执行时已经被垃圾回收感到困惑。
  • @herman 猜测是因为尾递归函数依赖于它们的参数,但是当你调用的方法是 Stream 类的成员
  • @herman 没有专家这样的东西,只有那些已经学到了足够多的东西才能真正意识到他们可以学到更多东西的人。