🦀Tutorial: Rust Part 3

Welcome to the third tutorial in the Rust series! This guide builds on Tutorials 1 and 2, covering the topics from Chapter 8 that are essential for structuring real-world Rust applications: organising code into modules, writing closures fluently, building powerful iterator pipelines, understanding lifetime annotations, and using trait objects for runtime polymorphism.
Work through the exercises below. Each question introduces one new concept and builds on the one before it.
Question 0. Preparation
Section titled “Question 0. Preparation”Create a new project for this tutorial:
cargo new een1097_tutorial3Open the project folder in VSCode. For each question, replace the contents of src/main.rs entirely unless the question says otherwise.
Question 1. Modules and Visibility
Section titled “Question 1. Modules and Visibility”Rust organises code into named scopes called modules using the mod keyword. Everything inside a module is private by default; add pub to make items accessible to code outside that module.
Place the following code in main.rs and run it:
mod sensor { pub struct Reading { pub id: u32, pub value: f32, }
pub fn describe(r: &Reading) -> String { format!("Sensor {}: {:.1}", r.id, r.value) }
fn internal_check() { println!("Internal check passed."); }}
fn main() { let r = sensor::Reading { id: 1, value: 23.7 }; println!("{}", sensor::describe(&r));}Expected output:
Sensor 1: 23.7-
Add a second public function
is_critical(r: &Reading) -> boolinside thesensormodule that returnstrueifvalueis above 30.0. Call it frommainwith the reading above and print a message such as"Critical: true"or"Critical: false". -
Try adding
sensor::internal_check();inmain. What error does the compiler produce? Add a comment in the code explaining why, then remove the line. -
The
Readingstruct haspubon its fields. Remove thepubfrom thevaluefield and try to accessr.valuedirectly inmain. What happens? Restorepubon the field once you have noted the error.
Question 2. The use Keyword and Nested Modules
Section titled “Question 2. The use Keyword and Nested Modules”The use keyword creates a local shortcut to any item, so you do not have to write the full module path every time.
Adapt the code from Question 1:
- Add a nested module inside
sensor:
mod sensor { pub mod types { pub struct Reading { pub id: u32, pub value: f32, } }
pub fn describe(r: &types::Reading) -> String { format!("Sensor {}: {:.1}", r.id, r.value) }}The full path to the struct is now sensor::types::Reading. Confirm the code compiles and produces the same output as Question 1.
-
Add
use sensor::types::Reading;at the top ofmain(after themodblock). Confirm that you can now writeReading { ... }inmainwithout the full path. -
Add a type alias import:
use sensor::types::Reading as SensorReading;. Create one instance of each alias and pass both tosensor::describe. Print both results to show they refer to the same type.
Question 3. Closures and Captured Environment
Section titled “Question 3. Closures and Captured Environment”A closure is an anonymous function written with |parameters| body syntax that can capture variables from the surrounding scope. Unlike a plain function, it “remembers” the context where it was defined.
Place the following code in main.rs:
fn apply<F: Fn(f32) -> f32>(f: F, value: f32) -> f32 { f(value)}
fn main() { let offset = 2.5_f32; let adjust = |x| x + offset; // captures `offset` by immutable borrow
println!("{}", apply(adjust, 10.0)); // Expected: 12.5 println!("{}", apply(adjust, 20.0)); // Expected: 22.5 println!("offset is still: {}", offset); // borrow ended — offset is available}-
Verify the output. Note that
offsetis still accessible after the closure calls because the closure only borrowed it. -
Add a second closure
scalethat multiplies its input byoffset. Callapply(scale, 4.0)andapply(scale, 7.0)and print the results (expected: 20.0 and 35.0). -
What does the trait bound
F: Fn(f32) -> f32onapplyexpress? Doesapplyneed to know aboutoffset? Add a short comment in the code answering both questions.
Question 4. The move Keyword
Section titled “Question 4. The move Keyword”By default, closures capture variables by reference. The move keyword forces the closure to take ownership of all captured variables — essential when the closure must outlive the scope where it was defined (for example, when returning it from a function).
Place the following code in main.rs:
fn make_threshold_check(threshold: f32) -> impl Fn(f32) -> bool { // Without `move`, threshold would be a reference to a local variable // that is dropped when make_threshold_check returns. move |value| value > threshold}
fn main() { let above_30 = make_threshold_check(30.0); let above_50 = make_threshold_check(50.0);
println!("25.0 above 30? {}", above_30(25.0)); // false println!("45.0 above 30? {}", above_30(45.0)); // true println!("60.0 above 50? {}", above_50(60.0)); // true}-
Verify the output.
-
Remove the
movekeyword and try to compile. What error does the compiler produce, and why? Add a comment explaining the reason, then restoremove. -
Write a second factory function
make_sensor_label(name: String) -> impl Fn(f32) -> Stringthat returns a closure formatting a reading as"<name>: <value:.1>". The parameter must be an ownedString(not&str) so it can be moved into the closure. Test it:
let label_temp = make_sensor_label("Temperature".to_string());let label_hum = make_sensor_label("Humidity".to_string());println!("{}", label_temp(22.4)); // Expected: Temperature: 22.4println!("{}", label_hum(58.0)); // Expected: Humidity: 58.0Question 5. The Three Closure Traits
Section titled “Question 5. The Three Closure Traits”Every Rust closure automatically implements one or more of three traits based on how it uses its captured variables:
| Trait | Captures | Can be called |
|---|---|---|
Fn | Immutable borrow | Any number of times |
FnMut | Mutable borrow | Any number of times |
FnOnce | By ownership | Exactly once |
-
Create a closure
read_onlythat capturesdata: Vec<i32>by immutable borrow and returnsdata.len(). Call it twice and confirm both calls succeed. (This implementsFn.) -
Create a mutable variable
count: i32 = 0and a closuremut incrementthat mutatescountby adding 1 and returns the new value. Call it three times, printing the result each time. After the loop, calldrop(increment)to release the mutable borrow, then print the final value ofcount. (This implementsFnMut.) -
Create
data2: Vec<i32> = vec![10, 20, 30]and a closureconsume_datawhose body returnsdata2(moving it out). Call it once and print the retrieved vec. Add a commented-out second call with a comment explaining why it would not compile. (This implementsFnOnce.)
Question 6. Advanced Iterator Pipelines
Section titled “Question 6. Advanced Iterator Pipelines”Tutorial 2 introduced map and filter. This question adds fold, enumerate, and zip to build more powerful data processing pipelines typical of edge sensor applications.
Starting data:
let temperatures: Vec<f32> = vec![18.2, 22.4, 19.8, 35.1, 21.3, 30.0, 17.9];-
Use
iter().filter(|&&x| x > 20.0).count()to count how many readings exceed 20.0 and print the result. -
Use
iter().copied().fold(0.0f32, |acc, x| acc + x)to compute the sum, then divide by the length to get the average. Print it to one decimal place. -
Use
iter().enumerate()to print each reading with its zero-based index in the format:
[0] 18.2°C[1] 22.4°C...- Create a second
Vec<f32>:
let humidity: Vec<f32> = vec![55.0, 60.1, 58.3, 70.2, 62.0, 67.5, 54.8];Use temperatures.iter().zip(humidity.iter()) to print paired readings:
Temp: 18.2 Humidity: 55.0Temp: 22.4 Humidity: 60.1...Question 7. Custom Iterator
Section titled “Question 7. Custom Iterator”Any struct that implements the Iterator trait — which requires only a single next() method — gains the full suite of adaptor methods (map, filter, fold, etc.) automatically.
Implement a SlidingWindowAverage iterator that yields the moving average for each valid window of a fixed size:
struct SlidingWindowAverage<'a> { data: &'a [f32], window: usize, pos: usize,}
impl<'a> SlidingWindowAverage<'a> { fn new(data: &'a [f32], window: usize) -> Self { SlidingWindowAverage { data, window, pos: 0 } }}
impl<'a> Iterator for SlidingWindowAverage<'a> { type Item = f32;
fn next(&mut self) -> Option<Self::Item> { // Task 1: complete this method todo!() }}
fn main() { let temps = [20.0f32, 22.0, 19.0, 25.0, 21.0, 23.0]; let averages: Vec<f32> = SlidingWindowAverage::new(&temps, 3).collect(); for avg in &averages { print!("{:.2} ", avg); } println!(); // Expected: 20.33 22.00 21.67 23.00}-
Replace
todo!()with a working implementation: ifpos + window <= data.len(), compute the mean ofdata[pos..pos+window], advanceposby 1, and returnSome(mean); otherwise returnNone. -
The struct carries a lifetime parameter
'a. What does it guarantee? Add a one-sentence comment to the struct definition explaining it. -
After your
collect()call works, add a second use of the iterator that chains.filter(|avg| *avg > 21.0)beforecollect(). Print the filtered results.
Question 8. Lifetimes: Annotating References
Section titled “Question 8. Lifetimes: Annotating References”Rust’s borrow checker tracks how long each reference lives. When a function takes references as inputs and returns one, the compiler needs to know how the output lifetime relates to the inputs. Lifetime annotations supply this information.
Consider this function:
fn longest(x: &str, y: &str) -> &str { // compile error: missing lifetime if x.len() > y.len() { x } else { y }}
fn main() { let s1 = String::from("a long string"); let result; { let s2 = String::from("xyz"); result = longest(s1.as_str(), s2.as_str()); println!("Longest: {}", result); }}-
The function does not compile. Add the lifetime annotation
'ato fix it:fn longest<'a>(x: &'a str, y: &'a str) -> &'a str. Add a comment explaining what'atells the borrow checker. -
Move the
println!to after the closing}of the inner scope (sos2has already been dropped). What error does the compiler produce, and why does it reject this? -
Restore the
println!inside the inner scope. Addlet s3 = String::from("medium length");outside the inner scope and calllongest(s1.as_str(), s3.as_str()), using the result after the inner scope ends. Confirm this compiles and explain why it is valid: boths1ands3live long enough.
Question 9. Trait Objects and Dynamic Dispatch (Advanced)
Section titled “Question 9. Trait Objects and Dynamic Dispatch (Advanced)”Generic bounds such as <T: Sensor> use static dispatch: the compiler generates a separate version of the function for each concrete type. A trait object &dyn Trait uses dynamic dispatch: a single pointer pairs the value with a vtable that resolves the correct method at runtime. This allows values of different concrete types to live in the same collection.
-
Define a trait
Sensorwith two methods:fn read(&self) -> f32andfn label(&self) -> &str. -
Implement
Sensorfor two structs:
struct TemperatureSensor { name: String, value: f32 }struct HumiditySensor { name: String, value: f32 }- Create a
Vec<Box<dyn Sensor>>containing oneTemperatureSensorand oneHumiditySensor. Iterate over it and print a summary line for each:
Temperature-1: 22.4Humidity-1: 58.0-
Explain in a comment why
Vec<T>(with a generic bound) would not work here: aVec<T>must hold values of a single concrete type, but aVec<Box<dyn Sensor>>can hold any mix of types that implementSensor. -
Add a third struct
PressureSensorthat implementsSensorand add an instance to the same vec. Confirm that no changes are needed to the iteration loop.
Question 10. Custom Error Types (Advanced)
Section titled “Question 10. Custom Error Types (Advanced)”Returning a String as the error type works in simple programs, but in larger applications a dedicated error enum is preferred: each variant precisely describes a distinct failure mode, the compiler exhaustively checks all cases in a match, and the Display trait provides readable messages.
- Define the following error enum:
#[derive(Debug)]enum SensorError { ReadTimeout, ValueOutOfRange(f32), DeviceNotFound(String),}-
Implement
std::fmt::DisplayforSensorError:ReadTimeoutprints"Sensor read timed out"ValueOutOfRange(v)prints"Reading out of range: <v:.1>"DeviceNotFound(id)prints"Device not found: <id>"
-
Add
impl std::error::Error for SensorError {}(an empty body is sufficient — the trait has no required methods whenDisplayandDebugare already implemented). -
Write a function
read_sensor(id: &str, raw: f32) -> Result<f32, SensorError>that:- Returns
Err(SensorError::DeviceNotFound("(no id)".to_string()))ifidis empty - Returns
Err(SensorError::ValueOutOfRange(raw))ifraw < 0.0orraw > 100.0 - Returns
Ok(raw)otherwise
- Returns
-
In
main, callread_sensorwith the following inputs and usematchto print the outcome for each:
let cases = [ ("Sensor-A", 25.0_f32), ("Sensor-B", -5.0), ("", 50.0), ("Sensor-D", 110.0),];Expected output:
OK: Sensor-A = 25.0Error: Reading out of range: -5.0Error: Device not found: (no id)Error: Reading out of range: 110.0Solutions
Section titled “Solutions”Here are the video solutions: please do not watch these solutions without having attempted the questions first.
© 2026 Derek Molloy, Dublin City University. All rights reserved.