Rust - Variabili e costanti
Le variabili in un programma servono, in buona sostanza, per contenere dei valori. Fisicamente questi valori sono contenuti in una certa locazione di memoria e vengono individuati tramite dei "nomi", in realtà detti identificatori, scelti dal programmatore. Questi nomi devono seguire certe regole formali. La prima e ovvia è che gli identificatori prescelti non devono essere uguali alle keyword del linguaggio. Esiste un modo per aggirare questo ostacolo, come vedremo, che può essere utile in caso si debba far cooperare il nostro programma scritto in Rust con altri moduli scritti magari in C e che contengono identificatori identici alle keyword di Rust. Oppure nel caso in cui in versione future vengano introdotte delle keyword che tali non sono adesso e vi troviate a cooperare con programmi scritti con vecchie versioni. A parte questi casi che devono essere previsti in fase di design del linguaggio ma sono decisamente rari, tenete presente questa limitazione.
Da un punto di vista formale le regole che gestiscono i nomi da attribuire alle variabili, possono essere riassunte come segue:
- il primo carattere è una lettera
- i successivi (facoltativi) sono alfanumerici oppure _ (underscore)
- il primo carattere è _ (underscore)
- i successivi (almeno uno, _ da solo non è un identificatore) sono caratteri alfanumerici o uno o più _
Il carattere _ usato da solo come identificatore ha però anche un uso pratico peculiare che può risultare utile: in questa forma significa sostanzialmente "ignorami" e vedremo in azione questo apparentemente strano comportamento in vari casi.
E' il momento di un esempio, anticipando l'uso della keyword let che serve per definire una variabile, ma lo ne parleremo in dettaglio tra breve, qui concentriamoci sull'identificatore:
Esempio 3.1
fn main() {
let x = "ciao";
}
Questo programma è formalmente corretto ma il compilatore emette un
warning:
warning: unused variable: `x`
--> r014.rs:2:9
|
2 | let x = "ciao";
| ^ help: if this is intentional, prefix it with an underscore: `_x`
|
= note: `#[warn(unused_variables)]` on by default
Ho evidenziato le due righe di interesse che indicano:
1) che la variabile x non è utilizzata per alcuno scopo e in effetti è
solo definita
2) e qui si vede la bontà del compilatore, vi dice come fare per evitare
il warning (un altro avviso interessante è nell'ultima riga del
messaggio ma di esso riparleremo a suo tempo)
Se provate a modificare il programma indicando nella riga 2 la variabile
non
x ma
_x
ovvero con il carattere _ in prima posizione,
il messaggio viene silenziato.
Caso particolare, come detto, sono gli identificatori raw. Essi
permettono utilizzare le keyword del linguaggio come identificatori. La
forma che li distingue è la seguente:
r#identificatore
vediamo un esempio:
Esempio 3.2
fn main() {
let r#type = "ciao";
print!("{}", r#type)
}
Per capire come il compilatore vede realmente la variabile
r#type (con type che è
keyword in altri linguaggi) basta togliere la riga 3 e, compilando otterrete
un warning, di cui ormai sapete l'origine, che inizia così:
warning: unused variable: `type`
Ci sono però 4 keyword che non
possono essere usate come identificatori nemmeno se precedute dal prefisso
r#; esse sono: crate,
self, Self,
super. Come detto, le variabili raw le
dovrete usare solo in casi abbastanza particolari.
DICHIARAZIONE DELLE
VARIABILI
Abbiamo anticipato poco sopra la keyword let. Essa
serve per creare una variabile, come abbiamo fatto nell'esempio
precedente, ovvero introduce il binding tra un identificatore ed un valore e
quindi permette di attribuire un valore ad una nuova variabile tramite il
successivo operatore di assegnazione =. Formalmente:let identificatore = valore
In
realtà la definizione più corretta dovrebbe essere let pattern =
espressione;
in quanto a sinistra può esserci qualcosa di più di un
singolo identificatore e a destra qualcosa di più di un semplice valore,
come vedremo poco più avanti. Ma, per i nostri scopi iniziali, ci rifacciamo
alla via più comoda.
Quindi, per esempio:let x = "ciao";
let
y = 3;
sono dichiarazione valide di variabili.
La dichiarazione
può avvenire anche per gruppi di variabili (si parla di tuple
o ennuple, ne parleremo
tra qualche paragrafo) appunto un pattern invece di una singola voce.
let (var-1, var-2, var-3... var-n) = (valore-1, valore-2, valore-3,...
valore-2)
esempiolet(x, y, x) = (1, 2, "ciao")
oppure tramite
espressioni più complesse di varia naturalet x = ((4 /2) * 3)
o anche tramite valori di ritorno di funzioni, come vedremo in molti
esempi futuri. Non si può invece scrivere cose come:let x = (let y =
6);
perchè l'assegnazione non è una espressione che restituisce un
valore, quindi il compilatore non sa cosa attribuire a x.
Il termine
"variabili" in questi casi è poco appropriato, almeno nell'accezione comune,
perchè le definizioni precedenti attribuiscono certamente il valore di
destra agli elementi di sinistra ma tale valore è immutabile, quindi
non
possiamo attribuire agli elementi inizializzati un valore diverso da quello
originale. Quindi
una volta attribuito ad esempio il valore 3 a y se scriviamo una ulteriore
assegnazione y = 4;
riceveremo un chiaro messaggio di errore:
error[E0384]: cannot assign twice to immutable variable `y`
-->
r016.rs:3:5
|
2 | let y = 3;
| -
| |
| first assignment to
`y`
| help: consider making this binding mutable: `mut y`
3 | y = 4;
| ^^^^^ cannot assign twice to immutable variable
Anche qui il
compilatore non solo è chiaro ma ci propone anche una soluzione, come
evidenziato. Si tratta di legare a let un'altra keyword ovvero
mut. Essa,
come suggerisce il suo stesso nome, serve a rendere modificabile una
variabile.let mut identificatore = valore;
variabile =
nuovovalore;
Grazie a mut la nostra variabile diventa a tutti gli
effetti variabile (anche più volte).
Esempio 3.3
fn main() {
let mut x = 0;
x = x + 1;
print!("{}", x)
}
che stampa il valore 1 a video. Va da sè, che il nuovo valore
deve essere coerente per tipologia con quello già attribuito alla variabile,
vedremo qui di seguito un chiarimento sull'aspetto relativo ai tipi.
Rust ci permette anche di scrivere sequenze di questo genere, che non sono
banali in realtà nell'economia del linguaggio:let x = 1;
let x = "aaa";
ovviamente le due dichiarazioni possono non essere consecutive ma quello
che è importante notare è che in questa sequenza il valore finale di x è
"aaa" mentre il valore 1 precedentemente attribuito non è più raggiungibile.
Questo sembra in contrasto con quanto detto relativamente alla immutabilità
delle variabili non precedute da mut. In realtà la seconda definizione
crea
una nuova variabile in un nuovo spazio di memoria, (il precedente può essere
riutilizzato) variabile completamente indipendente dall'altra. E' una scelta
di design del linguaggio che ha i suoi pro e i suoi contro, a mio avviso,
nell'ambito di un discorso complesso. Esistono tecniche per ora al di fuori
della nostra portata per blindare un valore, in fondo al paragrafo troveremo
un primo metodo, valido a certe condizioni.
Ogni variabile, come
noto, ha un valore che appartiene ad un tipo ben preciso e definito. Questo
può essere un intero, un carattere, un valore booleano, ecc, li vedremo
tutti. Per la gestione dei tipi Rust prevede anche l'inferenza di tipo, un potente
meccanismo che, sulla base del valore attribuito alla variabile, si incarica
di assegnare alla variabile stessa il tipo corretto per quel valore. In
pratica se scrivo:let x = 3;
il meccanismo di inferenza
assegnerà a x il tipo i32, che è il default per gli interi. Sulla base di
questo si capisce come l'inizializzazione di una variabile sia importante.
Se scriviamo infatti qualcosa come:let x;
e basta, il
compilatore si fa sentire:
error[E0282]: type annotations needed
--> r025.rs:2:9
|
2 | let x;
| ^
|
help: consider giving `x`
an explicit type
|
2 | let x: /* Type */;
| ++++++++++++
sulla base del help incluso nel messaggio
e in particolare nella riga evidenziata, potremmo pensare di aggiungere noi
il tipo (cosa che è perfettamente lecita, come vedremo di seguito)
let x: i32;
che il compilatore accetta, pur col solito warning
relativo alle variabili non utilizzate. Se però proviamo ad usare la nostra
x ad esempio cercando di stamparla a video avremo questo ulteriore
messaggio:
error[E0381]: used binding `x` isn't initialized
-->
r027.rs:3:18
|
2 | let x: i32;
| - binding declared here but left
uninitialized
3 | print!("{}", x);
| ^ `x` used here but it isn't
initialized
anche questo direi piuttosto chiaro, leggendo con
attenzione. Si capisce quindi anche che non esiste una inizializzazione di
default. Se successivamente alle due forme di dichiarazione si aggiunge poi esplicitamente un
valore, allora le cose funzionano:let x: i32; // definizione.
x =
0; // attribuzione del valore iniziale
let x; // definizione
x =
0; // definizione del valore iniziale e del tipo via inferenza
così
va bene. Queste assegnazioni possono essere fatte anche non immediatamente
di seguito alla creazione della variabile ma comunque prima di qualsiasi
utilizzo.
Abbiamo accennato alla possibilità di attribuire
manualmente un tipo alle variabili senza lasciar fare alla inferenza. Come
visto negli esempi presentati appena sopra, questo è possibile tramite
l'operatore : . Quindi formalmente la cosa funziona così:let
identificatore: tipo = espressione.
l'espressione ovviamente deve
essere di tipo coerente col tipo dichiarato. Alcuni tipi permettono di
inserire un valore di default per il tipo come segue:let x1 : i32 =
Default::default();
questo mi pare possa avere senso solo se non vi
ricordate quale possa essere tale valore....
Un breve ragionamento,
per chi non lo conoscesse, lo dobbiamo fare sul significato di ambito di
visibilità (o scope, nella dizione inglese), delle variabili che in questo
linguaggio non è dissimile da quanto si incontra in altri. Sostanzialmente
si tratta del perimetro entro il quale una variabile esiste e si può
interagire con essa. Non è mia intenzione sviscerare un argomento piuttosto
intuitivo e non peculiare di Rust ma mostrerò come le cose funzionino
secondo logica anche in questo linguaggio (tra quelli che conosco solo i
linguaggi V e Zig non ammettono questo meccanismo). L'esempio seguente
chiarirà un po' le cose:
1 fn main() {
2 let x = 0;
3 {
4 let x = 9;
5 println!("{}", x);
6 }
7
println!("{}", x);
8 }
Questo programma dà come output:
| 9 0 |
a seguito delle istruzioni alla riga 5 e 7. La cosa funziona così in quanto la parentesi alla riga 3 apre un perimetro interno a quello aperto dalla graffa alla riga 1. All'intero del perimetro aperto alla riga 3 abbiamo la definizione di una variabile x che non copre quella definita alla 2 in quanto, appunto, si trova in uno scope diverso che termina alla riga 6 laddove la x definita alla riga 4 cessa di esistere. Anche se si chiamano allo stesso modo le due "x" sono totalmente separate. Eliminando le graffe alle righe 3 e 6 invece, la x ora definita alla riga 4 ricoprirebbe (si parla di "shadowing") quella alla riga 2 ed il risultato dell'esecuzione del programma sarebbe
| 9 9 |
Abbiamo accennato alla possibilità di blindare un valore ad un
certo identificatore senza che questo possa essere toccato. Un modo
è quello che uso della keyword const che,
come intuibile, introduce un valore costante. Le condizioni per l'uso delle
costanti sono:
1) bisogna sapere in fase di scrittura del codice il
valore da attribuire alla costante
2)
bisogna esplicitare il tipo
3) seppure opzionalmente, è proposto tra le
linee guida, l'identificatore della costante dovrebbe essere scritto con
lettere tutte maiuscole. Se non lo fate il compilatore emette un warning ma
fa il suo lavoro lo stesso.
Quindi formalmente l'ideale sarebbe:
const NOMECOSTANTE: tipo = valore;
esempio:const X: i32 =
4;
const ST: String = "ciao"
Il tentativo di modificare un valore
costante tramite assegnazione diretta o tramite let dà origine ad un errore,
come è facile constatare. E' consequenziale che cercare di usare
mut su
elemento const, vista la sua finalità, originerà un errore. La attribuzione
di un valore ad una costante può essere effettuato solo tramite valori
costanti, anche sotto forma di espressioni, non tramite variabili pur se
inizializzate mentre sono utilizzabili altre costanticonst X:i32 = 6
* 3; // va bene
let y = 9;
const X:i32 = 9 * y; // non compila
const Y: i32 = 9;
const X: i32 = 9 * Y // va bene