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.
Yup, that's wrong. I've updated the code to change it. It is a bit of a pity that the type names shadow the interface names. Maybe there's some way of changing the syntax so we can use dot-qualified names in a contract body, but I can't think of a good one right now.
I think you're wondering what happens if you do:
I think it might be OK to allow use of contract instances that have only a single type member, but I think it's easiest just to disallow it and say that contract instances can only be used for type definitions.
I'm not sure I like the special-cased "input" and "output" distinction here. As I see it, the contract argument types are just related to one another - they're all output types in that sense. For example, in a graph with nodes and edges, which is superor?
As for being weird, it's all weird - this is Go and it's new territory. We still have the capability to pass individual, unrelated type parameters, but here we require people to bundle related types into a single thing. I'd like to try to explore how that might turn out in larger programs vs the unnamed argument list approach.
I guess it depends quite a bit on whether people end up making larger contracts containing quite a few types. Here it seemed quite easy to get into that kind of territory.
One thing that I think this design leads towards is the possibility of being able to add type parameters to contracts without breaking backward compatibility, but that needs further exploration and may not actually be possible in fact.
I'm not sure what you're getting at here. Please explain the issue a little more so my small brain can cope :)