Skip to content

Instantly share code, notes, and snippets.

@pat42smith
Last active October 16, 2018 23:58
Show Gist options
  • Save pat42smith/ccf021193971f6de6fdb229d68215302 to your computer and use it in GitHub Desktop.
Save pat42smith/ccf021193971f6de6fdb229d68215302 to your computer and use it in GitHub Desktop.
Go Generics with Adaptors

Go Generics with Adaptors

Contracts — Draft Design presents a draft proposal for adding generic types and functions to the Go programming language, using contracts to specify the behavior required of type parameters.

This document discusses two topics.

First, suppose generic types and functions are added to Go as in the contracts proposal, but without contracts. When a generic function f has a type parameter T, the only operations f can perform on values of type T are the operations that are available on all types. Is there a reasonable way to write generic code in these circumstances, with no other additions to the language? The answer seems to be

  • Yes, this is quite doable.
  • And it works well with both built-in and user-defined types.
  • But it is verbose.
  • And it is likely slower than code generated under the contracts proposal.

Second, can we extend Go in ways that improve the code from the first topic, making it less verbose and more efficient?

Generics without contracts

Assumptions

We assume that Go is extended to allow generic types and functions, as discussed in the contracts proposal, but without contracts. There are two additional rules.

If an instantiation of a generic type or function creates an illegal type, then that instantiation is not allowed. Under the contracts proposal, the type or function's contract would prevent such instantiations, but we are not using contracts, so we need this rule.

type Set(type T) map[T]struct{}
var s1 Set(int)   // ok
var s2 Set([]int) // illegal

func Unique(type T)(list []T) []T {
	var m := make(map[T]bool)
	for _, t := range list {
		m[t] = true
	}
	var result []T
	for t := range m {
		result = append(result, t)
	}
	return result
}

fmt.Println(Unique(make([]int, 5)))	// ok; can make map[int]bool
fmt.Println(Unique(make([][]int, 5)))	// illegal; can't make map[[]int]bool

When a generic function manipulates a value of unknown type, it may only use operations that are applicable to all types.

func Min(type T)(a, b T) T {
	if a < b {		// illegal; T might not have the < operator
		return a
	}
	return b
}

func Min(type T)(a, b T, less func(T, T) bool) T {
	if less(a, b) {		// this is ok
		return a
	}
	return b
}

Writing generic functions

Suppose we want a function to add the elements of a slice. We can't write the obvious

func SumSlice(type T)(s []T) T {
	var sum T
	for _, t := range s {
		sum += t	// illegal; T might not have the + operator
	}
	return sum
}

One traditional way to do this uses an adaptor function passed as an additional parameter.

func SumSlice(type T)(s []T, add func(T, T) T) T {
	var sum T
	for _, t := range s {
		sum = add(sum, t)
	}
	return sum
}

This approach works, but can be quite verbose. If a generic function needs five operations on its type parameters, then each call to the function will have to supply five additional function arguments. And all those adaptor functions have to be written somewhere and organized appropriately.

We suggest instead using interfaces to express the operations needed.

type Adder(type T) interface {
	Zero() T
	Add(T, T) T
}

func SumSlice(type T)(s []T, a Adder(T)) T {
	sum := a.Zero()
	for _, t := range s {
		sum = a.Add(sum, t)
	}
	return sum
}

type IntAdder struct {}
func (IntAdder) Zero() int { return 0 }
func (IntAdder) Add(a, b int) int { return a + b }
var ints []int
fmt.Println(SumSlice(ints, IntAdder{}))

type BigIntAdder struct {}
func (BigIntAdder) Zero() *big.Int { return nil }
func (BigIntAdder) Add(a, b *big.Int) *big.Int { return big.NewInt(0).Add(a, b) }
var bigints []*big.Int
fmt.Println(SumSlice(bigints, BigIntAdder{}))

Unlike many other proposals, it is not expected that the type T should implement the interface Adder(T). Instead, T is used only in the parameter and result types of the Adder(T) methods. This allows us to create an Adder(T) when T is a built-in type. For example, IntAdder above implements Adder(int).

When a type XAdder implements Adder(X), it will usually be true that the methods of XAdder do not use the content of the XAdder receiver, so XAdder can be defined as struct{}, as in the examples above. However, there can be cases in which other definitions are useful.

type ModularAdder uint
func (ModularAdder) Zero() uint { return 0 }
func (m ModularAdder) Add(a, b uint) uint { return (a + b) % uint(m) }
var modints []uint
fmt.Println(SumSlice(modints, ModularAdder(49999)))

Type adaptors

We can also create adaptor types and objects that describe the behaviors of the types we use with generic functions.

type Float64Adaptor struct{}
func (Float64Adaptor) Zero() float64 { return 0.0 }
func (Float64Adaptor) One() float64 { return 1.0 }
func (Float64Adaptor) Add(a, b float64) float64 { return a + b }
func (Float64Adaptor) Sub(a, b float64) float64 { return a - b }
func (Float64Adaptor) Mul(a, b float64) float64 { return a * b }
func (Float64Adaptor) Div(a, b float64) float64 { return a / b }
func (Float64Adaptor) AddAssign(a *float64, b float64) { *a += b }
func (Float64Adaptor) SubAssign(a *float64, b float64) { *a -= b }
func (Float64Adaptor) MulAssign(a *float64, b float64) { *a *= b }
func (Float64Adaptor) DivAssign(a *float64, b float64) { *a /= b }
func (Float64Adaptor) Equal(a, b float64) bool { return a == b }
func (Float64Adaptor) NotEqual(a, b float64) bool { return a != b }
func (Float64Adaptor) Less(a, b float64) bool { return a < b }
func (Float64Adaptor) LessOrEqual(a, b float64) bool { return a <= b }
func (Float64Adaptor) Greater(a, b float64) bool { return a > b }
func (Float64Adaptor) GreaterOrEqual(a, b float64) bool { return a >= b }

// And the same again, with 64 everywhere replaced by 32

Now we can use these adaptors to call SumSlice without having to create helper types for the calls:

var f32 []float32
var f64 []float64
fmt.Println(SumSlice(f32, Float32Adaptor{}))
fmt.Println(SumSlice(f64, Float64Adaptor{}))

This is the main advantage of this programming style: we don't need helper types for each possible call. Instead, each type parameter of a generic function uses an interface to express the operations required on that type parameter, and each concrete type uses a separate adaptor type to express the operations it provides.

Examples

Min and Max

type Order(type T) interface {
	Less(T, T) bool
}

func Min(type T)(o Order(T), first T, rest ...T) T {
	min := first
	for _, x := range rest {
		if o.Less(x, min) {
			min = x
		}
	}
	return min
}

func Max(type T)(o Order(T), first T, rest ...T) T {
	max := first
	for _, x := range rest {
		if o.Less(max, x) {
			max = x
		}
	}
	return max
}

fmt.Println(Min(Float64Adaptor, 1.5, 3, -17))

Polynomials

type Coefficient(type C) interface {
	Adder(C)	// Embedding
	Mul(C, C) C
}

type Polynomial(type C) []C

func (p Polynomial(C)) Eval(x C, c Coefficient(C)) C {
	y := c.Zero()
	for n := len(p) - 1; n >= 0; n-- {
		y = c.Add(p[n], c.Mul(x, y))
	}
	return y
}

func (p Polynomial(C)) Add(q Polynomial(C), c Coefficient(C)) Polynomial(C) {
	r := make(Polynomial(C), Max(IntAdaptor{}, len(p), len(q)))
	for n := 0; n < len(p) && n < len(q); n++ {
		r[n] = c.Add(p[n], q[n])
	}
	if len(p) < len(q) {
		copy(r[len(p):], q[len(p):])
	} else {
		copy(r[len(q):], p[len(q):])
	}
	return r
}

func (p Polynomial(C)) Mul(q Polynomial(C), c Coefficient(C)) Polynomial(C) {
	if len(p) == 0 || len(q) == 0 {
		return nil
	}
	r := make(Polynomial(C), len(p) + len(q) - 1)
	for n := range r {
		r[n] = c.Zero()
	}
	for i, pi := range p {
		for j, qj := range q {
			c.AddAssign(&r[i+j], c.Mul(pi, qj))
		}
	}
	return r
}

And we can create polynomials whose coefficients are themselves polynomials. This shows the construction of an adaptor for a user-defined type.

type PolynomialAdaptor(type C) struct{}
func (PolynomialAdaptor(C)) Zero() Polynomial(C) { return nil }
func (PolynomialAdaptor(C)) Eval(p Polynomial(C), x C, c Coefficient(C)) C {
	return p.Eval(x, c)
}
func (PolynomialAdaptor(C)) Add(p, q Polynomial(C), c Coefficient(C)) Polynomial(C) {
	return p.Add(q, c)
}
func (PolynomialAdaptor(C)) Mul(p, q Polynomial(C), c Coefficient(C)) Polynomial(C) {
	return p.Mul(q, c)
}

var pp Polynomial(Polynomial(float64))
var px Polynomial(float64)
fmt.Println(pp.Eval(px, Float64Adaptor{}))

Slice adaptors

type SliceAdaptor(type T) struct{}
func (SliceAdaptor(T)) Len(s []T) int { return len(s) }
func (SliceAdaptor(T)) At(s []T, n int) T { return s[n] }
func (SliceAdaptor(T)) SetAt(s []T, n int, t T) { s[n] = t }
func (SliceAdaptor(T)) PtrAt(s []T, n int) *T { return &s[n] }
func (SliceAdaptor(T)) Slice(s []T, low, high int) []T { return s[low:high] }
func (SliceAdaptor(T)) CapSlice(s []T, low, high, cap int) []T { return s[low:high:cap] }
func (SliceAdaptor(T)) Make(n int) []T { return make([]T, n) }
func (SliceAdaptor(T)) MakeCap(n, m int) []T { return make([]T, n, m) }
func (SliceAdaptor(T)) Range(s []T, f func(int, T)) { for n, t := range s { f(n, t) } }
func (SliceAdaptor(T)) BreakableRange(s []T, f func(int, T) bool) {
	for n, t := range s {
		if !f(n, t) { break }
	}
}

Sorting

See also the example in the contracts proposal.

type ConstList(type L, T) interface {
	Len(L) int
	At(L, int) T
}

type List(type L, T) interface {
	ConstList(L, T)		// embedding
	SetAt(L, int, T)
}

func SortSlowly(type L, T)(list L, l List(L, T), o Order(T)) {
	for i := l.Len(list) - 1; i > 0; i-- {
		for j := 0; j < i; j++ {
			lj := l.At(list, j)
			lj1 := l.At(list, j + 1)
			if o.Less(lj1, lj) {
				l.SetAt(list, j, lj1)
				l.SetAt(list, j + 1, lj)
			}
		}
	}
}

type StdSorter(type L, T) struct {
	list L
	l List(L, T)
	o Order(T)
}
func (ss StdSorter(L, T)) Len() int { return ss.l.Len(ss.list) }
func (ss StdSorter(L, T)) Less(i, j int) bool {
	return ss.o.Less(ss.l.At(ss.list, i), ss.l.At(ss.list, j))
}
func (ss StdSorter(L, T)) Swap(i, j int) {
	li, lj := ss.At(ss.list, i), ss.At(ss.list, j)
	ss.l.SetAt(ss.list, i, lj)
	ss.l.SetAt(ss.list, j, li)
}

func StdSort(type L, T)(list L, l List(L, T), o Order(T)) {
	sort.Sort(StdSorter{ list, l, o })
}

func SortSlice(type T)(s []T, o Order(T)) {
	StdSort(s, SliceAdaptor(T){}, o)
}

type LessAdaptor(type T) struct {
	Less func(T, T) bool
}
func (l LessAdaptor(T)) Less(a, b T) bool { return l.Less(a, b) }

func SortSliceBy(type T)(s []T, less func(T, T) bool) {
	SortSlice(s, LessAdaptor{ less })
}

Note the difference between StdSorter here and Polynomial above. StdSorter includes the adaptor objects it needs; Polynomial assumes each function call will provide a suitable adaptor, and that the same adaptor will be provided in each call. Which strategy is best will depend on the circumstances.

Map keys

See the example in the contracts document.

type MapAdaptor(type K, V) struct{}
func (MapAdaptor(K, V)) Len(m map[K]V) int { return len(m) }
func (MapAdaptor(K, V)) At(m map[K]V, k K) V { return m[k] }
func (MapAdaptor(K, V)) GetAt(m map[K]V, k K) (V, bool) {
	v, have := m[k]
	return v, have
}
func (MapAdaptor(K, V)) SetAt(m map[K]V, k K, v V) { m[k] = v }
func (MapAdaptor(K, V)) Range(m map[K]V, f func(K, V)) { for k, v := range m { f(k, v) } }
func (MapAdaptor(K, V)) BreakableRange(m map[K]V, f func(K, V) bool) {
	for k, v := range m {
		if !f(k, v) { break }
	}
}

type Ranger(M, K, V) interface {
	Len(M) int
	Range(M, f func(K, V))
}

func Keys(type M, K, V)(m M, r Ranger(M, K, V)) []K {
	list := make([]K, 0, r.Len(m))
	r.Range(m, func(k K, v V) {
		list = append(list, k)
	})
	return list
}

var m map[int]string
fmt.Println(Keys(m, MapAdaptor(int, string){}))

Adding slice elements, take 2

Suppose we want to add the elements of a slice, but the element type might be large enough to hold the sum, so we need to use a different type for the sum.

func SumSlice2(type T, S)(slice []T, convert func(T) S, a Adder(S)) S {
	sum := a.Zero()
	for _, t := range slice {
		sum = a.Add(sum, convert(t))
	}
	return sum
}

var bytes []byte
fmt.Println(SumSlice2(bytes, func(b byte) int { return int(b) }, IntAdaptor{})

This illustrates that conversion between types is best handled through a separate function. We can't expect the ByteAdaptor type to include a method to convert a byte to an int.

Comments

Verbosity

Code using this approach will be quite a bit more verbose than corresponding code using contracts.

  • Each concrete type needs an adaptor written.
  • Each call to a generic function requires additional arguments.
  • The body of a generic function, instead of directly using methods and operators, must call methods of the adaptor interfaces.

Also, adaptor interfaces must be written to express what each generic function requires from its type parameters. These should be roughly equivalent to contracts, so do not make this code more verbose than code using contracts.

In exchange for this verbosity, we are easily able to write generic functions operating on both built-in and user-defined types.

Efficiency

This section is pure speculation.

There are probably two main alternatives for compiling a generic function when using contracts.

  1. Compile a single version applicable to all combinations of type parameters.
  2. Compile a separate version for each unique combination of type parameters.

For the adaptor programming style described here, there seem to be three alternatives.

  1. Compile a single version applicable to all combinations of type parameters.
  2. For each unique combination of type parameters, compile a version of the function that works with any adaptors for those type parameters.
  3. For each unique combination of type parameters and adaptors, compile a version of the function.

Contracts alternative 1 and adaptors alternative 1 seem roughly equivalent, using function pointers or interfaces to specify how to perform operations. Adaptors have an additional, unused, value: the adaptor itself. So adaptors might be marginally slower.

Contracts alternative 2 and adaptors alternative 3 also seem equivalent, with operations going directly to built-in operators or type parameter method calls. However, it is unreasonable to expect the compiler to implement adaptors alternative 3.

Adaptors alternative 2 retains a significant amount of indirection, so will probably be slower than contracts alternative 2.

Together with contracts

The use of adaptors described above is a programming style, not an extension to the language. So even if contracts are implemented, these adaptors can still be used. It is not clear what programmers would choose to do in this event.

Operations involving two types

Most operations in Go involve one type that constrains which types are valid for the other operands. For example,

  • in an index operation, the slice, array, or map being indexed
  • the receiver type in a method call
  • either operand type from an arithmetic operation

These operations can be properly handled by type adaptors as described above.

However, some operations can involve two types in such a way that neither type clearly constrains the other type. These are

  • conversions
  • type assertions
  • equality comparisons

These operations are probably best handled with simple adaptor functions, as in Adding slice elements, take 2.

Equality comparisons are a special case. The adaptor for a type T can include a method Equal(T, T), but a separate adaptor function should be used when comparing values of two different types.

Automatically generated adaptor types

What would we do if we were willing to extend the language in order to support this programming style? (Yes, a large assumption, but just suppose...).

The first target should be the default adaptor types. These are almost entirely predictable from the base types, so let's have the compiler generate them. For the purposes of this dicussion, given a type X, we will assume the syntax adaptortype(X) for the adaptor type of X, and adaptor(X) for an object of this type. adaptortype(X) will always be defined as struct{}, so adaptor(X) is just shorthand for adaptortype(X){}.

With this, we can use SumSlice from above this way:

var ints []int
fmt.Println(SumSlice(ints, adaptor(int)))

This is essentially the same as before, but now we don't have to write the IntAdaptor type and its methods.

Rules

Here we suggest rules for the compiler to use in generating a type adaptor for a type X. These fall into two categories, rules pertaining to methods, and rules pertaining to operators.

Method rules

adaptortype(X) should have one method for each method of X or *X, with the corresponding type X or *X prepended to the argument list. This will call the original method.

If X is an interface type, then *X has no methods, so only the methods of X are considered.

type X interface {
	Foo(int)
}

// Pseudo-code generated by the compiler
type adaptortype(X) struct{}
func (adaptortype(X)) Foo(x X, i int) { x.Foo(i) }

Otherwise, methods of both X and *X are considered.

type X int
func (X) Foo(float64, int) uint { ... }
func (*X) Bar() string { ... }
func (X) Add(X) X { ... }

// Pseudo-code generated by the compiler
type adaptortype(X) struct{}
func (adaptortype(X)) Foo(x X, f float64, i int) uint { return x.Foo(f, i) }
func (adaptortype(X)) Bar(x *X) string { return x.Bar() }
func (adaptortype(X)) Add(x, y X) X { return x.Add(y) }

// The methods of this X are also methods of *X; but **X has no methods
type adaptortype(*X) struct{}
func (adaptortype(*X)) Foo(x *X, f float64, i int) uint { return x.Foo(f, i) }
func (adaptortype(*X)) Bar(x *X) string { return x.Bar() }
func (adaptortype(*X)) Add(x *X, y X) X { return x.Add(y) }

Operator rules

There are special rules corresponding to the built-in operations. For example, if X has the + operator, then adaptortype(X) has a method

func (adaptortype(X)) Add(a, b X) X { return a + b }

However, the method rules take priority; if X also has a method named Add, that is used to generate adaptortype(X).Add.

Here are some more examples of methods that could be generated.

// type X int
func (adaptortype(X)) AddAssign(a *X, b ) { a += b }
func (adaptortype(X)) Equal(a, b X) bool { return a == b }

// type M map[string]int
func (adaptortype(M)) At(m M, s string) int { return m[s] }
func (adaptortype(M)) Range(m M, f func(string, int)) {
	for k, v := range m { f(k, v) }
}

Other adaptor methods

It might be useful to have adaptors include some methods other than those corresponding to the methods and operations of the base type. For example, Float64Adaptor above included Zero and One methods. An adaptor for an integer type might usefully have MinValue and MaxValue methods. But the rules above won't create these methods; what can we do?

For user-defined types, we can simply give the type a method whose receiver is not used.

type MyUint uint
func (MyUint) MinValue() MyUint { return 0 }

There will now be an adaptortype(MyUint).MinValue method. This construction is not possible for the built-in types, but there could be a few standard methods the compiler generates in adaptors for built-in types.

Another option is to use type embedding to create a new adaptor type with additional methods.

type MinUintAdaptorType {
	adaptortype(uint)
}
func (MinUintAdaptorType) MinValue() uint { return 0 }
var MinUintAdaptor MinUintAdaptorType{}

Yet another option is to simply not have these additional methods; workarounds surely exist.

Default adaptors

If the compiler can generate default adaptor types as discussed above, then we have the option of extending the syntax for generic functions to indicate that adaptor arguments are optional. For example, we might put a semi-colon in the argument list, indicating that the following arguments are optional.

func Sort(type L, T)(list L; o Order(T), l List(L, T)) { ... }
var ints []int

// The following three calls are equivalent
Sort(ints; adaptor(int), adaptor([]int))
Sort(ints; adaptor(int))
Sort(ints)

type CustomAdaptor struct{}
func (CustomAdaptor) Less(a, b int) bool { return fmt.Sprint(a) < fmt.Sprint(b) }

// The following two calls are equivalent
Sort(ints; CustomAdaptor{}, adaptor([]int))
Sort(ints; CustomAdaptor{})

The rule would be that each argument type after the semi-colon must be an instantiation of a generic interface type; the default value for that argument is the default adaptor for the first type argument of the interface type. Also, when one generic function calls another, it must explicitly pass the adaptors required by the called function.

func Foo(type L, T)(list L; o Order(T), l List(L, T)) {
	Sort(list; o, l)
	// Sort(list) would be illegal,
	// because adaptortype(L) and adaptortype(T) have no methods
}

This syntax clearly marks off the function parameters which are expected to be adaptor types. So it might now be reasonable for the compiler to produce a separate version of the function for each combination of type arguments, using the default adaptors for those types. This should yield similar runtime speed to that which could be obtained with contracts.

As a variant on this, we might want to rule that when there is a default value for an adaptor interface, then only that default may be used. The default may not be overridden with a custom adaptor. In that case, a more suitable syntax might be to put the adaptors with the type parameters, separated by a colon.

func Sort(type L, T : o Order(T), l List(L, T))(list L) { ... }
var ints []int

// Now there's only one way to do this call.
// It implicitly uses o = adaptor(int) and l = adaptor([]int).
Sort(ints)

// I lied. You can also do this. But it doesn't gain you anything here.
Sort([]int, int)(ints)

But we would need additional logic to specify what adaptor to use when one generic function calls another.

Generic function bodies

We might try to make the bodies of generic functions a little bit shorter and clearer by allowing methods of the adaptor interfaces to be called through what appear to be calls of methods on the parameter types. For example,

func SumSlice(type T)(s []T, a Adder(T)) T {
	sum := a.Zero()
	for _, t := range s {
		sum = sum.Add(t)	// Equivalent to sum = a.Add(sum, t)
	}
}

Any method of Adder(T) whose first parameter type is T can be used as if were a method of T; methods of Adder(T) with first parameter type *T can be called as if they were methods of *T. Of course, this only works if the choice of adaptor interface to use is unambiguous or somehow indicated by syntax.

I don't recommend this. The improvement seems too small.

Final thoughts

The important point here is that very basic generics, without contracts, provide us a great deal of flexibility in writing code, even if the code is verbose. Furthermore, the amount of code required scales as the sum of the number of generic functions and the number of types (and their methods) that might be passed to those functions, not as the product of those two numbers.

This suggests that those very basic generics, without contracts, might be useful as a first step in implementing generics, so that people may experiment before deciding on the next steps.

This also sets up minimal expectations for generics. Any approach to be considered should either provide more flexibility than the adaptors discussed in this document or yield shorter and clearer code.

Version history (excluding trivial edits)
  • 2018/10/16: Initial version.
  • 2018/10/16: Bug fix in Float64(32)Adaptor: can't assign 0.0 or 1.0 to a variable whose type is a type parameter.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment