Borrowing and References in Rust: Sharing Data With Absolute Safety
Immutable References: Reading Without Owning
In the previous lesson, we saw that passing a value to a function transfers ownership. But what if you want a function to read sensor data without taking ownership? This is where borrowing through references comes in:
fn print_average(readings: &Vec<f64>) {
let sum: f64 = readings.iter().sum();
let avg = sum / readings.len() as f64;
println!("Average: {:.2}°C", avg);
}
fn main() {
let data = vec![23.5, 24.1, 22.8, 25.0];
print_average(&data); // borrow — we still own data
print_average(&data); // can borrow multiple times
println!("Reading count: {}", data.len()); // still valid!
}
&data creates an immutable reference — it allows read-only access. The function borrows the data temporarily and returns it when done. You can create multiple immutable references at the same time because concurrent reads are safe.
Mutable References: Exclusive Write Access
Sometimes a function needs to modify data. For this, use &mut — a mutable reference:
fn add_reading(readings: &mut Vec<f64>, value: f64) {
readings.push(value);
}
fn main() {
let mut data = vec![23.5, 24.1];
add_reading(&mut data, 22.8);
add_reading(&mut data, 25.0);
println!("Readings: {:?}", data); // [23.5, 24.1, 22.8, 25.0]
}
The critical rule: you cannot have more than one mutable reference at the same time:
let mut data = vec![1.0, 2.0, 3.0];
let r1 = &mut data;
// let r2 = &mut data; // compile error! second mutable reference forbidden
r1.push(4.0);
The Borrowing Rules
The compiler enforces two strict rules:
Rule 1: Multiple Immutable References OR One Mutable Reference
let mut data = vec![1.0, 2.0, 3.0];
// ✅ allowed: multiple immutable references
let r1 = &data;
let r2 = &data;
println!("{:?} {:?}", r1, r2);
// ✅ allowed: one mutable reference (after immutable refs are done)
let r3 = &mut data;
r3.push(4.0);
let mut data = vec![1.0, 2.0, 3.0];
// ❌ forbidden: immutable and mutable reference at the same time
let r1 = &data;
let r2 = &mut data; // error! cannot borrow mutably while immutable borrow exists
println!("{:?}", r1);
Rule 2: References Must Always Be Valid
The compiler ensures a reference never outlives the data it points to:
// ❌ this will not compile:
fn dangling_reference() -> &String {
let s = String::from("data");
&s // error! s is freed at the end of the function — reference would dangle
}
// ✅ solution: return ownership
fn owned_value() -> String {
let s = String::from("data");
s // ownership transfer — safe
}
Why These Rules Matter in Industry
Imagine two threads reading sensor data:
- Without Rust's rules (C++): one thread reads while another modifies → data race → wrong values → wrong control decision
- With Rust's rules: the compiler rejects the code before it runs — the problem is impossible
Dangling References: What Rust Prevents
A dangling reference points to freed memory — a common cause of crashes in C/C++. Rust prevents them entirely:
fn main() {
let reference;
{
let value = String::from("temporary");
reference = &value;
} // value is freed here
// println!("{}", reference); // compile error! reference points to freed memory
}
The compiler tracks the lifetime of every reference and ensures it never points to invalid memory.
Introduction to Lifetimes
Sometimes the compiler needs your help understanding reference lifetimes. This is where lifetime annotations come in — they tell the compiler about the relationship between reference lifetimes:
// without explicit lifetime — the compiler infers it
fn first_element(list: &[f64]) -> &f64 {
&list[0]
}
// with explicit lifetime — when there are multiple input references
fn longer_name<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
'a tells the compiler: "the output reference lives at least as long as the shorter-lived input." In most cases, the compiler infers lifetimes automatically and you do not need to write them.
Practical Example: Shared Sensor Configuration
struct SensorConfig {
name: String,
unit: String,
min_threshold: f64,
max_threshold: f64,
}
fn check_reading(config: &SensorConfig, value: f64) -> &str {
if value < config.min_threshold {
"below minimum threshold"
} else if value > config.max_threshold {
"above maximum threshold"
} else {
"within normal range"
}
}
fn log_reading(config: &SensorConfig, value: f64) {
println!("[{}] {:.1} {} — {}",
config.name, value, config.unit, check_reading(config, value));
}
fn main() {
let temp_config = SensorConfig {
name: String::from("temp_sensor_01"),
unit: String::from("°C"),
min_threshold: 10.0,
max_threshold: 80.0,
};
// same config shared (borrowed) by multiple functions
log_reading(&temp_config, 23.5);
log_reading(&temp_config, 85.0);
log_reading(&temp_config, 5.0);
// temp_config is still ours — can use it later
println!("Sensor name: {}", temp_config.name);
}
Here SensorConfig is borrowed by multiple functions — each reads the configuration without copying it or taking ownership.
Summary
Borrowing allows sharing data safely: &T for reading (multiple references allowed) and &mut T for writing (one exclusive reference). The compiler prevents data races and dangling references at compile time. Lifetimes ensure references never outlive their data. This system means absolute safety with zero performance cost. In the next lesson, we will learn how to create custom data types using structs and enums.