Rust structs, enums and pattern matching interview questions
Rust models data with structs and enums, and it reads that data with pattern matching. Enums in particular are more powerful than in most languages: each variant can carry its own data, and the compiler makes sure every match handles every variant. Interview questions check that you can use that to design types, replace null with Option, and read values out of nested data concisely.
The answers below cover structs and impl blocks, enums with data, Option, match and its exhaustiveness, if let and let else, derive and destructuring, with code compiled and run on rustc 1.98. Then take the free Rust diagnostic — ten questions across every Rust topic in the bank — to see which of these you can explain but not yet predict.
The questions, with answers
1.How do you define a struct and its methods in Rust?
In short: Declare the fields with struct, then add methods in an impl block; a method's first parameter, &self, &mut self or self, says how it uses the value.
A struct groups named fields, and its methods are defined separately in one or more impl blocks. A method whose first parameter is &self reads the value, &mut self changes it, and self takes ownership of it. Functions in an impl block without a self parameter are associated functions, called with Rect::new(...), which is how Rust writes constructors by convention. Fields are private to the module unless marked pub, so a type can keep its invariants by exposing only methods. Tuple structs, such as struct Meters(f64), and unit structs with no fields are also available.
struct Rect { w: u32, h: u32, } impl Rect { fn area(&self) -> u32 { self.w * self.h } } fn main() { let (w, h) = (3, 4); let r = Rect { w, h }; println!("{}", r.area()); // 12 }
2.How are Rust enums different from enums in C or Java?
In short: Each variant of a Rust enum can carry its own data, of its own shape, so an enum can represent one of several different kinds of value safely.
A C enum is a set of named integers. A Rust enum is a sum type: a value is exactly one of the variants, and each variant can hold data, such as Add(i32) below, while others, such as Neg, hold none. The only way to reach that data is to match on the variant, so code cannot read an Add's number from a Neg by mistake. Enums replace class hierarchies in many designs: a message type, a command, a node of a syntax tree. The standard library's Option and Result are ordinary enums defined this way. An enum is as large as its largest variant plus a tag.
enum Op { Add(i32), Neg, } fn ap(v: i32, op: Op) -> i32 { match op { Op::Add(n) => v + n, Op::Neg => -v, } } fn main() { let a = ap(5, Op::Add(2)); let b = ap(5, Op::Neg); println!("{a} {b}"); // 7 -5 }
3.What is Option in Rust, and why does Rust have no null?
In short: Option<T> is an enum, Some(value) or None, used wherever a value may be missing; because the absence is in the type, the compiler forces code to handle it.
In languages with null, any reference might be missing, and forgetting to check crashes the program at run time. Rust has no null. A value that may be absent has the type Option<T>, and to use the T inside, code must first handle the None case, with match, if let, or a method such as unwrap_or. Functions that can come up empty return an Option: str::find below returns Some with the byte index where the character first occurs, or None. The compiler optimises Option<&T> and Option<Box<T>> to the size of a plain pointer, using the null address for None, so safety costs no space.
let s = "hello"; let at = s.find('l'); let no = s.find('z'); println!("{:?} {:?}", at, no); // Some(2) None
4.How does match work in Rust, and what does exhaustive mean?
In short: match compares a value against patterns in order and runs the first arm that fits; exhaustive means the arms must cover every possible value, or the code does not compile.
A match expression lists arms of the form pattern => expression, tried from top to bottom, and evaluates to the chosen arm's value. Patterns can be literals, ranges such as 1..=9, enum variants, tuples and structs, and a guard, if condition, adds extra tests. The compiler requires the arms to be exhaustive, so every value of the type must match some arm; _ matches anything and is typically last. That check turns a forgotten case into a compile error and makes adding a new enum variant show every place that needs updating. Arms must all return the same type.
fn sz(n: u8) -> &'static str { match n { 0 => "none", 1..=9 => "few", _ => "many", } } fn main() { println!("{}", sz(4)); // few }
5.What are if let and let else in Rust?
In short: if let runs a block only when a value matches one pattern; let else binds a pattern's variables or, if it does not match, runs an else block that must leave the scope.
A full match is verbose when only one case matters. if let Some(p) = port { ... } runs the block with p bound when port is Some, and skips it otherwise, optionally with an else branch. let else, stable since Rust 1.65, handles the opposite shape: let Some(p) = port else { return; }; binds p for the rest of the function, and the else block runs when the pattern fails and must diverge, by returning, breaking or panicking. It keeps the main path of a function unindented, like a guard clause. while let loops while a pattern keeps matching, as when popping from a stack.
let port = Some(8080); if let Some(p) = port { println!("port {p}"); } // port 8080 let Some(p) = port else { return; }; println!("{}", p + 1); // 8081
6.What does #[derive] do in Rust?
In short: It asks the compiler to generate standard trait implementations, such as Debug, Clone, PartialEq or Hash, for a struct or enum from its fields.
Traits like Debug, which formats a value with {:?}, or PartialEq, which defines ==, have obvious implementations for most types: apply the same operation to every field. #[derive(Debug, PartialEq)] writes those implementations for you, and it works only if every field's type implements the trait too. The commonly derived traits are Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash and Default. A type that needs different behaviour, such as equality that ignores a cached field, implements the trait by hand instead. Derived PartialOrd on an enum orders variants by declaration order, and on a struct compares fields in order.
#[derive(Debug, PartialEq)] struct Id(u32); fn main() { let a = Id(4); let same = a == Id(4); println!("{a:?} {same}"); // Id(4) true }
7.How do you destructure structs and tuples in Rust?
In short: A pattern that mirrors the value's shape, such as let P { x, y: down } = p or let (a, b) = pair, binds its parts to new variables.
Patterns work in let statements, function parameters and match arms, not just in match. let P { x, y: down } = p binds the field x to a variable x and the field y to a variable named down; field shorthand requires the names to match, so renaming uses field: name. Tuples destructure by position, and .. ignores the remaining fields, as in let P { x, .. } = p. Destructuring a value moves its non-Copy parts out of it, while matching on a reference binds references. Closures and for loops accept patterns too, so for (i, v) in v.iter().enumerate() destructures each pair.
struct P { x: i32, y: i32, } fn main() { let p = P { x: 3, y: -1 }; let P { x, y: down } = p; let (a, b) = (x * 2, down); println!("{a} {b}"); // 6 -1 }
How the diagnostic asks it
One question from the Rust bank, exactly as a sitting would show it. The bank has 3 on structs, enums & matching and 30 across Rust.
What does this Rust program print?
enum Shape { Circle(f64), Rect { w: f64, h: f64 }, } fn area(s: &Shape) -> f64 { match s { Shape::Circle(r) => 3.0 * r * r, Shape::Rect { w, h } => w * h, } } fn main() { let shapes = [ Shape::Circle(1.0), Shape::Rect { w: 2.0, h: 3.0 }, ]; let total: f64 = shapes.iter().map(area).sum(); println!("{}", total); }
- 19.0
- 2It does not compile: r is a &f64 and cannot be multiplied
- 39correct
- 46
Each enum variant carries its own data, and match both picks the variant and binds its fields: Circle(r) binds the radius and Rect { w, h } binds the two fields. Matching on a &Shape binds references, &f64, but Rust implements multiplication between f64 and &f64, so the arms compile. The areas are 3.0 * 1.0 * 1.0 = 3 and 2.0 * 3.0 = 6, and sum gives 9.0. The {} format prints a float with its shortest exact representation, so 9.0 is printed as 9, unlike Python or Java, which print 9.0; {:?} would print 9.0. 6 counts only the rectangle.
Measure it
Reading answers tells you what’s true. A diagnostic tells you what you get wrong.
10 Rust questions across its topics, easy to hard, about fifteen minutes. You get a readiness figure with the arithmetic shown, the topics you missed named, and a practice set sized for today. Free: 1 diagnostic a month and 15 problems a day. No card.