Rust · Interview
Rust interview questions
Twelve questions of the kind asked in Rust interviews at junior, mid and senior level: ownership and borrowing, the String and &str split, iterators, pattern matching, Result and Option, and the trade-off between generics and trait objects. Try to answer each one before opening the answer; for the output questions, write down what you expect and only then compare. Every code snippet here is compiled and run with rustc 1.94 (edition 2021) and the outputs shown are what it printed.
ConceptJuniorWhat is the difference between String and &str, and which one should a function that only reads text take as its parameter?
String and &str, and which one should a function that only reads text take as its parameter?Answer
String is an owned, growable buffer on the heap: the variable that holds it is responsible for freeing it, and it can be pushed to and mutated. &str is a borrowed view of UTF-8 text, a pointer plus a length, into text that something else owns: a String, a literal baked into the binary, or a slice of either. A function that only reads text should take &str. Callers with a String pass &s and deref coercion produces the &str for free, callers with a literal pass it directly, and nothing is copied or allocated. Taking String forces every caller to give up or clone their value, and taking &String is needlessly restrictive because a literal or a substring cannot be passed. Take String only when the function needs to own the text, for example to store it in a struct that outlives the call.
Predict the outputJuniorWhat does this program print, and why is the first line not 7?
fn main() {
let count = 7;
let count = count / 2;
let count = count as f64 * 2.0;
println!("{}", count);
let label = "km";
let label = label.len();
println!("{}", label);
}
Answer
It prints 6 and then 2. count starts as the integer 7, and count / 2 is integer division, which gives 3 with the remainder discarded. The next let count does not modify the old variable; it shadows it with a brand-new binding, and shadowing is allowed to change the type, so this binding is an f64 holding 3.0 * 2.0 = 6.0. Rust's {} formatting prints a whole-number float without a decimal point, hence 6. The label lines show the same mechanism: a &str is shadowed by a usize, the byte length of "km", which is 2. An interviewer is listening for the distinction between shadowing (a new binding, type may change) and mutation with let mut (same binding, type fixed).
It prints
6 2
ConceptJuniorWhy are Rust variables immutable unless you write mut, and how is a let binding different from a const?
mut, and how is a let binding different from a const?Answer
Immutability by default makes the data flow of a program readable and checkable: when you see a plain let, you know that value will not change for the rest of its scope, and the compiler can rely on the same fact. That guarantee is what makes shared references safe: a &T promises the value behind it is not being changed, and only a binding declared mut can hand out a &mut T. Writing mut is therefore a deliberate, visible signal that state changes here. A const is different in kind: it is a compile-time constant with a mandatory type annotation, its value must be computable at compile time, and it is inlined wherever it is used rather than living at one address. A let binding is a runtime value, computed when the statement runs, even if it never changes afterwards.
CodingJuniorRead one line from standard input and print how many vowels (a, e, i, o, u, in either case) it contains.
Answer
The program locks stdin, takes the first line, and hands it to count_vowels, which walks the text with chars(), keeps the vowels with filter, and counts what is left. matches! is a macro that returns true when a value fits a pattern, so matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u') replaces a chain of five comparisons and handles case in one place. The line "Harbour ferry at noon" has the vowels a, o, u, e, a, o, o: seven. An interviewer is listening for two things: that the candidate iterates with chars() instead of trying to index the string (Rust strings are UTF-8 and cannot be indexed by position), and that input is read through a locked handle rather than assumed to be a command-line argument.
use std::io::{self, BufRead};
fn count_vowels(text: &str) -> usize {
text.chars()
.filter(|c| matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u'))
.count()
}
fn main() {
let stdin = io::stdin();
let line = stdin.lock().lines().next().unwrap().unwrap();
println!("{}", count_vowels(&line));
}
Input given to the program: Harbour ferry at noon
Output
7
DebuggingMid-levelThis program is meant to print platform 4 has 10 chars but does not compile. What is wrong, and what is the idiomatic fix?
platform 4 has 10 chars but does not compile. What is wrong, and what is the idiomatic fix?The code with the bug
fn label_length(label: String) -> usize {
label.len()
}
fn main() {
let label = String::from("platform 4");
let n = label_length(label);
println!("{} has {} chars", label, n);
}
Answer
label_length(label) moves the String into the function, because String is not Copy: the function now owns the buffer and frees it when it returns. Back in main, label is no longer valid, so the println! is a use after move and the compiler reports error E0382, borrow of moved value. The fix is to make the function borrow instead of own. It only reads the text, so it should take &str, and the caller passes &label. That keeps main as the owner, costs no copy, and also lets the function accept string literals. Two alternatives an interviewer may probe: label.clone() compiles but allocates a copy for no reason, and &String works but is less general than &str. The underlying rule is that every value has exactly one owner, which is what lets Rust free memory without a garbage collector.
fn label_length(label: &str) -> usize {
label.len()
}
fn main() {
let label = String::from("platform 4");
let n = label_length(&label);
println!("{} has {} chars", label, n);
}
Output
platform 4 has 10 chars
CodingMid-levelWrite fn second_largest(values: &[i32]) -> Option<i32> that returns the second largest distinct value in a slice, or None when there is no such value. Show it on [40, 12, 40, 33], [5, 5, 5], an empty slice and [-2, -9].
fn second_largest(values: &[i32]) -> Option<i32> that returns the second largest distinct value in a slice, or None when there is no such value. Show it on [40, 12, 40, 33], [5, 5, 5], an empty slice and [-2, -9].Answer
One pass with two Option<i32> values does it in O(n) without sorting or allocating. For each value there are three cases, expressed as match arms with guards: it beats the current largest, so the old largest becomes the second; it equals the largest, so nothing changes (that is what makes the answer distinct: [40, 12, 40, 33] gives 33, not 40); or it is smaller, so it may become the new second. Returning Option makes the no-answer cases explicit: an empty slice and [5, 5, 5] both give None, and the caller must decide what that means. Using Option rather than a sentinel such as i32::MIN is also why negative inputs work: [-2, -9] correctly gives Some(-9). An interviewer listens for the duplicate handling, for not sorting when a linear scan suffices, and for map_or or a match on the second value instead of an unwrap.
fn second_largest(values: &[i32]) -> Option<i32> {
let mut largest: Option<i32> = None;
let mut second: Option<i32> = None;
for &v in values {
match largest {
Some(l) if v > l => {
second = largest;
largest = Some(v);
}
Some(l) if v == l => {}
Some(_) => {
if second.map_or(true, |s| v > s) {
second = Some(v);
}
}
None => largest = Some(v),
}
}
second
}
fn main() {
println!("{:?}", second_largest(&[40, 12, 40, 33]));
println!("{:?}", second_largest(&[5, 5, 5]));
println!("{:?}", second_largest(&[]));
println!("{:?}", second_largest(&[-2, -9]));
}
Output
Some(33) None None Some(-9)
Predict the outputMid-levelWhat does this program print? Explain each of the three lines.
fn main() {
let stock = [4, 0, 9, 2, 0, 7];
let restock: Vec<usize> = stock
.iter()
.enumerate()
.filter(|(_, &q)| q == 0)
.map(|(i, _)| i)
.collect();
println!("{:?}", restock);
let total: i32 = stock.iter().skip(2).take(3).sum();
println!("{}", total);
println!("{}", stock.iter().any(|&q| q > 8));
}
Answer
Line one is [1, 4]. enumerate pairs each element with its index, filter receives a reference to that pair, and the pattern (_, &q) reaches through the reference to copy the quantity, so the closure keeps the pairs whose quantity is 0: indexes 1 and 4. map throws the quantity away and collect builds the Vec<usize>. Line two is 11: skip(2) drops 4 and 0, take(3) keeps 9, 2 and 0, and sum adds them. Line three is true: any stops at the first element that satisfies the closure, which is 9. The point an interviewer wants to hear is that iterator adaptors are lazy, so nothing runs until collect, sum or any pulls values through the chain, and that any short-circuits.
It prints
[1, 4] 11 true
ConceptMid-levelWhat does the ? operator do, and what does it require of the function it is used in?
? operator do, and what does it require of the function it is used in?Answer
Applied to a Result, ? unwraps the Ok value and lets execution continue, or returns early from the enclosing function with the Err. Applied to an Option, it unwraps Some or returns None. On the error path it also calls From::from on the error, so a function declared to return Result<T, MyError> can use ? on a call that fails with io::Error as long as impl From<io::Error> for MyError exists. That conversion is what lets one function collect several underlying error types under a single error enum. The requirement is that the enclosing function returns a compatible type: Result for ? on a Result, Option for ? on an Option, so it cannot be used in a main that returns (); declare main as -> Result<(), E> first. ? exists to remove the nested match boilerplate of propagating errors while keeping every fallible call visibly marked at the call site.
DebuggingSeniorThe intent is to append the doubled value of every entry above 4 to the same vector and print [3, 8, 5, 16, 10]. The code does not compile. Explain the error and fix it.
[3, 8, 5, 16, 10]. The code does not compile. Explain the error and fix it.The code with the bug
fn main() {
let mut queue = vec![3, 8, 5];
for q in &queue {
if *q > 4 {
queue.push(q * 2);
}
}
println!("{:?}", queue);
}
Answer
for q in &queue takes a shared borrow of the vector that lives for the whole loop, and queue.push needs a mutable borrow, so the compiler reports E0502: cannot borrow as mutable because it is also borrowed as immutable. This is not pedantry. push may reallocate the vector's buffer, which would leave the iterator pointing at freed memory; in languages without this check that is a classic iterator-invalidation bug. The fix separates the read from the write: build the additions into their own Vec with a filter/map chain, which finishes borrowing queue, then extend the original. An index loop over a snapshot of the length, let n = queue.len(); for i in 0..n { ... }, also works because each queue[i] borrow ends immediately. A senior candidate should name the rule behind the error, aliasing XOR mutability, and explain why it prevents undefined behaviour rather than just how to appease the compiler.
fn main() {
let mut queue = vec![3, 8, 5];
let extra: Vec<i32> = queue.iter().filter(|&&q| q > 4).map(|q| q * 2).collect();
queue.extend(extra);
println!("{:?}", queue);
}
Output
[3, 8, 5, 16, 10]
Predict the outputSeniorWhat does this program print? Pay attention to the order of the arms and to the guards.
fn classify(reading: (i32, bool)) -> &'static str {
match reading {
(v, true) if v < 0 => "sensor fault",
(0, _) => "idle",
(v, _) if v % 2 == 0 => "even",
(1..=9, false) => "small odd",
_ => "other",
}
}
fn main() {
let readings = [(0, true), (-3, true), (-3, false), (4, false), (7, false), (7, true), (11, false)];
for r in readings {
println!("{}", classify(r));
}
}
Answer
match tries the arms from top to bottom and takes the first whose pattern matches and whose guard is true; a failing guard moves on to the next arm. (0, true): the first arm's guard v < 0 fails, the second arm matches, idle. (-3, true): pattern and guard both hold, sensor fault. (-3, false): the first arm needs true, the second needs 0, and -3 % 2 is -1 in Rust because the remainder takes the sign of the dividend, so the even guard fails; the range 1..=9 excludes -3; other. (4, false): even. (7, false): 7 % 2 is 1, so it falls to the range arm, small odd. (7, true): the range arm requires false, other. (11, false): outside the range, other. Two things a senior candidate should add: guards do not count towards exhaustiveness, which is why the _ arm is required even though it looks unreachable, and the sign of % on negatives is a common source of wrong parity checks.
It prints
idle sensor fault other even small odd other other
ConceptSeniorWhen would you write fn total_area(shapes: &[Box<dyn Shape>]) rather than fn area<T: Shape>(shape: &T), and what does each choice cost?
fn total_area(shapes: &[Box<dyn Shape>]) rather than fn area<T: Shape>(shape: &T), and what does each choice cost?Answer
A generic function with a trait bound is monomorphised: the compiler emits a separate copy of the function for every concrete type it is called with, so calls are direct, can be inlined, and cost nothing at runtime. The price is code size, longer compile times, and one constraint: every element of a Vec<T> must be the same T. A trait object, dyn Shape behind a Box or a reference, is a pointer to the value plus a pointer to a vtable, so one copy of the code handles any implementing type and a single collection can mix circles and rectangles. That flexibility costs an indirect call per method, prevents inlining, and requires the trait to be dyn-compatible (no generic methods, no methods returning Self by value without a where Self: Sized bound). The practical rule is to default to generics for performance-sensitive or single-type code, and reach for dyn when the set of types is only known at runtime, when you need a heterogeneous collection, or when you want to keep compile times and binary size down for a large API.
ScenarioSeniorYou are building a command-line tool that loads a settings file with dozens of keys. Some keys are optional, some are required, and any value may be malformed. How would you design the error handling so the user gets a useful message and the code stays readable?
Answer
Start by separating the two kinds of absence. An optional key that is missing is a normal outcome, so its lookup returns Option and the caller applies a default with unwrap_or. A required key that is missing, or any value that fails to parse, is an error, so those paths return Result. Define one error enum for the module, for example ConfigError::Missing(key), ConfigError::Invalid { key, reason } and ConfigError::Io(io::Error), implement Display so each variant prints a sentence with the key name, implement std::error::Error, and add From<io::Error> so the ? operator converts file errors automatically. Each key then gets a small function such as fn port(&self) -> Result<u16, ConfigError> that parses with ?, and the top-level loader is a straight sequence of ? calls instead of nested matches. Reserve panic! and unwrap for programmer errors, never for user input: a malformed file must produce a message and a non-zero exit code, which main can do by returning Result<(), ConfigError> or by matching once and calling std::process::exit. Finally, test the error paths as carefully as the happy path, since the message a user sees on a bad file is part of the tool's interface. In larger projects the ecosystem has crates that generate the boilerplate, but the design is the same with the standard library alone.
How this page was checked. Every program on it was run with rustc 1.94 at build time by the publishing checks, and the output shown is what it printed. Running Rust inside the browser is not available yet, so the Run button is absent rather than pretending; copy the code and run it with rustc 1.94 locally.