Roger Peppe, 2018-09-05
In my previous post, I talked about an issue with a real world use case for contracts - the fact that all type parameters must be mentioned every time a contract is used in a definition. In this post, I introduce an idea for a possible way to fix this.
The most important feature of contracts, in my view, is that they bind together several types into one unified generic relationship. This means that we can define a function with several type parameters from a contract and we can be assured that they all work together as we expect.
In some sense, those type parameters really are bundled together by a contract. So... what if we changed contracts to be a little more similar to that familiar construct that bundles things together: a struct?
Here's the graph example from the design doc rewritten with this idea in mind. I've rewritten the contract to use interfaces conversions, because I think it's clearer and less ambiguous that way, but it's not crucial to this idea.
package graph
type Edger(Edge) interface {
Edges() []Edge
}
type Noder(Node) interface {
Nodes() (n1, n2 Node)
}
contract Contract(n Node, e Edge) {
Edger(n)
Noder(e)
}
type Graph(type G Contract) struct { ... }
func New(type G Contract)(nodes []G.Node) *Graph(G) { ... }
func (*Graph(G)) ShortestPath(from, to G.Node) []G.Edge { ... }
Instead of declaring all the type parameters positionally every time
we use a contract, we now use a single identifier.
This acts as a kind of "type struct" - we can select different
types from it by name using the familiar .
operator.
It's as if the contract is a type that contains other types,
which seems to me to fit nicely with its role as a
"bundler" of several types and their relationship to one another.
Note that when a contract is defined, its parameters are a little different from function parameters - not only do the parameters need to be distinct, but the type names do too, so there's no potential for ambiguity here.
Using named rather than positional type parameters means that we don't need to remember which order the type parameters are in. Passing the contract around as a whole makes the code easier and shorter.
It has the additional advantage that there's now no syntactic need for type parameters to use only one contract.
What we haven't covered yet is how we might actually create an instance of G in the first place. We can use a similar syntax to struct literals, except here we're associating type members of the contract with their actual types.
g := graph.New(graph.Contract{
Node: myNodeType,
Edge: myEdgeType,
})([]myNodeType{...})
We could even define this as its own type:
type myGraph = graph.Contract{
Node: myNodeType,
Edge: myEdgeType,
}
g := graph.New(myGraph)([]myNodeType{...})
In cases where type unification fails (for example when we need to specify a type that's in a return parameter), this should allow us to avoid the burden of passing the types each time.
Let's rewrite the example from my previous post to use this new syntax.
func PrintVariousDetails(type Mgo SessionContract)(s Mgo.Session) {
db := s.DB("mydb")
PrintBobDetails(db)
}
func PrintBobDetails(type Mgo SessionContract)(db Mgo.Database) {
iter := db.C("people").Find(bson.M{
"name": "bob",
})
... etc
}
I hope we can agree that this seems quite a bit nicer than the original.
It's not clear here where the type-member name comes from. In the contract, they have the names
N
andE
, withNode
andEdge
being interface conformance constraints. Multiple types could conform to the same interface, though. Of course, it would be unfortunate to have to call the member types anything other thanG.Node
andG.Edge
, but if you used those names for the input parameters, the names of the interfaces would be shadowed in the body of the contract, which is a hinderance.One hazard around an approach like this is that contract instances don't have a nominal identity. You accommodated that in your example—
—by using alias syntax to avoid creating a nominal type, but because of the pervasiveness of "declared" types in Go, encouraging people to think of contract instances as a type like this might lead to misunderstanding. I suppose it would be easy enough just to specify that "declared" contract instances aren't allowed.
When I've toyed with generics proposals at various times, I've also stumbled across using explicit type bundles like this in order to organize things better. What I always concluded was that, while the benefits of named over positional parameters are nice, it might be too 'weird' compared to other languages' syntax for the comparable feature. This is why my feedback focused on introducing output types, like
Here, you can infer all the other types based on a
Session
, so using that as the input type parameter makes sense; you don't need an explicit contract instance type to act as your single input. And, in fact, this will often be the case in Go as long as methods cannot be polymorphic or overloaded, since a given concrete type can only satisfy a generic interface for one set of type arguments. You only need multiple input parameters if you have two or more families of types which don't appear in methods on each other's members.One challenge here is if you don't actually need the
Session
type in this instance, you ought to be able to take theDatabase
type as the parameter. It doesn't seem too problematic to require a distinct contract for this, though:Coming up with syntax to make this easily composable is a challenge, though. Maybe something like:
I suppose you could streamline that by leveraging contract embedding a bit further:
This has the advantage that you don't have to manually feed through each output type, and the disadvantage that you don't get to manually feed through each output type, making it harder to resolve potential overlaps and less obvious what all the outputs of a contract are.