Skip to content

Instantly share code, notes, and snippets.

@rcoreilly
Last active May 20, 2024 16:05
Show Gist options
  • Save rcoreilly/89f6be0d901a3ee12004f34f9e581d04 to your computer and use it in GitHub Desktop.
Save rcoreilly/89f6be0d901a3ee12004f34f9e581d04 to your computer and use it in GitHub Desktop.
Generic Types in Go (golang)

This proposal changes the syntax for type parameters on functions, to eliminate the extra parens which are widely raised as a major problem with the draft proposal. It retains type parameters for types, and should otherwise be very similar to the draft proposal, just with a simpler syntax.

The key idea is: use generic type names just as we use concrete types now, instead of having separate type parameters. (Generic Types, GT)

GT puts all the type information in one place, instead of distributing it across two locations, greatly reducing cognitive load, and eliminates the extra parens in function calls which are confusing and a constant complaint about the draft proposal.

  • Constraints are now interface types, so use these interface types directly as type names.
  • For fully generic, unconstrained types, use the keyword type.
  • Semantically, there are two categories of interface types: generic, and non-generic.
    • Generic interfaces have a type list, non-generic do not.
    • To create an unconstrained yet generic interface, specify a type list of type type.

Compare the following examples against those in the draft proposal -- starting at the start:

func Print(s []type) {
    for _, v := range s {
        fmt.Println(v)
    }
}

Print([]int{1, 2, 3})

For constrained types:

type Stringer interface {
    type type  // this marks as a generic interface
    String() string
}

// Because Stringer is defined as a generic interface (type type),
// any type satisfying Stringer can be used directly, without explicit conversion.
func Stringify(s []Stringer) (ret []string) {
	for _, v := range s {
		ret = append(ret, v.String())
	}
	return ret
}
// s1 and s2 are slices of any type, and each can be any type
func Print2(s1 []type, s2 []type) { ... }

Compare this to:

// s1 and s2 can be slice of any type, but it must be the *same* type
func Print2Same(s1, s2 []type) { ... }

To emphasize the difference from the draft proposal, here's a direct comparison:

func StrAndPrint(type L interface{}, T Stringer)(labels []L, vals []T) { ... }

func StrAndPrint(labels []type, vals []Stringer) { ... }

GT consolidates the type information in one place, where it has always been.

Type expressions

To refer to the concrete type of a generic arg, use type(x) -- this is needed for return values etc:

func Min(x, y Numeric) type(x) {
  if x < y {
     return x
  }
  return y
}

For slices, maps and channels, type(x) returns the element type -- for maps, type(m[]) returns the key type:

func Keys(m map[comparable]type) []type(m[]) {
	r := make([]type(m[]), 0, len(m))
	for k := range m {
		r = append(r, k)
	}
	return r
}

For func types with generic arg / rval types, you must name any args or return values you need the type of, and access like a named field: type(f.arg2).

This is the main downside of the GT proposal -- referring to the type elsewhere is now more cumbersome. Fortunately, Go's type inference means that this doesn't happen that much. And if a given arg type is going to be used a lot, you can define an inline type alias as is done in the draft proposal (see Container example below).

Generic types

Generic types are essentially identical to the draft proposal (except with the different type naming convention).

Additional proposal (emailed to go-nuts):

  • In methods, do not replicate the type params -- just refer back to the original type parameter names using field access syntax (e.g., m.T)
  • In case of an anonymous embedded field of same type as type param, use type(m.T) to refer to the type and m.T to refer to the field (can define a type alias fof the type expression if used frequently).
type Vector(T type) []T

var v Vector(int)

func (v *Vector) Push(x v.T) { *v = append(*v, x) }
type List(T type) struct {
	next *List(T) // this reference to List(T) is OK
	val  T
}
type StringableVector(T Stringer) []T

func (s StringableVector) String() string { ... }

Methods may not take additional type arguments

From the draft proposal:

Although methods of a generic type may use the type's parameters, methods may not themselves have additional type parameters. Where it would be useful to add type arguments to a method, people will have to write a suitably parameterized top-level function.

There would seem to be no reason to have such a constraint under GT, as generic args are really no different syntactically than concrete ones -- no extra parens, etc.

Type Lists in Constraints

Type lists are exactly as in the draft proposal, and their presence is essential for making the type a generic interface type (type type being the fully unconstrained version of this).

type SignedInteger interface {
	type int, int8, int16, int32, int64
}

...
// again much simpler to use type name directly, and note use of type(s) expression for return
func Smallest(s []constraints.Ordered) type(s) { ... }

Type args

To enable New functions, and any other case where type values need to be specified as such, we need to support explicit type arguments -- these are just like regular arguments, in the same parenthesized list, but start with the type keyword and must be passed a type expression (a type literal or a type() expression).

Containers Example

This provides a good example of how it all works -- very similar to the draft example overall because parameterized types are essentially the same, so it doesn't really show off the main strengths of the GT proposal, but at least concretely demonstrates that it should have the same overall expressive scope.

// Package orderedmap provides an ordered map, implemented as a binary tree.
package orderedmap

import "chans"

// Map is an ordered map.
// note: presence of type args defines a generic struct.
// if wrote: `K, V type` then K and V would be constrained to be the *same* generic type.
// could have written: `K number, V type` etc to specify constraints.
type Map(K type, V type) struct {
	root    *node(K, V)
	compare func(K, K) int
}

// node is the type of a node in the binary tree.
type node(K type, V type) struct {
	k           K
	v           V
	left, right *node(K, V)
}

// New returns a new map.
// note: first two args are type args (type comes first), not generic var args
func New(type K, type V, compare func(K, K) int) *Map(K, V) {
	return &Map(K, V){compare: compare}
}

// find looks up k in the map, and returns either a pointer
// to the node holding k, or a pointer to the location where
// such a node would go.
// note: methods do NOT need to keep re-specifying the type params!
// using explicit field access to refer to type parameters so it is clearer where they are defined.
// this is portable to draft proposal.
func (m *Map) find(k m.K) **node(m.K, m.V) {
	pn := &m.root
	for *pn != nil {
		switch cmp := m.compare(k, (*pn).k); {
		case cmp < 0:
			pn = &(*pn).left
		case cmp > 0:
			pn = &(*pn).right
		default:
			return pn
		}
	}
	return pn
}

// Insert inserts a new key/value into the map.
// If the key is already present, the value is replaced.
// Reports whether this is a new key.
func (m *Map) Insert(k m.K, v m.V) bool {
	pn := m.find(k)
	if *pn != nil {
		(*pn).v = v
		return false
	}
	*pn = &node(m.K, m.V){k: k, v: v}
	return true
}

// Find returns the value associated with a key, or zero if not present.
// The bool result reports whether the key was found.
func (m *Map) Find(k m.K) (m.V, bool) {
	pn := m.find(k)
	if *pn == nil {
		var zero m.V // see the discussion of zero values, above
		return zero, false
	}
	return (*pn).v, true
}

// keyValue is a pair of key and value used when iterating.
type keyValue(K type, V type) struct {
	k    K
	v    V
}

// InOrder returns an iterator that does an in-order traversal of the map.
func (m *Map) InOrder() *Iterator(m.K, m.V) {
	type kv = keyValue(m.K, m.V)
	sender, receiver := chans.Ranger(kv)
	var f func(*node(m.K, m.V)) bool
	f = func(n *node(m.K, m.V)) bool {
		if n == nil {
			return true
		}
		// Stop sending values if sender.Send returns false,
		// meaning that nothing is listening at the receiver end.
		return f(n.left) &&
			sender.Send(kv{n.k, n.v}) &&
			f(n.right)
	}
	go func() {
		f(m.root)
		sender.Close()
	}()
	return &Iterator(m.K, m.V){receiver}
}

// Iterator is used to iterate over the map.
type Iterator(K type, V type) struct {
	r *chans.Receiver(keyValue(K, V))
}

// Next returns the next key and value pair. The bool result reports
// whether the values are valid. If the values are not valid, we have
// reached the end.
func (it *Iterator) Next() (it.K, it.V, bool) {
	kv, ok := it.r.Next()
	return kv.k, kv.v, ok
}

Note: this GT proposal builds on the core idea from the Generic Native Types (GNT) proposal, but is much simpler and closer to the draft proposal.

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