[Pre-RFC] Forward impls

This is an interesting idea but it pushes the problem into the namespace system; when two libraries expect you to import their impls (which aren’t coherent), you now can’t compose their behaviors inside the same module.

1 Like

All lang items are conceptually nullary type classes underneath, even if they’re not implemented that way. The compiler declares the classes and their signatures (which may include both functions and data types), and the whole lang-item system is dedicated to maintaining coherence in the face of another crate providing a “blanket” implementation (which is the only kind of implementation, as far as NTCs are concerned). This “forward-declaration” idea is the same thing, but exposed to the user.

This is akin to Idris’s “named implementations;” maybe looking at that can give some ideas?

That’s an interesting perspective. To make sure I understand this, you’re saying that the downstream client is providing impl StringAddition, and the upstream client is essentially saying impl Add<str> for str where StringAddition.

But it seems the significant thing is that these ‘nullary impls’ wouldn’t be required to obey the orphan rules (under the orphan rules, you couldn’t impl another crate’s nullary trait), which isn’t inherent in the idea of nullary impls. Perhaps nullary impls provides a less exotic syntax at least.

2 Likes

What is the advantage of these "named impl blocks" over the current semi-standard workaround of a "newtype wrapper"? Both disambiguate by creating a new name that the user has to explicitly import.


More generally, I very strongly agree with this point:

Forward impls are one of many interesting ideas to mitigate orphan rule frustration, but I think we need to finish the existing work on specialization before we can tell which of those ideas still fill a gap that needs filling.

Yes, that’s exactly what I’m saying. Of course, the syntax could be wildly different (bikeshedding is the soul of feature design, after all :sweat_smile:), but the semantics should be exactly that of NTCs.

Hmm, true. An workaround for that would be to require the universal function call syntax to specify the desired implementation if you wanted to use a trait with two conflicting implementations imported to the same module, but that could get pretty ugly. Another potential issue would be deciding which implementation to use if a user passed the implemented type to a generic function that’s unaware that there are two conflicting implementations - one option would be to use as $impl to specify the desired implementation, but that’s also pretty wordy.

Although, a wordy solution is still better than code unexpectedly breaking upon including a new crate.

In any case, all such NTCs can be emulated with existing traits by providing and relying on impls for (e.g.) () and nothing else.

Except for the orphan rule aspect, which seems like the most important part.

Its worth remembering that we’re running up against a fundamental limit here - this is the same problem that manifests in other systems as diamond inheritance or the wild results you can get from monkeypatching in dynamic languages. I think our current solution can get better, but I think its impossible to find a perfect solution.

Depends on what you mean by “perfect.” It’s pretty obvious that it’s mathematically impossible to solve this problem in the general case, but I think there’s an optimal solution to any specific case that has any solution.

One advantage is that it allows for multiple custom trait implementations to exist, and for those to all be used on the same type without much hackery by the user. Say we had a type Foo, and one external library implemented ToString on Foo and another external library implemented FromStr on Foo. With wrapper types you’d have an unnecessarily difficult time using both of those implementations on the same instance of a type, whereas “named impl blocks” would let them be used simultaneously pretty easily.

Yeah, sorry, I read too much into this proposal without reading it carefully first. What I had in mind was a different kind of a proposal, where a crate may forward declare an impl, and then other crates that depend on that crate may assume that some impl exists, but they – diesel and rocket for example – don't get to decide which one. Finally, the bottom crate – usually a binary – has the power to decide which implementation to use, and the implementing crates are required to only contain the impl. It's not a foolproof system, I guess, but I think it enables a social dynamic where the library authors are encouraged to be careful not to depend on specific impls.

The problem with such a system is that it implicitly encourages there being multiple implementations.

Also, stuff gets complicated when you start dealing with code accepting generic parameters.

Also hard to say you can’t depend on the specific implementation. Different implementations could have semantically significant differences, such as opposite implementations of PartialOrd.

1 Like

Just as an aside, this is what Coq does (the naming, at least - it's mandatory, as a matter of fact). I'm unsure of how Coq handles imports of such.

One nasty facet is that Coq allows declaring two such impls in the same place (so long as the names differ) - the last declared is silently used.

As an example, if I implemented Add<Foo> for Foo twice, and then wrote a function fn frob<F: Add<Foo>>(f: F) and invoked it with a Foo, it would silently use the second impl, without disambiguation. (I am, in fact, unsure disambiguation is possible without creating a module boundary and importing only one - I ran afoul of this in a project.)

Older versions of Rust had named impls that had to be imported. We backed away from it over time for a variety of reasons.

1 Like

We ‘support’ named impls today through this very wild hack. If you would like your trait to support named impls, you can do this:

trait ToString<Name: NamedToString<ToString = Self>> {
    fn to_string(&self) -> String;
}

trait NamedToString {
    type Receiver: ?Sized;
    fn to_string(this: &Self::Receiver) -> String;
}

impl<Name> ToString<Name> for Name::Receiver where Name: NamedToString {
    fn to_string(&self) -> String {
        Name::to_string(self)
    }
}

Your clients can provide named impls for foreign traits:

struct ToStringForIntSlice;

impl NamedToString for ToStringForIntSlice {
    type Receiver = [i32];
    fn to_string(this: &[i32]) -> String {
        panic!()
    }
}

To be clear: I don’t think anyone should do this.

2 Likes

It doesn't require wrapping the object to get the functionality. In fact, another way of looking at named implementations could be to have a way to make a newtype that can implicitly wrap an object if a method of the implementation is called and there are no other conflicting implicit newtypes in scope (similar to how implicit classes work in scala).

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