SE VUOI PRENDERE LA CERTIFICAZIONE PER QUESTO CORSO CLICCA QUI
Cos’è il Concetto di Proprietà?
La proprietà è un insieme di regole che governano come un programma Rust gestisce la memoria. Tutti i programmi devono gestire il modo in cui utilizzano la memoria del computer durante l’esecuzione. Alcuni linguaggi hanno un meccanismo di garbage collection che cerca regolarmente la memoria non più utilizzata mentre il programma viene eseguito; in altri linguaggi, il programmatore deve allocare e liberare esplicitamente la memoria. Rust utilizza un terzo approccio: la memoria è gestita attraverso un sistema di proprietà con un insieme di regole che il compilatore controlla. Se una qualsiasi delle regole viene violata, il programma non verrà compilato. Nessuna delle caratteristiche della proprietà rallenterà il programma durante l’esecuzione.
Poiché la proprietà è un nuovo concetto per molti programmatori, ci vuole del tempo per abituarsi. La buona notizia è che più diventi esperto con Rust e le regole del sistema di proprietà, più troverai facile sviluppare naturalmente del codice che è sicuro ed efficiente. Continua così!
Quando capirai la proprietà, avrai una solida base per comprendere le caratteristiche che rendono Rust unico. In questo capitolo, imparerai la proprietà lavorando su alcuni esempi che si concentrano su una struttura dati molto comune: le stringhe.
Lo Stack e l’Heap
Molti linguaggi di programmazione non richiedono di pensare allo stack e all’heap molto spesso. Ma in un linguaggio di programmazione di sistema come Rust, se un valore è nello stack o nell’heap influisce su come si comporta il linguaggio e perché devi prendere certe decisioni. Parti della proprietà saranno descritte in relazione allo stack e all’heap più avanti in questo capitolo, quindi ecco una breve spiegazione in preparazione.
Sia lo stack che l’heap sono parti della memoria disponibili al tuo codice per l’utilizzo durante l’esecuzione, ma sono strutturati in modi diversi. Lo stack memorizza i valori nell’ordine in cui li riceve e rimuove i valori nell’ordine opposto. Questo è chiamato ultimo dentro, primo fuori. Pensalo come a una pila di piatti: quando aggiungi più piatti, li metti in cima al mucchio, e quando hai bisogno di un piatto, ne prendi uno dalla cima. Aggiungere o rimuovere piatti dal centro o dal fondo non funzionerebbe altrettanto bene! Aggiungere dati è chiamato push sullo stack, e rimuovere dati è chiamato pop dallo stack. Tutti i dati memorizzati nello stack devono avere una dimensione conosciuta e fissa. I dati con dimensione sconosciuta al momento della compilazione o una dimensione che potrebbe cambiare devono essere memorizzati nell’heap invece.
L’heap è meno organizzato: quando metti dati nell’heap, richiedi una certa quantità di spazio. L’allocazione della memoria trova un posto vuoto nell’heap abbastanza grande, lo segna come in uso e restituisce un puntatore, che è l’indirizzo di quella posizione. Questo processo è chiamato allocare nell’heap ed è talvolta abbreviato come semplicemente allocare (mettere i valori nello stack non è considerato allocare). Poiché il puntatore all’heap ha una dimensione conosciuta e fissa, puoi memorizzare il puntatore nello stack, ma quando vuoi i dati effettivi, devi seguire il puntatore. Pensa di essere seduto in un ristorante. Quando entri, indichi il numero di persone nel tuo gruppo, e l’host trova un tavolo vuoto che ospita tutti e vi conduce lì. Se qualcuno del tuo gruppo arriva tardi, può chiedere dove siete stati seduti per trovarvi.
Pushing sullo stack è più veloce rispetto all’allocare nell’heap perché l’allocazione non deve mai cercare un posto per memorizzare nuovi dati; quella posizione è sempre in cima allo stack. In confronto, l’allocazione di spazio nell’heap richiede più lavoro perché l’allocazione deve prima trovare uno spazio abbastanza grande per contenere i dati e quindi eseguire delle operazioni contabili per prepararsi per la prossima allocazione.
Accedere ai dati nell’heap è più lento rispetto all’accesso ai dati nello stack perché devi seguire un puntatore per arrivarci. I processori contemporanei sono più veloci se si muovono meno nella memoria. Continuando l’analogia, considera un server in un ristorante che prende ordini da molti tavoli. È più efficiente ottenere tutti gli ordini da un tavolo prima di passare al tavolo successivo. Prendere un ordine dal tavolo A, poi un ordine dal tavolo B, poi uno ancora da A e poi uno da B sarebbe un processo molto più lento. Allo stesso modo, un processore può svolgere il suo lavoro meglio se lavora su dati vicini ad altri dati (come avviene nello stack) piuttosto che lontano (come può essere nell’heap).
Quando il tuo codice chiama una funzione, i valori passati alla funzione (inclusi, potenzialmente, i puntatori ai dati nell’heap) e le variabili locali della funzione vengono pushate sullo stack. Quando la funzione è finita, quei valori vengono poppate dallo stack.
Tenere traccia di quali parti del codice stanno usando quali dati nell’heap, minimizzare la quantità di dati duplicati nell’heap e pulire i dati non utilizzati nell’heap in modo da non esaurire lo spazio sono tutti problemi che la proprietà affronta. Una volta capita la proprietà, non sarà necessario pensare spesso allo stack e all’heap, ma sapere che lo scopo principale della proprietà è gestire i dati nell’heap può aiutare a spiegare perché funziona come funziona.
Regole della Proprietà
Innanzitutto, diamo un’occhiata alle regole della proprietà. Tieni presente queste regole mentre lavoriamo sugli esempi che le illustrano:
sql
Ogni valore in Rust ha un proprietario.
Può esserci un solo proprietario alla volta.
Quando il proprietario esce dallo scope, il valore verrà eliminato.
Ambito delle Variabili
Ora che abbiamo superato la sintassi di base di Rust, non includeremo tutto il codice fn main() {…} negli esempi, quindi se stai seguendo, assicurati di inserire manualmente gli esempi seguenti all’interno di una funzione main. Di conseguenza, i nostri esempi saranno un po’ più concisi, permettendoci di concentrarci sui dettagli effettivi piuttosto che sul codice di supporto.
Come primo esempio di proprietà, esamineremo lo scope di alcune variabili. Un ambito è l’intervallo all’interno di un programma per il quale un elemento è valido. Prendi la seguente variabile:
rust
let s = "ciao";
La variabile s si riferisce a una stringa letterale, dove il valore della stringa è codificato nel testo del nostro programma. La variabile è valida dal punto in cui viene dichiarata fino alla fine dello scope corrente. Il seguente programma con commenti mostra dove la variabile s sarebbe valida.
rust
{ // s non è valido qui, non è ancora dichiarato
let s = "ciao"; // s è valido da questo punto in poi
// fai qualcosa con s
} // questo scope è ora finito, e s non è più valido
In altre parole, ci sono due punti importanti qui:
css
Quando s entra nello scope, è valido.
Rimane valido fino a quando esce dallo scope.
A questo punto, la relazione tra gli scope e quando le variabili sono valide è simile a quella in altri linguaggi di programmazione. Ora costruiremo su questa comprensione introducendo il tipo di dato Stringa.
Il Tipo Stringa
Per illustrare le regole della proprietà, abbiamo bisogno di un tipo di dati più complesso rispetto a quelli coperti nella sezione “Tipi di Dati” del Capitolo 3. I tipi trattati precedentemente hanno una dimensione nota, possono essere memorizzati nello stack e rimossi dallo stack quando il loro scope è finito, e possono essere copiati rapidamente e trivialmente per creare una nuova istanza indipendente se un’altra parte del codice ha bisogno di utilizzare lo stesso valore in uno scope diverso. Ma vogliamo guardare i dati memorizzati nell’heap e esplorare come Rust sappia quando pulire quei dati, e il tipo di dato Stringa è un ottimo esempio.
Ci concentreremo sulle parti di Stringa che riguardano la proprietà. Questi aspetti si applicano anche ad altri tipi di dati complessi, che siano forniti dalla libreria standard o creati da te. Discuteremo di Stringa più approfonditamente nel Capitolo 8.
Abbiamo già visto le stringhe letterali, dove un valore di stringa è codificato nel nostro programma. Le stringhe letterali sono comode, ma non sono adatte a ogni situazione in cui potremmo voler usare il testo. Un motivo è che sono immutabili. Un altro motivo è che non ogni valore di stringa può essere conosciuto quando scriviamo il nostro codice: ad esempio, cosa succede se vogliamo prendere in input dell’utente e memorizzarlo? Per queste situazioni, Rust ha un secondo tipo di stringa, Stringa. Questo tipo gestisce dati allocati nell’heap e quindi è in grado di memorizzare una quantità di testo che è sconosciuta per noi al momento della compilazione. Puoi creare una Stringa da una stringa letterale utilizzando la funzione from, così:
rust
let s = String::from("ciao");
L’operatore doppio doppio :: ci permette di mettere in namespace questa particolare funzione from sotto il tipo Stringa anziché utilizzare qualche tipo di nome come string_from. Discuteremo di questa sintassi più in dettaglio nella sezione “Sintassi dei Metodi” del Capitolo 5, e quando parleremo di namespaces con i moduli in “Percorsi per Fare Riferimento a un Elemento nell’Albero del Modulo” nel Capitolo 7.
Questo tipo di stringa può essere mutato:
rust
let mut s = String::from("ciao");
s.push_str(", mondo!"); // push_str() aggiunge una stringa letterale a una Stringa
println!("{}", s); // Questo stampa `ciao, mondo!`
Quindi, qual è la differenza qui? Perché Stringa può essere mutata ma le stringhe letterali no? La differenza sta nel modo in cui questi due tipi trattano la memoria.
Memoria e Allocazione
Nel caso di una stringa letterale, conosciamo il contenuto al momento della compilazione, quindi il testo è codificato direttamente nell’eseguibile finale. Ecco perché le stringhe letterali sono veloci ed efficienti. Ma queste proprietà derivano solo dall’immortalità delle stringhe letterali. Purtroppo, non possiamo mettere un blob di memoria nell’eseguibile per ogni pezzo di testo la cui dimensione è sconosciuta al momento della compilazione e potrebbe cambiare durante l’esecuzione del programma.
Con il tipo Stringa, per supportare un pezzo di testo mutevole e espandibile, dobbiamo allocare una quantità di memoria nell’heap, sconosciuta al momento della compilazione, per contenere i contenuti. Ciò significa:
vbnet
La memoria deve essere richiesta dall'allocatore di memoria a tempo di esecuzione.
Abbiamo bisogno di un modo per restituire questa memoria all'allocatore quando abbiamo finito con la nostra Stringa.
La prima parte la facciamo noi: quando chiamiamo String::from, la sua implementazione richiede la memoria di cui ha bisogno. Questo è praticamente universale nei linguaggi di programmazione.
Tuttavia, la seconda parte è diversa. Nei linguaggi con un garbage collector (GC), il GC tiene traccia e pulisce la memoria che non viene più utilizzata, e non dobbiamo pensarci. Nella maggior parte dei linguaggi senza un GC, è nostra responsabilità identificare quando la memoria non viene più utilizzata e chiamare il codice per liberarla esplicitamente, proprio come abbiamo fatto per richiederla. Fare questo correttamente è stato storicamente un problema di programmazione difficile. Se dimentichiamo, sprecheremo memoria. Se lo facciamo troppo presto, avremo una variabile non valida. Se lo facciamo due volte, anche quello è un bug. Dobbiamo abbinare esattamente un’allocazione con esattamente una liberazione.
Rust intraprende una strada diversa: la memoria viene restituita automaticamente una volta che la variabile che la possiede esce dallo scope. Ecco una versione del nostro esempio di scope da Lista 4-1 utilizzando una Stringa invece di una stringa letterale:
rust
{
let s = String::from("ciao"); // s è valido da questo punto in poi
// fai qualcosa con s
} // questo scope è ora finito, e s non è più valido
C’è un punto naturale in cui possiamo restituire la memoria di cui ha bisogno la nostra Stringa all’allocatore: quando s esce dallo scope. Quando una variabile esce dallo scope, Rust chiama una funzione speciale per noi. Questa funzione si chiama drop, ed è dove l’autore di Stringa può mettere il codice per restituire la memoria. Rust chiama drop automaticamente alla graffa di chiusura.
sql
Nota: In C++, questo pattern di deallocazione delle risorse alla fine della vita di un elemento è talvolta chiamato "Acquisizione delle Risorse è Inizializzazione" (RAII). La funzione drop in Rust ti sarà familiare se hai usato pattern RAII.
Questo pattern ha un impatto profondo sul modo in cui viene scritto il codice Rust. Può sembrare semplice ora, ma il comportamento del codice può essere inaspettato in situazioni più complicate quando vogliamo che più variabili utilizzino i dati che abbiamo allocato nell’heap. Esploreremo alcune di quelle situazioni ora.
Variabili e Dati che Interagiscono con il Movimento
Più variabili possono interagire con gli stessi dati in modi diversi in Rust. Vediamo un esempio utilizzando un intero nella Lista 4-2.
rust
let x = 5;
let y = x;
Probabilmente possiamo indovinare cosa sta facendo questo: “associa il valore 5 a x; quindi fai una copia del valore in x e associalo a y.” Ora abbiamo due variabili, x e y, e entrambe sono uguali a 5. Questo è effettivamente ciò che sta accadendo, perché gli interi sono valori semplici con una dimensione nota e fissa, e questi due valori 5 sono pushati nello stack.
Ora guardiamo la versione Stringa:
rust
let s1 = String::from("ciao");
let s2 = s1;
Questo sembra molto simile, quindi potremmo presumere che il modo in cui funziona sarebbe lo stesso: cioè, la seconda riga farebbe una copia del valore in s1 e lo assocerebbe a s2. Ma questo non è esattamente quello che succede.