Rust: ownership, borrow checker i systemowa niezawodność bez GC
Głębokie, praktyczne kompendium Rust: ownership, borrow checker, lifetimes, traits, async/await z tokio, unsafe i Cargo, dla inżynierów budujących systemy produkcyjne bez garbage collectora.
- Ownership i move semantics: jeden właściciel, deterministyczny drop, brak GC
- Borrow checker: jedna referencja mutowalna XOR wiele niemutowalnych, non-lexical lifetimes
- Lifetimes i trzy reguły elizji
- Option<T>/Result<T,E> i operator ? do propagacji błędów
- Traits: dyn (dynamic dispatch, vtable) vs generyki (static dispatch, monomorfizacja), zero-cost abstractions
- async/await: leniwe Future, executor-agnostic design, tokio jako de facto runtime
1 · Ownership i semantyka przenoszenia (move semantics)
★ egzaminOwnership to fundament pamięciowego modelu Rust: każda wartość ma dokładnie jednego właściciela, a kompilator statycznie, bez udziału runtime'u, decyduje kiedy pamięć zostanie zwolniona. To zastępuje zarówno ręczne malloc/free, jak i garbage collector.
fn main() {
let s1 = String::from("hello");
let s2 = s1; // move: s1 nie jest już ważne
// println!("{}", s1); // error[E0382]: borrow of moved value
println!("{}", s2);
let n1 = 5; // i32 implementuje Copy
let n2 = n1;
println!("{n1} {n2}"); // obie zmienne dalej ważne
} Move (domyślne dla typów bez Copy)
- Przenosi ownership przy przypisaniu lub wywołaniu
- Oryginał staje się nieważny
- Zero kosztu w runtime, kopiowany jest tylko wskaźnik/metadane
- Dotyczy np. String, Vec<T>, Box<T>
Copy (dla typów prostych)
- Tworzy bitową kopię przy przypisaniu
- Oryginał pozostaje ważny
- Wymaga, by typ nie implementował Drop
- Dotyczy np. i32, bool, char, krotek Copy
struct Item {
name: String,
qty: u32,
}
fn main() {
let item = Item { name: String::from("śruba"), qty: 10 };
let name = item.name; // move: przenosi tylko pole name, nie cały item
println!("{name} x{}", item.qty); // qty to Copy, wciąż dostępne mimo częściowego move
} - String, Vec<T>, Box<T>, HashMap: typy zarządzające pamięcią na stercie, zawsze move
- i32, u64, f64, bool, char: typy Copy, zawsze kopiowane
- Tuple i array są Copy tylko jeśli wszystkie elementy są Copy
- struct/enum trzeba jawnie oznaczyć #[derive(Copy, Clone)], by uzyskać semantykę Copy
2 · Borrow checker: reguły referencji i non-lexical lifetimes
★ egzaminBorrow checker to statyczny analizator, który przed uruchomieniem programu udowadnia brak data races i dangling references. Cała jego logika sprowadza się do jednej reguły aliasowania, egzekwowanej wyjątkowo rygorystycznie.
fn main() {
let mut v = vec![1, 2, 3];
let r1 = &v;
let r2 = &v;
println!("{r1:?} {r2:?}"); // ostatnie użycie r1, r2 tutaj
let r3 = &mut v; // OK: dzięki NLL zasięg r1/r2 już się skończył
r3.push(4);
println!("{r3:?}");
} Przed NLL (do edycji 2018)
- Zasięg pożyczenia = cały blok leksykalny
- Referencja 'żyła' aż do zamykającej klamry
- Częste fałszywe alarmy kompilatora
Po NLL (2018+)
- Zasięg pożyczenia = do ostatniego realnego użycia
- Wcześniejsze zakończenie pożyczenia = szybsze ponowne pożyczenie
- Ten sam poziom bezpieczeństwa, mniej tarcia w codziennym kodzie
| Typ referencji | Symbol | Liczba naraz | Modyfikacja danych |
|---|---|---|---|
| Niemutowalna (shared) | &T | dowolnie wiele | nie |
| Mutowalna (exclusive) | &mut T | dokładnie jedna | tak |
- Referencje nie mogą przeżyć wartości, na którą wskazują (brak dangling references)
- Mutowalna referencja daje wyłączny dostęp, wyklucza wszystkie inne referencje w tym samym czasie
- Wiele referencji niemutowalnych może współistnieć bezpiecznie
- NLL analizuje faktyczny przepływ sterowania, nie tylko strukturę bloków kodu
3 · Lifetimes i reguły elizji
★ egzaminLifetime to nie mechanizm wydłużający czas życia wartości. To adnotacja opisująca relacje między czasem życia różnych referencji, którą kompilator wykorzystuje do wykrywania dangling references w typach zwracanych z funkcji.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
} - 1 Reguła 1 Każdy elidowany parametr wejściowy referencyjny dostaje własny, świeży parametr lifetime.
- 2 Reguła 2 Jeśli istnieje dokładnie jeden parametr wejściowy z lifetime, ten sam lifetime jest przypisywany wszystkim elidowanym lifetime'om wyjściowym.
- 3 Reguła 3 Jeśli wśród parametrów jest &self lub &mut self, lifetime self jest przypisywany wszystkim elidowanym lifetime'om wyjściowym.
struct Parser<'a> {
input: &'a str,
}
impl<'a> Parser<'a> {
fn remaining(&self) -> &str {
self.input
}
} - Elizja działa tylko dla funkcji i metod, nie dla definicji struct
- Kompilator nigdy nie zgaduje, gdy wynik jest niejednoznaczny, wtedy wymaga jawnej adnotacji
- Lifetime to relacja, nie wartość, nie istnieje w skompilowanym kodzie maszynowym
4 · Option<T>, Result<T,E> i operator ?
★ egzaminRust nie ma null. Brak wartości i błędy są częścią systemu typów, nie wyjątkiem w runtime, dzięki dwóm enumom z biblioteki standardowej: Option<T> i Result<T, E>.
use std::num::ParseIntError;
fn parse_and_double(s: &str) -> Result<i32, ParseIntError> {
let n: i32 = s.parse()?; // propaguje ParseIntError wcześnie
Ok(n * 2)
} fn first_char_upper(s: &str) -> Option<char> {
let c = s.chars().next()?; // propaguje None wcześnie
Some(c.to_ascii_uppercase())
} - map/and_then: transformacja wartości wewnątrz Some/Ok bez ręcznego rozpakowywania
- unwrap_or/unwrap_or_else: wartość domyślna przy None/Err
- ok_or: konwersja Option na Result z podanym błędem
- unwrap/expect: wyłuskanie z panic! przy None/Err, tylko do prototypowania
unwrap() / expect()
- Panikuje (crash) przy None/Err
- Szybkie do prototypów i testów
- W kodzie produkcyjnym to code smell
Operator ?
- Propaguje błąd w górę stosu wywołań
- Wymaga typu zwracanego Result/Option
- Idiomatyczny sposób obsługi błędów w produkcji
5 · Traits: dyn vs generyki, dispatch i zero-cost abstractions
★ egzaminTraits definiują współdzielone zachowanie, podobnie do interfejsów, ale Rust daje dwa fundamentalnie różne sposoby ich użycia w czasie wykonania: monomorfizowane generyki i dynamiczne dyn Trait.
trait Shape {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
} fn print_area<T: Shape>(shape: &T) {
println!("{}", shape.area());
} fn print_area_dyn(shape: &dyn Shape) {
println!("{}", shape.area());
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 2.0 })];
for s in &shapes {
print_area_dyn(s.as_ref());
}
} Generyki (static dispatch)
- Kompilator generuje osobną kopię kodu per konkretny typ (monomorfizacja)
- Zero narzutu w runtime, pełny inlining
- Większy rozmiar binarki przy wielu instancjacjach
- Typ musi być znany w czasie kompilacji
dyn Trait (dynamic dispatch)
- Jedna kopia skompilowanego kodu, wywołanie przez vtable
- Mały, stały narzut jednego wskaźnikowego skoku, brak inliningu
- Mniejsza binarka, pozwala na heterogeniczne kolekcje jak Vec<Box<dyn Trait>>
- Konkretny typ może być nieznany w czasie kompilacji
- Wybierz generyki, gdy typ jest znany statycznie i liczy się maksymalna wydajność
- Wybierz dyn Trait, gdy potrzebujesz heterogenicznej kolekcji lub chcesz ograniczyć rozrost binarki
- Można łączyć oba podejścia w jednym programie bez konfliktu
6 · Enumy, pattern matching, RAII i brak garbage collectora
Enumy w Rust to algebraiczne typy danych: każdy wariant może nieść własne dane, a match wymusza wyczerpującą obsługę wszystkich przypadków. To samo ownership, które zarządza pamięcią, napędza też deterministyczne czyszczenie zasobów przez trait Drop.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(u8, u8, u8),
}
fn process(msg: &Message) {
match msg {
Message::Quit => println!("quit"),
Message::Move { x, y } if *x == *y => println!("diagonal move to {x},{y}"),
Message::Move { x, y } => println!("move to {x},{y}"),
Message::Write(text) => println!("write: {text}"),
Message::ChangeColor(r, g, b) => println!("color: #{r:02x}{g:02x}{b:02x}"),
}
} - if let Some(x) = opt { ... }: dopasowanie jednego wariantu bez pełnego match
- while let Some(x) = stack.pop() { ... }: pętla dopóki wzorzec pasuje
- wzorzec_a | wzorzec_b: dopasowanie alternatywy (OR-pattern)
- n @ 1..=5: związanie zmiennej z jednoczesnym sprawdzeniem zakresu
struct FileGuard {
name: String,
}
impl Drop for FileGuard {
fn drop(&mut self) {
println!("closing {}", self.name); // automatyczne, bez GC
}
}
fn main() {
let _a = FileGuard { name: "a.txt".into() };
let _b = FileGuard { name: "b.txt".into() };
println!("working...");
} // kolejność drop: najpierw _b, potem _a 7 · async/await i tokio: wykonawcy niezależne od języka
★ egzaminasync/await w Rust to składnia nad leniwym trait Future: sam język i standardowa biblioteka nie narzucają, jak i gdzie future zostanie wykonany, tę odpowiedzialność przejmuje zewnętrzny executor, w praktyce niemal zawsze tokio.
use tokio::time::{sleep, Duration};
async fn fetch(id: u32) -> u32 {
sleep(Duration::from_millis(10)).await;
id * 2
}
#[tokio::main]
async fn main() {
// futures są leniwe: nic się nie dzieje, dopóki nie zostaną odpytane
let (a, b) = tokio::join!(fetch(1), fetch(2));
println!("{a} {b}");
} multi_thread (domyślny)
- Wielowątkowy scheduler typu work-stealing
- Taski mogą migrować między wątkami
- Lepsze wykorzystanie wielu rdzeni
current_thread
- Jeden wątek dla całego runtime'u
- Mniejszy narzut dla prostych/CLI programów
- Wymaga jawnego #[tokio::main(flavor = "current_thread")]
- tokio::time::sleep: nieblokujące oczekiwanie
- tokio::select!: odpytywanie kilku futures naraz, pierwszy gotowy wygrywa
- tokio::sync::Mutex: blokada async-aware, nie blokuje wątku podczas oczekiwania
- tokio::join!: równoległe uruchomienie i oczekiwanie na kilka futures
8 · Cargo workspaces, feature flags i bloki unsafe
★ egzaminPoza semantyką języka, produkcyjny Rust to też organizacja projektu (workspaces), warunkowa kompilacja (feature flags) i świadome wyjście poza gwarancje bezpieczeństwa (unsafe), gdy jest to jedyny sposób na dany poziom kontroli.
# Cargo.toml (root workspace)
[workspace]
members = ["core", "cli"]
resolver = "2"
# core/Cargo.toml
[package]
name = "core"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
[features]
default = []
json = ["dep:serde"] - Dereferencja surowego wskaźnika (*const T / *mut T)
- Wywołanie unsafe fn lub unsafe metody
- Odczyt lub modyfikacja mutowalnej zmiennej statycznej (static mut)
- Implementacja unsafe trait
- Dostęp do pól unii (union)
fn split_at_mut_manual(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = slice.len();
assert!(mid <= len);
let ptr = slice.as_mut_ptr();
// SAFETY: mid <= len sprawdzone wyżej; obie połowy [0, mid) i
// [mid, len) nigdy się nie nakładają, więc dwie mutowalne referencje
// z jednego wskaźnika są bezpieczne.
unsafe {
(
std::slice::from_raw_parts_mut(ptr, mid),
std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
} Sprawdź się - testowanie to nauka
24 pytań w losowej kolejności. Twoje wyniki zapisują się lokalnie.