Associated items
2 April 2013
I’ve been doing a lot of thinking about Rust’s trait system lately. The current system is a bit uneven: it offers a lot of power, but the implementation is inconsistent and incomplete, and in some cases we haven’t thought hard enough about precisely what should be allowed and what should not. I’m going to write a series of posts looking at various aspects of the trait system and trying to suss out what we should be doing in each case. In particular I want to be sure that our trait design is forwards compatible: that is, I expect that we will defer final decisions about various aspects of the trait system until after 1.0, but we should look now and try to anticipate any future difficulties we may encounter.
As the inaugural post in this series, I want to take a look at associated items (e.g., associated types, constants, functions, etc). Associated items are requested often, though under various names. When I first started this post, it was actually part of a larger post, but I quickly found that the topic of associated items was too large to be a footnote of another post. In fact, I’m finding it’s too large to fit into one post at all. So I’ll be breaking this post up until multiple pieces. This first post will cover what an associated item is and what you might want to use it for, and it will do so from a C++ perspective.
I will also propose some changes to how we handle so-called “static” fns (which I will be calling “associated” functions, because the name “static” gives all the wrong connotations). These changes are not backwards compatible. I do not take such an idea lightly; we are trying very hard to stabilize Rust so such changes must pass a high bar (I personally think the change would be worth it, but opinions will vary). In the next post, I will present the Haskell approach to associated items, which is closer to what we have today and which can be adapted in a mostly backwards-compatible fashion.
Associated items sound like some kind of crazy language extension, but they’re actually pretty straight-forward and natural. They are used very frequently both in C++ and Haskell, as well as other languages. To get an idea what you might want one for, imagine you were going to design a generic graph library, and you want to implement some algorithms that operate over any sort of graph.
You might begin with defining a generic graph trait that defines the interface your algorithms will expect to manipulate the graph:
trait Graph<Node> {
fn get_visited(&self, n: &Node) -> bool;
fn set_visited(&mut self, n: &Node);
fn get_successors(&self, n: &Node) -> ~[Node];
...
}
The details are not too important but you get the idea. Now, we might
implement a function like depth_first_search
, which executes a depth
first search and returns the nodes we visited in order:
fn depth_first_search<N, G: Graph<N>>(
graph: &mut Graph,
start_node: &N) -> ~[N]
{
let mut nodes = ~[];
let mut stack = ~[start_node];
while !stack.is_empty() {
let node = stack.pop();
if graph.get_visited(node) {
loop; // already visited
}
graph.set_visited(node);
nodes.push(node);
stack.push_all(graph.get_successors(node));
}
return nodes;
}
Notice that depth_first_search
takes two type parameters, N
and
G
, where N
is the type of the nodes used by the graph G
. If you
think about it, this is a bit odd, because these two type parameters
are not really independent. Typically, when one implements a graph,
you implement it for a specific kind of node, and only that kind of
node. Now, so long as the Graph
trait is only parameterized by the
type of the nodes, this is not so bad, but in practice a real graph
library will grow a number of similar type parameters. For example, we
might want the type of the edges, which would give us two type parameters:
trait Graph<Node, Edge> {
...
}
fn depth_first_search<N, E, G: Graph<N, E>>(
graph: &mut Graph,
start_node: &N) -> ~[N]
{
...
}
Already you can see that our signatures are getting complicated.
There is another problem as well: even though depth_first_search
does not need to consider edges, after all we saw the implementation
before and it only needed the type N
, we must include the edge type
E
in the signature.
Now imagine that we want to make an efficient graph type. It is
likely that we can use a specialized type to represent a set of edges
or nodes; a bitset, for example. In that case, we would want a third
and maybe even a fourth type parameter (NodeSet
or EdgeSet
). The
list just keeps growing. And for each such type parameter, we will
have to extend the signature of depth_first_search
along with every
generic function that is implemented over our graph. This is not only
unwieldy, it’s a refactoring hazard that will limit the ability of
people to write generic libraries.
Enter associated types
C++ had a similar problem in the design of the STL. Because C++
traits are basically just macros, however, clever C++ programmers were
able to come up with a useful pattern that avoids all these hazards (I
probably have my history wrong here, no doubt C++ programmers adapted
a solution first used in other languages, perhaps without even knowing
it, but it reads better this way, doesn’t it?). Instead of defining
the trait Graph
as being parameterized over the node type Node
,
define the node type Node
as an “associated type”:
trait Graph {
type Node; // associated type
fn get_visited(&self, n: &N) -> bool;
fn set_visited(&mut self, n: &N);
fn get_successors(&self, n: &N) -> ~[N];
...
}
Notice that the definition of Node
has moved inside the trait.
The meaning of this is that any given Graph
implementation will
define a type Node
that represents nodes. That is, rather than
Node
being a “input” to the trait, it is an “output”, just like the
functions get_visited()
etc are “outputs”.
Now we can adapt our depth_first_search
routine as follows:
fn depth_first_search<G: Graph>(
graph: &mut Graph,
start_node: &G::Node) -> ~[G::N]
{
/* same as before */
}
Note that depth_first_search
only takes one type parameter, the
graph type G
. The type of the node is then relative to G
(so
G::Node
would be “the node type used by the graph type G
”).
Interestingly, I can now add as many associated types to Graph
as I
like without affecting the signature of depth_first_search
in the
slightest.
Associated constants
It is not hard to imagine extending this idea to other kinds of
associated members. For example, we might write up a trait like
Vector
that has an associated constant specifying the number of
dimensions in vectors of this type:
trait Vector {
static dims: uint;
fn get(&self, dim: uint) -> uint;
}
Now I can write up an implementation, say for a two-dimensional point type:
struct Point2D { x: uint, y: uint }
impl Vector for Point2D {
static dims: uint = 2;
fn get(&self, dim: uint) -> uint {
assert!(dim < 2);
if dim == 0 {self.x} else {self.y}
}
}
And then I can use this with generic code:
fn sum<V: Vector>(v: &V) -> uint {
let sum = 0;
for uint::range(0, V::dims) |i| {
sum += v.get_dim(i);
}
return sum;
}
Associated functions
Associated functions are useful in a couple of different contexts.
One common example is where you would like to define a trait that
includes some sort of constructor, such as FromStr
:
trait FromStr {
fn parse(input: &str) -> Self;
}
Here the trait defines an associated function parse()
that will
parse a string and return an instance of the Self
type. I could
for example implement FromStr
for integers:
impl FromStr for uint {
fn parse(input: &str) -> uint {
uint::parse(input, 10) // 10 is the radix
}
}
Using FromStr
, I can write a generic routine that, for example,
parses a comma-separate list of values:
fn parse_comma_separated<T: FromStr>(input: &str) -> ~[T] {
let substrings = input.split(",");
substrings.map(|substring| T::parse(substring))
}
Experienced Rust users might note that the syntax in that example is actually not what one would write today. This is the “non-backwards-compatible change” I alluded to earlier. In Rust today, when one invokes an associated function, it is not named via the self type as I did above, but rather it is named via the trait to which the function belongs:
substrings.map(|substring| FromStr::parse(substring))
The compiler uses inference to decide that the return type here is T
and therefore the self type for this call to parse
must be T
. This
approach is elegant in many ways, as I’ll cover in the next post in
more detail, but it also has some downsides. Perhaps the most serious
is that, if the associated function does not return an instance of
Self
, then the compiler cannot disambiguate what version of the
function you are trying to call!
To see where you might have an associated function that does not
return Self
, consider a trait like the following:
trait TemperatureUnit {
fn to_kelvin(f: float) -> float;
}
Using the C++-approach I have been describing thus far, I could write a generic function like:
fn do_some_chemistry<TU: TemperatureUnit>(f: float) -> float {
let kelvin = TU::to_kelvin(f);
...
}
Of course, this example is somewhat artificial, because one would be
better off integrate the temperature units as types in your type
system rather than using floats. But real examples like this do come
up. The associated constant V::dims
is an example.
So is there a proposal here?
Yes and no. Partially I just wanted to explain what an associated item is and what you might use it for. But I’ve also kind of baked in an alternate proposal for how we should address associated items, which is to switch from a Haskell-like approach to a C++-like approach. In the next post, I’ll explain how the Haskell solution works, and what it would look like in Rust. Frankly the difference is not so great so it’s a matter of taste.
Anyway, if you wanted to implement the scheme I’ve described in this
post, it would work as follows. When resolving a path, if you find
that some prefix of the path evaluates to a type, then later elements
in the path are resolved using the same algorithm that we use today
for method lookup. So, to look at our examples, if I wrote G::Node
,
the path G
here is a type, which means that the type Node
would be
determined by examining the traits that are in scope to see whether
any of them both (1) define a type member Node
and (2) are
implemented by G
.
This is exactly analogous to how method lookup operates. When you see
a call a.b()
, we determine the type T
of the expression a
and
then look to see whether any of the traits which are in scope (1)
offer a method b()
and (2) are implemented by T
.
In fact, it’s a bit more complex, because we also consider the inherent members of a type that are defined without any trait at all. We can do the same thing when resolving associated items.
Interestingly, unifying the algorithm used to specify associated items
and method calls also allows us to say that a call like a.b(...)
is
just sugar for T::b(a, ...)
where T
is the type of a
.
Corner cases
There are a few corner cases to consider in this proposal.
Ambiguous references
It is possible to have two traits A
and B
that define the same
associated item I
. If both those traits are imported, and both
those traits are implemented by the same type T
, then a reference
like T::I
could refer to the item defined by A
or the item defined
by B
. If we wish to provide an explicit syntax to disambiguate the
reference, it could be something like T::(A::I)
. That is, we refer
to the item I
as defined in the trait A
implemented for the type
T
.
Another possible ambiguity can arise when you have a generic trait. Consider something like the following:
trait Getter<T> {
static default: T;
fn get(&self) -> T;
}
Now imagine that I have some type with two implementations of
Getter
:
struct Circle {
center: Point, radius: float
}
impl Getter<Point> for Circle {
static default: Point = Point {x: 0, y: 0};
fn get(&self) -> Point { self.point }
}
impl Getter<float> for Circle {
static default: float = 0;
fn get(&self) -> Point { self.radius }
}
If I then write a generic routine such as:
fn is_default<G: Getter<float> Getter<Point>>(g: &G) -> bool {
let x = G::default;
let y = g.get();
x == y
}
Then what value for default
is G::default
are we going to obtain?
The Point
or the float
?
Using the syntax that I proposed, one could write this unambiguously, if verbosely:
fn is_default<G: Getter<float> Getter<Point>>(g: &G) -> bool {
let x = G::(Getter::<float>::default);
let y = g.(Getter::<float>::get)();
x == y
}
Not all types are paths
Another problem is that if you wanted to get an associated member
of a type like ~[int]
, you couldn’t write ~[int]::foo
. But
this is easily circumvented by creating a type alias
type T<U> = U;
and writing T::<~[int]>::foo
, or else by permitting the syntax
<~[int]>::foo
.