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 ofCopy
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).-
The one true property one can derive from this is:
If a program compiled without some type being
Copy
, then adding* animpl Copy
on that code will not change the emitted bit-copies.
-
-
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 theErr
branch within theResult
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 aResult
with thatError
type succeeds, the compiler may be emitting a big bitwise copy since the size of theenum
itself needs to be able to hold theError
variant.So, in that case, a good strategy is to
Box
the data / payload / info bundled within theError
variant, so as to have an upper-bound on the size of that variant (1-pointer-wide in the case of aBox
-ed fixed type, and two in the case of a "fat pointer", that is, a pointer to a!Sized
type such asdyn 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-boxBox<Box<dyn Error>>
, so as to make sure theError
variant is not more than 1-pointer-wide, or use special layout-optimized type-erasedError
types, such as::anyhow
's.
- Since 2-pointer-wide is already quite big for some small return types in the