RFC 1156: adjust-default-object-bounds

lang (traits | trait-object)

Summary

Adjust the object default bound algorithm for cases like &'x Box<Trait> and &'x Arc<Trait>. The existing algorithm would default to &'x Box<Trait+'x>. The proposed change is to default to &'x Box<Trait+'static>.

Note: This is a BREAKING CHANGE. The change has been implemented and its impact has been evaluated. It was found to cause no root regressions on crates.io. Nonetheless, to minimize impact, this RFC proposes phasing in the change as follows:

Motivation

When we instituted default object bounds, RFC 599 specified that &'x Box<Trait> (and &'x mut Box<Trait>) should expand to &'x Box<Trait+'x> (and &'x mut Box<Trait+'x>). This is in contrast to a Box type that appears outside of a reference (e.g., Box<Trait>), which defaults to using 'static (Box<Trait+'static>). This decision was made because it meant that a function written like so would accept the broadest set of possible objects:

fn foo(x: &Box<Trait>) {
}

In particular, under the current defaults, foo can be supplied an object which references borrowed data. Given that foo is taking the argument by reference, it seemed like a good rule. Experience has shown otherwise (see below for some of the problems encountered).

This RFC proposes changing the default object bound rules so that the default is drawn from the innermost type that encloses the trait object. If there is no such type, the default is 'static. The type is a reference (e.g., &'r Trait), then the default is the lifetime 'r of that reference. Otherwise, the type must in practice be some user-declared type, and the default is derived from the declaration: if the type declares a lifetime bound, then this lifetime bound is used, otherwise 'static is used. This means that (e.g.) &'r Box<Trait> would default to &'r Box<Trait+'static>, and &'r Ref<'q, Trait> (from RefCell) would default to &'r Ref<'q, Trait+'q>.

Problems with the current default.

Same types, different expansions. One problem is fairly predictable: the current default means that identical types differ in their interpretation based on where they appear. This is something we have striven to avoid in general. So, as an example, this code will not type-check:

trait Trait { }

struct Foo {
    field: Box<Trait>
}

fn do_something(f: &mut Foo, x: &mut Box<Trait>) {
    mem::swap(&mut f.field, &mut *x);
}

Even though x is a reference to a Box<Trait> and the type of field is a Box<Trait>, the expansions differ. x expands to &'x mut Box<Trait+'x> and the field expands to Box<Trait+'static>. In general, we have tried to ensure that if the type is typed precisely the same in a type definition and a fn definition, then those two types are equal (note that fn definitions allow you to omit things that cannot be omitted in types, so some types that you can enter in a fn definition, like &i32, cannot appear in a type definition).

Now, the same is of course true for the type Trait itself, which appears identically in different contexts and is expanded in different ways. This is not a problem here because the type Trait is unsized, which means that it cannot be swapped or moved, and hence the main sources of type mismatches are avoided.

Mental model. In general the mental model of the newer rules seems simpler: once you move a trait object into the heap (via Box, or Arc), you must explicitly indicate whether it can contain borrowed data or not. So long as you manipulate by reference, you don't have to. In contrast, the current rules are more subtle, since objects in the heap may still accept borrowed data, if you have a reference to the box.

Poor interaction with the dropck rules. When implementing the newer dropck rules specified by RFC 769, we found a rather subtle problem that would arise with the current defaults. The precise problem is spelled out in appendix below, but the TL;DR is that if you wish to pass an array of boxed objects, the current defaults can be actively harmful, and hence force you to specify explicit lifetimes, whereas the newer defaults do something reasonable.

Detailed design

The rules for user-defined types from RFC 599 are altered as follows (text that is not changed is italicized):

Timing and breaking change implications

This is a breaking change, and hence it behooves us to evaluate the impact and describe a procedure for making the change as painless as possible. One nice propery of this change is that it only affects defaults, which means that it is always possible to write code that compiles both before and after the change by avoiding defaults in those cases where the new and old compiler disagree.

The estimated impact of this change is very low, for two reasons:

Nonetheless, to minimize impact, this RFC proposes phasing in the change as follows:

Drawbacks

The primary drawback is that this is a breaking change, as discussed in the previous section.

Alternatives

Keep the current design, with its known drawbacks.

Unresolved questions

None.

Appendix: Details of the dropck problem

This appendix goes into detail about the sticky interaction with dropck that was uncovered. The problem arises if you have a function that wishes to take a mutable slice of objects, like so:

fn do_it(x: &mut [Box<FnMut()>]) { ... }

Here, &mut [..] is used because the objects are FnMut objects, and hence require &mut self to call. This function in turn is expanded to:

fn do_it<'x>(x: &'x mut [Box<FnMut()+'x>]) { ... }

Now callers might try to invoke the function as so:

do_it(&mut [Box::new(val1), Box::new(val2)])

Unfortunately, this code fails to compile -- in fact, it cannot be made to compile without changing the definition of do_it, due to a sticky interaction between dropck and variance. The problem is that dropck requires that all data in the box strictly outlives the lifetime of the box's owner. This is to prevent cyclic content. Therefore, the type of the objects must be Box<FnMut()+'R> where 'R is some region that strictly outlives the array itself (as the array is the owner of the objects). However, the signature of do_it demands that the reference to the array has the same lifetime as the trait objects within (and because this is an &mut reference and hence invariant, no approximation is permitted). This implies that the array must live for at least the region 'R. But we defined the region 'R to be some region that outlives the array, so we have a quandry.

The solution is to change the definition of do_it in one of two ways:

// Use explicit lifetimes to make it clear that the reference is not
// required to have the same lifetime as the objects themselves:
fn do_it1<'a,'b>(x: &'a mut [Box<FnMut()+'b>]) { ... }

// Specifying 'static is easier, but then the closures cannot
// capture the stack:
fn do_it2(x: &'a mut [Box<FnMut()+'static>]) { ... }

Under the proposed RFC, do_it2 would be the default. If one wanted to use lifetimes, then one would have to use explicit lifetime overrides as shown in do_it1. This is consistent with the mental model of "once you box up an object, you must add annotations for it to contain borrowed data".