Advanced Rust Questions
Why doesn't this code compile, and how does it relate to the rules for &mut?
let mut x = 42;
let a = &mut x;
let b = &x; // error
println!("{}", *a);Answer
The error is on let b = &x; (E0502: cannot borrow x as immutable because it is also borrowed as mutable).
The rule for &mut T is "no aliasing": while a mutable reference is live, no other reference to the same value may exist, not even a read-only one.
Thanks to NLL (Non-Lexical Lifetimes), a borrow lasts until the reference's last use, not until the end of the scope. Here a is used later in println!, so it's still live when b is created, and the two borrows overlap.
The fix is to end the mutable borrow before taking the shared one:
let mut x = 42;
let a = &mut x;
println!("{}", *a); // last use of `a`
let b = &x; // OK: `a` is no longer live
println!("{}", *b);How does drop check work, and why does a destructor affect how long references must live?
Answer
Normally, the borrow checker only cares about where a reference is actually used. But if a type implements Drop, the compiler has to assume the destructor might read the type's fields. So any references inside the value must still be valid at the moment the value is dropped. This is called drop check (dropck).
struct MyStruct<'a> {
data: &'a i32,
}
impl<'a> Drop for MyStruct<'a> {
fn drop(&mut self) {
println!("{}", self.data); // the destructor uses the reference
}
}
fn main() {
let s;
{
let y = 20;
s = MyStruct { data: &y };
} // `y` is dropped here, but `s` is still alive
// E0597: `y` does not live long enough
}If you remove impl Drop, this code compiles: s is never used after the inner block, so the reference isn't live anymore. With impl Drop, dropping s at the end of main counts as a use of the reference, but y is already gone by then.
The standard library uses the unstable #[may_dangle] attribute to promise the compiler that a destructor won't touch borrowed data. That's why Vec<&T> doesn't run into this problem.
What's the difference between Pin<Box<T>>, Box<Pin<T>>, and Pin<&mut T>? When do you need Pin outside of async?
Answer
Pin<Box<T>> is an owning pointer that guarantees the value on the heap will never move again (this matters only when T: !Unpin). The Box itself can be moved around, but the memory it points to stays put. You usually create one with Box::pin(value).
Box<Pin<T>> is almost never what you want. Pin is designed to wrap a pointer (Pin<P>), so Pin<T> with a non-pointer T gives you no guarantees. You won't see it in real code.
Pin<&mut T> is a pinned reference to data owned by someone else. It doesn't own anything; it just guarantees the data won't be moved through this reference. This is the type of self in Future::poll. You can create one on the stack with std::pin::pin!.
When you need Pin: for self-referential types, where one field points into another field of the same value. Async state machines are the most common case, but the same applies to hand-written generators, intrusive linked lists, and some FFI types. Without Pin, moving the value would leave the internal pointer dangling.
use std::marker::PhantomPinned;
use std::pin::Pin;
struct SelfReferential {
data: String,
ptr: *const String, // points to `data`
_pin: PhantomPinned, // makes the type `!Unpin`
}
impl SelfReferential {
fn new(data: String) -> Self {
SelfReferential {
data,
ptr: std::ptr::null(),
_pin: PhantomPinned,
}
}
fn init(self: Pin<&mut Self>) {
// SAFETY: we don't move the value, we only write a field
let this = unsafe { self.get_unchecked_mut() };
this.ptr = &this.data as *const String;
}
}
fn main() {
let mut s = Box::pin(SelfReferential::new("hello".into()));
s.as_mut().init();
// The value can't move anymore, so `ptr` stays valid
println!("{}", unsafe { &*s.ptr });
}How does impl Trait work in argument position and in return position? Is fn foo(x: impl Debug) exactly the same as fn bar<T: Debug>(x: T)?
Answer
Argument position. fn foo(x: impl Debug) is mostly syntactic sugar for fn foo<T: Debug>(x: T). Both are generic, and both are monomorphized the same way: the compiler generates a separate copy for each concrete type the caller uses.
They still aren't exactly the same:
- No name for the type. Inside
fooyou can't refer to the type, so you can't write things likeT::default()or say that two parameters have the same type. - Every
impl Traitis its own type.fn foo(a: impl Debug, b: impl Debug)accepts two different types, whilefn bar<T: Debug>(a: T, b: T)requires both to be the same type. - No turbofish. The caller can't write
foo::<i32>(...)for animpl Traitparameter; the type is always inferred.
Return position. fn foo() -> impl Debug means "this returns one specific type that implements Debug, but I won't tell you which." Here the function chooses the type, not the caller. Compare that with fn bar<T: Debug>() -> T, where the caller picks T.
Every call returns the same concrete type, so you can't return different types from different branches (use Box<dyn Trait> for that). The main benefit is returning closures and long iterator chains without naming their types and without boxing.
fn evens(limit: u32) -> impl Iterator<Item = u32> {
(0..limit).filter(|n| n % 2 == 0)
}What is lifetime subtyping, and how does it relate to variance? Why is Cell<T> invariant?
Answer
Subtyping. 'static outlives any other lifetime 'a, so 'static is a subtype of 'a (written 'static: 'a). That's why you can pass a &'static T where a &'a T is expected: a reference that lives longer can always stand in for one that lives shorter. The reverse isn't allowed, since it would let a reference outlive its data.
Variance describes how subtyping of a type parameter carries over to the type that contains it:
- Covariant: if
'long: 'short, then&'long Tcan be used as&'short T.&'a T,Box<T>, andVec<T>are covariant. - Invariant: no substitution is allowed at all.
&mut Tis invariant inT, and so areCell<T>andRefCell<T>. - Contravariant: the relationship is reversed. This only happens for function arguments, as in
fn(T).
Why Cell<T> must be invariant. A Cell lets you write through a shared reference. If it were covariant, you could treat a Cell<&'static str> as a Cell<&'a str> and store a short-lived string in it. Once that string was freed, anyone still holding the original Cell<&'static str> would read a dangling reference.
use std::cell::Cell;
fn store<'a>(cell: &Cell<&'static str>, s: &'a str) {
let c: &Cell<&'a str> = cell; // error: lifetime may not live long enough
c.set(s);
}The same reasoning applies to &mut T: anything that allows writing must be invariant.
When should you use std::mem::take or std::mem::replace instead of std::mem::swap?
Answer
You can't move a value out of a &mut reference, because that would leave a hole behind (E0507: cannot move out of borrowed content). take and replace solve this by moving the old value out and putting a valid new value in its place in a single step:
mem::take(&mut x)takes the value and leavesDefault::default()behind (Nonefor anOption).Option::takedoes the same thing.mem::replace(&mut x, new)takes the value and leavesnewbehind. It works for types that don't implementDefault.mem::swap(&mut a, &mut b)exchanges two values that already exist. To use it for "take the value out," you'd have to create a temporary variable first, andreplacedoes exactly that for you.
A classic example is reversing a linked list. node.next.take() moves the tail out of the node, so we can reuse the node without cloning anything or fighting the borrow checker:
struct Node {
value: i32,
next: Option<Box<Node>>,
}
fn reverse(mut head: Option<Box<Node>>) -> Option<Box<Node>> {
let mut prev = None;
while let Some(mut node) = head {
head = node.next.take(); // move the tail out, leave `None` behind
node.next = prev;
prev = Some(node);
}
prev
}replace is handy for state machines, where you need the old state by value in order to build the new one:
enum State {
Idle,
Running(String),
Done,
}
fn step(state: &mut State) {
*state = match std::mem::replace(state, State::Done) {
State::Idle => State::Running("job".into()),
State::Running(_job) => State::Done,
State::Done => State::Done,
};
}Can you assign a closure to a fn pointer? How are closures different from function pointers?
Answer
A closure that doesn't capture anything coerces to a function pointer automatically, so this compiles:
let f: fn() -> i32 = || 42; // OKA closure that does capture variables can't be turned into a fn pointer:
let n = 10;
let g: fn() -> i32 = || n; // E0308: closures can only be coerced to `fn` types if they do not capture any variablesWhy. Every closure has its own anonymous type. Under the hood it's a struct that holds the captured variables, plus an implementation of Fn, FnMut, or FnOnce. A fn pointer is just the address of some code, with no room for data. A capturing closure needs its data, so it can't be represented as a bare fn pointer. For those, use generics (impl Fn() -> i32) or a trait object (Box<dyn Fn() -> i32>).
Sizes on a 64-bit target:
fn() -> i32is 8 bytes (one pointer).|| 42is 0 bytes: a non-capturing closure is a zero-sized type.|| s.len()that captures aStringby reference is 8 bytes (one reference).move || s.len()that captures aStringby value is 24 bytes (the wholeString).
Capture modes. The compiler picks the least restrictive mode that works:
- By shared reference (
&T), if the closure only reads the variable. - By mutable reference (
&mut T), if it modifies the variable. - By value (
T), if it moves the variable out, or if you writemove.
There's also a less-known unique immutable borrow: if a closure modifies data through a captured &mut reference (let r = &mut x; let c = || *r += 1;), it can't take &mut r (since r isn't declared mut), but a plain &r wouldn't be exclusive enough. So the compiler captures r with a special borrow that is immutable but still unique.
How do custom allocators work in Rust? Can a Vec use something other than the global allocator?
Answer
The global allocator. On stable Rust (since 1.28), you can replace the allocator used by the whole program with #[global_allocator]. People do this to use jemalloc or mimalloc, or to add instrumentation:
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};
struct Counting;
static ALLOCATED: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOCATED.fetch_add(layout.size(), Ordering::Relaxed);
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
ALLOCATED.fetch_sub(layout.size(), Ordering::Relaxed);
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;Per-collection allocators. Vec, Box, and other collections have an allocator type parameter with a default: Vec<T, A: Allocator = Global>. You can create a collection with a specific allocator using Vec::new_in(alloc) or Box::new_in(value, alloc). However, the Allocator trait is still unstable (#![feature(allocator_api)], nightly only). On stable, crates like bumpalo and allocator-api2 provide the same idea.
&A implements Allocator when A does, so a collection can borrow an arena. The borrow checker then makes sure the vector can't outlive the arena:
#![feature(allocator_api)]
use std::alloc::Allocator;
struct MyArena { /* ... */ }
unsafe impl Allocator for MyArena { /* ... */ }
fn main() {
let arena = MyArena { /* ... */ };
let mut v: Vec<i32, &MyArena> = Vec::new_in(&arena);
v.push(1);
}When Vec doesn't touch the global allocator at all: when it was created with a different allocator via new_in, and when it doesn't need memory. Vec::new(), an empty Vec with zero capacity, and a Vec of zero-sized types never allocate.
What are codegen units and incremental compilation? How do crate boundaries affect optimization, and why would you ever prevent inlining in release builds?
Answer
Codegen units (CGUs). rustc splits each crate into several chunks and hands each one to LLVM separately, so they can be compiled in parallel. More CGUs mean faster builds but slower code, because LLVM can't inline or optimize across CGU boundaries. The defaults are 16 CGUs in release and 256 in debug. For maximum performance, people set codegen-units = 1.
Incremental compilation saves intermediate results between builds and recompiles only the parts affected by a change. It's on by default for debug builds and off for release builds.
Crate boundaries. Each crate is compiled separately, so by default a regular (non-generic) function from another crate can't be inlined. It's only available as a compiled symbol. There are three exceptions:
- Generic functions are monomorphized in the crate that uses them, so they can be inlined.
- Functions marked
#[inline]have their body available to other crates. - LTO (Link-Time Optimization) lets LLVM optimize the whole program at link time, at the cost of a slower build.
lto = "thin"is a good compromise;lto = "fat"gives the most optimization.
Why prevent inlining. Inlining isn't free. Each inlined copy grows the binary, which wastes CPU cache and can end up making the code slower. It's often worth keeping rarely used code out of the hot path:
#[cold]marks a function as unlikely to be called (error handling, for example).#[inline(never)]keeps a large function from being copied into every caller. It also makes profiles easier to read.opt-level = "s"or"z"inCargo.tomlmakes the compiler optimize for size, which makes it inline less.
Always measure: the effect of inlining is hard to predict.
What is autoref in method calls, and when does it lead to surprising behavior?
Answer
let x = String::from("hello");
x.len(); // `len` takes `&self`, but `x` is a `String`, not a referenceWhen you call a method with ., the compiler automatically adjusts the receiver to match the method's signature. For each candidate type, starting with the receiver's own type T, it tries:
Tas is (by value),&T(autoref),&mut T(autoref),
and if nothing matches, it dereferences (*T, autoderef) and repeats the process. Here String::len takes &self, so the compiler turns x.len() into String::len(&x). This also works through smart pointers: Box<String> → String → str.
The compiler picks the first match it finds. That's usually what you want, but it can surprise you when a trait is implemented for both T and &T.
Example 1: clone on a reference. If T doesn't implement Clone, then x.clone() for x: &T won't fail. It finds Clone for &T (every shared reference is Clone) and returns another &T, not a T:
struct NotClone;
fn f(x: &NotClone) {
let y = x.clone(); // y: &NotClone, only the reference was copied
}The compiler warns about this (noop_method_call), but in generic code it's easy to miss, and you'll get a confusing type error later on.
Example 2: into_iter on arrays. Before the 2021 edition, arrays didn't implement IntoIterator by value, so [1, 2, 3].into_iter() autoref'd to (&[1, 2, 3]).into_iter() and yielded &i32. When IntoIterator for arrays was added, the same code started yielding i32. The change was so disruptive that the old behavior was kept for method-call syntax in editions 2015 and 2018.
Autoref only applies to method calls. Regular function arguments are never adjusted like this:
fn takes_ref(s: &String) {}
let s = String::new();
takes_ref(&s); // OK
// takes_ref(s); // E0308: expected `&String`, found `String`