The short answer is it depends, and it depends mainly on the compiler optimizations:
-
machine code wise, there is, at first, no difference between a Copy
and a non-Copy
value. Indeed, the true meaning of Copy
is: "the compiler won't invalidate / prevent you from using a "moved" value". And "moving" a value is, by default, achieved by bitwise-copying the value (memmove
), in all cases (in the !Copy
case, the compiler will just forbid you from using the initial data / memory, which has become stale).
-
Now, in practice, this is actually very hard to predict, since both Rust and its compiler backend (LLVM) will obviously try to optimize "unnecessary bit-copies".
A good example of the latter, and completely on point for this thread, is the following quote:
Indeed, in that example we can see that a Vec
, which is three-pointers wide, is considered wide enough for it to be "passed by reference with indirection" even when moved (I think this can happen thanks to the undefined (extern "
)Rust("
) ABI for functions).
So, ownership is used to express who drops a value, and Copy
to express wether a moved-out-of value gets invalidated / becomes unusable or not.
Anything machine-related, on the other hand (e.g., whether to perform a bitwise copy or to use indirection), is left for the compiler to decide, and is thus quite hard to predict.
In practice,
on the other hand, there are some cases that guarantee that a bitwise copy cannot be happening: when using types that do bundle indirection within them, such as:
-
indeed, a Box
ed type,
-
ref-counted references types (Arc
(or its single-threaded optimization, Rc
)),
-
or when using compile-time-checked short-lived borrows (shared, &
, or exclusive, &mut
).
But, imho, if an element is big enough for implicit compiler-inserted bitwise copies to be an issue (even when moving a value around), then it is not for the user of the type to start using, for instance, a Box
around it, but rather the one defining the type to bundle an internal Box
-like indirection to improve that.
One good example of that is when dealing with enum
s, and when there is one variant that may be quite bigger than then others. In that case, Box
-ing that variant will mitigate the cost of moving around such an enum
, even in the case of other variants being used.
-
One very frequent case of that are Error
types: those get bundled as the Err
branch within the Result
enum
, which is one branch which is not expected to be hit that much, in theory (failure path).
And yet, if the Error
type is big, every time a function returning a Result
with that Error
type succeeds, the compiler may be emitting a big bitwise copy since the size of the enum
itself needs to be able to hold the Error
variant.
So, in that case, a good strategy is to Box
the data / payload / info bundled within the Error
variant, so as to have an upper-bound on the size of that variant (1-pointer-wide in the case of a Box
-ed fixed type, and two in the case of a "fat pointer", that is, a pointer to a !Sized
type such as dyn Error
).
- Since 2-pointer-wide is already quite big for some small return types in the
Ok
case, Box<dyn Error>
is already a bit suboptimal, despite it being used in many places. In order to palliate that, one can double-box Box<Box<dyn Error>>
, so as to make sure the Error
variant is not more than 1-pointer-wide, or use special layout-optimized type-erased Error
types, such as ::anyhow
's.