Skip to content

proposal: Go 2: spec: for range with defined types #47707

Closed
@leighmcculloch

Description

@leighmcculloch

The proposal is to allow any defined type to be iterated on using the for with range clause if that type loosely implements the following interface. Its Do function is called with the body of the for block as the argument of the function parameter, where breaks and returns inside the for block are translated into returning false inside the function, and continues are translated into returning true.

type Rangeable interface {
    Do(f func(...) bool)
}

The parameters of f are not defined because they can be zero or more parameters of any types, and those parameters would map to being iteration variables of the for block.

This would allow user-defined types to be iterated with a for block. The most relevant and useful example is the container/set type currently being discussed in #47331:

package sets

type Set[Elem] struct {
    m map[Elem]struct{}
}

func (s Set) Do(f func(e Elem) bool) {
    for k := range s.m {
        stop := f(k)
        if stop {
            return
        }
    }
}
func main() {
    s := sets.Set{}
    // ...
    for e := range s {
        // ...
    }
}

Another trivial example is types such as a numerical range or interval:

package rng

type Interval struct {
    Start int
    Finish int
    Step int
}

func (i Interval) Do(f func(v int) bool) {
    for j := i.Start; j < i.Finish; j+=i.Step {
        stop := f(j)
        if stop {
            return
        }
    }
}
func main() {
    r := &rng.Interval{Start: 0, Finish: 100; Step: 2}
    for i := range r {
        // ...
    }
}

Why

User-defined types cannot be iterated in a way that retains control of the current scope using the return, break, continue keywords. This results in two approaches to iteration being used for built-ins vs defined types. This is already evident in discussions about how the containers/set package may be used and will become more relevant as folks write more collection types once generics are released.

For example, to search a Set type for an element and exit early, code like the below would need to be written:

func _() (Elem, bool) {
    set := sets.Set{}
    // ...
    found := false
    var foundE Elem
    set.Do(func(e Elem) bool {
        if condition {
            found = true
            foundE = e
            return false
        }
        return true
    })
    return foundE, found
}

That code is significantly harder to understand than if the Set type supported iteration using this proposal:

func _() (Elem, bool) {
    set := sets.Set{}
    // ...
    for e := range set {
        if condition {
            return e, true
        }
    }
    return Elem{}, false
}

Prior Discussion

I can't take credit for the ideas that form this proposal. As far as I know the ideas were first suggested by @rsc and further elaborated on by @Merovius. The ideas were shared in response to the problem of how do we make iteration of new generic data structure types, like container/set, as simple as built-in types that can use for range.

That said, if we were going to allow the use of range syntax on user-defined types, the best way I can see to do it would be to compile the syntax into a call to the Do function.

-- @rsc #47331 (reply in thread)

AUI such an implementation would rewrite

for v := range x {
    if cond1 { break }
    if cond2 { continue }
    if cond3 { return a }
}

into something akin to

var (
    _tmp_ret bool
    _tmp_a A
)
x.Do(func(v B) bool {
    if cond1 { return false }
    if cond2 { return true }
    if cond3 {
        _tmp_a, _tmp_ret = a, true
        return false
    }
})
if _tmp_ret {
    return _tmp_a
}

I think you'll find that this construct could do everything a normal range loop could do.

-- @Merovius #47331 (reply in thread)

Further Details

This proposal changes the Go spec's description of the range expression so that in addition to the range expression being allowed to be an array, pointer to an array, slice, string, map, or channel permitting receive operations, it may also be any defined type that has a Do function with one func parameter discussed below, and a single bool return value.

If a value that is a defined type is provided as a range expression and does not have a Do function, it is a compiler error.

If a value that is a defined type is provided as a range expression and it has a Do function, the one func argument of the Do function is called for each iteration. The body of the for with range block becomes the func argument given for the one func parameter of the Do function.

In the same way that a for with range may have zero or more iteration variables up to the number of iteration values for the built-in types, a for with range of a defined type may also have zero or more iteration variables up to the number of parameters that the Do function's signature defines in its one func parameter.

In the same way that iterations are defined by the built-in types to mean different things for slices, maps, channels, etc, a defined type also defines an iteration for itself the spec doesn't limit or constrain how a defined type might iterate. For example, a container/set-like type may iterate for each element of the set. Or, a type representing a numerical range or interval, may iterate for each interval in the range with some defined step.

The bool return value of the Do function signals if iteration should continue. breaks or returns in a for with range block are translated into returning false inside the function passed to the Do function and setting necessary temporary variables to communicate back the intention to return. See @Merovius's example above.

Proposal template
  • Would you consider yourself a novice, intermediate, or experienced Go programmer?
    Experienced

  • What other languages do you have experience with?
    Java, Ruby, C#, C, JavaScript

  • Would this change make Go easier or harder to learn, and why?
    A little harder since there's one more thing to learn, that implementing the interface supports iteration.

  • Has this idea, or one like it, been proposed before? If so, how does this proposal differ?
    I couldn't find a proposal issue that was identical. There are some proposals that attempt to do similar things:

    • proposal: Go 2: iterators #40605 - Provides a Next function that can be called repeatedly, but it is less adaptable to different types of iterables since it is limited to a single element/item being returned on each iteration.
    • proposal: Go 2: function values as iterators #43557 - Allow functions to be a range expression and used for iteration, however if a type is iterable, it seems clearer and easier to understand if the type itself can be passed to the range expression rather than a function generated by the type.
  • Who does this proposal help, and why?
    Anyone building data structures that can be iterated, and anyone using them so that their iteration code is simple and much the same to iterating any built-in type.

  • What is the proposed change?
    See above.

    • Please describe as precisely as possible the change to the language.
      See above.

    • What would change in the language spec?
      See above.

    • Please also describe the change informally, as in a class teaching Go.
      See above.

  • Is this change backward compatible?
    Yes

  • Show example code before and after the change.
    See above.

  • What is the cost of this proposal? (Every language change has a cost).

    • How many tools (such as vet, gopls, gofmt, goimports, etc.) would be affected?
      I'm not sure.
    • What is the compile time cost?
      Little.
    • What is the run time cost?
      None.
  • Can you describe a possible implementation?
    See above.

    • Do you have a prototype? (This is not required.)
      No.
  • How would the language spec change?
    See above.

  • Orthogonality: how does this change interact or overlap with existing features?
    It builds on the existing for range semantics without changing existing semantics. It supports new data structures such as container/set such that they may be iterated in the same way as built in types, maintaining consistency for use.

  • Is the goal of this change a performance improvement?
    No.

    • If so, what quantifiable improvement should we expect?
    • How would we measure it?
  • Does this affect error handling?
    No.

  • Is this about generics?
    No. But related, because generics appear to be driving general data structures, and that's raising the issue of iteration.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions