Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

🦀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.


Create a new project for this tutorial:

Terminal window
cargo new een1097_tutorial3

Open the project folder in VSCode. For each question, replace the contents of src/main.rs entirely unless the question says otherwise.


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
  1. Add a second public function is_critical(r: &Reading) -> bool inside the sensor module that returns true if value is above 30.0. Call it from main with the reading above and print a message such as "Critical: true" or "Critical: false".

  2. Try adding sensor::internal_check(); in main. What error does the compiler produce? Add a comment in the code explaining why, then remove the line.

  3. The Reading struct has pub on its fields. Remove the pub from the value field and try to access r.value directly in main. What happens? Restore pub on 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:

  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.

  1. Add use sensor::types::Reading; at the top of main (after the mod block). Confirm that you can now write Reading { ... } in main without the full path.

  2. Add a type alias import: use sensor::types::Reading as SensorReading;. Create one instance of each alias and pass both to sensor::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
}
  1. Verify the output. Note that offset is still accessible after the closure calls because the closure only borrowed it.

  2. Add a second closure scale that multiplies its input by offset. Call apply(scale, 4.0) and apply(scale, 7.0) and print the results (expected: 20.0 and 35.0).

  3. What does the trait bound F: Fn(f32) -> f32 on apply express? Does apply need to know about offset? Add a short comment in the code answering both questions.


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
}
  1. Verify the output.

  2. Remove the move keyword and try to compile. What error does the compiler produce, and why? Add a comment explaining the reason, then restore move.

  3. Write a second factory function make_sensor_label(name: String) -> impl Fn(f32) -> String that returns a closure formatting a reading as "<name>: <value:.1>". The parameter must be an owned String (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.4
println!("{}", label_hum(58.0)); // Expected: Humidity: 58.0

Every Rust closure automatically implements one or more of three traits based on how it uses its captured variables:

TraitCapturesCan be called
FnImmutable borrowAny number of times
FnMutMutable borrowAny number of times
FnOnceBy ownershipExactly once
  1. Create a closure read_only that captures data: Vec<i32> by immutable borrow and returns data.len(). Call it twice and confirm both calls succeed. (This implements Fn.)

  2. Create a mutable variable count: i32 = 0 and a closure mut increment that mutates count by adding 1 and returns the new value. Call it three times, printing the result each time. After the loop, call drop(increment) to release the mutable borrow, then print the final value of count. (This implements FnMut.)

  3. Create data2: Vec<i32> = vec![10, 20, 30] and a closure consume_data whose body returns data2 (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 implements FnOnce.)


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];
  1. Use iter().filter(|&&x| x > 20.0).count() to count how many readings exceed 20.0 and print the result.

  2. 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.

  3. Use iter().enumerate() to print each reading with its zero-based index in the format:

[0] 18.2°C
[1] 22.4°C
...
  1. 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.0
Temp: 22.4 Humidity: 60.1
...

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
}
  1. Replace todo!() with a working implementation: if pos + window <= data.len(), compute the mean of data[pos..pos+window], advance pos by 1, and return Some(mean); otherwise return None.

  2. The struct carries a lifetime parameter 'a. What does it guarantee? Add a one-sentence comment to the struct definition explaining it.

  3. After your collect() call works, add a second use of the iterator that chains .filter(|avg| *avg > 21.0) before collect(). 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);
}
}
  1. The function does not compile. Add the lifetime annotation 'a to fix it: fn longest<'a>(x: &'a str, y: &'a str) -> &'a str. Add a comment explaining what 'a tells the borrow checker.

  2. Move the println! to after the closing } of the inner scope (so s2 has already been dropped). What error does the compiler produce, and why does it reject this?

  3. Restore the println! inside the inner scope. Add let s3 = String::from("medium length"); outside the inner scope and call longest(s1.as_str(), s3.as_str()), using the result after the inner scope ends. Confirm this compiles and explain why it is valid: both s1 and s3 live 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.

  1. Define a trait Sensor with two methods: fn read(&self) -> f32 and fn label(&self) -> &str.

  2. Implement Sensor for two structs:

struct TemperatureSensor { name: String, value: f32 }
struct HumiditySensor { name: String, value: f32 }
  1. Create a Vec<Box<dyn Sensor>> containing one TemperatureSensor and one HumiditySensor. Iterate over it and print a summary line for each:
Temperature-1: 22.4
Humidity-1: 58.0
  1. Explain in a comment why Vec<T> (with a generic bound) would not work here: a Vec<T> must hold values of a single concrete type, but a Vec<Box<dyn Sensor>> can hold any mix of types that implement Sensor.

  2. Add a third struct PressureSensor that implements Sensor and 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.

  1. Define the following error enum:
#[derive(Debug)]
enum SensorError {
ReadTimeout,
ValueOutOfRange(f32),
DeviceNotFound(String),
}
  1. Implement std::fmt::Display for SensorError:

    • ReadTimeout prints "Sensor read timed out"
    • ValueOutOfRange(v) prints "Reading out of range: <v:.1>"
    • DeviceNotFound(id) prints "Device not found: <id>"
  2. Add impl std::error::Error for SensorError {} (an empty body is sufficient — the trait has no required methods when Display and Debug are already implemented).

  3. Write a function read_sensor(id: &str, raw: f32) -> Result<f32, SensorError> that:

    • Returns Err(SensorError::DeviceNotFound("(no id)".to_string())) if id is empty
    • Returns Err(SensorError::ValueOutOfRange(raw)) if raw < 0.0 or raw > 100.0
    • Returns Ok(raw) otherwise
  4. In main, call read_sensor with the following inputs and use match to 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.0
Error: Reading out of range: -5.0
Error: Device not found: (no id)
Error: Reading out of range: 110.0

Here are the video solutions: please do not watch these solutions without having attempted the questions first.