About the implied bounds

I have noticed that the implementation of the Implied Bounds RFC is... progressing slowly. Personally I am uneasy about the proposed design, because it can lead to some very confusing long-range bound inference. The usage and declaration of bounds could even be in entirely different crates, though I'm unsure whether that was in the end accepted in the RFC. So there are two alternatives which look more reasonable in my view.

Allow omitting bounds only if the function doesn't make any use of them.

Here is an example from the RFC:

    fn panic_if_not_borrowed<'a, B>(cow: Cow<'a, B>) -> &'a B
//      where B: ToOwned
    {
        match cow {
            Cow::Borrowed(b) => b,
            Cow::Owned(_) => panic!(),
        }
    }
//  ^ the trait `std::borrow::ToOwned` is not implemented for `B`

In this example, the function doesn't actually use the B: ToOwned bound in any way, so no confusion can stem from omitting it. There are many similar utility functions in practice, which could be entirely bound-agnostic. In a sense, it would work just as where-clauses on methods: if the clauses are satisfied for the given type, then the method is present, otherwise it is deleted. Functions with implied bounds would similarly be available whenever the types are well-formed, and unavailable otherwise.

Introduce constraint functions

I believe I saw similar suggestions in the past, but I couldn't find any open or closed RFC for "constraint". The idea is that we allow declaring parametric constraints, like (bikeshed warning)

constraint FilteredIterator<I, F, B>  = {
    I: Iterator,
    F: FnMut(I::Item) -> Option<B>,
};

Then we could reference the constraint on types, impls and functions:

fn filter_show<I, F, B>(it: I, f: F) 
    where FilteredIterator<I, F, B>,
               B: Display,
{
    for (n, item) in it.filter_map(f).enumerate() { 
        println!("pos {n}: {item}");
    }
}

The example is obviously artificial, but e.g. in generic numeric code it's quite common to have bounds like

where 
    T: Add<T, Output=T> + for<'a> Add<&'a T, Output = T> + Sized,
    for<'b> &'b T: Add<T, Output=T> + for<'a> Add<&'a T, Output = T>,

The trait part can at least be extracted into a new type:

trait Addable: Sized + Add<Self, Output=Self> + for<'a> Add<&'a Self, Output=Self> {}
impl<T> Addable for T 
    where T: Sized + Add<Self, Output=Self> + for<'a> Add<&'a Self, Output=Self> 
{}

The bounds on references and associated types, unfortunately, generally cannot be simplified in the same way.

The syntax overhead of declaring constraints on usages is relatively minimal, but at the same time it is explicit, easily searchable, and composable for arbitrary usages (unlike implied bounds, which are supposed to work only for bounds on types).


The common reasoning for implied bounds is "we don't require you to write 'b: 'a for functions fn f<'a, 'b>(_: &'a Foo<'b>)". However, in that function declaration both 'a, 'b and the relation between them is explicit. Everyone knows that the lifetime of the inner type must outlive the lifetime of the reference. It is much more confusing when all type constructors are custom, and the bounds on generic parameters are also whatever defined in the crate, rather than something well-known and intuitively obvious.

Thoughts?

2 Likes

The implementation is progressing? AFAIK from casually observing the tracking issue for years nothing has ever happened.

Here are some quotes from the tracking issue:

scalexm commented on Jan 2, 2019

Chalk already handles implied bounds. I don’t know of any plan for implied bounds in Rust outside of pushing on chalk integration.

nikomatsakis commented on Mar 16, 2022

I am actively working on this

But regardless, the topic isn't about the progress of Chalk.

This would be great to have!

The main problem with implied bounds across crate boundaries is that historically removing a bound[1] has always been a nonbreaking change, as the bounds were always required to be written downstream. Elaborating the bounds means that downstream can rely on the bounds you wrote, even if you no longer use them, so relaxing bounds becomes a breaking change.

Your constraint/blanket impl case is covered by the current implementation of trait aliases. Unlike how normal supertraits behave, bounding on a trait alias does imply/elaborate the trait alias's where bounds.

trait FilteredIterator<I, F, B> = where
    I: Iterator,
    F: FnMut(<I as Iterator>::Item) -> Option<B>,
;

trait Addable = Sized + Add<Self, Output=Self> + for<'a> Add<&'a Self, Output=Self>
where
    for<'b> &'b Self: Add<Self, Output=Self> + for<'a> Add<&'a Self, Output = Self>,
;

Allow omitting bounds only if the function doesn't make any use of them.

First off, your example does still use the B: ToOwned bound, because that's involved in the structural definition of Cow. You physically cannot know the layout of Cow<'_, B> without knowing B: ToOwned.

Still, this combines very poorly with implied bounds in general. It is effectively the opposite of implied bounds. If you allow implying the bound if it's used in the body, now you need to check the body of the function in order to determine the correct signature to typecheck callers with. Rust deliberately chooses to treat function signatures as authoritative and trusted; this prevents the need for global type inference but more importantly allows errors to know whether the caller or implementation is at fault for a type mismatch. This is no longer the case if the signature depends on the body.

But this is in practice what the "require minimal bounds" API guideline's effect is. If all I do is construct a HashSet<T>, I don't need to fulfil T: Hash, because HashSet::new doesn't require that bound. You can't elide bounds on the type itself, since those can impact the layout of the type, but you can elide bounds required for functions you don't use.


One concept I've thrown out elsewhere is to allow "explicitly implied bounds." Specifically, to be able to write something like

where
    fn panic_if_not_borrowed<'a, B>,

to inherit the bounds required to call the named function. I'd need to do some more thinking on if/how this can be done while still minimizing the semver risk of changing public API signatures at a distance, but I personally do like this -- if struct implied bounds happen, this definitely should be allowed. (You might even be able to jankily emulate it with fn-typed const generics.)


  1. Other than a singular exception of where Self: Supertrait bounds on trait items, which is at the moment semantically equivalent to writing a more usual supertrait bound trait T: Supertrait. ↩︎

3 Likes

Yeah, the unknown layout is a gnarly issue which I didn't expect. Still, the point is that to remove the burden of trait bounds from the API, unless they are required, so there is no issue with the compiler inferring the required bounds on its own. It certainly does so in the implied bounds RFC.

I'm not sure what you mean by

That seems like the opposite of what I propose. The bounds must always be specified explicitly, and they can be omitted only if they aren't used in the body. In which case they are still present, but inferred by the compiler. Again, like in the accepted RFC, except that I propose to restrict it only to the cases where the inference is simple for the programmer, not just the compiler.

Actually, I can see one issue with my proposal: composability. Consider the following example:

struct Hashable<H: Hash>(H);

fn see_hashable<H>(val: Hashable<H>) {
    println!("Omg it can hash!");
}

fn feel_hashable<H>(val: Hashable<H>) {
    println!("It's soft and warm.");
    // Is this call allowed?
    see_hashable::<H>(val);
}

If we assume that the bounds are present but implicit, then feel_hashable calls a function see_hashable with trait bounds on H. That would imply that feel_hashable actually uses those trait bounds, so it should explicitly specify them. But that looks weird, since see_hashable has no explicit bounds, and can also cause issue e.g. if see_hashable would just call itself recursively (it would require bounds, even though they don't seem to be used anywhere).

That means that the bounds must not be implied, they must be really absent. It's just that the function cannot be called if H: !Hash, since the parameter type would not be well-formed.

It could inhibit separate compilation of see_hashable since we wouldn't have full information about the layout of the argument type. But on the other hand, is the layout even relevant at this point? I would expect that it matters only for the codegen of a fully monomorphized function, but at that point the layout is fully known (or the function cannot be called due to non-well-formed parameters).

The problem is that HashSet<!Hash> is pretty much useless. You can't do anything HashSet-y with it, can add elements, can't take elements. It's confusing that you add a type, try to call a method, and instead get a bucket list of unfulfilled trait obligations, which may not be even directly related to your types (if your types are generic and their is some complex trait inference going on). I would much prefer if an unusable HashSet could not be constructed in the first place. Sure, it may occasionally hinder someone who could make use of your type even if it didn't have most functionality, but that's a rare edge case rather than a common one. I struggle to think where I could have a use for such half-usable types, though I guess it would be more common if people carried around larger trait bounds.

I know the reasoning and the benefits, but I also think it is too strict requirement in many cases. There is a finite amount of load that a function signature may bear. At a certain point it becomes more ergonomic to just write a macro and ignore the strict API boundaries altogether. I would much prefer if there was a way to relax the strict signature boundary in some cases, rather than resort to entirely unstructured token trees. But that's a topic for a different discussion.

This feels like the same constraints with a different syntax. It's just that now if I want to name arbitrary constraints, I need to create a dummy function just to give them a name. That certainly reduces the number of separate concepts in the language, but personally I would prefer a more direct way to say the desired thing. Trait aliases seem more reasonable, but afaik that implementation has also stalled.

On the other hand, it would give an easy way to say "I have the same requirements as these several functions I call".

Nit: there are other exceptions.

1 Like