The return type of the "Index" trait has a big restriction

The thing that makes Index special is that self[index] does not evaluate to a value like a method call does; instead, it evaluates to a place. (In C++ terms, these would be an rvalueC++ and an lvalueC++, respectively.) To make the difference obvious: you can never write self.method() = 0;, but you can write self[index] = 0;. (This split requires mut access to demonstrate, but applies the same to shared access.)

self[index] desugars[1] to either *Index::index(&self, index) or *IndexMut::index_mut(&mut self, index) depending on how the place is used -- if it's used in a way that requires mut access, IndexMut is used; otherwise Index is used. This is why the equivalence is self.index(index) and &self[index].

In C++, this behavior comes from operator[] conventionally returning T&C++: an lvalue referenceC++. Rust does not have an equivalent to C++'s lvalue reference, and imho this is a good thing, because Rust's references are just normal types/values that behave like any other type/value, whereas C++'s references behave distinctly differently from nonreference types[2].

Rust's a[b] syntax will always evaluate to a place, just like *a evaluates to a place despite Deref::deref returning a reference. Nobody's really asking for Deref to be able to return things other than a reference[3], so what makes Index any different?

Where Index[Mut] do fall short and could be improved is that the existing traits only cover two of the four ways that a place can be used. A place can be used by-ref (&place), by-ref-mut (&mut place), by-move ({place}), or as the lhs of an assignment (place =).

Additionally, it would be nice if indexing could return reference wrapping types like RefMut) -- and with some use of temporary lifetime extension, this is actually possible to do.

The most general that Index[Mut] may be in the future would probably look something like this:

trait Index<Ix: ?Sized> {
    type Output: ?Sized;
    type Ref<'a>: Deref<Target=Self::Output> = &'a Self::Output;
    fn index(&self, ix: Ix) -> Self::Ref<'_>;
}

trait IndexMut<Ix: ?Sized>: Index<Ix> {
    type RefMut<'a>: DerefMut<Target=Self::Output> = &'a mut Self::Output;
    fn index_mut(&mut self, ix: Ix) -> Self::RefMut<'_>;

    fn index_set(&mut self, ix: Ix, val: Self::Output) {
        *self.index_mut() = val;
    }
}

with the desugarings of

& self[index] as & *Index::index(&self, index),
&mut self[index] as &mut *IndexMut::index_mut(&mut self, index), and
self[index] = value as IndexMut::index_set(&mut self, index, value).

Note, however, that I do not think that index_set will ever happen nor that it is a good idea; overloading assignment is something Rust has rightly avoided so far and I think should absolutely continue to avoid[4]. Assignment should be a move should be a simple bitcopy; if you want behavior, use a method.

Note also that IndexMove is a very awkward trait, even when assuming the presence of something like &move references. I originally considered including a shape for IndexMove, but it's extremely unclear how such should even function. We already have "IndexMove" for all indexables where Output: Copy, as well as for [T; N] even when T isn't Copy, so whatever shape IndexMove takes would have to work for both of those. And I don't think that it's possible, unfortunately, to have an IndexMove which provides the correct semantics. (This is in contrast to DerefMove, which I absolutely think is something which can be made available to user types.)

Relaxing Index[Mut] in this way works because of temporary lifetime extension. Even if the reference wrapper is immediately dereferenced, Rust will hoist it to only drop at the end of scope if it's used -- this scope may be the end of the containing expression[5] or the containing block depending on if its borrow escapes the expression containing the temporary. Here's an example.


  1. Very rough desugar, and I'm skimming over auto(de)ref behavior a bit since it's not super relevant here. ↩︎

  2. Notably, you cannot nest C++ references, thus T&& being yet another different kind (rvalue reference) and the existence of std::reference_wrapper to lift from a reference kind to a normal type kind. ↩︎

  3. Although, now that I've said this, I'm basically guaranteed to find someone who unironically thinks this should happen. ↩︎

  4. The singular reason I could see something changing is for Cell. Cell::set is still just a simple bitcopy, but working with Celld data has a significant amount of extra red tape which doesn't need to exist, and Rust would probably benefit from removing somehow. ↩︎

  5. There's an existing footgun here: used temporaries in the scrutinee of match or if or other block-like expressions live until after the expression's block, even if the temporary is no longer accessible from within the block. This doesn't get in the way most of the time since "non lexical lifetimes" (NLL) allows regular references' lifetimes (and many struct's lifetimes) to be ended after the last use "prematurely" before the end of scope, but this does not happen if the struct containing the lifetime has any drop glue (implements Drop or contains a type with drop glue)[6]. ↩︎

  6. There's an unstable opt-out called the "eyepatch" with #[may_dangle], where with a type like Box<T>, despite having a Drop implementation, does not use T in any way, so any lifetimes in T are allowed to be NLLd the same as if T were held directly on the stack. ↩︎

6 Likes