Associated items continued
3 April 2013
I want to finish my discussion of associated items by taking a look at how they are handled in Haskell, and what that might mean in Rust.
These proposals have the same descriptive power as what I described before, but they are backwards compatible. This is nice.
Object-oriented style name resolution
In the object-oriented, C++-like version of associated items that I introduced before, the names of associated items and methods were resolved relative to a type. To see what I mean by this, consider a (slightly expanded) variant the graph example I introduced before:
trait Graph {
type Node; // associated type
static K: uint; // associated constant
}
mod graph {
fn depth_first_search<G: Graph>(
graph: &G) -> ~[G::Node]
{
let k = G::K;
...
}
}
Consider a path like graph::depth_first_search
, which names an item
within a module. This kind of path is based solely on the module
hierarchy and can be resolved without knowing anything types
whatsoever.
Now consider the paths G::Node
and G::K
that appear in
depth_first_search
. These paths look similar to in form to
graph::depth_first_search
, but they are resolved quite
differently. Because G
is a type and not a module, if we want to
figure out what names Node
and K
refer to, we don’t examine the
module hierarchy but rather the properties of the type G
. In this
case, G
is a type parameter that implements the Graph
trait, and
the Graph
trait defines an associated type Node
and an associated
constant K
.
Note that the name lookup process here is exactly analogous to what
happens on a method call. With an expression like a.b()
, the meaning
of the name b
is resolved by first examining the type of the
expression a
and then checking to see what methods that type
offers. The module hierarchy is not consulted.
The object-oriented style of naming specification is not fully
explicit. In particular, a path like G::Node
does not specify the
trait in which Node
was defined, the compiler must figure it out.
It is also possible that the type G
implements multiple traits that
have an associated item Node
, so the syntax could be ambiguous. To
make things fully explicit, I proposed in my previous post that the
full syntax would be Type::(Trait::Item)
. So the fully explicit
form of G::Node
would be G::(Graph::Node)
, since the type Node
is defined in the trait Graph
.
Functional-style name resolution (take 1)
In Haskell, all name resolution is done based on lexical scoping and the module hierarchy. This is no accident. It means that the Haskell compiler can figure out what each name in a program refers to without knowing anything about the types involved, which is helpful when performing aggressive type inference.
What this means is that we can’t use a path like G::Node
to mean
“the type Node
relative to the type G
”, because interpreting this
path would require examining the definition of the type G
(as we saw
before). Instead, if we were to use a syntax analogous to what Haskell
uses, one would write something like Graph::Node<G>
. Note that all
the names here (Graph::Node
, G
) can be resolved using only the
module hierarchy.
So the example I gave before would look as follows:
trait Graph {
type Node; // associated type
static K: uint; // associated constant
}
mod graph {
fn depth_first_search<G: Graph>(
graph: &G) -> ~[Graph::Node<G>]
{
let k = Graph::K::<G>;
...
}
}
Note that where before we wrote G::K
to refer to the constant K
associated with the type G
, we would now write Graph::K::<G>
. As
is typical in Rust, the extra ::
that appears before the type
parameter <G>
is necessary to avoid parsing ambiguities when the
path appears as part of an expression.
Let’s look a bit more closely at what’s going on here. Effectively
what is happening is that, for each associated item within a trait, we
are adding a synthetic type parameter. For any reference to an
associated item, this type parameter tells the compiler which type is
implementing the trait. The path Graph::Node
by itself is not
complete; Graph::Node<G>
means “the type Node
defined for the type
G
”.
Let’s dig into it this Haskell-style convention a bit to see some of the implications.
Return type inference
One benefit of the Haskell style convention is that the values for
the type parameters can often be deduced by inference. For example,
let’s return to the trait FromStr
from my previous post.
The trait FromStr
is used to parse a string and produce a value of
some other type:
trait FromStr {
fn parse(input: &str) -> Self;
}
We might implement FromStr
for unsigned integers as follows:
impl FromStr for uint {
fn parse(input: &str) -> uint {
uint::parse(input, 10) // 10 is the radix
}
}
Now we could write a function that invokes parse()
like so:
fn parse_strings(v: &[&str]) -> ~[uint] {
v.map(|s| FromStr::parse(*s))
}
Note that when we called FromStr::parse(*s)
, we did not say what
type it should parse to. The compiler was able to infer that we wanted
to parse a string into a uint
based on the return type of
parse_strings()
as a whole. A fully explicit version of parse_strings
would look like:
fn parse_strings(v: &[&str]) -> ~[uint] {
v.map(|s| FromStr::parse::<uint>(*s))
// ^~~~~~ specify return type
}
Generic traits
Imagine that we have a generic trait, like this Add
trait:
trait Add<Rhs> {
type Sum;
fn add(&self, r: &Rhs) -> Sum<Self, Rhs>;
}
This trait is very similar to the trait used in Rust to implement
operator overloading, except it has been adapted to use an associated
type for the Sum
(which is probably how the Rust type should be
defined as well, since the type of the sum ought to be determined by
the types of the things being added).
Previously, with the associated type Node
, we said that any
reference to node had to include a single type parameter to indicate
the type that was implementing Graph
. But with a generic trait like
Add
a simple type parameter is not enough. To fully specify all the
types involved, we need to include both the Self
type and any type
parameters. This is why the return type of the method add()
is
Sum<Self, Rhs>
—a mere reference to Sum
or Sum<Self>
would be incomplete.
Comparison to object-oriented form. Interestingly, this case is
something that the object-oriented style of naming cannot handle very
well. This is because the object-oriented convention is strongly
oriented towards specifying the Self
type but does not easily expand
to accommodate generic traits. Using the fully explicit syntax that I
suggested in my previous post, I think the result would look
like Self::(Add<Rhs>::Sum)
. (It’s plausible that, within the trait
definition itself, one could simply write Sum
, but from outside the
trait definition I think it would be necessary to specify the full
type parameters).
Generic items
It is also possible to have an associated item which itself has type parameters. For example, we might want to have a graph where kind of node can carry its own userdata:
trait Graph {
type Node<B>;
fn get_node_userdata<B>(n: &Node<Self, B>) -> B;
}
Here we see that when we refer to the Node
type in
get_node_userdata
, we specify both the Self
type parameter and the
type parameters defined on Node
itself. I think this is a bit
surprising.
Comparison to object-oriented form. The object-oriented naming
scheme handles this case very naturally. For example, get_node_userdata()
would be declared as follows:
fn get_node_userdata<B>(n: &Self::Node<B>) -> B;
Functional-style name resolution (take 2)
In the previous section we added implicit type parameters to each associated item. Particularly in the cases of generic traits or generic items, this can be a bit confusing. You wind up mixing type parameters that were declared on the trait together with type parameters declared on the item.
An alternative that would be a bit more explicit is to (1) designate
the implicit parameter for the trait’s Self type using a special
keyword, such as for
or self
(I prefer for
since it echoes the
impl Trait for Type
form) and (2) push the trait type parameters
into the path itself. So instead of writing Graph::Node<G>
you
would write Graph::Node<for G>
, and instead of Add::Sum<Lhs, Rhs>
you would write Add<Rhs>::Sum<for Lhs>
. You’ll see more examples of
how this looks in the next section.
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.
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 forT::b(a, ...)
whereT
is the type ofa
, 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.