Skip to content

Instantly share code, notes, and snippets.

@rogpeppe
Last active September 14, 2018 07:18
Show Gist options
  • Save rogpeppe/7ea0cb6037aa520934257bf88a1012c5 to your computer and use it in GitHub Desktop.
Save rogpeppe/7ea0cb6037aa520934257bf88a1012c5 to your computer and use it in GitHub Desktop.
Go Contracts as type structs

Go contracts as type structs

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.

@rogpeppe
Copy link
Author

func ShortestPath(type Node NodeContract)(from, to Node) []NodeContract.Edge { ... }

This seems a bit odd to me. Does that type declaration declare NodeContract as a local identifier so it can be used in that NodeContract.Edge type expression? Presumably that implies that we can't have more than one contract in a function's type parameters (otherwise you'd have an ambiguity when the same contract was used twice). That being so, the only way to have multiple independent types in a contract would be to have multiple inputs to the contract.

For example, say we're implementing our own map type.

contract KeyValue(k Key, v Value) {
	k == k
}

Neither key nor value can be derived from one another, so neither can be an output type from the contract.

So I guess we'd need to define the map type like this:

type Map(type Key, Value KeyValue) struct {
	// unexported fields
}

But this seems very like contracts as they are in the draft proposal right now. It seems to me that any contract of the form:

   contract C(A1, A2, ..., An) (B1, B2, ..., Bn)

can be rewritten without loss of generality to:

contract C(A1, A2, ..., An, B1, B2, ... Bn)

The only distinction between "input" and "output" types is whether one type might be derived from another, and output types are entirely optional. In another sense, they're all "output" types (from the perspective of the body of a generic function) and they're all "input" types (from the perspective of a caller).

Unification can do a fine job of type inference. I'm not convinced that this fairly arbitrary separation into input and output types is going to help much.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment