Skip to content

Instantly share code, notes, and snippets.

@alanfo
Last active September 12, 2018 16:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alanfo/5da5932c7b60fd130a928ebbace1f251 to your computer and use it in GitHub Desktop.
Save alanfo/5da5932c7b60fd130a928ebbace1f251 to your computer and use it in GitHub Desktop.
A suggested amendment to the Go 2 generics draft design.

A suggested amendment to the Go 2 generics draft design.

Introduction

As many others have said, a lot of thought has obviously gone into the Go 2 draft design for generics and, personally, I'm very happy with the basic syntax both at the declaration and call sites. However, I'm far less happy about the use of 'contracts', as currently envisaged, to constrain the types which can be used to instantiate a generic function or type. This is because:

  1. Contracts seem a rather amorphous concept and it is not at all clear to me how the compiler is going to check that a contract is sufficient to satisfy what the function/type is doing in all circumstances. If a very complex algorithm is required, then this is going to slow down compilation.

  2. There seems to be too many problem cases for comfort. Dealing with untyped numeric constants looks to be a particularly unintuitive area and not being able to fully determine method signatures (without casting to an interface) or to distinguish value receivers (without the use of a code device) are other 'less than satisfactory' areas.

  3. It looks to me as though, if it requires a contract at all, you might end up writing one from scratch for most generic functions/types you need. It would certainly make life easier if the commoner ones could be included in a 'contracts' package in the standard library.

  4. Even if the present design proves workable, I think writing contracts may prove a bit of a black art and that, if things are at all complicated, some programmers may just give it up and embed the function's code in the contract which defeats the object of having them in the first place!

  5. Given that there are potentially many different ways of expressing allowable operations etc. on types, there will certainly need to be some extensive guidelines on how best to do this to establish some sort of consistency when writing them. A tool could certainly help with this though it would be a pity if the tool were a necessity for anything remotely complicated.

Suggested solution

As Go doesn't have operator overloading (and I've seen no indication that it may be added), I don't see why it is necessary to individually specify each operator that is used in the contract. Operators can for the most part only be applied to the built in types (and types based on them) and particular sets of operators only apply to certain subsets of those types. Similar remarks apply to conversions.

I believe generic contracts would be much simpler to write and understand if there were a way to assert that a type parameter belonged to a certain group of types, failing which the code would not compile. The use of these 'type groups' would mean that both the programmer and the compiler would know immediately what operators a type parameter supported, what conversions were possible and so forth.

My proposal is therefore that the language should have one additional keyword, typecheck, whose signature would be: typecheck(T, G) where:

'T' is a type parameter passed to the contract.

'G' is a group of types expressed in string form.

typecheck would only be a keyword within a contract (it could be used as a normal identifier elsewhere) and the proposal would therefore be backwards compatible with Go 1 assuming that contract would also be a contextual keyword.

The proposed fundamental generic type groups would be as follows:

Type Group Constituents
Any any allowable type at all
Comparable any allowable type which supports '==' and '!='
Integer any integer type
Signed any signed integer type
Unsigned any unsigned integer type
Float any floating point type
Complex any complex type
Boolean any boolean type
String any string type

An allowable type would be any type which isn't a pointer type or an interface type.

Composite types could also be defined:

Type Group Description
[n]V array type of size n
[]V slice type
Map[V]W map type
Chan V channel type
Struct{v V; ...} struct type with certain fields of certain types
Func(v V, ...) function type having certain (or no) parameters but no return type
Func(v V, ...) W function type having certain (or no) parameters and a return type

where:

'V' is either a type parameter or a non-generic type

'W' is another type parameter or non-generic type.

It would also be possible to combine these type groups together using 'or'. For example "Float or Complex" would mean either a floating point or complex type. The most useful of these 'union' types could be given synonyms:

Type Group Composition
Real Integer or Float
Numeric Integer or Float or Complex
Ordered Integer or Float or String
Addable Integer or Float or Complex or String
Bytes String or []byte
Runes String or []rune

To make life easier for the user a 'contracts' package could be added to the standard library with a short name such as 'ct'. The fundamental or most useful combined type groups could then be represented by public contracts within this package. For example:

contract Integer(T) {
    typecheck(T, "Integer")
}

Where only a single parameter needed a constraint and it was satisfied by a library contract, it could then be constrained by ct.Int, ct.Real or whatever without further ado.

In more complicated cases, you'd need to define your own contract rather than use the library ones. However, contracts would be limited to containing one or more of the following constructs:

  • A typecheck assertion.

  • An assertion that a type parameter (or its corresponding pointer type) implemented a particular interface.

  • An assertion that a type parameter or parameters also satisfied another contract.

  • A list of additional method signatures which the type parameter or parameters (or their corresponding pointer types) included in their method sets.

A contract's type parameters could only be allowable types though pointer and interface types could, of course, still be represented indirectly using the above constructs. If one only wanted to use interface types, then there would be no need to use generics at all!

Purely for illustration purposes, here's an example of a contract which contains instances of all of these constructs (note that type variables are no longer required though an extension to interface conversion syntax would be needed to allow a type parameter to be used directly as an expression):

contract other(T) {
    T.StringMethod() string    // T has a method called StringMethod() which returns a string
}

contract examples(T, U) {
    typecheck(T, "Comparable") // T belongs to the Comparable group
    typecheck(U, "String")     // U belongs to the String group
    fmt.Stringer(T)            // T implements the fmt.Stringer interface
    other(T)                   // T satifies the 'other' contract
    T.ValueMethod(U) T         // T has a method, ValueMethod, which takes a U and returns a T
    (*T).PointerMethod() *T    // *T has a method, PointerMethod, which returns a *T
}

It would now be easy for contracts to express all types of methods and their return types (if any) including methods with variadic parameters.

Note that the last two constructs differ from current method expression syntax which would require instead:

    T.ValueMethod(T, U) T 
    (*T).PointerMethod(*T) *T  

However, the problem with using the existing syntax is that:

  1. The type parameter would have to be repeated as the first (or only) method parameter.

  2. It doesn't necessarily follow from the use of a pointer type that it's a method with a pointer receiver - it could still be a method with a value receiver.

Because of this, I believe it would be sensible to use a different syntax within (but only within) contracts to avoid needless repetition and to make it clear which type of receiver was required.

In addition I would propose the following convenience feature:

  • If only a single type parameter required a constraint and the constraint were an interface, then one could use the interface directly in the function/type definition rather than having to embed it in a separate contract. For example, if a sole type parameter were constrained by an interface I then it would behave exactly the same as if it were constrained by a contract 'temp' defined as follows:
contract temp(T) {
    I(T)
}

Examples

Let's take those examples from the design paper which would change if this suggestion were implemented and see how they now look:

contract stringer(T) {
    T.String() string // or alternatively fmt.Stringer(T)
}

func Stringify(type T stringer)(s []T) (ret []string) {
    for _, v := range s {
       ret = append(ret, v.String())
    }
    return ret
}

// Alternatively fmt.Stringer could be used directly.
func Stringify(type T fmt.Stringer)(s []T) (ret []string) { // ... }

contract PrintStringer(X) {
    stringer(X)  // embedded contract
    X.Print()
}
// No need for a 'convertible' contract as only integer & float types can be converted to uint64.
func FormatUnsigned(type T ct.Real)(v T) string {
    return strconv.FormatUint(uint64(v), 10)
}

s := FormatUnsigned('a')  // 'a' inferred to be rune which belongs to Real
contract Readable(T) {
    io.Reader(T)  // or use directly
}
contract viaStrings(To, From) {
    From.String() string
    To.Set(string)
}

func SetViaStrings(type To, From viaStrings)(s []From) []To {
    r := make([]To, len(s))
    for i, v := range s {
        r[i].Set(v.String())
    }
    return r
}
contract equal(T) {
   T.Equal(T)
}

func Index(type T equal)(s []T, e T) int {
    for i, v := range s {
        // Both e and v are type T, so it’s OK to call e.Equal(v).
        if e.Equal(v) {
            return i
        }
    }
    return -1
}
contract Graph(Node, Edge) {
    Node.Edges() []Edge
    Edge.Nodes() []Node
}

func ShortestPath(type N, E Graph)(src, dst N) []E
contract adjustable(T) {
    T.Adjust() T
    T.Apply()
}

func Apply(type T adjustable)(v T) {
    v.Adjust().Apply()
}
func Contains(type T ct.Comparable)(s []T, e T) bool {
    for _, v := range s {
        if v == e {
           return true
        }
    }
    return false
}
contract convert(To, From) {
    typecheck(To, "Real")
    typecheck(From, "Real")
}

func Convert(type To, From convert)(from From) To {
    to := To(from)
    if From(to) != from {
        panic("conversion out of range")
    }
    return to
}
func Add1K(type T ct.Integer)(s []T) {
    for i, v := range s {
        s[i] = v + 1000
    }
}
func Join(type T ct.Bytes)(a []T, sep T) (ret T) {
    if len(a) == 0 {
        // Use the result parameter as a zero value.
        return ret
    }
    if len(a) == 1 {
        return T(append([]byte(nil), []byte(a[0])...))
    }
    n := len(sep) * (len(a) - 1)
    for i := 0; i < len(a); i++ {
        n += len(a[i])
    }

    b := make([]byte, n)
    bp := copy(b, []byte(a[0]))
    for _, s := range a[1:] {
        bp += copy(b[bp:], []byte(sep))
        bp += copy(b[bp:], []byte(s))
    }
    return T(b)
}
contract counters(T1, T2) {
    // Use the Struct type group to assert that both types must have a
    // Count field of type int.
    typecheck(T1, "Struct{Count int}")
    typecheck(T2, "Struct{Count int}")
}

func Corresponding(type T1, T2 counters)(p1 *T1, p2 *T2) {
    p1.Count = p2.Count
}
type orderedSlice(type Ele ct.Ordered) []Ele

func (s orderedSlice(Ele)) Len() int           { return len(s) }
func (s orderedSlice(Ele)) Less(i, j int) bool { return s[i] < s[j] }
func (s orderedSlice(Ele)) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }

// OrderedSlice sorts the slice s in ascending order.
// The elements of s must be ordered using the < operator.
func OrderedSlice(type Ele ct.Ordered)(s []Ele) {
    sort.Sort(orderedSlice(Ele)(s))
}
func Keys(type K, V ct.Comparable(K))(m map[K]V) []K {
    r := make([]K, 0, len(m))
    for k := range m {
        r = append(r, k)
    }
    return r
}

k := maps.Keys(map[int]int{1:2, 2:4})
type Set(type Ele ct.Comparable) map[Ele]struct{}

func Make(type Ele ct.Comparable)() Set(Ele) {
    return make(Set(Ele))
}

// etc
type Metric1(type T ct.Comparable) struct {
    mu sync.Mutex
    m  map[T]int
}

contract cmp2(T1, T2) {
    typecheck(T1, "Comparable")
    typecheck(T2, "Comparable")
}

type key2(type T1, T2 cmp2) {
    f1 T1
    f2 T2
}  

type Metric2(type T1, T2 cmp2) struct {
    mu sync.Mutex
    m  map[key2(T1, T2)]int
}

// etc

Implementation

As in the original proposal, contracts would be used to validate the actual type argument(s). The only difference is that, if a type group were involved, the compiler would need to parse the string passed as the second argument to typecheck to check that the first argument belonged to that group.

A possible strategy for checking the generic function/type itself might be for the compiler to have a default type for each fundamental group. It could then check that the function/type compiled for the default type and repeat this process for each fundamental group involved.

As the fundamental type groups have been chosen so that their members support exactly the same operations, conversions etc., it should then follow that if the function/method compiles for the default types, it will compile for any member type of the relevant groups.

Conclusion

So, in a nutshell, what I'm suggesting here is that 'contracts' in the original design should be refashioned so that they are more clearly delineated. In particular, a new contextual keyword typecheck should be introduced to check, where appropriate, that a type belongs to a particular group for which certain operations, conversions, assignments etc. are built into the language.

As the additional syntactic overhead is fairly minimal (and still backwards compatible with Go 1), I think it would represent a huge simplification in the writing and understanding of constraints on generic type parameters. It might also lead to easier and quicker compilation as the compiler would be dealing with stuff it already knows about.

There will probably be cases which the original design (which is very powerful) could cater for but which the revised design cannot. However, in my view, some loss in power would be a worthwhile price to pay for a more clearly defined solution and, given what other commentators have said on this topic, I'm certainly not alone in thinking this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment