Datatype - Tipi di dato
Ogni valore in Rust è di un determinato type, il che dice a Rust che tipo di
dati vengono specificati in modo che sappia come lavorare con quei dati.
Esamineremo due sottoinsiemi di tipi di dati: scalare e composto. Tieni presente
che Rust è un linguaggio tipizzato staticamente, il che significa che deve
conoscere il type di tutte le variabili in fase di compilazione. Il
compilatore di solito può dedurre quale type vogliamo utilizzare in base al
valore e al modo in cui lo utilizziamo. Nei casi in cui sono possibili
molteplici type, come quando abbiamo convertito uno String
in un type
numerico usando parse
nella sezione “Confrontare l’ipotesi con il numero
segreto” del
Capitolo 2, dobbiamo aggiungere un’annotazione, specificando il type in questo
modo:
fn main() {
let ipotesi: u32 = "42".parse().expect("Non è un numero!");
}
Se non aggiungiamo l’annotazione del type : u32
mostrata nel codice
precedente, Rust visualizzerà il seguente errore, il che significa che il
compilatore ha bisogno di ulteriori informazioni per sapere quale type
vogliamo utilizzare:
$ cargo build
Compiling annotazione_senza_type v0.1.0 (file:///progetti/annotazione_senza_type)
error[E0284]: type annotations needed
--> src/main.rs:3:9
|
3 | let ipotesi = "42".parse().expect("Non è un numero!");
| ^^^^^^^ ----- type must be known at this point
|
= note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `ipotesi` an explicit type
|
3 | let ipotesi: /* Type */ = "42".parse().expect("Non è un numero!");
| ++++++++++++
For more information about this error, try `rustc --explain E0284`.
error: could not compile `annotazione_senza_type` (bin "annotazione_senza_type") due to 1 previous error; 1 warning emitted
Potrai vedere annotazioni di type diverso per altri tipi di dati.
I Type Scalari
Un type scalare rappresenta un singolo valore. Rust ha quattro type scalari primari: numeri interi, numeri in virgola mobile, booleani e caratteri. Potresti riconoscerli da altri linguaggi di programmazione. Andiamo a vedere come funzionano in Rust.
Type Intero
Un intero, integer d’ora in poi, è un numero senza una componente frazionaria.
Nel Capitolo 2 abbiamo utilizzato un tipo integer, il type u32
. Questa
dichiarazione del type indica che il valore a cui è associato deve essere un
integer senza segno (i type integer con segno iniziano con i
invece che
con u
) che occupa 32 bit di spazio. La Tabella 3-1 mostra i type integer
incorporati in Rust. Possiamo usare una qualsiasi di queste varianti per
dichiarare il type di un valore intero.
Tabella 3-1: Type Integer in Rust
Lunghezza | Con Segno | Senza Segno |
---|---|---|
8-bit | i8 | u8 |
16-bit | i16 | u16 |
32-bit | i32 | u32 |
64-bit | i64 | u64 |
128-bit | i128 | u128 |
in base all’architettura | isize | usize |
Ogni variante può essere con segno o senza e ha una dimensione esplicita. Con segno e senza segno si riferisce alla possibilità che il numero sia negativo. In altre parole, se il numero deve avere un segno con sé (signed in inglese) o se sarà sempre e solo positivo e potrà quindi essere rappresentato senza segno (unsigned in inglese). È come scrivere numeri su carta: quando il segno conta, un numero viene indicato con il segno più o con il segno meno; tuttavia, quando è lecito ritenere che il numero sia positivo, viene visualizzato senza segno. I numeri con segno vengono memorizzati utilizzando la rappresentazione del complemento a due.
Ogni variante con segno può memorizzare numeri da -(2n - 1) a
2n - 1 - 1 inclusi, dove n è il numero di bit che la variante
utilizza. Quindi un i8
può memorizzare numeri da -(27) a
27 - 1, il che equivale a -128 a 127. Le varianti senza segno possono
memorizzare numeri da 0 a 2n - 1, quindi un u8
può memorizzare
numeri da 0 a 28 - 1, il che equivale a 0 a 255.
Inoltre, i type isize
e usize
dipendono dall’architettura del computer su
cui viene eseguito il programma: 64 bit se si tratta di un’architettura a 64 bit
e 32 bit se si tratta di un’architettura a 32 bit.
Puoi scrivere i letterali integer in una qualsiasi delle forme mostrate nella
Tabella 3-2. Nota che i letterali numerici che possono essere di più type
numerici permettono, tramite un suffisso, di specificarne il type in questo
modo 57u8
. I letterali numerici possono anche usare _
come separatore visivo
per rendere il numero più facile da leggere, come ad esempio 1_000
, che avrà
lo stesso valore che avrebbe se avessi specificato 1000
.
Tabella 3-2: letterali Integer in Rust
Letterali numerici | Esempio |
---|---|
Decimale | 98_222 |
Esadecimale | 0xff |
Ottale | 0o77 |
Binario | 0b1111_0000 |
Byte (solo u8 ) | b'A' |
Come si fa a sapere quale type numerico utilizzare? Se non sei sicuro, le
impostazioni predefinite di Rust sono in genere un buon punto di partenza: il
type integer di default è i32
. La situazione principale in cui puoi usare
isize
o usize
è quando indicizzi qualche tipo di collezione.
Integer Overflow
Supponiamo di avere una variabile di type u8
che può contenere valori
compresi tra 0 e 255. Se provi a cambiare la variabile con un valore al di
fuori di questo intervallo, ad esempio 256, si verificherà un integer
overflow, che può portare a uno dei due comportamenti seguenti. Quando stai
compilando in modalità debug, Rust include controlli per l’integer overflow
che fanno sì che il tuo programma vada in panico (panic d’ora in poi) in
fase di esecuzione se si verifica questo comportamento. Rust usa il termine
panic quando un programma termina con un errore; parleremo in modo più
approfondito di panic nella sezione “Errori irrecuperabili con
panic!
” nel Capitolo 9.
Quando si compila in modalità release con il flag --release
, Rust non
include i controlli per l’overflow degli integer che causano il panic.
Invece, se si verifica l’overflow, Rust esegue l’avvolgimento del complemento
a due. In pratica, i valori maggiori del valore massimo che il type può
contenere si “avvolgono” fino al minimo dei valori che il type può
contenere. Nel caso di un u8
, il valore 256 diventa 0, il valore 257 diventa
1 e così via. Il programma non andrà in panic, ma la variabile avrà un
valore che probabilmente non è quello che ci si aspettava che avesse.
Affidarsi all’avvolgimento del complemento a due degli integer è
considerato un errore. Per gestire esplicitamente la possibilità di overflow,
puoi utilizzare queste famiglie di metodi forniti dalla libreria standard per
i type numerici primitivi:
- Racchiudere tutte le modalità con i metodi
wrapping_*
, come ad esempiowrapping_add
. - Restituire il valore
None
se c’è overflow con i metodichecked_*
. - Restituire il valore e un booleano che indica se c’è stato overflow con i
metodi
overflowing_*
. - Saturare i valori minimi o massimi del valore con i metodi
saturating_*
.
Type a Virgola Mobile
Rust ha anche due type primitivi per i numeri in virgola mobile, abbreviato
float in inglese, che sono numeri con punti decimali. I type in virgola
mobile di Rust sono f32
e f64
, rispettivamente di 32 e 64 bit. Il tipo
predefinito è f64
perché sulle CPU moderne ha più o meno la stessa velocità di
f32
ma è in grado di garantire una maggiore precisione. Tutti i type in
virgola mobile sono con segno.
Ecco un esempio che mostra i numeri in virgola mobile in azione:
File: src/main.rs
fn main() { let x = 2.0; // f64 let y: f32 = 3.0; // f32 }
I numeri in virgola mobile sono rappresentati secondo lo standard IEEE-754.
Operazioni numeriche
Rust supporta le operazioni matematiche di base che ti aspetteresti per tutte le
tipologie di numero: addizione, sottrazione, moltiplicazione, divisione e resto.
La divisione degli interi tronca verso lo zero al numero intero più vicino. Il
codice seguente mostra come utilizzare ogni operazione numerica in una
dichiarazione let
:
File: src/main.rs
fn main() { // addizione let somma = 5 + 10; // sottrazione let differenza = 95.5 - 4.3; // multiplicazione let prodotto = 4 * 30; // divisione let quoziente = 56.7 / 32.2; let troncato = -5 / 3; // Restituisce -1 // resto let resto = 43 % 5; }
Ogni espressione in queste dichiarazioni utilizza un operatore matematico e valuta un singolo valore, che viene poi legato a una variabile. Appendice B contiene un elenco di tutti gli operatori che Rust mette a disposizione.
Type Booleano
Come nella maggior parte degli altri linguaggi di programmazione, un type
booleano in Rust ha due valori possibili: vero o falso (true
e false
rispettivamente d’ora in poi). I booleani hanno la dimensione di un byte. Il
type booleano in Rust viene specificato con bool
. Ad esempio:
File: src/main.rs
fn main() { let t = true; let f: bool = false; // con specificazione del type }
Il modo principale per utilizzare i valori booleani è attraverso i condizionali,
come ad esempio un’espressione if
. Tratteremo il funzionamento delle
espressioni if
in Rust nella sezione “Controllo del
flusso”.
Type Carattere
Il type carattere (char
d’ora in poi) di Rust è il tipo alfabetico più
primitivo del linguaggio. Ecco alcuni esempi di dichiarazione di valori char
:
File: src/main.rs
fn main() { let c = 'z'; let z: char = 'ℤ'; // con specificazione del type let gattino_innamorato = '😻'; }
Nota che specifichiamo i letterali char
con le singole virgolette, al
contrario dei letterali stringa, che utilizzano le virgolette doppie. Il tipo
char
di Rust ha la dimensione di quattro byte e rappresenta un valore scalare
Unicode, il che significa che può rappresentare molte altre cose oltre
all’ASCII. Le lettere accentate, i caratteri cinesi, giapponesi e coreani, le
emoji e gli spazi a larghezza zero sono tutti valori char
validi in Rust. I
valori scalari Unicode vanno da U+0000
a U+D7FF
e da U+E000
a U+10FFFF
inclusi. Tuttavia, un “carattere” non è un concetto vero e proprio in Unicode,
quindi quello che tu potresti concettualmente pensare essere un “carattere”
potrebbe non corrispondere a cosa sia effettivamente un char
in Rust.
Discuteremo questo argomento in dettaglio in “Memorizzazione del testo
codificato UTF-8 con le stringhe” nel Capitolo 8.
I Type Composti
I type composti possono raggruppare più valori in un unico type. Rust ha due type composti primitivi: le tuple e gli array.
Type Tupla
Una tupla è un modo generale per raggruppare una serie di valori di tipo diverso in un unico type composto. Le tuple hanno una lunghezza fissa: una volta dichiarate, non possono crescere o diminuire di dimensione. Creiamo una tupla scrivendo un elenco di valori separati da virgole all’interno di parentesi tonde. Ogni posizione nella tupla ha un type e i type dei diversi valori nella tupla non devono essere necessariamente gli stessi. In questo esempio abbiamo aggiunto annotazioni del type opzionali:
File: src/main.rs
fn main() { let tup: (i32, f64, u8) = (500, 6.4, 1); }
La variabile tup
si lega all’intera tupla perché una tupla è considerata un
singolo elemento composto. Per ottenere i singoli valori di una tupla, possiamo
fare pattern matching per destrutturare il valore di una tupla, in questo
modo:
File: src/main.rs
fn main() { let tup = (500, 6.4, 1); let (x, y, z) = tup; println!("Il valore di y è: {y}"); }
Questo programma crea prima una tupla e la associa alla variabile tup
. Quindi
utilizza un pattern con let
per prendere tup
e trasformarlo in tre
variabili separate, x
, y
e z
. Questa operazione è chiamata
destrutturazione perché spezza la singola tupla in tre parti. Infine, il
programma stampa il valore di y
, che è 6,4
.
Possiamo anche accedere direttamente a un elemento della tupla utilizzando un
punto (.
) seguito dall’indice del valore a cui vogliamo accedere. Ad esempio:
File: src/main.rs
fn main() { let x: (i32, f64, u8) = (500, 6.4, 1); let cinque_cento = x.0; let sei_virgola_quattro = x.1; let uno = x.2; }
Questo programma crea la tupla x
e poi accede a ogni elemento della tupla
utilizzando i rispettivi indici. Come nella maggior parte dei linguaggi di
programmazione, il primo indice di una tupla è 0.
La tupla senza valori ha un nome speciale, unit. Questo valore e il suo type
corrispondente sono entrambi scritti ()
e rappresentano un valore vuoto o un
type di ritorno vuoto. Le espressioni restituiscono implicitamente il valore
unit se non restituiscono nessun altro valore.
Type Array
Un altro modo per avere una collezione di valori multipli è un array. A differenza di una tupla, ogni elemento di un array deve avere lo stesso type. A differenza degli array in altri linguaggi, gli array in Rust hanno una lunghezza fissa.
Scriviamo i valori di un array come un elenco separato da virgole all’interno di parentesi quadre:
File: src/main.rs
fn main() { let a = [1, 2, 3, 4, 5]; }
Gli array sono utili quando vuoi che i tuoi dati siano allocati sullo stack, come gli altri type che abbiamo visto finora, piuttosto che sull’heap (parleremo dello stack e dell’heap in modo più approfondito nel Capitolo 4) o quando vuoi assicurarti di avere sempre un numero fisso di elementi. Un array, però, non è flessibile come il type vettore (vector d’ora in poi). Un vector è un type simile, che consente la collezione di dati, fornito dalla libreria standard ma che è autorizzato a crescere o a ridursi di dimensione perché il suo contenuto risiede sull’heap. Se non sei sicuro se usare un array o un vector, è probabile che tu debba usare un vector. Il Capitolo 8 tratta i vector in modo più dettagliato.
Tuttavia, gli array sono più utili quando sai che il numero di elementi non dovrà cambiare. Ad esempio, se dovessi utilizzare i nomi dei mesi in un programma, probabilmente utilizzeresti un array piuttosto che un vector perché sai che conterrà sempre 12 elementi:
#![allow(unused)] fn main() { let mesi = ["Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno", "Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre"]; }
Il type array si scrive utilizzando le parentesi quadre con il type di ogni elemento, il punto e virgola e il numero di elementi dell’array, in questo modo:
#![allow(unused)] fn main() { let a: [i32; 5] = [1, 2, 3, 4, 5]; }
In questo caso, i32
è il type di ogni elemento. Dopo il punto e virgola, il
numero 5
indica che l’array contiene cinque elementi.
Puoi anche inizializzare un array in modo che contenga lo stesso valore per ogni elemento specificando il valore iniziale, seguito da un punto e virgola, e poi la lunghezza dell’array tra parentesi quadre, come mostrato qui:
#![allow(unused)] fn main() { let a = [3; 5]; }
L’array chiamato a
conterrà 5
elementi che saranno tutti impostati
inizialmente al valore 3
. Questo equivale a scrivere let a = [3, 3, 3, 3, 3];
ma in modo più conciso.
Accesso agli elementi dell’array
Un array è un singolo blocco di memoria di dimensione fissa e nota che può essere allocato nello stack. Puoi accedere agli elementi di un array utilizzando l’indicizzazione, in questo modo:
File: src/main.rs
fn main() { let a = [1, 2, 3, 4, 5]; let primo = a[0]; let secondo = a[1]; }
In questo esempio, la variabile denominata primo
otterrà il valore 1
perché
è il valore all’indice [0]
dell’array. La variabile denominata secondo
otterrà il valore 2
dall’indice [1]
dell’array.
Accesso all’elemento dell’array non valido
Vediamo cosa succede se cerchi di accedere a un elemento di un array che si trova oltre la fine dell’array stesso. Supponiamo di eseguire questo codice, simile al gioco di indovinelli del Capitolo 2, per ottenere un indice dell’array dall’utente:
File: src/main.rs
use std::io;
fn main() {
let a = [1, 2, 3, 4, 5];
println!("Digita un indice dell'array.");
let mut indice = String::new();
io::stdin()
.read_line(&mut indice)
.expect("Errore di lettura");
let indice: usize = indice
.trim()
.parse()
.expect("L'indice inserito non è un numero");
let elemento = a[indice];
println!("Il valore dell'elemento all'indice {indice} è: {elemento}");
}
Se esegui questo codice utilizzando cargo run
e inserisci 0
, 1
, 2
, 3
o
4
, il programma stamperà il valore corrispondente a quell’indice nell’array.
Se invece inserisci un numero oltre la fine dell’array, come ad esempio 10
,
vedrai un risultato come questo:
thread 'main' panicked at src/main.rs:19:20:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Il programma ha generato un errore durante l’esecuzione (at runtime in
inglese) nel momento in cui ha utilizzato un valore non valido nell’operazione
di indicizzazione. Il programma è uscito con un messaggio di errore e non ha
eseguito l’istruzione finale println!
. Quando si tenta di accedere a un
elemento utilizzando l’indicizzazione, Rust controlla che l’indice specificato
sia inferiore alla lunghezza dell’array. Se l’indice è maggiore o uguale alla
lunghezza, Rust va in panic. Questo controllo deve avvenire in runtime,
soprattutto in questo caso, perché il compilatore non può sapere quale valore
inserirà l’utente quando eseguirà il codice in seguito.
Questo è un esempio dei principi di sicurezza della memoria di Rust in azione. In molti linguaggi di basso livello, questo tipo di controllo non viene fatto e quando si fornisce un indice errato, si può accedere a una memoria non valida. Rust ti protegge da questo tipo di errore uscendo immediatamente invece di consentire l’accesso alla memoria e continuare. Il Capitolo 9 tratta di altri aspetti della gestione degli errori di Rust e di come puoi scrivere codice leggibile e sicuro che non va in panic né consente l’accesso non valido alla memoria.