What's Missing From Golang Generics?
Last time, I discussed a specific design problem that I encountered while writing generic code in Go, and how I eventually solved it. I was proud that I had found a solution, although less proud that it wasn't the cleanest.
My attempts to write cleaner code had felt stymied by the limitations of the language. It left me wondering if what I'd written was clever, or just a hack.
So like a bad carpenter, I blamed my tools. I criticized the language for not giving me the tools to solve the problem my way, and I described the Go language as "incomplete." Thankfully, the /r/golang subreddit was there to set the record straight:
A small sample of the comments:
dude, this guy uses interfaces for everything
The code looks like Java
You think too much in OOP
I think that Java comment stung the most.
Now to be fair, they raise some excellent points, particularly about how OOP is a limiting mindset that too often leads to trying to shove a square peg in a round hole. Go is not an object-oriented language. It does not have things like inheritance or virtual methods because they are too often used to make code that is difficult to reason about. Trying to force an OOP style onto Go is an unnecessary exercise in futility.
But I also think that these reactions missed the core thrust of my last article, or perhaps I failed to convey it. which is this:
The Point
At its core, the problem I presented in the last article has nothing to do with interfaces or objects or any other specific language feature, and everything to do with types and APIs. In this case, we had a set of types which were already extensively used in the codebase. The functions that take and return these types, and the methods defined on them, form a de-facto API, and the bodies of those methods form a de-facto implementation. We found ourselves in the position of needing to add a second implementation, with the following goals:
- Minimize refactoring places where these types are used.
- Minimize code duplication.
- Maximize code readability.
- Maximize type safety.
These are subjective metrics, and different solutions may score differently on each of them. But for any strongly typed language to be considered "complete", a reasonable solution to this problem must exist. And ideally, it exists within the language's encouraged style: no "hacks" required.
Additionally, professional software development often involves contributing to existing codebases with existing code patterns that are outside your control. You can't just throw out the baby with the bathwater and insist on mass refactors in the name of design purity. The problem you're presented with is the problem you have to solve, and you don't get to flip the table over and go home because you don't like how the existing code is designed.
Go is not an object-oriented language. But interfaces, combined with either dynamic dispatch or generics, are still the mechanism that Go provides for polymorphism. Thus, they must be able to solve this problem in a clean way. Otherwise, the language is incomplete.
The big challenge we encountered was in cleanly writing type constraints that referenced each other.
Dolt is a SQL database with Git-style version control semantics, written entirely in Go. We have multiple different implementations for our indexes, each consisting of a couple different types.
To that end, we had two type parameters, MapType
and MutableMapType
, and the following constraints:
MapType
has a methodMutate
that returns aMutableMapType
MutableMapType
has a methodFlush
that returns aMapType
.
This would allow us to write an expression like this...
var m MapType
m.Mutate().Flush()
...and be able to statically verify that the type of the expression m.Mutate().Flush()
is equal to MapType
.
A Correction, and a new Solution
One commenter, ar1819
, pointed out that I had gotten a critical detail wrong: I had claimed that type parameter constraints in a function couldn't reference each other in a cycle. But I was wrong: the following is completely valid:
func ApplyEditsToIndex[MT MutableMap[M], M Map[MT], IndexType Index[M]](index IndexType, edits Edits) { ... }
Callsites to this function even participate in type inference, meaning that while the declaration is still a little bit messy, the callsites themselves are clean.
This has its limitations. This user pointed out that is has some gotchas around pointer receivers. In addition, this only works for type constraints, and not similar contexts like type aliases. Still, this does simplify the problem and provide a cleaner solution than the one I presented. Thank you very much, ar1819
! I've updated the original post to mention this.
Can We Do Better?
Still, I can't help but wonder if an even cleaner solution might be possible if Go's generics weren't so constrained. It's a slippery slope, since feature creep can just as easily make code confusing and unreadable: just look at C++ templates. Go's restraint in adding features needlessly is laudable. But too much restraint means that Go could be avoiding improvements that might help readability.
But how do we tell the difference between useful features and feature creep? We can explore this by looking at features that exist in other languages and imagining:
- What they would look like if they existed in Go
- How they might have allowed us to solve this problem differently.
If these features only lead to more complicated, less readable code, that's a sign that Go is better off without them. However, if they let us solve this problem more cleanly, that's a sign that perhaps Go's generic handling is incomplete.
As a reminder, here's what our original solution looked like: the creation of a new generic interface, "IndexContract", which described the relationship between a set of type parameters. Each implementation of the Contract describes a group of types that collectively implement that behavior:
type IndexContract[IndexType Index, MapType Map, MutableMapType MutableMap] interface {
Mutate(MapType) MutableMapType
Flush(MutableMapType) MapType
GetMapFromIndex(IndexType) MapType
SetMapOnIndex(IndexType, MapType)
}
var _ IndexContract[VectorIndex, VectorMap, MutableVectorMap] = VectorIndexContract{ ... }
func ApplyEditsToIndex[IndexType Index,
MapType Map,
MutableMapType MutableMap,
IndexContractType IndexContract[IndexType, MapType, MutableMapType]
] (contract IndexContractType, index IndexType, edits Edits) {
oldMap := contract.GetMapFromIndex(index)
mutableMap := contract.Mutate(oldMap)
mutableMap.ApplyEdits(edits)
newMap := contract.Flush(mutableMap)
contract.SetMapOnIndex(index, newMap)
}
Note that this is a simplified toy example: you shouldn't actually use javabeans style getters and setters in your code, they're just a simple way to showcase using a type as both a parameter and a return type.
Expanding Our Horizons
Let's start with a simple feature that gets requested a lot and see whether it helps: parameterized type aliases.
Parameterized Type Aliases
A parameterized type alias is, unsurprisingly, a type alias that has type parameters. For example:
type Set[T comparable] = map[T]bool
Amazingly, this isn't supported, and throws a compiler error: generic type cannot be alias
Parameterized type aliases on their own don't increase the expressiveness of a language, but they can make type expressions simple and more readable while reducing code duplication.
For example, consider our problem from last time. One thing that I briefly mentioned but didn't dwell on was the fact that some of these various Map/Index types were themselves parameterized by their key and value types. In our toy example, let's say that Map and MutableMap take parameters for their key and value types, while Index, which sits on top of them, is designed to work with specific types that satisfy those parameters.
So the nice one-liner ApplyEditsToIndex
function declaration from above would actually need to look like this:
func ApplyEditsToIndex[
MT MutableMap[IndexKey, IndexValue, IndexKeyOrdering, M],
M Map[IndexKey, IndexValue, IndexKeyOrdering, MT],
IndexType Index[M]](index IndexType, edits Edits) { ... }
It's not the worst clutter, but it adds up. It makes the code harder to read and harder to maintain.
We might want to use a type alias to remove the repeated instances of IndexKey, IndexValue, IndexKeyOrdering
, but we can't, because those types also take additional type parameters: type aliases can't choose to supply only some of the parameters, it's all or none.
But parameterized type aliases would provide a way out:
type IndexMap[MutableMapType any] = Map[IndexKey, IndexValue, IndexKeyOrdering, MutableMapType]
type IndexMutableMap[MapType any] = MutableMap[IndexKey, IndexValue, IndexKeyOrdering, MapType]
type VectorIndex {
m IndexMap[IndexMutableMap]
}
func ApplyEditsToIndex[
MT IndexMutableMap[M],
M IndexMap[MT],
IndexType Index[M]](index IndexType, edits IndexEdits) {
The goal here is to keep any additional complexity contained and in context. Here it lives alongside the definition of VectorIndex and avoids polluting the definition of ApplyEditsToIndex
. That's a win in my book.
You could make an argument that the best design is to reconsider whether Key
and Value
need to be generic in the first place. But again, part of working on an established code base is working with inherited design choices. Parameterized type aliases allow us to protect other parts of the code from the consequences of those choices by keeping the added complexity localized.
Should Golang adopt this feature:
Absolutely. Naming sub-expressions via aliases is a great way improve readability. There's no reason why Golang shouldn't extend this to generics.
And good news, the language maintainers seem to agree, because Golang is adding parameterized type aliases in version 1.24.
Self-referential Type Aliases
The following is not currently allowed:
type Pair[L any, R any] struct {
left L
right R
}
type Tree = Pair[*Tree, *Tree]
Attempting to compile this code results in: invalid recursive type: Tree refers to itself
This is despite the fact that the following equivalent type definition is perfectly valid:
type Tree struct {
left *Tree
right *Tree
}
As are each of the following examples:
// Non-aliasing type declaration
type Tree Pair[*Tree, *Tree]
// Recursive type constraint
func foo[Tree Pair[*Tree, *Tree]]() {...}
It appears to just be type aliases specifically that disallow all forms of recursion. Some research suggests that this is the result of how type aliases are type checked. Perhaps validating them in all cases was determined to not be worth the benefits?
Should Golang adopt this feature?
I have no strong feelings here. Mostly I just found it amusing. I suspect there's some valid technical reason for this decision.
The self keyword
What it would look like in Go:
type Comparable[T self] interface {
Equals(other T) bool
}
The self
keyword, when it appears in an interface type constraint, refers the to the type that is implementing the interface. In the example above, the Comparable[T]
interface is satisfied by every type T containing a method Equals(other T) bool
.
By comparison, Golang currently has a built-in comparable
interface, which is satisfied by all types that support ==
. But this has several shortcomings:
comparable
has to be built-in. There's no way for user-defined interfaces to have similar behavior.- Because there's no operator overloading, no user-defined types can implement
comparable
Something like the self
keyword would allow for interfaces that can't currently be expressed.
Note that for self
to work, it has to appear in a type constraint. It can't just appear as part of a function signature for a non-generic interface, because it's not possible to pass self
in as a parameter if you don't know the underlying type of a value:
type Comparable interface {
Equals(other self) bool
}
func IsEqual(a, b Comparable) {
return a.Equals(b) // <- No way to tell whether a and b are the same implementation.
}
self
is useful because it allows us to express interfaces that aren't otherwise possible. For example, imagine if we could do the following in our case study:
type Map[MutableMapType MutableMap[T, MutableMapType], T self] interface {
Mutate() MutableMapType
}
type MutableMap[MapType Map[T, MapType], T self] interface {
Flush() MapType
}
Now, the Map
interface is satisfied by any type T that allows calling T.Mutate().Flush()
with a result type of T
. This is the exact constraint that we were having so much trouble expressing last time. While self
may make a type that uses it slightly harder to parse, that complexity is limited to the type definitions, plus an extra type parameter that is always trivially inferred.
A final note. The self
type would be identical to using a "type term" constraint like the following:
type Comparable[T any] interface {
T
Equals(other T) bool
}
However, Golang does not allow type parameters to appear in type terms. The self
type is a specific subset of the behavior that would be enabled by generic type terms, making it easier to implement and without many of the corner cases that would need to be considered.
Should Golang adopt this feature?
There's a clear benefit in specific cases, but it does add marginal extra complexity to interfaces, as well as complicating the interface validation logic that the language would need to implement. This added complexity may be too much to justify its inclusion.
Member Types
A member type is a type that exists in the namespace of another type. It is effectively a member of that type, like a field or a method.
If Golang had member types, the syntax might look like this, mirroring the syntax of methods:
type Foo struct {}
type (Foo) Inner = int64
// Foo.Inner is equivalent to int64
var _ int64 = Foo.Inner(0)
Member types can be considered a type of static member, that is, a member that is bound to the type itself rather than an instance of that type. Golang currently doesn't have any static members, preferring functions over static methods, package-local variables over static fields, and package-local types over static member types.
This is a reasonable decision: static members, and the idea of types-as-namespaces are both a consequence of object oriented languages, which Go is not. Thus, early Go would not have benefitted at all from static members, as they did not offer any additional expressability.
An oft-cited benefit of member types is namespacing. This is somewhat a matter of taste: Golang seems largely uninterested in having types also serve as namespaces, preferring to keep the two separate. Even enum values exist at the package level. Ultimately there's no real difference between Foo.Inner
and Foo_Inner
.
So why do I bring this up now? Because there are two non-OOP use cases where static members are valuable when combined with type parameters.
Firstly, including a member type inside a generic struct allows all the same behavior as parameterized type aliases above, and more:
type Index[MutableMapType any] struct{ ... }
type (IndexMap[MutableMapType]) Map = Map[IndexKey, IndexValue, IndexKeyOrdering, MutableMapType]
// Index[VectorMutableMap].Map is equal to Map[IndexKey, IndexValue, IndexKeyOrdering, VectorMutableMap]
But the bigger benefit is what happens when the type containing the member type is used as a type parameter: it allows us to "tag" types with other types, in the same way that static fields (if they existed) allow us to "tag" types with values.
A simple example might be to "retrieve" a type parameter out of an aliased specialized type:
package p1;
type map[KeyType any, ValueType any] struct {}
type (map[KeyType, ValueType]) Key = KeyType
type IntMap = map[int, int]
type FloatMap = map[float64, float64]
package p2;
func main() int {
var zero p1.IntMap.KeyType
return zero
}
// IntMap.KeyType is equivalent to int
This example is contrived, the value is marginal, and used poorly it could actually hurt readability by littering code with extra type aliases. This is not a compelling reason to support this feature.
But we get the most value out of it when we combine it with the next feature: using member types as type constraints.
Should Golang adopt this feature
It's a big ask, since until now Go doesn't support any kind of static members on types. On its own, I don't think it's worth it. But I think there's a compelling use case for it if we combine it with the next section. Keep reading.
Member Type Constraints
This is the real power behind member types, which allows us to express type relationships that weren't previously possible.
Golang interfaces impose constraints on the types that implement them. The most common constraint is a method constraint: the underlying type must expose a specific method. But other, less common constraints exist too.
If member types existed, we could easily imagine a corresponding member type constraint in Go. A member type constraint would require that implementing types have a specific member type. An example might look like this:
type Array interface {
type ElemType any
Size() sizet
Get(index int) ElemType
Set(index int, val ElemType)
}
This would constrain the Array
interface to types that contain a member type named ElemType
whose type extends any
.
This looks very similar to the following generic interface:
type Array[ElemType] interface {
Size() sizet
Get(index int) ElemType
Set(index int, val ElemType)
}
But it has one very crucial difference: the type Array
is not generic. It does not require a type parameter in order to be used. Instead, every type that satisfies Array
provides its own type for ElemType
. And furthermore, this type can be referred to by other constraints in the interface.
Member type constraints are powerful and open a lot of doors, but as we can see in the comparison above, are no more verbose and no less readable than generics.
The main advantage from a code cleanliness perspective is that it allows us to codify the relationships between tightly-coupled types within the implementations themselves, removing almost all of the messy boilerplate from their uses.
Member type constraints massively simplify our case study. One possible solution now looks like this:
type Index interface {
type MapType Map[MutableMapType]
type MutableMapType MutableMap[MapType]
...
}
type VectorIndex struct { ... }
type (VectorIndex) MapType = VectorMap
type (VectorIndex) MutableMap = VectorMutableMap
func ApplyEditsToIndex[IndexType Index](index IndexType, edits Edits) {
oldMap := index.GetMap()
mutableMap := oldMap.Mutate()
mutableMap.ApplyEdits(edits)
newMap := mutableMap.Flush()
index.setMap(newMap)
}
Wow. Look at that. Isn't that readable? The definition of is ApplyEditsToIndex
is so clean! Everything fits on one line. No awkward type parameters sprinkled throughout the code. The member type constraints that appear in the Index
interface fulfill the same role as the messy mutually recursive type constraints from before. There's no code duplication, no boilerplate.
And remember how I said before that the production code is more complicated because Map
and MutableMap
take additional type parameters for their key, value, and key ordering? Let's see what this looks like if we add those back in:
type Index interface {
type MapType Map[IndexKey, IndexValue, IndexKeyOrdering, MutableMapType]
type MutableMapType MutableMap[IndexKey, IndexValue, IndexKeyOrdering, MapType]
...
}
type VectorIndex struct { ... }
type (VectorIndex) MapType = VectorMap[IndexKey, IndexValue, IndexKeyOrdering]
type (VectorIndex) MutableMap = VectorMutableMap[IndexKey, IndexValue, IndexKeyOrdering]
func ApplyEditsToIndex[IndexType Index](index IndexType, edits Edits) {
oldMap := index.GetMap()
mutableMap := oldMap.Mutate()
mutableMap.ApplyEdits(edits)
newMap := mutableMap.Flush()
index.setMap(newMap)
}
It's still readable! In fact, it barely changed at all.
The declaration of ApplyEditsToIndex
is exactly the same! Everything involving these new generic types is safely contained in the Index
and VectorIndex
definitions.
This is what I wanted to write. It's clean. It's elegant. It's readable. The hacks and type boilerplate that I ended up writing weren't because I was trying to do something complicated, it was because I was trying to do something simple, but the language wouldn't allow it.
I reckon this wouldn't be hard to implement either: the interface validation is identical to the case of validating a generic interface, and the type inference rules here are mapped 1:1 onto the existing ones.
Should Golang adopt this feature:
Please. Oh my God, yes please. Let me write clean code I beg of you.
Conclusion
There's a lot that Golang could adopt from other languages. But just because it can doesn't mean it should. The language maintainers are very reluctant to add new features unless they deem them absolutely necessary, and I think this is a fine default stance. It certainly prevents the feature overload that can make other languages nigh-unreadable.
But that doesn't mean that you never add features. Even Go eventually added generics, because a sufficient case was made for their inclusion, and a design presented that they liked, that added expressiveness without adding too much complexity.
It's reasonable to believe that other features will eventually meet this bar. Of the features listed here, I'm excited to see parameterized type aliases joining the language. I pray that one day we'll see member types (and the associated member type constraints), they allow an expressiveness that's currently only possible with a lot of verbose and noisy generics: the fact that adding them made the case study code objectively cleaner and more readable makes a compelling case for their eventual inclusion.
The other features listed here? I could take them or leave them, which means they probably don't meet the bar. And that's fine. A language shouldn't support every single coding style, and it's fine for languages to be opinionated about how code should be written.
But for God's sake let me write clean code to solve a problem. Because we know what the alternative is:
And nobody wants that.
Until Next Time
Thanks for once again reading me rant about the language I am forced to use on a daily basis. If you think I'm one of those people who's going to ruin Go, then you'll be pleased to know that Dolt is written entirely in Go, and most of it isn't written by me! Why not join our Discord, where we can help you figure out if Dolt is the right fit for your version-controlled database needs.