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)
oppure
  • il primo carattere è _ (underscore)
  • i successivi (almeno uno, _ da solo non è un identificatore) sono caratteri alfanumerici o uno o più _
Non ho trovato indicazioni relative alla lunghezza massima degli identificatori ma possiamo tagliar corto dicendo che non è un problema. Fate qualche prova su quanto detto senza perderci troppo tempo. Per chi ha voglia di approfondire la cosa, lo standard seguito per la composizione degli identificatori è Unicode Annex 31.
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)
esempio
let(x, y, x) = (1, 2, "ciao")

oppure tramite espressioni più complesse di varia natura

let 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 costanti

const 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