Skip to content

proposal: spec: improve for-loop ergonomics #24282

Open
@bcmills

Description

@bcmills

Motivation

Go's for-loops encourage difficult-to-read code.

  1. The Go 1 loop syntax sets the wrong defaults. The syntax is optimized for three-part ForClause loops, but range loops are far more common (by a ratio of nearly 4:1 in the code I sampled) and arguably ought to be viewed as the “default”.

    • The three-part ForClause form is nearly always used for iterating one variable over sequential integers. That puts the interesting part — the condition — in the middle, where it is the hardest to find.

      (For the rare other cases, it is always possible to express a three-part ForClause as an equivalent one-part ForClause with an extra scope block. Loops that use continue require care, but continue in a three-part non-integer loop is especially rare.)

    • Nothing else in the language has a three-part form, and the existence of the three-part for loop precludes a more useful two-part alternative (for APIs such as io.Reader), because it would be too easy to confuse a two-part loop with a three-part one.

  2. The range keyword is confusing to newcomers.

    • In set theory, range means "image" or "codomain", but the single-value version of a Go 1 range loop instead iterates over the domain of the slice, map, or array. That makes the single-value form confusing, especially when the index and element types are mutually assignable (https://play.golang.org/p/c-lWoTI_Z-Y) or when the value is used as an interface{} (https://play.golang.org/p/cqZPSHZtuwH).

    • In some other programming languages (such as Python), range refers to a sequence of points in a numerical interval, evoking line segment range or statistical range. In contrast, the Go range keyword doesn't have anything to do with numerical intervals, except to the extent that slice indices happen to be intervals.

    • The fact that range modifies the semantics of := and = is surprising. The only other Go operator that modifies the semantics of another operator is = itself, which (beyond the , ok idiom) modifies the semantics of the index operator ([]) for map assignments. (I think we should fix that too; see proposal: spec: disallow NaN keys in maps #20660 (comment).)

      It is rarely useful to have a range loop assign to existing variables, and we could address that use-case more cleanly with a finally or else keyword anyway.

  3. Eliminating the range keyword would allow us to fix variable capture (proposal: spec: redefine range loop variables in each iteration #20733) in a way that does not unexpectedly change the semantics of for-loops written in the Go 1 style. (That is, old-style loops would no longer compile, instead of successfully compiling to something different from before.)


Proposal

  1. Remove the range keyword and the three-part loop form.

  2. Make the range form of the for loop more concise, and add a two-part form and optional else block.

    • For the one-part form:

      • If the first part is of the form x : z or x, y : z, it introduces new variables x and y (as applicable), which take the value of each successive element of z. The one-variable form can be used only for channels and numeric intervals (see interval below). The two-variable form can be used only for maps, slices, strings, and arrays.

      • Otherwise, the first part must be a boolean expression and specifies the Condition of the loop.

    • The new two-part form parallels the two-part form of switch. The first part is an arbitrary statement (usually a short variable declaration) to be evaluated before every iteration, and the second part is the Condition:

      for x, err := f(); err == nil {

    • An else block may follow a loop that has with a Condition. Control transfers to the else block when the condition is false (like else in Python loops). The variables declared in the first part of the two-part form remain in scope for the else block.

      • (If we don't like the way else reads, we could drop that part entirely, or use some other keyword — such as finally — and/or tweak the semantics, for example by also transferring control to the block in case of a break.)
  3. Add a built-in pseudofunction interval to replace the vast majority of existing 3-part loops.

    • interval(m, n) returns a container that iterates over [m, n) by increments of 1.

    • interval(m, n, step) returns a container that iterates from m (inclusive) to n (exclusive) by step.


Examples

Simple conditions

Loops with just a Condition remain the same as in Go 1.

for a < b {
	a *= 2
}

for len(h) > 0 {
	x := heap.Pop(h)
	f(x.(someType))
}

Ranges

Range loops lose a little bit of boilerplate, and gain a closer resemblance to for-each loops in other languages with C-like syntax (such as C++ and Java).

for i, s := range a {
	g(i, s)
}

becomes

for i, s : a {
	g(i, s)
}

(from https://github.com/golang/go/wiki/SliceTricks#filtering-without-allocating)

b := a[:0]
for _, x := range a {
	if f(x) {
		b = append(b, x)
	}
}

becomes

b := a[:0]
for _, x : a {
	if f(x) {
		b = append(b, x)
	}
}

Intervals

Simple numeric intervals move the limit closer to the end of the line (where it is easier to find), and in some cases drop the need for redundant variables.

for i, n := 0, f(x); i < n; i++ {
	g(i)
}

becomes

for i : interval(0, f(x)) {
	g(i)
}

for n := runtime.GOMAXPROCS(0); n > 0; n-- {
	go …
}

becomes

for n : interval(runtime.GOMAXPROCS(0), 0, -1) {
	go …
}

(from https://github.com/golang/go/wiki/SliceTricks#reversing, noting that the original goes out of its way
— and loses some clarity in the process — to avoid re-evaluating len(a)/2 at each iteration)

for i := len(a)/2-1; i >= 0; i-- {
	opp := len(a)-1-i
	a[i], a[opp] = a[opp], a[i]
}

becomes

for i : interval(0, len(a)/2) {
	opp := len(a)-1-i
	a[i], a[opp] = a[opp], a[i]	
}

or

for i, _ : a[:len(a)/2] {
	opp := len(a)-1-i
	a[i], a[opp] = a[opp], a[i]	
}

Iterators

Iterator patterns shed boilerplate and/or levels of indentation.

for {
	n, err := r.Read(p)
	if err != nil {
		if err != io.EOF {
			return err
		}
		return nil
	}
	f(p[:n])
}

becomes

for n, err := r.Read(p); err == nil {
	f(p[:n])
} else if err != io.EOF {
	return err
}
return nil

iter := Begin()
for x, ok := iter.Next(); ok; x, ok = iter.Next() {
	f(x)
}

becomes

iter := Begin()
for x, ok := iter.Next(); ok {
	f(x)
}

Lists

Loops iterating over certain custom containers (such as linked lists) become a bit more awkward.
(On the other hand, I would argue that they were awkward to begin with — and they could be fixed by a further change to allow types to implement the range-like behavior directly.)

for e := l.Front(); e != nil; e = e.Next() {
	f(e)
}

becomes

e := l.Front()
for e != nil {
	f(e)
	e = e.Next()
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions