On learning specialization and UFCS

This is my experience so far with specialization (and traits in general). My use case is the following: given two traits A and B, and a generic function:

foo<T: A>(x: T) {}

I want to “specialize” foo for T: A + B, so that foo(x) chooses one function or another depending on whether x implements A or A + B. Nothing fancy I would say. Obviously, this:

fn foo<T: A+ B>(x: T) {}

doesn’t work (specialization doesn’t work this way). So let’s get started.

First, we’ll need a trait:

trait Foo {
  fn foo(self);  // need to use self or we break UFCS
}

which we can then implement for all types that implement A:

impl<T: A> trait Foo for T { 
  default fn foo(self) {};  // allow specialization here
}

and then specialize that for all types that implement A + B:

impl<T: A + B> trait Foo for T { 
  fn foo(self) {};  // specialize here
}

So now, Foo::foo(x) and x.foo() both work, but foo(x) still doesn’t, so the missing piece of the puzzle is:

fn foo<T: Foo>foo(x: T) {
  Foo::foo(x)
}

And in just 12 lines (1 trait declaration, 2 trait implementations, and an extra function), we are done!

I think this amount of boilerplate is bearable, but I wish that the 2 line version:

fn foo<T: A>(x: T) {}
fn foo<T: A + B>(x: T) {}

will some day work (since this is what I wanted to do).

I would like trait methods and functions to be equally powerful. Right now this isn’t the case and if you want the power you need to chose trait methods independently of whatever makes more sense. I would also like to have UFCS in Rust. Currently, the function call syntax degrades significantly when moving from trait methods that take self (which are the “most uniform to call”: can’t be called with the foo(x) syntax, but can at least be called with Trait::foo(x)) to both, trait methods that don’t take self (but, e.g., Self, which can’t be called with the .foo() syntax) and functions that take T: Trait (which can’t be called with the .foo() syntax either). Both the specialization and the function call syntax feel very “asymmetric”: you really need to pick the right combination of language features and workaround to be able to write an API that is symmetric/nice to use.

3 Likes

In general, traits are more permissive about implementation than functions. You also can’t arbitrarily overload a function (with the same arity) like this, even though you could implement a trait for both of these types:

fn foo(x: String) { }
fn foo(x: Vec<u8>) { }

The reason for this seems to me that traits provide a “prototype” through their trait definition, which is the contract that all implementers are guaranteed provide, and which is easy to find. With function overloading, the “root contract” would be implicit. In your example, fn<T: A>(T) is the contract, until you add an implementation for fn<T>(T).

I’m having a bit of trouble parsing your comments about UFCS, what actionable changes would you like to see to function call syntax?

I would just like function calls to be uniform, that is, whenever you can use x.foo(...), you can use foo(x, ...), and vice-versa. Both syntaxes are always equivalent. If there is an ambiguity, you get an error, and need to manually disambiguate. That is fine.

Currently, Rust has 3 different function syntax calls foo(x, ...) ,x.foo(...), and Trait::foo(x, ...), and they are far from uniform. For example, if you have a trait that takes self you cannot use foo(x, ...). If you have a trait that takes Self as first argument, you can only use Trait::foo(x, ...), and if you have a free function, you cannot use x.foo(...).

I wouldn't say this different syntax are bad per se, but I think UFCS is actually a really nice thing to have (and something I really like about D), and I also think that was is actually bad is saying that rust has a feature that it doesn't. Maybe I misinterpreted what I read, but I actually read often that Rust does have UFCS, which is far from being true.

But the syntaxes are not equivalent. The . operator will reference and dereference the value as needed in order to match the signature of the receiver. This is a special property of methods, and I don't that either a) it should be applied to the first argument of functions without a receiver, or b) you should be able to use the . operator in a context in which it doesn't implement this behavior.

The distinction also helps with documentation; you always know that a method will be defined by an impl (and therefore documented on that type's rustdoc page), whereas if the method is a free function it could be anywhere.

I do think its odd that you can't import associated functions directly as in use Read::read; or use Vec::len; (but I also don't think its a big deal).

Essentially, when people say that Rust has UFCS, they mean that there is a consistent syntax for calling all functions, including associated functions and methods. Method call syntax, however, has different semantics from function call syntax.

UFCS in Rust is a different feature from UFCS in D, In fact it is sort of the opposite.

Rust’s feature is diciplined and allows to call a trait method as a function (this is the Foo:foo(x) example). D implements the opposite transformation - from a function call to a method call syntax. This is undiciplined and allows (toghether with D’s optional parens rule) to write strange things such as:

writeln = 3; 

The rest is about function overloading which also entails (other) dificulties. I don’t think it is helpful to confuse these unrelated features.

We’ve wanted to change the name of UFCS for a long time for this reason, I just haven’t gotten a PR together.

2 Likes

I thought that when passing arguments to a function autoderef would do the same thing but I am not that comfortable with the auto-dereferencing rules of rust yet. Anyhow, since Rust doesn't have overloading, I don't see why the same couldn't/shouldn't be done for function arguments since there is only one function that you can actually mean in any context (besides for making the creation of e.g. references explicit which is a good thing).

The distinction also helps with documentation; you always know that a method will be defined by an impl (and therefore documented on that type's rustdoc page), whereas if the method is a free function it could be anywhere.

When I look for a type in rustdoc, I would like to see all the functions that I can use with that type, not only methods, but all the free functions as well. So this is a problem that we already have and that, if anything, is actually a missing feature in rustdoc (or a bug).

@yigal100

This is undiciplined

How is this undisciplined? The raison-d'etre of UFCS is actually discipline! When you use a function you should not need to care whether it is a function or a method, only that such a named operation exist for the types involved. In Rust I have to keep in my mind whether an operation I want to perform is a trait method, or a free function, and chose the appropriate syntax, and even worse, it can be both! Then I have to check how do they differ, and which one I need to use.

With "true" UFCS none of this problems exist. First, you can't have both functions since that would be ambiguous, so if you want to have two different operations you need to give them different names. Second, the call syntax is irrelevant, so you can just use whatever is more readable (which really depends on what one is doing, no syntax is always better than the other).

I don't know what yigal100 meant by "undiciplined", but this came to my mind immediately:

In the following example, you might be tempted to think that the method foo and the free function bar are 100% equivalent (barring the name difference) since the implicit this parameter is a ref S. And like you said, you should not need to care whether it is a function or a method, but you do need to care, because they're not 100% equivalent:

struct S {
    int i = 0;

    void foo() {
        this.i += 10;
    }
}

void bar(ref S s) {
    s.i += 10;
}

void main() {
    S* s = new S();
    s.foo(); // Here the pointer is implicitly dereferenced...
    s.bar(); // ...and here it's not. ERROR: [1]
}

// [1]: function bar(ref S s) is not callable using argument types (S*)

It is undiciplined becuase the set of methods is a strict subset of the set of functions.

I already gave one example, but here’s another one:

max(a, b);

What is the equivalent method for max? What should be the “self” here?

Method invocation syntax has semantic meaning - it denotes that there is a primary recipient for the message that the method represents. Rust denotes it with self and C# requires you to specify “this” in its extension methods. I do not know of any other language besides D that allows uncontrolled conversion of methods to functions or “true UFCS” as you called it.

I do not know of any other language besides D that allows uncontrolled conversion of methods to functions or "true UFCS" as you called it.

Another language that allows this is Nim.

It is also being proposed for C++:

Although C++ evolution is strongly constrained by backwards compatibility, Stroustrup's N4174 is surprisingly in favor of "true" UFCS:

Offering the choice between the x.f(y) and f(x,y) notations with different meanings means that different people will chose differently for their function definitions, so that users have to know the choice and write calls appropriately. This gives users more opportunities for making mistakes, makes it harder to write generic code, and has led to replication when people define both a member and a non-member function to express the same thing. I suggest that providing different meanings to the two syntaxes offers no significant advantage

On the points:

Method invocation syntax has semantic meaning - it denotes that there is a primary recipient for the message that the method represents.

The problem is that methods are just functions taking arguments. Whether it makes sense to have a primary recipient or not depends on the context: in some contexts vec.find(thing) reads better than find(vec, thing), in others it reads worse.

What is the equivalent method for max? What should be the "self" here?

As an example of how this depends on the context, consider a program that reads a temperature from a crappy sensor that sometimes return impossibly high values. The code reading from the sensor might want to truncate the values of the temperature like this: temp.max(3000). In this particular context a.max(b) actually might make sense since "there is a primary recipient". However, does a.max(b) always make more sense than max(a,b)? Of course not.

Both syntaxes should be equivalent, so that you can use a.max(b) or max(a, b) depending on whatever makes more sense in the current context.

I’m sorry but that completely off-point and does not address anything I just typed. I’ll reiterate my point: methods are a special case of functions. That means that in Rust, you can express any method as a function. You can always convert

a.max(b)  =>  Math::max(a, b)

What you should NOT be able to do is perform the opposite conversion:

max (a, b)  =>  a.max(b)

Even before reading the mentioned links, I’m 95% certain they discuss the former transformation and NOT the lattar one.

Edit (I forgot to address your temp example): the semantic meaning of “Math::max()” and your truncate behaviour is different. I’d expect the former to be encapsulated inside a proper method because this violates seperation of concerns.

How would the implementation of max(a,b) differ from that of a method max(self, b) ?

In my opinion they should be identical (and if not, they should have different names). Since there are contexts in which each syntax makes sense, why do we need to duplicate the code and introduce workarounds to be able to use whatever syntax makes most sense?

I'm 95% certain they discuss the former transformation and NOT the lattar one.

No, the two original papers propose both transformations because that's the right choice (which is what D has). C++, however, cannot break backwards compatibility, so the second and third paper propose workarounds (e.g. if there is a method, give the method priority over the free function).

“The right choice” is an opinion. I disagree with that opinion. I honestly would be confused if I saw a max() method as you describe and would not imediately recognize that you have used the standard math max function.

I honestly would be confused if I saw a max() method as you describe and would not imediately recognize that you have used the standard math max function.

Right now I would be confused too since I would wonder "How does it differ from the math max function? Is it just a wrapper? Does it do something different?".

With UFCS there is no room for confusion since only one max function can be in the current scope independently of how it is called. If math max is in scope then it is math max being called.

Sutter’s proposal for C++ seems to be motivated by a wish to make the ad-hoc polymorphism of templates even more ad-hoc. Namely, given the existence of a non-member function write(const char* s, FILE*), he would like generic code that does out->write("hello") to be able to invoke the non-member write overload. (The receiver ends up as the second argument, not the first!) In C++ this problem cannot be solved by adding a method to FILE.

In Rust this problem does not exist. Generic code cannot call out->write without out being already known to implement the Write trait. Unlike the case with C++, in Rust we can implement Write after the fact (or wrap FILE and implement Write for the wrapper, if necessary).

Since Rust doesn’t allow overloading, there’s no advantage in having a.foo(b) implicitly call foo(a,b). So the question is, what’s the benefit of having foo(a, b) implicitly call a.foo(b)?

I don’t much care for being allowed to choose whichever seems appropriate. That way lies PERL’s “there’s more than one way to do it” madness.

A-priori there can exist types A such that both a.foo(b) and foo(a,b) are defined. Bringing a new definition into scope must not silently change the meaning of code, so, given that foo(a,b) can implicitly call a.foo(b) we cannot allow the introduction of foo(a,b) to silently change that. Therefore foo(a,b) must be an error if both are in scope.

All of which seems to imply that foo(a,b) is syntactic sugar for a.foo(b) if the latter is in scope (in which case foo(a,b) must not be) and that it can only mean the free function foo(a,b) if A doesn’t define a foo method (in scope).

If that’s all correct, I can see no obvious problem with it.

On the other hand, I can’t yet see any big advantage. Having both, and having the function-call syntax call the function and the method call syntax dispatch to the appropriate method seems neither surprising nor problematic to me.

1 Like

In Rust std lib there's this function:

x.atan2(y)

Probably I'd like it to be a regular function, it seems more natural:

atan2(x, y)

Sometimes I've used f64::atan2(x, y).

On the other hand I'd like max/min to be methods of the numbers, so you can use them like this:

x.min(y)
x.max(y)

I use min/max far more than atan2, and apparently I think of max and min more as infix functions than atan2.

I'd even like a language that allows to use max and min like += and -=

x `max=` y;

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