Where to Go Next ~ Range

What next for this not-so-young programming language

Go version 1.0 arrived in late 2009. It’s now a well established language and ecosystem, having carved out a significant niche in server integration and bespoke webserver code, amongst others.

Unashamedly a quirky language, Go’s developers pioneered an opinionated approach to minimalism that has evidently served it well.

This post will focus on iteration in Go using range. Later posts will take a more detailed look at a few other Go features.

Quirky

Quirky? Definitely. For example, if you program Go already, you will already know the five categories of value that are passed by reference rather than by value (in case you’re learning, they’re pointers, slices, maps, channels and interfaces). Pointers are obvious but the other four are not.

You just have to know the special case rules. Go with it!

Many other languages have prioritised consistency and orthogonality in their concepts in order to make them easier to learn. Go doesn’t, and it doesn’t feel any need to apologise. Remarkably, this has worked well because the reasons are very well thought out.

We can see this in the range keyword.

Range

A really strong language feature is embodied in range - a keyword for iteration (see the range clause spec). For example

languages := []string{ "Go", "Scala", "Python", "Haskell" }
for i, v := range languages {
    fmt.Printf( "%s is my No. %d language.\n", v, i + 1 )
}

Right in the middle is the range keyword. The iterable value on its right hand side is a slice in this case, but it could be an array, a string, a map or a channel. All the values of the iterable will be returned in turn and in a particular order (except for maps which have no defined order). So the snippet above will print

Go is my No. 1 language.
Scala is my No. 2 language.
Python is my No. 3 language.
Haskell is my No. 4 language.

range works across the built-in collection types: arrays, slices, strings, maps and channels (channels are really a special case though). The great strength of range is the way it encapsulates all issues to do with indexing and termination. Every possibly value is returned exactly once. No out by one errors ever occur.

But only those built-in types can be iterated. There is no extension point; no general way to make user-defined collection types. Hmmmm. :-/

Limited Range

The capability of range is explicitly limited. One of the reasons offered for limiting it is to enforce a principle of least surprise. The argument goes that a developer sees a range and knows they can rely on there being no hidden or ‘magical’ behaviour.

Go is a what you see is what you get language. A range does what it says on the tin. No performance or other surprises are lurking.

There’s good merit in this argument. Some other languages have perhaps reinforced this argument by proving that the opposite can lead to bad things, perhaps by working so hard to provide extension points that some rather over-enthusiatic usage has led to programs that have difficult-to-predict behaviour (or maybe just difficult to comprehend behaviour). So you can leave those problems behind with Go.

But could we do better?

Extended Range

Just suppose we came up with a way of extending range. What would it unleash? First of all, let’s discuss a possible language extension.

The semantics of range would be extended to include

  • all existing types (map, slice, array, string, channel), plus
  • all types that implement iterable (a new built-in interface, like error).

The new iterable interface would co-reside with the error interface in package builtin. It’s not hard to conceive of possibilities for the new iterable built-in, such as

// The iterable built-in interface is the conventional interface for
// types that provide bespoke iteration.
type iterable interface {
    Iterator() iterator
}

and its associated iterator built-in, which is a function type:

// The iterator built-in function type will be called repeatedly until its
// third parameter returns false. It yields three values, of which its first
// result is an index (for example an int or a string), and its second is the
// value associated with that index.
type iterator func() (interface{}, interface{}, bool)

Let’s dig into this in more detail.

An iterable has a method Iterator() that returns an iterator function. Any type that wants to behave like a collection simply has to implement this Iterator() method.

For each step in the iteration, the iterator function returns three values. The first two are a pair and give some index (or key) and its corresponding value. The third result is a bool that is true whilst the iteration should continue.

At the end of iteration, the function would finally return nil, nil, false so that range would know it had to terminate.

This iterable interface would work well for many kinds of user-defined collection. The two-expression kind of range usage would be supported automatically by the compiler, i.e. with an index (as per range over a slice, array or string) or with a key (as per range over a map). (The compiler would not need to support single-expression range, i.e without an index, as per range over a channel).

Here’s a code sample:

type Languages []string

func (ln Languages) Iterator() iterator {
    i := -1
    return func() (interface{}, interface{}, bool) {
        i++
        if i < len(ln) {
            return i, ln[i], true
        }
        return nil, nil, false
    }
}

This works by using a closure over the free variable i. Pre-incrementing this kept the code simple; therefore the initial value is -1 (instead of 0 as might have been expected).

We can’t (yet) use this in range, but the equivalent for statement might be

languages := Languages([]string{"Go", "Scala", "Python", "Haskell"})
// range would start here
next := languages.Iterator()
for i, v, ok := next(); ok; i, v, ok = next() {
    fmt.Printf("%s is my No. %d language.\n", v, i.(int)+1)
}

which happens to behave just like the code snippet at the top of the page, which is remarkably similar to where we’d like to end up:

languages := Languages([]string{"Go", "Scala", "Python", "Haskell"})
for i, v := range languages {
    fmt.Printf("%s is my No. %d language.\n", v, i.(int)+1)
}

What this would achieve is the value languages of user-defined type Languages can now be used on the right-hand side of range. This is a contrived example because the underlying slice is barely hidden - but it serves to illustrate the point of what might be achieved with other user-defined types that want to behave as collections that can be iterated over.

See it in the Playground

Outcome

So … big question … would extending range like this open up the unwanted risks of badly-behaving loops? Unpexpected performance gotchas?

Or could the extra flexibility allow Go programs to deal really smartly with user-defined collections?

 
comments powered by Disqus