Skip to content

Commit 039f3e3

Browse files
committed
SI-8680 Stream.addString is too eager
Used the standard method of sending out two iterators, one twice as fast as the others, to avoid hanging on .force, .hasDefiniteSize, and .addString. .addString appends a "..." as the last element if it detects a cycle. It knows how to print the cycle length, but there's no good way to specify what you want right now, so it's not used. Added tests in t8680 that verify that cyclic streams give the expected results. Added to whitelist names of methods formerly used for recursion (now looping).
1 parent 5046f7b commit 039f3e3

File tree

4 files changed

+198
-14
lines changed

4 files changed

+198
-14
lines changed

bincompat-backward.whitelist.conf

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,19 @@ filter {
194194
{
195195
matchName="scala.collection.immutable.Stream.filteredTail"
196196
problemName=MissingMethodProblem
197+
},
198+
// https://github.com/scala/scala/pull/3848 -- SI-8680
199+
{
200+
matchName="scala.collection.immutable.Stream.scala$collection$immutable$Stream$$loop$6"
201+
problemName=MissingMethodProblem
202+
},
203+
{
204+
matchName="scala.collection.immutable.Stream.scala$collection$immutable$Stream$$loop$5"
205+
problemName=MissingMethodProblem
206+
},
207+
{
208+
matchName="scala.collection.immutable.Stream.scala$collection$immutable$Stream$$loop$4"
209+
problemName=MissingMethodProblem
197210
}
198211
]
199212
}

bincompat-forward.whitelist.conf

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,27 @@ filter {
368368
{
369369
matchName="scala.reflect.io.AbstractFile.filterImpl"
370370
problemName=MissingMethodProblem
371+
},
372+
// https://github.com/scala/scala/pull/3848 -- SI-8680
373+
{
374+
matchName="scala.collection.immutable.Stream.scala$collection$immutable$Stream$$loop$6"
375+
problemName=MissingMethodProblem
376+
},
377+
{
378+
matchName="scala.collection.immutable.Stream.scala$collection$immutable$Stream$$loop$5"
379+
problemName=MissingMethodProblem
380+
},
381+
{
382+
matchName="scala.collection.immutable.Stream.scala$collection$immutable$Stream$$loop$4"
383+
problemName=MissingMethodProblem
384+
},
385+
{
386+
matchName="scala.collection.immutable.Stream.scala$collection$immutable$Stream$$loop$3"
387+
problemName=MissingMethodProblem
388+
},
389+
{
390+
matchName="scala.collection.immutable.Stream.scala$collection$immutable$Stream$$loop$2"
391+
problemName=MissingMethodProblem
371392
}
372393
]
373394
}

src/library/scala/collection/immutable/Stream.scala

Lines changed: 111 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ import scala.language.implicitConversions
176176
* loop(1, 1)
177177
* }
178178
* }}}
179+
*
180+
* Note that `mkString` forces evaluation of a `Stream`, but `addString` does
181+
* not. In both cases, a `Stream` that is or ends in a cycle
182+
* (e.g. `lazy val s: Stream[Int] = 0 #:: s`) will convert additional trips
183+
* through the cycle to `...`. Additionally, `addString` will display an
184+
* un-memoized tail as `?`.
179185
*
180186
* @tparam A the type of the elements contained in this stream.
181187
*
@@ -253,12 +259,22 @@ self =>
253259
* @note Often we use `Stream`s to represent an infinite set or series. If
254260
* that's the case for your particular `Stream` then this function will never
255261
* return and will probably crash the VM with an `OutOfMemory` exception.
262+
* This function will not hang on a finite cycle, however.
256263
*
257264
* @return The fully realized `Stream`.
258265
*/
259266
def force: Stream[A] = {
260-
var these = this
261-
while (!these.isEmpty) these = these.tail
267+
// Use standard 2x 1x iterator trick for cycle detection ("those" is slow one)
268+
var these, those = this
269+
if (!these.isEmpty) these = these.tail
270+
while (those ne these) {
271+
if (these.isEmpty) return this
272+
these = these.tail
273+
if (these.isEmpty) return this
274+
these = these.tail
275+
if (these eq those) return this
276+
those = those.tail
277+
}
262278
this
263279
}
264280

@@ -309,9 +325,24 @@ self =>
309325

310326
override def toStream: Stream[A] = this
311327

312-
override def hasDefiniteSize = {
313-
def loop(s: Stream[A]): Boolean = s.isEmpty || s.tailDefined && loop(s.tail)
314-
loop(this)
328+
override def hasDefiniteSize: Boolean = isEmpty || {
329+
if (!tailDefined) false
330+
else {
331+
// Two-iterator trick (2x & 1x speed) for cycle detection.
332+
var those = this
333+
var these = tail
334+
while (those ne these) {
335+
if (these.isEmpty) return true
336+
if (!these.tailDefined) return false
337+
these = these.tail
338+
if (these.isEmpty) return true
339+
if (!these.tailDefined) return false
340+
these = these.tail
341+
if (those eq these) return false
342+
those = those.tail
343+
}
344+
false // Cycle detected
345+
}
315346
}
316347

317348
/** Create a new stream which contains all elements of this stream followed by
@@ -690,7 +721,8 @@ self =>
690721
* `end`. Inside, the string representations of defined elements (w.r.t.
691722
* the method `toString()`) are separated by the string `sep`. The method will
692723
* not force evaluation of undefined elements. A tail of such elements will be
693-
* represented by a `"?"` instead.
724+
* represented by a `"?"` instead. A cyclic stream is represented by a `"..."`
725+
* at the point where the cycle repeats.
694726
*
695727
* @param b The [[collection.mutable.StringBuilder]] factory to which we need
696728
* to add the string elements.
@@ -701,16 +733,81 @@ self =>
701733
* resulting string.
702734
*/
703735
override def addString(b: StringBuilder, start: String, sep: String, end: String): StringBuilder = {
704-
def loop(pre: String, these: Stream[A]) {
705-
if (these.isEmpty) b append end
706-
else {
707-
b append pre append these.head
708-
if (these.tailDefined) loop(sep, these.tail)
709-
else b append sep append "?" append end
736+
b append start
737+
if (!isEmpty) {
738+
b append head
739+
var cursor = this
740+
var n = 1
741+
if (cursor.tailDefined) { // If tailDefined, also !isEmpty
742+
var scout = tail
743+
if (scout.isEmpty) {
744+
// Single element. Bail out early.
745+
b append end
746+
return b
747+
}
748+
if ((cursor ne scout) && scout.tailDefined) {
749+
cursor = scout
750+
scout = scout.tail
751+
// Use 2x 1x iterator trick for cycle detection; slow iterator can add strings
752+
while ((cursor ne scout) && scout.tailDefined) {
753+
b append sep append cursor.head
754+
n += 1
755+
cursor = cursor.tail
756+
scout = scout.tail
757+
if (scout.tailDefined) scout = scout.tail
758+
}
759+
}
760+
if (!scout.tailDefined) { // Not a cycle, scout hit an end
761+
while (cursor ne scout) {
762+
b append sep append cursor.head
763+
n += 1
764+
cursor = cursor.tail
765+
}
766+
}
767+
else {
768+
// Cycle.
769+
// If we have a prefix of length P followed by a cycle of length C,
770+
// the scout will be at position (P%C) in the cycle when the cursor
771+
// enters it at P. They'll then collide when the scout advances another
772+
// C - (P%C) ahead of the cursor.
773+
// If we run the scout P farther, then it will be at the start of
774+
// the cycle: (C - (P%C) + (P%C)) == C == 0. So if another runner
775+
// starts at the beginning of the prefix, they'll collide exactly at
776+
// the start of the loop.
777+
var runner = this
778+
var k = 0
779+
while (runner ne scout) {
780+
runner = runner.tail
781+
scout = scout.tail
782+
k += 1
783+
}
784+
// Now runner and scout are at the beginning of the cycle. Advance
785+
// cursor, adding to string, until it hits; then we'll have covered
786+
// everything once. If cursor is already at beginning, we'd better
787+
// advance one first unless runner didn't go anywhere (in which case
788+
// we've already looped once).
789+
if ((cursor eq scout) && (k > 0)) {
790+
b append sep append cursor.head
791+
n += 1
792+
cursor = cursor.tail
793+
}
794+
while (cursor ne scout) {
795+
b append sep append cursor.head
796+
n += 1
797+
cursor = cursor.tail
798+
}
799+
// Subtract prefix length from total length for cycle reporting.
800+
// (Not currently used, but probably a good idea for the future.)
801+
n -= k
802+
}
803+
}
804+
if (!cursor.isEmpty) {
805+
// Either undefined or cyclic; we can check with tailDefined
806+
if (!cursor.tailDefined) b append sep append "?"
807+
else b append sep append "..."
710808
}
711809
}
712-
b append start
713-
loop("", this)
810+
b append end
714811
b
715812
}
716813

test/files/run/t8680.scala

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
object Test extends App {
2+
def pre(n: Int) = (-n to -1).toStream
3+
4+
def cyc(m: Int) = {
5+
lazy val s: Stream[Int] = (0 until m).toStream #::: s
6+
s
7+
}
8+
9+
def precyc(n: Int, m: Int) = pre(n) #::: cyc(m)
10+
11+
def str(s: Stream[Int]) = {
12+
val b = new StringBuilder
13+
s.addString(b, "", "", "")
14+
b.toString
15+
}
16+
17+
def goal(n: Int, m: Int) = (-n until m).mkString + "..."
18+
19+
// Check un-forced cyclic and non-cyclic streams
20+
assert(str(pre(2)) == pre(2).take(1).toList.mkString + "?")
21+
assert(str(cyc(2)) == cyc(2).take(1).toList.mkString + "?")
22+
assert(str(precyc(2,2)) == precyc(2,2).take(1).toList.mkString + "?")
23+
assert(!pre(2).hasDefiniteSize)
24+
assert(!cyc(2).hasDefiniteSize)
25+
assert(!precyc(2,2).hasDefiniteSize)
26+
27+
// Check forced cyclic and non-cyclic streams
28+
assert(str(pre(2).force) == (-2 to -1).mkString)
29+
assert(str(cyc(2).force) == (0 until 2).mkString + "...")
30+
assert(str(precyc(2,2).force) == (-2 until 2).mkString + "...")
31+
assert(pre(2).force.hasDefiniteSize)
32+
assert(!cyc(2).force.hasDefiniteSize)
33+
assert(!precyc(2,2).force.hasDefiniteSize)
34+
35+
// Special cases
36+
assert(str(cyc(1).force) == goal(0,1))
37+
assert(str(precyc(1,6).force) == goal(1,6))
38+
assert(str(precyc(6,1).force) == goal(6,1))
39+
40+
// Make sure there are no odd/even problems
41+
for (n <- 3 to 4; m <- 3 to 4) {
42+
assert(precyc(n,m).mkString == goal(n,m), s"mkString $n $m")
43+
assert(!precyc(n,m).force.hasDefiniteSize, s"hasDef $n$m")
44+
}
45+
46+
// Make sure there are no cycle/prefix modulus problems
47+
for (i <- 6 to 8) {
48+
assert(precyc(i,3).mkString == goal(i,3), s"mkString $i 3")
49+
assert(precyc(3,i).mkString == goal(3,i), s"mkString 3 $i")
50+
assert(!precyc(i,3).force.hasDefiniteSize, s"hasDef $i 3")
51+
assert(!precyc(3,i).force.hasDefiniteSize, s"hasDef 3 $i")
52+
}
53+
}

0 commit comments

Comments
 (0)