Salta ai contenuti

Unsafe in Rust

Tutte le garanzie di cui abbiamo parlato in questo corso (niente riferimenti pendenti, niente data race, niente accessi fuori dai limiti) le fa rispettare il compilatore. unsafe è il modo per dirgli: «lasciami fare una cosa di cui non puoi verificare la sicurezza; me ne prendo io la responsabilità».

Un equivoco comune è pensare che unsafe spenga del tutto i controlli di sicurezza di Rust. Non è così. Dentro un blocco unsafe puoi fare esattamente cinque cose in più, che altrimenti sono vietate:

  1. Dereferenziare un puntatore grezzo (raw pointer)
  2. Chiamare una funzione unsafe
  3. Leggere o modificare una variabile static mutabile
  4. Implementare un trait unsafe
  5. Accedere ai campi di una union

Tutto il resto (le regole del prestito, la semantica dello spostamento, il controllo dei tipi) vale ancora per intero dentro il codice unsafe. È un bisturi per cinque operazioni precise, non un interruttore per spegnere tutto.

I riferimenti normali di Rust (& e &mut) puntano sempre a dati validi e allineati correttamente: fa parte di ciò che il compilatore garantisce. Un puntatore grezzo (*const T o *mut T) rinuncia a questa garanzia, ed è per questo che leggerlo richiede unsafe:

fn main() {
let value = 5;
let pointer = &value as *const i32;
unsafe {
println!("{}", *pointer);
}
}

Output:

5
▶ Provalo tu

Puoi creare un puntatore grezzo nel codice sicuro: solo dereferenziarlo (cioè leggere il valore a cui punta) richiede unsafe, perché il compilatore non può più verificare che il puntatore sia valido.

Alcune funzioni sono marcate unsafe perché hanno dei requisiti che il compilatore non può controllare al posto tuo. Chiamarle è la tua promessa di aver verificato quei requisiti da solo:

unsafe fn dangerous() {
println!("questa funzione si fida che tu abbia controllato i suoi requisiti");
}
fn main() {
unsafe {
dangerous();
}
}

Output:

questa funzione si fida che tu abbia controllato i suoi requisiti
▶ Provalo tu

La libreria standard ha delle vere funzioni unsafe che puoi chiamare. Una è get_unchecked sulle slice. Funziona come numbers[i], ma salta il controllo dei limiti. Se i è fuori intervallo, non va in panic: legge qualunque cosa si trovi in quella memoria. Questo si chiama comportamento indefinito (undefined behavior): il programma può andare in crash, stampare spazzatura o sembrare a posto finché non lo è più.

Quindi la regola per chiamarla è «solo con un indice che sai essere valido». Lo schema tipico è controllare tu stesso questa regola, dentro una normale funzione sicura:

fn first_or_zero(numbers: &[i32]) -> i32 {
if numbers.is_empty() {
return 0;
}
// SAFETY: la slice non è vuota, quindi l'indice 0 è nei limiti.
unsafe { *numbers.get_unchecked(0) }
}
fn main() {
println!("{}", first_or_zero(&[7, 8, 9]));
println!("{}", first_or_zero(&[]));
}

Output:

7
0
▶ Provalo tu

Guarda cosa vede chi la chiama: fn first_or_zero(numbers: &[i32]) -> i32. Nella firma non c’è nessun unsafe, e non c’è modo di chiamarla male, perché la funzione controlla la regola prima ancora di arrivare al blocco unsafe. L’unsafe resta piccolo e nascosto all’interno.

Due abitudini che seguono gli sviluppatori Rust esperti:

  • Scrivi un commento // SAFETY: sopra ogni blocco unsafe, spiegando perché le regole sono rispettate. Serve a chi leggerà il codice dopo di te, e anche a te tra qualche mese.
  • Tieni il blocco unsafe il più piccolo possibile, così c’è meno codice da ricontrollare.

A essere onesti, nel codice vero non scriveresti proprio questa funzione: numbers.first() fa lo stesso lavoro in modo sicuro, e il compilatore è bravissimo a eliminare i controlli dei limiti quando può dimostrare che non servono. Ma lo schema (controlla le regole, poi chiama unsafe dentro una funzione sicura) è esattamente il modo in cui sono costruiti Vec, String e compagnia. La pagina sull’FFI usa lo stesso trucco per avvolgere le funzioni C.

Se Rust punta tutto sulla sicurezza, perché permettere una cosa del genere? Perché alcune cose sono davvero impossibili da dimostrare sicure in fase di compilazione, anche se in pratica lo sono: parlare direttamente con l’hardware, chiamare una libreria C (vedi FFI) o costruire certe strutture dati ad alte prestazioni. unsafe permette alla libreria standard (e, raramente, al tuo codice) di implementare questi pezzi di basso livello, mentre tutto ciò che ci viene costruito sopra resta normale Rust sicuro, controllato in ogni dettaglio.

Scrivere unsafe non significa che ora il tuo codice abbia dei bug. Significa che, in quel blocco preciso, il compilatore non può più scovare certe categorie di errori al posto tuo, e tocca a te mantenere a mano le stesse garanzie. La maggior parte degli sviluppatori Rust esperti scrive pochissimo codice unsafe direttamente: di solito vive in piccole sacche, revisionate con cura, in fondo a una libreria, avvolte in un’API sicura che tutti gli altri possono usare.

Crea let mut score = 10;, poi un puntatore grezzo *mut i32 verso di esso, e usa il puntatore per aggiungere 5. Infine stampa score. Suggerimento: crea il puntatore con &mut score as *mut i32 e scrivi *pointer += 5 dentro un blocco unsafe.

Mostra la soluzione
fn main() {
let mut score = 10;
let pointer = &mut score as *mut i32; // crearlo è sicuro
unsafe {
*pointer += 5; // scriverci attraverso no
}
println!("score = {score}");
}

Output:

score = 15
▶ Provalo tu

In pratica è sicuro, perché score è ancora vivo e nient’altro lo sta usando mentre ci scriviamo. Mantenere queste promesse diventa compito tuo nel momento in cui usi unsafe.

Scrivi fn get_or(numbers: &[i32], index: usize, default: i32) -> i32. Restituisce l’elemento in posizione index, oppure default se l’indice è fuori intervallo. Usa get_unchecked per la lettura, e fai in modo che la funzione non possa mai essere chiamata in un modo che violi la sua regola. Suggerimento: confronta prima index < numbers.len() e chiama get_unchecked solo quando è vero.

Mostra la soluzione
fn get_or(numbers: &[i32], index: usize, default: i32) -> i32 {
if index < numbers.len() {
// SAFETY: abbiamo appena controllato che l'indice è nei limiti.
unsafe { *numbers.get_unchecked(index) }
} else {
default
}
}
fn main() {
let numbers = [10, 20, 30];
println!("{}", get_or(&numbers, 1, -1));
println!("{}", get_or(&numbers, 5, -1));
}

Output:

20
-1
▶ Provalo tu

L’indice 5 non arriva mai al blocco unsafe, quindi viene gestito in modo sicuro. Per confronto, la versione completamente sicura è numbers.get(index).copied().unwrap_or(default), ed è quella che scriveresti di solito.

  • unsafe sblocca esattamente cinque operazioni (soprattutto i puntatori grezzi e la chiamata di funzioni unsafe): tutto il resto viene ancora controllato per intero.
  • Un puntatore grezzo (*const T / *mut T) si può creare nel codice sicuro; dereferenziarlo richiede unsafe.
  • Avvolgi unsafe in una piccola funzione sicura che controlla prima le regole, e spiega perché è corretta in un commento // SAFETY:.
  • unsafe esiste per poter costruire il codice di basso livello (accesso all’hardware, interoperabilità con C), mentre il codice che lo usa resta sicuro.
  • Da principiante ti capiterà di rado di scriverlo: cerca prima un’astrazione sicura.

Verifica veloce

1. Cosa sblocca davvero un blocco unsafe?

2. Puoi creare un puntatore grezzo nel codice sicuro?

3. Da principiante, quanto spesso ti capiterà di scrivere unsafe?

4. Scrivere codice unsafe significa che ora il programma ha dei bug?

Punteggio: 0 / 4