Alternative syntax for fully-qualified associated items

In this topic, let’s just think of methods as “special” associated functions and assume that “associated items” includes methods.

The current syntax for fully-qualified associated items is:

  • <Type as Trait>::assoc_func([receiver, ]args)
  • <Type as Trait>::AssocType
  • <Type as Trait>::ASSOC_CONST
  • etc.

I’ve always found this syntax to be a bit confusing. The most jarring thing is that it seems to be casting Type into Trait, where no such thing is happening. Here’s a potential alternative:

  • Type::<Trait::assoc_func>([receiver, ]args) (or even receiver.<Trait::method>(args))
  • Type::<Trait::AssocType>
  • Type::<Trait::ASSOC_CONST>

This feels more natural to me, as I’m still talking about some associated item of Type – it’s just that the specific associated item needs disambiguation (and is thus qualified with Trait::). The receiver.<Trait::method>(args) syntax is especially nice as it’s a natural extension of the normal method call syntax. (This also means that method chaining is easier when disambiguation is needed, as you don’t have to rewrite a long chain to use the “UFCS” syntax.)

However, as this is mostly only solving a minor annoyance, I’m not sure whether it’s worth introducing new syntax for. I’m also not sure whether the Type::<Trait::assoc_func>(args) syntax collides with the turbofish thing (func::<Type[s]>(args)), or whether this whole syntax is just too hard to implement.

Thoughts?

1 Like

Interestingly, I think @nikomatsakisoriginal syntax way back in 2013 was quite similar to what you propose:

trait Getter<T> {
    static default: T;
    fn get(&self) -> T;
}

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
}

See his posts for more details:

Sadly the table at the bottom is borked - I think it used to show the differences in the syntaxes more clearly.

Somewhere along the line the syntax was changed. You can see where it was specified in RFC 0195. Not sure the reasoning behind it though - probably as a result of discussions on the (now defunct) mailing list. Here’s a post from @pcwalton about generic paths, for example.

1 Like

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::NodeNode<G>Node<for G>
G::(Graph::Node)Graph::Node<G>Graph::Node<for G>
Reference to an associated constant
G::KK::<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:

  1. Functional 1 is (mostly) backwards compatible with the current code.
  2. Functional 1 provides return-type inference, which many people find appealing.
  3. 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.
  4. 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: &amp;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.

3 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.