Skip to content

Rust Programming Practice Test — 30 Problems

Rust Programming Practice Test — 30 Problems

Section titled “Rust Programming Practice Test — 30 Problems”

This practice test covers 30 problems across five major domains of Rust programming: Ownership and Borrowing, Lifetimes, Traits and Generics, Async Programming, and Error Handling. Each problem tests code analysis, debugging, and understanding of Rust’s safety guarantees. Work through all problems before checking the answer key.

  • Time limit: 90 minutes (3 minutes per problem)
  • Format: Code analysis and debugging — trace the output, identify errors, or select the correct implementation
  • Marking: 1 mark per problem, 30 marks total
  • Conditions: Attempt without notes. Trace code by hand.
  • After the test: Check the answer key at the bottom. Study the explanations for any problems you got wrong.
DomainProblemsMarks
Ownership and BorrowingP1–P77
LifetimesP8–P125
Traits and GenericsP13–P197
Async ProgrammingP20–P245
Error HandlingP25–P306
Total3030

What is the output of the following code?

fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{} {}", s1, s2);
}
#Option
Ahello hello
BCompiler error — value used after move
CRuntime error
Dhello
EUndefined behaviour

Correct: B (index 1)

let s2 = s1 moves the String from s1 to s2. After the move, s1 is no longer valid. The println! tries to use s1, which has been moved. Rust’s borrow checker catches this at compile time: “value used here after move”.

easy — 1 mark


What is the output?

fn main() {
let x = 5;
let y = x;
println!("{} {}", x, y);
}
#Option
A5 5
BCompiler error — value used after move
CRuntime error
D5
EUndefined behaviour

Correct: A (index 0)

i32 implements Copy, so let y = x copies the value rather than moving it. Both x and y are valid after the assignment. Copy types are bitwise-copied and always remain valid after assignment.

easy — 1 mark


What is the output?

fn add_one(x: &mut i32) {
*x += 1;
}
fn main() {
let mut val = 10;
add_one(&mut val);
add_one(&mut val);
println!("{}", val);
}
#Option
A10
B11
C12
DCompiler error
E20

Correct: C (index 2)

val starts at 10. add_one takes a mutable reference and increments the value. First call: val becomes 11. Second call: val becomes 12. Mutable references allow modifying the borrowed value through dereferencing.

easy — 1 mark


What is the output?

fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} {}", r1, r2);
let r3 = &mut s;
r3.push_str(" world");
println!("{}", r3);
}
#Option
Ahello hello hello world
Bhello hello world
CCompiler error — cannot borrow as mutable while immutable references exist
DRuntime panic
Ehello world

Correct: B (index 1)

The immutable references r1 and r2 are used in the first println! and then go out of scope (NLL — non-lexical lifetimes). After that, r3 can take a mutable borrow. The first print outputs “hello hello”, the second outputs “hello world”. The borrow checker is satisfied because r1 and r2 are no longer used when r3 is created.

medium — 1 mark


What is the output?

fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &byte) in bytes.iter().enumerate() {
if byte == b' ' {
return &s[0..i];
}
}
s
}
fn main() {
let sentence = String::from("hello world");
let word = first_word(&sentence);
println!("{}", word);
}
#Option
Ahello
Bworld
Chello world
DCompiler error
ERuntime panic

Correct: A (index 0)

first_word takes a string slice (&str) and finds the first space. It returns a slice from the start to the space index. “hello world” has a space at index 5, so it returns &sentence[0..5] which is “hello”. String slices are references to a portion of a string’s data.

easy — 1 mark


Why can you not store a reference to a field within the same struct?

#Option
ARust does not allow references at all
BThe reference could outlive the struct, causing a dangling pointer
CReferences can only point to stack-allocated data
DStructs cannot contain references
EIt would cause a stack overflow

Correct: B (index 1)

If a struct contained a reference to one of its own fields, moving the struct would invalidate the reference (the field’s address changes). The borrow checker prevents this by refusing self-referential structs. Solutions include using Box, Rc, Pin, or splitting into separate allocations.

medium — 1 mark


What is the output?

fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
let s3 = s1;
println!("{} {} {}", s1, s2, s3);
}
#Option
Ahello hello hello
BCompiler error — s1 used after move
Chello
DRuntime error
Ehello hello

Correct: B (index 1)

clone() creates a deep copy — s2 gets its own independent String. However, let s3 = s1 still moves s1. After the move, s1 is invalid. The println! fails because s1 has been moved into s3.

medium — 1 mark


What is the lifetime of the return value in this function?

fn first_char(s: &str) -> &str {
&s[0..1]
}
#Option
A'static
BThe lifetime of the input s
CA new lifetime unrelated to the input
DThe function cannot compile
ECaller’s scope

Correct: B (index 1)

Lifetime elision rules apply: when there is exactly one input reference, its lifetime is assigned to all output references. The compiler treats this as fn first_char<'a>(s: &'a str) -> &'a str. The return value lives as long as the input.

medium — 1 mark


What is the output?

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let result;
let s1 = String::from("long string");
{
let s2 = String::from("hi");
result = longest(s1.as_str(), s2.as_str());
println!("{}", result);
}
}
#Option
Along string
Bhi
CCompiler error — s2 does not live long enough
DRuntime error
Elong string hi

Correct: C (index 2)

The lifetime 'a is constrained to the shorter of the two input lifetimes. s2 lives only in the inner block, so 'a is the inner block’s lifetime. result is assigned in the inner block but used after it ends — result outlives 'a. The borrow checker rejects this.

medium — 1 mark


Which statement about 'static is correct?

#Option
A'static means the value lives for the entire program
B'static should be used for all function return types
CString literals do not have 'static lifetime
D'static prevents memory leaks
E'static is only used for global variables

Correct: A (index 0)

'static means the reference is valid for the entire program duration. String literals ("hello") have 'static lifetimes because they are embedded in the binary. Trait objects can have 'static bounds. Do not use 'static to silence lifetime errors — it indicates the data truly lives forever.

easy — 1 mark


What is the output?

struct Excerpt<'a> {
text: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence;
{
let excerpt = Excerpt { text: &novel };
first_sentence = excerpt.text;
}
println!("{}", first_sentence);
}
#Option
ACall me Ishmael. Some years ago...
BCompiler error — excerpt does not live long enough
CRuntime error
Dexcerpt
EEmpty string

Correct: A (index 0)

Excerpt borrows novel with lifetime 'a. The excerpt struct is created and its text field is copied into first_sentence (a &str reference). excerpt is dropped at the end of the inner block, but first_sentence still references novel, which lives long enough. The borrow checker allows this because first_sentence borrows from novel directly.

medium — 1 mark


What does for<'a> Fn(&'a str) -> &'a str mean?

#Option
AThe function works for one specific lifetime 'a
BThe function works for any lifetime 'a
CThe function returns a 'static string
DThe function cannot take references
EThe function is async

Correct: B (index 1)

Higher-ranked trait bounds (for<'a>) mean the function must work for all possible lifetimes, not just one specific one. This is used when passing closures that borrow references — the closure must be valid regardless of the input reference’s lifetime. It is Rust’s way of expressing “for any lifetime you give me, I can handle it.”

hard — 1 mark


What is the output?

use std::fmt::Display;
fn print_it<T: Display>(item: T) {
println!("{}", item);
}
fn main() {
print_it(42);
print_it("hello");
print_it(3.14);
}
#Option
A42 hello 3.14
BCompiler error — Display not implemented
C42
DRuntime error
Ehello

Correct: A (index 0)

The generic function print_it accepts any type T that implements Display. i32, &str, and f64 all implement Display. The compiler generates specialised code for each concrete type. All three calls print their argument.

easy — 1 mark


What is the output?

trait Greet {
fn greet(&self) -> String {
String::from("Hello!")
}
}
struct User { name: String }
impl Greet for User {
fn greet(&self) -> String {
format!("Hi, I'm {}", self.name)
}
}
fn main() {
let u = User { name: String::from("Alice") };
println!("{}", u.greet());
}
#Option
AHello!
BHi, I'm Alice
CCompiler error
DAlice
ERuntime error

Correct: B (index 1)

Greet provides a default greet method returning “Hello!”. User overrides it to return a personalised greeting. The impl Greet for User block provides a custom implementation, so calling u.greet() uses the override, not the default.

easy — 1 mark


What is the key difference between dyn Trait and generic T: Trait?

#Option
Adyn Trait is faster at runtime
BGenerics use dynamic dispatch; trait objects use static dispatch
Cdyn Trait uses dynamic dispatch; generics use static dispatch (monomorphization)
DThere is no difference
Edyn Trait cannot be used in collections

Correct: C (index 2)

Generics are monomorphized — the compiler generates type-specific code at compile time, producing zero-overhead abstractions. Trait objects (dyn Trait) use dynamic dispatch via a vtable — the method to call is determined at runtime, adding a small overhead. Trait objects enable heterogeneous collections; generics do not.

medium — 1 mark


What is the output?

trait Container {
type Item;
fn get(&self) -> &Self::Item;
}
struct Wrapper(i32);
impl Container for Wrapper {
type Item = i32;
fn get(&self) -> &i32 {
&self.0
}
}
fn main() {
let w = Wrapper(42);
println!("{}", w.get());
}
#Option
A42
BWrapper
CCompiler error
D&42
ERuntime error

Correct: A (index 0)

Associated types define a type relationship: Container has an Item type. Wrapper specifies Item = i32. The get method returns &i32, which is &self.0 = &42. The macro println!("{}", ...) dereferences and prints 42.

easy — 1 mark


Which statement about blanket implementations is correct?

#Option
AThey can only be defined in the same crate as the trait
BThey apply a trait implementation to all types satisfying a bound
CThey override existing trait implementations
DThey are only allowed for Copy types
EThey require unsafe blocks

Correct: B (index 1)

A blanket implementation provides a trait for all types that meet certain criteria: impl<T: Display> ToString for T { ... }. This gives every Display type a to_string() method. Blanket implementations cannot be overridden — they apply globally.

medium — 1 mark


Why does Rust enforce the orphan rule?

#Option
ATo prevent trait name collisions
BTo ensure at least one of the trait or type is defined in the current crate
CTo prevent implementing traits for primitive types
DTo enforce memory safety
ETo prevent multiple trait implementations

Correct: B (index 1)

The orphan rule states: you can implement a trait for a type only if either the trait or the type is defined in the current crate. This prevents conflicting implementations — without it, two crates could implement the same trait for the same type differently, creating ambiguity.

medium — 1 mark


What is PhantomData used for?

#Option
ATo allocate memory for unused fields
BTo indicate that a type parameter is used for type-level reasoning without runtime cost
CTo make a struct thread-safe
DTo prevent the struct from being instantiated
ETo implement Default automatically

Correct: B (index 1)

PhantomData<T> tells the compiler that the type logically “owns” or “uses” a T, even though it contains no T data. It is used for variance annotations, drop check, and lifetime enforcement. It has zero size at runtime.

hard — 1 mark


What is the output?

async fn hello() -> String {
String::from("hello")
}
#[tokio::main]
async fn main() {
let result = hello().await;
println!("{}", result);
}
#Option
Ahello
BNothing — the future is never polled
CCompiler error
DRuntime panic
EFuture { output: String }

Correct: A (index 0)

hello() returns a future. .await drives the future to completion, running it on the Tokio runtime. The future returns String::from("hello"). result is String::from("hello"), which is printed.

easy — 1 mark


What happens when you call an async function without .await?

async fn do_work() -> i32 {
println!("working");
42
}
fn main() {
do_work();
}
#Option
Aworking is printed and 42 is returned
BNothing happens — the future is created but never polled
CCompiler error — must await async functions
DRuntime error
Eworking is printed but nothing is returned

Correct: B (index 1)

Async functions are lazy — calling do_work() creates a future but does not execute the body. The future must be .awaited or explicitly polled to run. The compiler allows this (the future is dropped immediately), and “working” is never printed.

medium — 1 mark


What is the output?

use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
sleep(Duration::from_millis(10)).await;
"task done"
});
let result = handle.await.unwrap();
println!("{}", result);
}
#Option
Atask done
BNothing — the task runs in the background and is never joined
CCompiler error
DRuntime panic
Ehandle

Correct: A (index 0)

tokio::spawn launches an async task on the runtime. handle.await waits for the task to complete and returns its result. The task sleeps 10ms, then returns “task done”. unwrap() extracts the value from the Result. The output is “task done”.

easy — 1 mark


What does tokio::select! do?

#Option
ARuns all futures to completion in parallel
BRaces multiple futures and completes when the first one finishes
CSelects a random future to run
DRuns futures sequentially
EBlocks until all futures are ready

Correct: B (index 1)

tokio::select! concurrently polls multiple futures. When the first future completes, its branch executes and the other futures are dropped. This is useful for implementing timeouts, cancellation, or handling whichever of several events happens first.

medium — 1 mark


Which statement about Send and Sync is correct?

#Option
ASend means a type can be shared between threads
BSync means a type can be moved between threads
CSend means a type can be moved to another thread; Sync means a type can be shared between threads
DThey are only needed for async code
EThey are optional for thread safety

Correct: C (index 2)

Send means T can be transferred to another thread (all fields are also Send). Sync means &T can be shared between threads (all fields are also Sync). Types like Rc are neither Send nor Sync (not thread-safe). Arc is both. These are marker traits — they have no methods.

medium — 1 mark


What is the output?

use std::fs;
fn read_file(path: &str) -> Result<String, std::io::Error> {
let content = fs::read_to_string(path)?;
Ok(content)
}
fn main() {
match read_file("nonexistent.txt") {
Ok(_) => println!("success"),
Err(e) => println!("error: {}", e),
}
}
#Option
Asuccess
Berror: No such file or directory
CCompiler error
DRuntime panic
ENone

Correct: B (index 1)

? propagates the error if read_to_string fails. Since “nonexistent.txt” does not exist, read_to_string returns Err(io::Error). The ? operator returns early with that error. The match in main hits the Err arm, printing the error message.

easy — 1 mark


What is the output?

fn find_first_even(nums: &[i32]) -> Option<&i32> {
nums.iter().find(|&&n| n % 2 == 0)
}
fn main() {
let nums = vec![1, 3, 4, 5];
match find_first_even(&nums) {
Some(n) => println!("found: {}", n),
None => println!("none"),
}
}
#Option
Afound: 4
Bnone
Cfound: 3
DCompiler error
ERuntime panic

Correct: A (index 0)

find returns Some(&n) for the first element satisfying the predicate. The first even number is 4. Some(4) matches the Some arm, printing “found: 4”.

easy — 1 mark


What is the difference between unwrap() and expect()?

#Option
Aunwrap() panics with a default message; expect() allows a custom message
Bexpect() is safer and never panics
Cunwrap() returns None on failure
DThey are identical
Eexpect() is for Option; unwrap() is for Result

Correct: A (index 0)

Both panic on Err or None. unwrap() panics with “called Result::unwrap() on an Err value”. expect("msg") panics with “msg: called Result::unwrap() on an Err value”. Use expect() in production code to provide meaningful panic messages.

easy — 1 mark


What is the idiomatic way to create a custom error type in Rust?

#Option
AUse panic! for all errors
BDefine an enum implementing std::error::Error (or use thiserror)
CUse String as the error type everywhere
DReturn () on error
EUse unsafe blocks to bypass error handling

Correct: B (index 1)

Idiomatic Rust uses enums for error types. Each variant represents a different failure mode. The thiserror crate derives std::error::Error, Display, and From automatically. For application-level errors, anyhow provides a dynamic error type. Never use panic! for recoverable errors.

medium — 1 mark


What does the ? operator do with error types?

#Option
AIt ignores the error
BIt automatically converts the error using From trait implementations
CIt panics with the error
DIt returns a default value
EIt logs the error and continues

Correct: B (index 1)

When ? encounters an error, it calls From::from to convert between error types. If a function returns Result<T, MyError> and an inner call returns Result<T, io::Error>, the ? operator converts io::Error into MyError via the From impl. This enables clean error propagation across different error types.

medium — 1 mark


What is the output?

use std::convert::TryFrom;
#[derive(Debug)]
struct Score {
value: u8,
}
impl TryFrom<i32> for Score {
type Error = String;
fn try_from(val: i32) -> Result<Self, Self::Error> {
if val >= 0 && val <= 100 {
Ok(Score { value: val as u8 })
} else {
Err(format!("invalid score: {}", val))
}
}
}
fn main() {
let s1 = Score::try_from(85);
let s2 = Score::try_from(150);
println!("{:?} {:?}", s1, s2);
}
#Option
AOk(Score { value: 85 }) Err("invalid score: 150")
BScore(85) Score(150)
CCompiler error
DRuntime panic
EOk(85) Ok(150)

Correct: A (index 0)

TryFrom defines fallible conversions. Score::try_from(85) succeeds (85 is in 0..=100), returning Ok(Score { value: 85 }). Score::try_from(150) fails (150 > 100), returning Err("invalid score: 150"). The Debug output shows both results.

medium — 1 mark


Click to reveal the answer key
QuestionAnswerQuestionAnswerQuestionAnswer
P1BP11AP21B
P2AP12BP22A
P3CP13AP23B
P4BP14BP24C
P5AP15CP25B
P6BP16AP26A
P7BP17BP27A
P8BP18BP28B
P9CP19BP29B
P10AP20AP30A

DifficultyCount
Easy13
Medium15
Hard2


  1. Trace ownership by hand. Follow each value through moves, borrows, and drops. The borrow checker is strict but predictable.
  2. Know the lifetime rules. Every reference has a lifetime. The compiler infers most; understand when annotations are needed.
  3. Understand the “why”. Rust’s design (ownership, lifetimes, no null) has clear rationale for preventing memory bugs at compile time.
  4. Practise reading compiler errors. Rust’s error messages are exceptionally helpful — learn to extract the key information.
  5. Retake after one week. Ownership and lifetimes require rewiring your mental model — spaced repetition is essential.

Last updated: 24 July 2026

Written by Wyatt. For questions or feedback, visit wyattau.com.