Variabili e tipi

Immutabilità

Come abbiamo visto, le variabili in Rust sono immutabili per default, questo potrebbe sembrare un ostacolo ma è una delle feature del linguaggio che tendono verso la safety e la sicurezza. Come abbiamo visto l'assegnamento di una variabile immutabile causa un errore di compilazione

fn main() {
    let var = 7;
    println!("var: {var}");
    var = 4;
    println!("var: {var}");
}

provando a compilare, ci viene restituito questo errore:

error[E0384]: cannot assign twice to immutable variable `var`
 --> src/main.rs:4:5
  |
2 |     let var = 7;
  |         ---
  |         |
  |         first assignment to `var`
  |         help: consider making this binding mutable: `mut var`
3 |     println!("var: {var}");
4 |     var = 4;
  |     ^^^^^^^ cannot assign twice to immutable variable

For more information about this error, try `rustc --explain E0384`.

Shadowing

Quello che però possiamo fare è il cosiddetto shadowing, ovvero possiamo definire un'altra variabile con lo stesso nome.

fn main() {
    let var = 7;
    println!("var: {var}");
    let var = 4;
    println!("var: {var}");
}

Questa volta il programma compila con successo, e ci viene restituito l'output atteso

var: 7
var: 4

In realtà quello che stiamo facendo è mascherare la prima variabile con la seconda definizione, ma concettualmente sono variabili diverse, entrambe immutabili. Il compilatore Rust è abbastanza intelligente nella maggior parte dei casi e riesce a ottimizzare in fase di compilazione, non allocando ulteriori variabili sullo stack. Le variabili possono anche avere lo stesso nome ma tipi diversi

fn main() {
    let var = 7;
    println!("var: {var}");
    let var = "stringa di test";
    println!("var: {var}");
}

questo avviene perché effettivamente sono variabili diverse, e la prima variabile esce dall'ambiente al momento della seconda definizione.

Variabili mutabili

Possiamo comunque rendere le nostre variabili mutabili con la keyword mut, e se ne potrà cambiare il valore.

fn main() {
    let mut var = 7;
    println!("var: {var}");
    var = 4;
    println!("var: {var}");
}

Come sopra, il risultato del programma è quello atteso:

var: 7
var: 4

Tipi di dato primitivi

Fino ad adesso non abbiamo specificato nessun tipo alle variabili, ma abbiamo detto all'inizio che Rust è un linguaggio di programmazione staticamente tipizzato. Questo vuol dire che il tipo di ogni variabile deve essere definito a tempo di compilazione, e non può cambiare durante l'esecuzione del programma. Come molti altri linguaggi, Rust supporta la type inference, ovvero per la maggior parte dei casi riesce a inferire il tipo delle variabili dal contesto. Questa feature è simile alle funzionalità var di Java, e auto di c++.

Avremmo potuto annotare il tipo esplicitamente con la seguente notazione

#![allow(unused)]
fn main() {
let var: u32 = 7;
}

dove u32 è il tipo di dato intero senza segno (unsigned) rappresentabile in 32 bit. Rust supporta numerosi tipi di dato numerici, sia interi che a virgola mobile. La lista completa è presente sulla reference ufficiale.

Su questi tipi di dato si possono effettuare le operazioni matematiche comuni

fn main() {
    let somma = 7 + 3;
    let sottrazione = 37.0 - 23.5;
    let prodotto = 17*23;
    let divisione = 50.3 / 23.1;
    let resto = 14 % 5; // => 4
    let divisione_intera = 7 / 3; // => 2
}

Bisogna notare che le operazioni con gli interi possono risultare in overflow. Rust gestisce questa occorrenza in due casi:

  • se il programma è compilato in modalità debug, include dei controlli e il programma si interrompe se incorre in un overflow
  • se il programma è compilato in modalità release, non include questi controlli e al risultato applicato un wrap around. Per un comportamento consistente, è possibile usare le funzioni che eseguono sempre il wrap come wrapping_add, oppure usare le funzioni che eseguono sempre i controlli come checked_add.

Altri tipi primitivi sono i booleani e i caratteri.

fn main() {
    let vero: bool = true;
    let falso: bool = false;

    // operatori logici
    let and = vero && falso;
    let or = vero || falso;
    let not = !vero;

    let c: char = 'A';
    // è supportato anche utf-8
    let fire: char = '🔥';
}

Tuple

Le tuple sono un modo per raggruppare elementi di tipo potenzialmente diverso in una unica struttura con dimensione fissa. Possiamo accedere agli elementi usando il loro indice, oppure tramite la destrutturazione. Vedremo che quest'ultimo è un caso particolare di una feature di Rust molto usata: il pattern matching.

Il programma

fn main() {
    let tupla: (i32, f64, bool) = (500, 6.4, true);

    println!("tupla: {:?}", tupla);

    // accesso con gli indici
    println!("tupla.0: {}", tupla.0);
    println!("tupla.1: {}", tupla.1);
    println!("tupla.2: {}", tupla.2);

    // destrutturazione
    let (x, y, z) = tupla;
    println!("x: {}", x);
    println!("y: {}", y);
    println!("z: {}", z);
}

restituisce il seguente risultato.

tupla: (500, 6.4, true)
tupla.0: 500
tupla.1: 6.4
tupla.2: true
x: 500
y: 6.4
z: true

Array

Un array è una collezione di elementi con lo stesso tipo e con una lunghezza fissa. Si può dichiarare con gli elementi tra parentesi quadre, separati con una virgola.

fn main() {
    let a: [i32; 6] = [1, 34, 27, 2, -1, 0];
}

Il tipo [i32; 6] indica che l'array contiene sei elementi di tipo i32.

Array locali sono allocati sullo stack (in maniera similare agli array in c++). Vedremo più avanti come creare collezioni memorizzate sullo heap con dimensione dinamica tramite la struttura Vec.

Si può accedere agli elementi con il loro indice, e Rust cerca di limitare gli accessi out of bound. Il seguente codice

fn main() {
    let a: [i32; 6] = [1, 34, 27, 2, -1, 0];

    println!("a: {:?}", a);
    println!("a[0]: {:?}", a[0]);
    println!("a[3]: {:?}", a[3]);

    // indice out of bounds
    println!("a[6]: {:?}", a[6]);
}

restituisce il seguente errore

error: this operation will panic at runtime
 --> src/main.rs:9:28
  |
9 |     println!("a[6]: {:?}", a[6]);
  |                            ^^^^ index out of bounds: the length is 6 but the index is 6
  |
  = note: `#[deny(unconditional_panic)]` on by default

Questo codice invece

fn main() {
    let a: [i32; 6] = [1, 34, 27, 2, -1, 0];

    println!("a: {:?}", a);
    println!("a[0]: {:?}", a[0]);
    println!("a[3]: {:?}", a[3]);
}

restituisce il seguente output

a: [1, 34, 27, 2, -1, 0]
a[0]: 1
a[1]: 2

Quando il compilatore non riesce a inferire la correttezza degli accessi, comunque inserisce dei controlli a runtime, e lancia una eccezione quando si prova ad accedere a un array out of bounds. Questa è una delle feature di memory safety di Rust, linguaggi come c o c++ semplicemente effettuano l'accesso anche se l'indice non è nei limiti dell'array.