Go2: Interface Default Methods?

Sindre Myren
8 min readAug 4, 2019

--

Could interface default methods be an alternative to other generic proposals in Go?

UPDATE: A GitHub propsal motivated by this article can be found here.

A spoken goal of the Go 2 roadmap is to implement a form of generics, or parametric polymorphism as it’s also called. But generics is a wide and complex topic, and it’s hard for it to fit orthogonality in with the existing features of Go, such as interfaces. Recently a rather complex draft proposal have been released for implementing more generics in Go.

In this article, we do not try to offer a solution that solves all the problems that this proposals solves, but rather look on how we can add a limited language feature that aim to make Go code simpler and more joyful to read or write.

TL;DR An interface default method is a method defined on the interface type that allows types that don’t define these methods to still implement the interface when the type is compatible with the default methods. This is a powerful construct that allows more types to implicitly pass as implementations of an interface. It is not as powerful as other generic proposals, but fits in well with existing Go language features.

The limitations of interfaces

Interfaces are, in fact, already a form of generics; they allow functions to be written once that work on all compatible types.

The intention with interfaces is that you can define which methods or properties you require of a particular type. Interfaces in Go are implicit, which means that you don’t have to explicitly state that a type implements a given interface. This has turned out to be a great idea, and has proven pretty powerfull. However, there are still some important limitations. E.g. you cannot through interfaces describe which built-in operations you require, such as:

  • I want to too be able to take the len of something.
  • I want to be able to access an index array[i].
  • I want to be able to tell if a map has a a key _, ok := map[k].
  • I want to rangeover the keys in a map type.
  • I want to be able to call arithmetic operations such as i++ on a value.

Not being able to require these operations, leads to several possible workarounds in our code:

  • We fall back to use interface{} and use type-switches or reflect to find out if an acceptable type was passed in.
  • When maps or slices of generalized type are desired, we fallback to converting the relevant data to map[string]interface{} and []interface{}.
  • Or finally, we describe the concrete functionality we want as an interface, and implement an adapter for each and every type permutation that we want to support. This has been done e.g. in the built-in sort package.

The last workaround in particular is what we will focus on turning into a more “generic” solution that is easier to apply without writing adapters.

The solution

Interface default methods holds the promise of implementing improved generics functionality for existing functions based on interfaces with little or no new syntax.

Interface default methods are method implementations defined on an interface. This is in contrast to method signatures, which is what an interface is composed of today. These implementations will look more or less like normal method implementations, but has some minimal syntax added to be able to parameterize the receiver type that is to implement the interface. This new syntax could in theory be omitted, but this would limit the solution to not be able to access the type. The additional syntax could also help to visually distinguish interface default methods from normal methods.

When passing a type that lacks an implementation for an interface method into an interface variable or parameter, the compiler wold check if the co-responding default method can compile successfully. A type will be considered to implement an interface if it implements all interface methods after considering default methods as a fallback implementation.

If an interface extends another interface, then the default methods are inherited similar to how a method is inherited when a struct contains one or more embedded fields.

If inheriting two interfaces that both implements a default method with the same name, then the compiler would reject the default method as ambiguous and ignore it. This is the same behavior that we have today for struct embedded fields.

Syntax example

We will now explore a syntax example with using $ as a special prefix to parameterize the receiver type of an interface default method. The prefix will be added before the interface type, where the compiler will replace the $TypeNamewith a concrete type.

type Equaler interface{
Equal(other Equal) bool
}
func (e $Equaler) Equal(other Equaler) bool {
t, ok := other.($Equaler)
return ok && e == t
}

We note that the special syntax can only be used to parameterize the method receiver type, and only within an interface default method. In particular, the following examples are invalid and should result in a compilation error:

// INVALID: type parametrization cannot be used for method
// parameters.
func (e $Equaler) Equal(other $Equaler) bool {
return e == t
}
type Foo struct {}// INVALID: type parameterization can only be used for interface
// default method declarations.
func (t $Foo) func Bar() {
}

Other syntax could be considered, this is just an example.

Comparison to contracts

This is a very brief comparison against the current draft contract proposal for generics in Go, as well as the status quo. For this, let’s first look at how we can modify the sort package in the standard Go library to support more generic usage.

To make a function that supports sorting comparable types based on the contracts proposal, one way to do it is to specify all the underlying types we accept. There appear to be more ways, but either way, it is my understanding that a new function must be declared to ensure backwards-compatibility.

package sortcontact Comparable(T) {
T int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64, uintptr,
float32, float64,
complex64, complex128,
char, rune, string,
}
// Comparables is like Sort, but for types that implements
// the Comparable contract.
func Comparables(type T Comparable)(data []T) {
// New implementation of sort.
}

The new function can now utilize the comparable properties of the underlying types to implement sorting.

With interface default methods however, we can extend the existing sort.Interface type so that the sort.Sort and sort.Stable functions will accepts a lot more types. In fact, they will support slices of all the types the contract based sort function would accept as well as any type that implements sort.Interface today. It should be additionally noted that the default methods would work not only on slices, but also on arrays and map[int] types.

package sort// No changes to Interface definition!
type Interface interface {
Len() int
Swap(i, j int)
Less(i, j int) bool
}
// Len provides a default implementation for determining the length
// of array, slice and map types.
func (s $Interface) Len() int {
return len(s)
}
// Swap provides a default implementation for swapping values on
// the given indices of array, slice and map[int] types.
func (s $Interface) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// Less provides a default implementation for comparing values on
// the given indices of array, slice and map[int] types.
func (s $Interface) Less(i, j int) bool {
return s[i] < s[j]
}
//Sort sorts data....
func Sort(data Interface) {
// No changes to the code!
}

Note that that a mix of methods declared on a type and default implementations can be combined for the same type. E.g. if one collection type need to implement it’s own Less method, we can now do so without also having to implement Swap and Len:

type usersByLastName []Userfunc (s usersByLastName) Less(i, j) {
return s[i].LastName < s[j].LastName
}
type usersByFirstName []Userfunc (s usersByFirstName) Less(i, j) {
return s[i].FirstName < s[j].FirstName
}

Such types can be easily used by the sort functions in the following way:

myUsers := []User{
User{FirstName: "Theodor", LastName: "Wane"},
User{FirstName: "John", LastName: "Doe"},
User{FirstName: "Jane", LastName: "Wane"},
}
// Sort users by LastName then FirstName.
sort.Sort(usersByFirstName(myUsers))
sort.Stable(usersByLastName(myUsers))

Coming to a conclusion for this example, it appears possible for some cases to create an even more generic solution using interface default methods than we can with contracts. The same can assumed to be true for some other collection rearranging exercises, such as reverse. This is possible because all built-in operations with this solution are accessed as methods that can be overridden.

Solution limitations

Although the default interface method approach demonstrates that in can work well for the case of the sort package, there is several limitations to this proposal compared to contracts. Here are some of the important ones:

  • Arrays, maps and slices need relevant default methods to support common operations such as indexing, range and assignment. These methods could impose performance penalties and delay type-checks from compile time to run-time.
  • Only the receiver type of the default method can use parameterization. This means we cannot use parameterization for other type methods, type declarations or package-level functions. Perhaps less obvious, this also means that there isn’t any designed way in which we can access the key or value type of a generic map, slice or array receiver type. This makes set and append operations in particular hard to provide default methods for.
  • As the solutions is based on interfaces, it likely has overall worse performance.

To illustrate some fo these points, the following examples can likely be done with full compile-time type safety by the contracts proposal only:

func Equal(type T equaler)(a, b T) bool {
return a == b
}
func Set(type T, U)(m map[U]T, k U, v T) {
m[k] = v
}
type SyncMap(type T, U) struct {
m map[T]U
l sync.RWMutex
}
func (m *SyncMap(type T, U)) Set(k U, v T) {
m.l.Lock()
m.m[k] = v
m.l.Unlock()
}
func (m *SyncMap(type T, U)) Get(k) (T, bool) {
m.l.RLock()
v, ok := m.m[k]
m.l.RUnlock()
return v, ok
}

In particular the SyncMap example shows some very appealing code we would miss out on 🙁

Conclusion

It isn’t hard to come up with a problem that can’t be solved with the same level of type-safety by use of interface default methods as it can with contracts or other more extensive generics proposals. On the other hand, it adds significantly less language complexity from a user’s perspective, and it can be fitted easily into existing programs that utilize interfaces. As long as Go doesn’t allow operator overloading, there are also cases where the proposal offer increased flexibility over alternative proposals.

Interface default methods can be seen as an extension of the implicit interface implementation philosophy, and as a feature that would add real value to Go programers and programs. However, more work is needed to see if it is an acceptable trade-off for Go compared to a more extensive and powerful proposal, how it can be implemented and whether it allows future extension.

Further work

It would be interesting to compare potential code for solving real-world problems. How does the solution compare to the code that must be written today, and how does it compare to code written with contracts?

It would also be interesting to understand how this feature can be implemented in the Go compiler, and if there are any significant issues that would require a different design.

Finally, it is not guaranteed that contracts and interface default methods are completely mutual exclusive proposals. Perhaps some of both can be implemented where eschewed and overlapping features are eliminated to reduce overall complexity. I wouldn’t know how, but everything is possible. Perhaps an interface with default methods could replace the contract bit?

Any help on further exploration by the readers are most welcome!

--

--

Sindre Myren
Sindre Myren

Written by Sindre Myren

Backend developer at Searis AS, and occasional tech blogger.

No responses yet