Project

General

Profile

Actions

Feature #22132

open

Scala-like for comprehensions

Feature #22132: Scala-like for comprehensions
1

Added by shugo (Shugo Maeda) about 2 months ago. Updated about 1 month ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:125855]

Description

Abstract

How about adding an expression form of for that desugars into nested flat_map/map and filter calls.

Here's an example, which computes all pairs of numbers between 0 and n-1 whose sum is equal to a given value v:

def foo(n, v)
  for i in 0...n,
      j in 0...n when i + j == v then
    [i, j]
  end
end

p foo(10, 10) #=> [[1, 9], [2, 8], [3, 7]...]

The above code is desugared as follows:

def foo(n, v)
  (0...n).flat_map { |i|
    (0...n).filter { |j|
      i + j == v
    }.map { |j|
      [i, j]
    }
  }
end

p foo(10, 10)

Background and Motivation

Some other languages have syntactic sugar that flattens nested code.

For example, Scala has for comprehensions:

def foo(n: Int, v: Int) =
   for i <- 0 until n
       j <- 0 until n if i + j == v
   yield (i, j)

Haskell has do notation:

foo :: Int -> Int -> [(Int, Int)]
foo n v = do
  i <- [0 .. n-1]
  j <- [0 .. n-1]
  guard (i + j == v)
  pure (i, j)

Blocks are often nested deeply in Ruby, so such syntactic sugar is useful.

Use cases

For comprehensions can be used to flatten nested blocks.

For example,

(1..).lazy.flat_map { |z|
  (1..z).lazy.flat_map { |x|
    (x..z).lazy.filter { |y|
      x**2 + y**2 == z**2
    }.map { |y|
      [x, y, z]
    }
  }
}.take(3).force

can be flattened as follows:

for z in (1..).lazy,
    x in (1..z).lazy,
    y in (x..z).lazy when x**2 + y**2 == z**2 then
  [x, y, z]
end.take(3).force

For comprehensions can be used not only for Enumerable objects, but also for other objects with lawful flat_map and map (i.e., any monad whose map is the functor map derived from flat_map). For example, flat_map and map must cohere so that nested calls can be flattened:

m.flat_map(&f).map(&g) == m.flat_map { |x| f.(x).map(&g) }

For example, for comprehensions can be used with a parser-combinator library:

class SimpleCalcParser < PackratParser
  def additive
    for x in multitive << term("+"), y in additive then x + y end |
      for x in multitive << term("-"), y in additive then x - y end |
      multitive
  end

Here << is equivalent to Scala's <~: it runs both parsers but keeps only the left operand's value.

Why then and when?

Scala's yield conflicts with the existing yield keyword in Ruby, so I chose then.
While a bare for ... then (no guard) reads a little unnaturally in English, Ruby already gives then a value-producing meaning (e.g., Kernel#then), so I consider it acceptable.

The guard keyword is not if but when, because if after the source would be ambiguous with the modifier if.

Limitations

The right operand of in is arg_value, not expr_value, to avoid conflicts, so unparenthesized method calls (command calls) must be parenthesized.

Backward compatibility

  • for x in xs do ... end (and the newline form) is unchanged: it still iterates via each and returns the collection.
  • All of the new forms (for ... then, for ... ,, for ... when) were SyntaxError before, so no existing program changes meaning.

Implementation

Implemented in both parsers (parse.y and Prism) so behavior is identical either way: https://github.com/ruby/ruby/pull/17500

Desugaring

A comprehension desugars to a chain of filter / flat_map / map sends, one stage per iterator:

for x in xs when x.even?, y in ys when x < y then [x, y] end
# => xs.filter { |x| x.even? }
#      .flat_map { |x| ys.filter { |y| x < y }.map { |y| [x, y] } }
  • A single iterator uses map; with multiple iterators, all but the innermost use flat_map.
  • A when guard applies filter to the preceding iterator's collection. Later iterators may reference variables bound
    by earlier ones.
  • Iterator variables are block parameters, so they do not leak into the enclosing scope (unlike legacy for). The g
    uard's filter block and the map/flat_map block are siblings, so a temporary assigned in a guard is not visible in
    the body.

Parsers

  • parse.y: a dedicated node NODE_FOR_COMP (one per iterator) is built at parse time, and compile.c emits the filter/flat_map/map sends, mirroring how legacy for keeps NODE_FOR and defers the each send. This keeps --du mp=parsetree honest and requires the scopes to be built at parse time (compile.c cannot allocate AST nodes).
  • Prism: source-faithful ForComprehensionNode / ForComprehensionIteratorNode, with prism_compile.c folding the
    same sends. Currently against the vendored prism/; upstreaming to ruby/prism is follow-up.

Diagnostics (parse-time, both parsers)

break, a non-local loop variable, a self-referential (circular) iterator, implicit block params (it/_1), and a mis
sing then/end are all rejected.

Open questions

  • Variable scope: A for comprehension currently scopes the loop variables unlike for ... do. Should it leak them?
  • Guards desugar to filter, not a lazy withFilter as in Scala. So in the strict case, each guard builds one intermediate array; users who want fusion can add .lazy. Is filter acceptable, or should we add a with_filter-like method?

Related issues 1 (1 open0 closed)

Related to Ruby - Feature #16147: List Comprehensions in RubyOpenActions
Actions

Also available in: PDF Atom