These notes are based on the August 27 2018 design draft for generic types in Go2.

Full disclosure, I'm against adding generics to Go. Arguments include:

  • additional complexity generics would add to the language
  • generics sacrifice readability for writability, which is the wrong tradeoff
  • non orthogonality with interfaces

Simultaneously, it's indisputible that certain aspects of existing Go programs would improve with generics. Improved type checking and reusable container types are high on the list.

In these notes, I'm assuming that the ship on this discussion has sailed. I think it's very unlikely that a Go2 will ship without generics.

The draft's focus is the addition of type parameters and contracts to describe those type parameters. Type parameters make their way into parameter lists and type declarations. Contracts use regular Go syntax to describe the capabilities and relationships of one or more type parameters.

Together, they feel similar to the way that interfaces interact with the language, which makes it approachable to Go programmers. Interfaces are, however, a much simpler design to reason about because their declarations more limited than contracts and they behave almost identical to other Go types.

On the subject of orthogonality, it's still worrisome. There are a lot of things that are easy and natural to do with interfaces that also feel natural to do with type parameters. As we have more time to work with both contracts and interfaces, they might start to resolve into more obviously separate concepts. As an example of this, Go beginners tend to overuse channels initially, and eventually settle into knowing when to use mutexes instead.

The draft starts by introducing high level concepts and then walks through the implications of various design decisions. It demonstrates explicit type contracts as function parameters and gradually introduces generic types, interaction with type inference, and finally the details of contract definitions.

Design:

A contract is a definition of what you can do with a type, and introduces the newcontract keyword. They can then be used in function and method definitions, and passed values must meet the requirements of the contract.
The document discusses several implementation plans with different tradeoffs with respect to backwards compatibility and simplicity. It looks like a function body, but:

The function body is never executed. Instead, it describes, by example, a set of types.

"It describes, by example, a set of types" sounds like a very nuanced way to describe a set of types. There is more detail at the end of the document, but it still doesn't feel like a specification. This decision results in code that can be difficult to scan. The first example in the document is:

contract convertible(_ To, f From) {
    To(f)
}

Owing to the overloading of function call syntax for type conversion (eg. int64(10)), this scans very bizarrely. This is not necessarily related to contracts, but it is exacerbated by the fact that contracts are an enumeration of capabilities and lack the context of intent found in normal code.

func FormatUnsigned(type T convertible(uint64, T))(v T) string {
    return strconv.FormatUint(uint64(v), 10)
}
s := FormatUnsigned(rune)('a')

We're losing the simplicity of function parameter lists here, which is a sad thing to lose. This is a toy example, but a lot of readability is lost; the argument list is dwarfed by type requirements. It's worth it to keep in mind what the generic-less version of this looks like:

func FormatUnsigned(v uint64) string {
    return strconv.FormatUint(v, 10)
}

s := FormatUnsigned(uint64('a'))

These are basically identical for single values, but the generic function is more flexible and can be more easily applied as a parameter:

func Map(type S, D)(fn func(S) D, ss []S) []D {
    r := make([]D, 0, len(ss))
    for _, v := range ss {
        r = append(r, fn(v))
    }
    return r
}

formatted := Map(FormatUnsigned(int32), []int32{0, 1, 2, 3})

Note that the typed version of FormatUnsigned is required; the only things you can do with generic functions is call them or instantiate them. There are no generic functors. Regardless, this can be pretty useful. In most code, contracts will apply to types with a specificity on the level of interfaces rather than requiring types to be convertible to eachother; this allows things like:

contract any(t T) {
    interface{}(t)
}

// Join concatenates the elements of ts to create a single string.
func Join(type T any)(ts []T, sep string) string {
    var b strings.Builder
    for i, v := range ts {
        b.WriteString(fmt.Sprint(v))
        if i < len(ts)-1 {
            b.WriteString(sep)
        }
    }
    return b.String()
}

This generic version of strings.Join can work on any slice whose type satisfies interface{}. I'm assuming that this has to be explicit in the context since the call to fmt.Sprint requires it, even though any type will work.

This all suggests a new decision for developers writing Go libraries. Integer types and the difference between string and []byte may lead a lot of programmers to reach for generics to bridge those gaps on the API side; after all, the standard library contains a strings and a bytes package with significant repetition.

There's a reductive take on this as well. Most functions you write end up being much less reusable than you expected them to be. The equivalent code without generics and without functions is a trivial for loop.

The body of a contract may not refer to any name defined in the current package. This rule is intended to make it harder to accidentally change the meaning of a contract. As a compromise, a contract is permitted to refer to names imported from other packages...

This sounds like an arbitrary restriction, but I'm guessing that it will prevent premature abstraction. It should also prevent contract usage in the way interfaces have been employed to describe types in a package rather than as a way to declare function parameter requirements.

As contract bodies are not executed, there are no restrictions about unreachable statements, or goto statements across declarations, or anything along those lines. Of course, it is completely pointless to use a goto statement, or a break, continue, or fallthrough statement, in a contract body, as these statements do not say anything about the type arguments.

I hope this ends up being illegal syntax or disallowed by the time this makes it through its paces. It follows from other Go design decisions (like requiring the use of imported packages and declared variables) that allowing for code that do nothing is invites waste and hurts maintainability and readability for no gain.

Parameterized Types:

Type aliases may have parameters.

This seems sound. I'm not sure if it will serve a purpose in practice; type aliases were already somewhat controversial for being useful in only very few circumstances, but because of their restrictions, aliases they have not found widespread use.

When a parameterized type is a struct, and the type parameter is embedded as a field in the struct, the name of the field is the name of the type parameter, not the name of the type argument.

type Lockable(type T) struct {
    T
    mu sync.Mutex
}

func (l *Lockable(T)) Get() T {
    l.mu.Lock()
    defer l.mu.Unlock()
    return l.T
}

It's a little awkward how this winds up being exposed to the user:

f, err := os.Open(...)
lr := Lockable(io.Reader){T:f} 
x := lr.Get() // x is an io.Reader

This could interfere with different types in different ways, depending on the way that the type variable was defined. This is true in general, but if this type of pattern became popular I would expect a lot of types to be defined with an embedded type named T.

It's nice that this allows you to write decorator types that implement the same interfaces that they wrap. However, if your contracts do not stick to things embedding exposes; attributes and methods; then these types will not be as interchangeable as they could be.

Values of type parameters are not boxed

One of the strongest aspects of this design. This should allow for an efficient implementation, which I believe is more important than the writers of the draft appear to think.

We could also consider supporting type inference in composite literals.

type Pair(type T) struct { f1, f2 T }
var V = Pair{1, 2} // inferred as Pair(int){1, 2}

One of the sources of Go's readability is that really basic syntax like this has always had an unambiguous meaning. It isn't clear how much this erodes with this type of feature, but Pair could already have been a struct, slice, or array type. However, named slice and array types are not common, so it was nearly always a struct type, and you could in most circumstances know its definition from its instantiation.

Contracts:

This section contains a lot of details about contracts and how they are interpreted. Clearly a lot of thought has been put into these mechanics.

contract adjustable(x T) {
    var _ T = x.Adjust()
    var f func() T
    f().Apply()
}

It is non-obvious that this specifies T.Apply() must be a value method; it's a clever way to describe these mechanics, but not a natural way. Similarly, it feels like:

Using the == operator with values of some type as both the left and right operands means that the type must be comparable, and implies that both == and != may be used with values of that type. Similarly, != permits ==.

Is useful but too implicit. These being interchangeable and implying each other feels is too TMTOWTDI. The tooling could require == and produce error messages if the others are listed, explaining the full implifications of ==. On top of this, addressability and comparability are already confusing concepts even for intermediate Go programmers. More on this in the conclusion.

For numeric types, the use of a single untyped constant only permits using the exact specified value.

This feels very arbitrarily limiting. The cited example (1000 excluding int8) is interesting, but in practice allowing the use of a very specific constant is unlikely to prove useful. One of the strengths to the otherwise annoying incompatible integer types in Go is that they make overflow errors a lot easier to find. Generics will likely mask these, though how much this matters in real code is not clear. A lot more programming these days is ruled by bytes and strings compared to the golden age of integer overflow.

Sequences:

Using an index expression x[y] in a contract body permits using an index expression with those types anywhere in the function body. For anything other than a map type, the type of y will normally be int; that case may also be written as x[0]. Naturally, z = x[y] permits an index expression yielding the specified type.

Does this allow type conversions and index expressions on dictionaries to return their optional second argument? Judging by the way that ranges allow for 1 and 2 argument versions, it seems likely, but it's not explicit in the draft. I can't think of a reason why not, as this proposal does not allow the overloading of these expressions and therefore does not expose this facility as programmable, so their presence should be reliable.

Fields:

contract counter(x T) {
    var _ int = x.Count
}

A significant amount of real-world generic use will be this, because it covers the polymorphism gap left by interfaces. The current solution, to use a type Counter interface { Count() int } is unwieldy: it generally means having a count field and a Count method as boilerplate. It also doesn't map well to slices et al of types implementing Counter unless they are declared as []Counter, etc.

I wonder if it is important enough that there could have been an alternative "type contract" syntax for describing these:

contract counter struct {
    Count int
}

It lends itself to alternate contract syntax, where these type requirements could be asserted in the body by overloading coersion:

contract counters(T1, T2) {
    T1.(counter)
    T2.(counter)
}

I'm not sure if this has too many confusing implications. You would certainly need to keep other aspects of the normal contract syntax to describe relationships with respect to operators and between the types. It makes it ambiguous whether counter is an interface or a contract, although the relationship of "is a" or "implements" seem to be roughly identical in this context.

Summary (Complexity, Pervasiveness, Efficiency):

We expect that most people will not write generic code themselves, but many people are likely to write packages that use generic code written by others.

This has been the experience of the reflect package. An old adage for Go programmers is "do not import reflect in package main"; the usage of reflect requires enough care and is cumbersome enough that it distracts from any business logic that it comes into direct contact with.

I wonder if the same will be true for contracts. Wrapper types seem particularly useful for things like telemetry and dependency injection, but in order for wrapped types to be useful you will need use type parameter arguments pervasively. I've seen programs whose readability was utterly ruined by the use of interfaces for things like this, and I'd guess that type parameters will have the same result and look even uglier in the process.

We do not expect approaches like the C++ STL iterator types to become widely used. In Go that sort of idea is more naturally expressed using an interface type. In C++ terms, using an interface type for an iterator can be seen as carrying an abstraction penalty, in that run-time efficiency will be less than C++ approaches that in effect inline all code; we believe that Go programmers will continue to find that sort of penalty to be acceptable.

I really wonder about this. Performance is one of the most popular subjects for talks at Go conventions and has been a primary attraction for a great deal of Python and Ruby programmers. Under efficiency, they discuss the fact that this design can be implemented at compile time or at run time:

Generic functions, rather than generic types, can probably be compiled using an interface-based approach. That will optimize compile time, in that the package is only compiled once, but there will be some run-time cost.

My immediate reaction, "why not both?". During development, I want fast compilation and runtime efficiency is less of an issue. When producing an artifact for production deployment, I'd like to be able to get a faster implementation if that costs some compile time. This sounds a lot like -O3, which the Go team has been on record numerous times as not wanting to implement.

Conclusion:

Contracts are an interesting approach to bring generics to Go that still feels peculiarly Go-like, both in the way they fit into the language and in their limitations.

I expect them to be adopted and implemented in the Go2 standard library somewhat unevenly, but this process should go a long way towards shaking out a lot of the concerns above and in eventually coming up with questions about "When should we use interfaces vs parameterized types?"

Beyond that, I think the expectations of the authors regarding the use of contracts in the wild will prove to be incorrect. The ascetic engineering philosophy of the Go team is not shared by most Go programmers. Go programmers want to do things that the Go team thinks are a bad idea.

Early on, the common refrain accompanying a request for generics was that things like map/filter/reduce were impossible to write in Go. The response to this was Rob Pike's filter package, which is a trite indictment of the notion that such a paradigm be enabled. The way that Russ resisted widely-adopted approaches to dependency management in favour of a simpler, more limited, novel approach is a more recent example of this. The Go team's great strengths are that they deliberate, experienced, and shrewd, and their great weakness is that they know it.

I'm against the inclusion of generics because of what they might do to programs, not because of what they do to the language. To its credit, the proposal has a lot of sample code, and some of it reads quite naturally.

Still, the language of contracts feels too nuanced. The sample code requires the reader to hold a lot of information in their head in order to understand what the writer has intended, and it seems easy to write something meaning one thing and getting another. Previously, there were only a few edge cases in the language like this (eg, using the address of range vars), but my initial reaction is that this proposal adds a lot more.

The set implementation in the example section is very clean, and its element contract requires only ==. However, this is only used implicitly; map keys must be comparable, and _, ok := set[v] uses that under the hood. For a programmer to read this and understand why it was written this way requires a lot of knowledge, in a way that most Go code does not.

I'm looking forward to seeing if and how the draft may change over time. By the standard of Go drafts, it leaves a lot of open questions. Before 1.0, the Go language changed frequently and often for the better. After 1.0, it has barely changed at all. Both of these periods were great times for the development of the language.

Sep 1