Recovered the original markup of the conclusion:
Edit: fixed some overlooked errors
Conclusion: Comparing the conventions
I think none of these conventions is perfect. Each has cases where it
is a bit counterintuitive or ugly. To try and make the comparison
easier, I’m going to create a table summarizing the object-oriented,
functional 1, and functional 2 styles, and show how each syntax looks
for each of the use cases I identified in this post. For each use
case, I’ll provide both the shortest possible form and the fully
explicit variant.
Reference to an associated type |
G::Node | Node<G> | Node<for G> |
G::(Graph::Node) | Graph::Node<G> | Graph::Node<for G> |
Reference to an associated constant |
G::K | K::<G> | K::<for G> |
G::(Graph::K) | Graph::K::<G> | Graph::K::<for G> |
Call of an associated function |
uint::parse() | parse() | parse() |
uint::(Graph::parse()) | FromStr::parse::<uint>() | FromStr::parse()::<for uint> |
Generic trait |
Self::(Add<Rhs>::Sum) | Sum<Self,Rhs> | Add<Rhs>::Sum<for Self> |
Self::(Add<Rhs>::Sum) | Add::Sum<Self,Rhs> | Add<Rhs>::Sum<for Self> |
Generic associated item |
Self::Node<B> | Node<Self,B> | Node<B for Self> |
Self::(Graph::Node<B>) | Graph::Node<Self,B> | Graph::Node<B for Self> |
Based on this table, my feeling is that the object-oriented style
handles the simple cases the best (G::Node
, G::K
), but it handles
the "generic trait" case very badly.
There are also some side considerations:
- Functional 1 is (mostly) backwards compatible with the current code.
- Functional 1 provides return-type inference, which many people find
appealing.
- The object-oriented style means that
a.b(...)
is always
sugar for T::b(a, ...)
where T
is the type of a
, which is
elegant.
- The functional styles mean that
::
is always module-based name
resolution and .
is always type-based resolution, which has an
elegance of its own.
It's a tough call, but right now I think on balance I lean towards one
of the two functional notations, probably functional 2 because,
despite being wordier, it seems a bit clearer what's going on. Just
appending the type parameters from the trait and the method together
is confusing.
Appendix A. Functional notation (take 3)
There is one other where you might handle the placement of type
parameters in the functional style. You might take the "self" type
and place it on the trait: i.e., instead of Graph::Node<for G="">
you'd
write Graph<for G="">::Node
. This is arguably more correct if you
think about traits in terms of Haskell type classes, since the self
type is really the same as any other type parameter on the generic
trait. But when I experimented with it I found that it was so wordy
and ugly it was a non-starter.
Appendix B. Haskell and functional dependencies
In addition to associated types, Haskell also offers a feature called
functional dependencies, which is basically another, independently
developed, means of solving this same problem. The idea of a
functional dependency is that you can define when some type parameters
of a trait are determined by others. So, if we were to adapt
functional dependencies in their full generality to Rust syntax, we
might write out the graph example as something like this:
// Associated types:
trait Graph {
type Node;
type Edge;
}
// Functional dependencies:
trait Graph<Node, Edge> {
Self -> Node;
Self -> Edge;
...
}
The line Self -> Node
states that, given the type of Self
, you can
determine the type Node
(and likewise for Edge
). You can see that
associated types can be translated to functional dependencies in a
quite straightforward fashion.
When functional dependencies have been declared, it implies that there
is no need to specify the values of all the type parameters. For
example, it would be legal to to write our depth_first_search
routine without specifying the type parameter E
on Graph
:
fn depth_first_search<N, G: Graph<N>>(
graph: &mut Graph,
start_node: &N) -> ~[N]
{
/* same as before */
}
The reason that we do not have to specify E
is because (1) we do not
use it and (2) it is fully determined by the type G
anyhow, so there
is no ambiguity here. In other words, there can't be multiple
implementations of Graph
that have the same self type but different
edge types.
Functional dependencies are more general than associated types. They
allow you to say a number of other things that you could never write
with an associated type, for example:
trait Graph<Node, Edge> {
Node -> Edge;
...
}
This trait declaration says that, if you know the type of the nodes
Node
, then you know the type of the edges Edge
. However, knowing the
type Self
isn't enough to tell you either of them. I don't know of
any examples where expressiveness like this is useful, however.
Appendix C. "where" clauses.
There is one not-entirely-obvious interaction between associated types
and other parts of the syntax. Suppose that I wanted to write a
function that worked over any graph whose nodes were represented as
integers (it is very common to represent graph nodes as integers when
working with large graphs). If we defined the graph trait using a
simple type parameter, like so:
trait Graph1<N> { ... }
then I could write a depth-first-search routine that expects
a graph with uint
nodes as follows:
fn depth_first_search_over_uints<G: Graph1<uint>>(graph: &G) { ... }
But we saw in the previous post that this definition of
Graph
has a number of downsides. In fact, it was the motivating
example for associated types. So we'd rather write the trait
like so:
trait Graph {
type N;
...
}
But now it seems that I cannot write a depth_first_search_over_uints
routine anymore! After all, where would I write it?
fn depth_first_search_over_uints<G: Graph="">(graph: &G) { ... }
Many languages answer this problem by adding a separate clause that
can be used to specify additional constraints. In Rust we might write
it like so (hearkening back to the typestate constraint syntax):
fn depth_first_search_over_uints<G: Graph="">(graph: &G)
: G::Node == uint
{ ... }
This is not the end of the world, but it's also unfortunate, since
this kind of clause leaks into closure types and all throughout the
language. But while discussing associated types with [Felix][pnkfelix]
at some point I realized that there is a workaround for this
situation. If you have a trait like Graph
that uses an associated
type, but you would like to write a routine like
depth_first_search_over_uints
, you can write an adapter:
trait Graph1<N> { ... } // as before
impl<G: Graph=""> Graph1<G::Node> for G { ... }
Now I can write depth_first_search_over_uints
and have it work for
any type that implements Graph
.
This adapter trait is not the most elegant solution but it works. I
would not expect this situation to arise that frequently, but it will
come up from time-to-time. The Add
and Iterable
traits come to
mind.