Return by Value in Rust: Why It’s Idiomatic and Efficient
Returning values from functions is the idiomatic way to produce outputs in Rust. Unlike languages that rely on out-parameters or reference-based mutation for performance, Rust’s ownership and move semantics make returning by value both safe and efficient—even for large structs. In this post, we’ll explore why returning by value is preferred, how it interacts with moves and copies, what the compiler optimizes, and when to consider alternatives like Box or references.
TL;DR
- Returning by value is idiomatic and usually zero-cost due to move semantics and optimization.
- Large objects can be returned efficiently; Rust avoids unnecessary copies.
- Prefer returning owned values over out-parameters or mutable references for clarity and safety.
- Use Box, Arc, or references only when ownership, lifetime, or size characteristics require them.
Ownership and Move Semantics
Rust enforces ownership: each value has a single owner. Moving a value transfers ownership without copying its bytes (for non-Copy types). This allows functions to return complex values without extra allocations or deep copies.
struct BigData { buffer: Vec<u8>, meta: String }
fn build() -> BigData {
let data = BigData { buffer: vec![0; 1_000_000], meta: "payload".into() };
// data is moved out; no deep copy of the Vec or String buffers
data
}
In the example above, returning data moves the Vec and String handles (pointers, length, capacity) — not their heap contents.
Copy vs Move
Types implementing Copy (like integers) are trivially copied. Most heap-owning types are Move-only by default. Returning by value uses a move for non-Copy types, which is as cheap as copying a few machine words.
Compiler Optimizations: NRVO/Copy Elision–Like Results
Rust does not specify C++-style copy elision in the language spec, but Rust compilers routinely optimize return paths to avoid temporaries. With MIR and LLVM optimizations, returning a local often becomes a direct write into the caller’s stack slot (Return Value Optimization effect), avoiding even a move.
fn make_point() -> (i64, i64) {
let p = (10, 20);
p // optimized to write directly into the caller's space
}
When to Consider Box or References
- Box: When you need a stable heap address, large unsized types, or to shrink stack frames for very large objects.
- Arc/Rc: For shared ownership and concurrency or graph-like structures.
- &T / &mut T: When borrowing from an existing owner and lifetimes allow it without extending ownership.
Avoid out-parameters unless you specifically must mutate an existing buffer for API or performance reasons after profiling.
Error Handling with Result
Return Result for fallible functions. This keeps errors explicit and integrates with the ? operator.
fn parse_u64(s: &str) -> Result<u64, std::num::ParseIntError> {
s.parse::<u64>()
}
fn read_config() -> Result<Config, Error> { /* ... */ }
Performance Considerations and Large Structs
- Returning by value is typically as fast as alternatives due to moves and optimizations.
- Benchmark if you’re concerned: cargo bench, criterion.
- Consider Box for extremely large stack objects or recursive enums.
enum Tree {
Leaf(i32),
Node(Box<Tree>, Box<Tree>),
}
Comparing with C++
C++ has copy elision and move semantics; Rust achieves similar performance via moves and LLVM optimizations, without implicit copies. Prefer returning by value in both languages for clarity; only optimize after measurement.
Best Practices
- Return owned values for clear ownership transfer.
- Use Result for fallible APIs; Option for absence.
- Don’t prematurely heap-allocate; favor stack allocations with moves.
- Profile before introducing complex patterns like out-parameters.
Conclusion
Rust’s design makes returning by value both clean and efficient. Embrace owned returns for clarity, safety, and performance, and reach for Box, references, or shared ownership types only when their semantics are truly needed.