lang (traits | trait-object)
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:
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>
.
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.
The rules for user-defined types from RFC 599 are altered as follows (text that is not changed is italicized):
SomeType
contains a single where-clause like T:'a
, where
T
is some type parameter on SomeType
and 'a
is some
lifetime, then the type provided as value of T
will have a
default object bound of 'a
. An example of this is
std::cell::Ref
: a usage like Ref<'x, X>
would change the
default for object types appearing in X
to be 'a
.SomeType
contains no where-clauses of the form T:'a
, then
the "base default" is used. The base default depends on the overall context:
'static
.
Hence Box<X>
would typically be a default of 'static
for X
,
regardless of whether it appears underneath an &
or not.
(Note that in a fn body, the inference is strong enough to adopt 'static
if that is the necessary bound, or a looser bound if that would be helpful.)SomeType
contains multiple where-clauses of the form T:'a
,
then the default is cleared and explicit lifetiem bounds are
required. There are no known examples of this in the standard
library as this situation arises rarely in practice.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:
&Box<Trait>
where the
(boxed) trait object may contain references should now be written
&Box<Trait+'a>
to disable the warning.The primary drawback is that this is a breaking change, as discussed in the previous section.
Keep the current design, with its known drawbacks.
None.
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".