Rust: ownership, borrow checker i systemowa niezawodność bez GC
Rust: ownership, borrow checker, lifetimes, traits, async/await z tokio, unsafe i Cargo, dla inżynierów budujących systemy bez garbage collectora.
Rust: ownership, borrow checker i systemowa niezawodność bez GC
Rust: ownership, borrow checker, lifetimes, traits, async/await z tokio, unsafe i Cargo, dla inżynierów budujących systemy bez garbage collectora.
Ownership i semantyka przenoszenia (move semantics)
na egzaminieOwnership 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.
Każda wartość ma dokładnie jednego właściciela w danym momencie. Gdy właściciel wychodzi poza zasięg (scope), wartość jest automatycznie zwalniana (drop). Ownership można przenieść (move) lub pożyczyć (borrow), ale nie zduplikować bez jawnego .clone().
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: Przeniesienie ownership przy przypisaniu lub przekazaniu wartości nie-Copy; oryginalna zmienna staje się nieważna, kompilator blokuje jej dalsze użycie statycznie.
Copy: Marker trait dla typów o taniej, bitowej kopii (np. i32, bool, f64, krotki z samych typów Copy); przypisanie takich wartości kopiuje, nie przenosi ownership.
Częsty błąd: próba użycia zmiennej po przekazaniu jej do funkcji lub przypisaniu innej zmiennej, gdy typ nie implementuje Copy. Kompilator zgłasza error[E0382]: borrow of moved value. Rozwiązanie to .clone() (świadomy koszt) albo przeprojektowanie własności, np. przekazanie referencji zamiast wartości.
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
} Dlaczego to działa bez GC: kompilator wie w czasie kompilacji, w którym dokładnie miejscu kodu każda wartość przestaje być używana. Wstawia tam wywołanie drop deterministycznie, bez śledzenia referencji w runtime.
Jedna wartość, jeden właściciel, jedno wyjście: gdy właściciel znika ze sceny, wartość gaśnie razem z nim.
- 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
Borrow checker: reguły referencji i non-lexical lifetimes
na egzaminieBorrow 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.
W danym momencie może istnieć albo dokładnie jedna referencja mutowalna (&mut T), albo dowolna liczba referencji niemutowalnych (&T) do tej samej wartości. Nigdy oba typy naraz.
Druga, równie ważna reguła: referencje muszą zawsze wskazywać na ważne dane. Kompilator nie pozwoli na dangling reference, czyli referencję przeżywającą wskazywaną przez nią wartość.
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:?}");
} Non-lexical lifetimes (NLL): Od edycji 2018 borrow checker liczy zasięg pożyczenia do ostatniego realnego użycia referencji, a nie do końca bloku leksykalnego. Dzięki temu można wcześniej pożyczyć tę samą wartość ponownie.
Przed NLL powyższy kod nie kompilował się, choć r1 i r2 nie są już używane przy tworzeniu r3. Zbyt konserwatywna analiza, oparta na granicach bloków, czasem odrzucała semantycznie bezpieczny kod. NLL poprawił precyzję analizy, a reguły bezpieczeństwa zostały bez zmian.
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 |
Reguła XOR eliminuje data races statycznie. Nie da się mieć jednocześnie czytelnika i pisarza tej samej pamięci bez synchronizacji, więc klasycznych wyścigów danych znanych z C/C++ nie da się skompilować.
Jeden pisarz i zero czytelników, albo zero pisarzy i wielu czytelników, nigdy pisarz z czytelnikiem naraz.
- 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
Lifetimes i reguły elizji
na egzaminieLifetime 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.
Lifetime ('a): Nazwany parametr opisujący, że dana referencja musi żyć co najmniej tak długo jak inna, powiązana z nią referencja. Nie wpływa na realny czas życia wartości w runtime. Istnieje wyłącznie na etapie kompilacji.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
} Reguły elizji pozwalają pominąć jawne adnotacje lifetime w większości sygnatur funkcji, kompilator wnioskuje je automatycznie według trzech deterministycznych reguł.
- Reguła 1 Każdy elidowany parametr wejściowy referencyjny dostaje własny, świeży parametr lifetime.
- 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.
- 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
}
} 'static: Specjalny lifetime oznaczający, że referencja (lub dane) może żyć przez cały czas trwania programu. Literały napisowe &'static str mają go domyślnie, ale 'static nie oznacza 'utworzone na starcie programu', tylko 'zdolne przeżyć wszystko'.
Częsty błąd początkujących: dodanie 'static jako szybkiej łatki na błąd lifetime zamiast zrozumienia realnej relacji czasów życia. To zwykle tylko przesuwa problem albo wymusza niepotrzebne klonowanie lub przeciek pamięci.
Jawne lifetime są wymagane, gdy struktura przechowuje referencję. Struct nie może istnieć dłużej niż dane, do których się odwołuje, więc kompilator wymaga adnotacji w definicji struct.
- 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
Lifetime nie przedłuża życia, tylko udowadnia, że jedno życie mieści się w drugim.
Option<T>, Result<T,E> i operator ?
na egzaminieRust 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>.
enum Option<T> { Some(T), None } koduje obecność lub brak wartości. enum Result<T, E> { Ok(T), Err(E) } koduje sukces z wartością T lub błąd z wartością 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())
} Operator ?: Składniowy skrót propagacji błędu: przy Err(e) natychmiast zwraca Err(From::from(e)) z bieżącej funkcji, przy Ok(v) wyłuskuje v. Działa analogicznie dla Option, gdzie None propaguje None.
? działa tylko wewnątrz funkcji zwracającej Result (lub Option, lub inny typ implementujący Try). fn main() domyślnie zwraca (), więc użycie ? w main wymaga zmiany sygnatury. Przykład: fn main() -> Result<(), Box<dyn std::error::Error>>.
- 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
Brak null eliminuje całą klasę błędów runtime (null pointer dereference, tzw. billion-dollar mistake Tony'ego Hoare'a), przenosząc obsługę braku wartości do czasu kompilacji.
Some albo None, Ok albo Err, nigdy cichy null w tle.
Traits: dyn vs generyki, dispatch i zero-cost abstractions
na egzaminieTraits 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: Zbiór sygnatur metod definiujących zachowanie, które typ może zaimplementować przez impl Trait for Type; odpowiednik interfejsu, ale z możliwością domyślnych implementacji metod.
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
Zasada przyjęta z C++ (Bjarne Stroustrup): czego nie używasz, za to nie płacisz; czego używasz, nie napiszesz ręcznie wydajniej. Generyki, iteratory i closures w Rust kompilują się do kodu porównywalnego z ręcznie pisanym imperatywnym odpowiednikiem.
Nie każdy trait można użyć jako dyn Trait. Wymóg object safety: metody nie mogą mieć parametrów generycznych ani zwracać Self (poza wyjątkami jak Box<Self>). Inaczej kompilator odrzuci dyn Trait błędem 'trait cannot be made into an object'.
Monomorfizacja dzieje się w czasie kompilacji. Każda konkretna instancjacja generyku (np. print_area::<Circle> i osobno dla innego typu) generuje osobny fragment kodu maszynowego. Umożliwia to agresywny inlining kosztem rozmiaru binarki.
- 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
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 z danymi (algebraic data type): Typ, którego wartość jest dokładnie jednym z kilku wariantów. Każdy wariant może przechowywać inne, nazwane lub pozycyjne dane, np. Message::Move { x, y } albo Message::Write(String).
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}"),
}
} match musi pokryć wszystkie warianty typu (lub jawny wildcard `_`), inaczej kompilator zgłasza błąd 'non-exhaustive patterns'. To eliminuje klasę błędów 'zapomniałem obsłużyć przypadek', częstą przy switch w innych językach.
- 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
RAII (Resource Acquisition Is Initialization): Wzorzec, w którym pozyskanie zasobu (pamięć, plik, blokada) wiąże się z inicjalizacją obiektu. Zasób zwalnia się automatycznie w destruktorze, na końcu zasięgu obiektu.
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 Wartości są zwalniane (drop) w kolejności odwrotnej do deklaracji w danym zasięgu: ostatnia zadeklarowana wartość jest niszczona jako pierwsza, analogicznie do stosu wywołań.
Nie można wywołać x.drop() bezpośrednio, to błąd kompilacji E0040 (explicit use of destructor method). Trzeba użyć std::mem::drop(x), zwykłej funkcji przejmującej ownership i kończącej zasięg wartości natychmiast.
Brak garbage collectora wynika wprost z ownership. Kompilator statycznie wylicza dokładny punkt w kodzie, w którym każda wartość przestaje być używana. Zwolnienie pamięci jest więc deterministyczne i nie wymaga mechanizmu trasującego w runtime.
Ostatni wszedł, pierwszy wyjdzie: drop działa jak stos, nie jak kolejka.
async/await i tokio: wykonawcy niezależne od języka
na egzaminieasync/await w Rust to składnia nad leniwym trait Future. Sam język i standardowa biblioteka nie narzucają, jak i gdzie future zostanie wykonany. Przejmuje to zewnętrzny executor, w praktyce niemal zawsze tokio.
Funkcja oznaczona async fn nie wykonuje swojego ciała od razu, zwraca wartość implementującą impl Future<Output = T>. Future jest odpytywane (polled) przez executor, dopóki nie zwróci Poll::Ready.
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}");
} Executor (runtime): Komponent odpytujący (poll) futures aż do ukończenia, planujący zadania (tasks) na wątkach i dostarczający asynchroniczne I/O oraz timery. Std nie dostarcza własnego executora.
Ten podział na 'język definiuje Future i .await' oraz 'crate dostarcza executor' nazywa się projektem executor-agnostic. Ten sam kod async może teoretycznie działać pod tokio, async-std czy smol. W praktyce ekosystem skonsolidował się wokół tokio.
tokio::spawn: Funkcja planująca nowy, niezależny task na executorze tokio; task wykonuje się współbieżnie z innymi, zwraca JoinHandle do odebrania wyniku.
Długa, blokująca operacja synchroniczna (np. ciężkie obliczenia CPU-bound albo blokujące I/O) wewnątrz async fn bez tokio::task::spawn_blocking zatrzymuje cały wątek workera. Wstrzymuje wszystkie inne taski współdzielące ten wątek. Kooperatywne planowanie zakłada, że taski oddają kontrolę na .await.
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")]
.await zawiesza wykonanie bieżącego taska bez blokowania wątku systemowego (OS thread): kontrola wraca do executora, który w tym czasie może odpytywać inne taski. To fundament wysokiej gęstości współbieżności, tysięcy tasków na garstce wątków.
- 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
Cargo workspaces, feature flags i bloki unsafe
na egzaminieProdukcyjny Rust to nie tylko semantyka języka. To także 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.
Workspace grupuje wiele powiązanych pakietów (crates) pod jednym Cargo.lock i wspólnym katalogiem target, deklarowanym w sekcji [workspace] z listą members w głównym Cargo.toml.
# 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"] Feature flag: Nazwana, opcjonalna funkcjonalność crate'a włączana w sekcji [features], pozwalająca konsumentom kompilować tylko potrzebne części kodu: mniejsza binarka, mniej zależności tranzytywnych.
Składnia dep:nazwa pozwala uczynić opcjonalną zależność włącznikiem feature bez automatycznego tworzenia identycznie nazwanego, ukrytego feature'a, co wcześniej było źródłem konfliktów nazw.
Blok unsafe: Fragment kodu odblokowujący dokładnie pięć operacji niemożliwych do statycznego zweryfikowania przez kompilator; reszta reguł języka, w tym borrow checker, nadal obowiązuje wewnątrz unsafe.
- 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),
)
}
} unsafe nie wyłącza borrow checkera ani sprawdzania granic (bounds checking) dla zwykłego indeksowania []. Odblokowuje wyłącznie pięć operacji z listy powyżej. Niezmienniki, np. że wskaźnik jest wyrównany i wskazuje na ważną pamięć, pozostają w całości odpowiedzialnością programisty, kompilator ich nie zweryfikuje.
Komentarz // SAFETY: tuż przed blokiem unsafe to konwencja społeczności Rust. Wymaga jej też wiele lintów (np. clippy::undocumented_unsafe_blocks). Komentarz dokumentuje, dlaczego dany niezmiennik jest zachowany, i ułatwia review oraz przyszłe refaktoryzacje.
unsafe nie znaczy 'wyłącz mózg', znaczy 'kompilator ci teraz ufa, uzasadnij to'.
Sprawdź się
20 z 24 pytań, losowo, z wyjaśnieniem po każdym. Wynik zostaje na tym urządzeniu.
Rozpocznij quiz