Rust - Le struct
In questo capitolo affrontiamo un argomento molto importante in Rust per due motivi: primo, è un costrutto molto usato in Rust stesso, secondo permette all'utente di creare dei tipi, fortemente personalizzati e quindi conferisce al programmatore una grande capacità descrittiva ed architetturale. Il discorso relativo alle struct è davvero molto ampio e variegato e qui affrontiamo solo una parte, nel prosieguio del nostro cammino vedremo molte altre cose interessanti e più complesse.
Come definizione possiamo dire che una struct è un tipo composto
nominato che raggruppa più campi eterogenei sotto un unico nome.
Sono il principale meccanismo di Rust per modellare dati complessi, e
risultano più espressive delle tuple perché ogni campo ha un nome e quindi
può essere più facilmente reperito e non è necessario riferirsi ad un
ordinamento sequenziale per accedere ad esso il che evidentemente ne
facilita l'utilizzo. Va inoltre sottolineato come anche le struct
offrano astrazione a costo zero, se costruiamo tipi complessi non
ci sono quindi overhead nascosti. In questo paragrafo dovremo giocoforza
anticipare alcuni argomenti che spiegheremo più avanti. Caso mai potrete
tornarci sopra, intanto da qui non scappano.
Rust definisce 3 tipi di struct:
- struct classica - con campi nominati
- tuple struct - hanno campi senza nome ma con tipo specificato
- unit-like struct - che non hanno campi
Inizieremo ovviamente a descrivere il tipo più usato, la struct
classica, ma li
vedremo tutti.
la keyword che descrive una struct è, banalmente,
struct (che vale per tutti i tipi di struct
ovviamente) e il fomato più generale è il
seguente:
struct Identificatore {
campo-1: tipo-1,
campo-2: tipo-2,
....
campo-n: tipo-n
}
si noti che i singoli elementi sono separati da una virgola (eventualmente l'ultima, prima della graffa di chiusura, può essere omessa) e l'identificatore della struct, come da linee guida, inizia con una maiuscola, o meglio segue la consueta convenzione CamelCase, il compilatore emette un warning se non trova l'iniziale maiuscola. Ora vediamo un classico esempio, anzi più che classico, quasi un tormentone sull'argomento, che ricalca la formulazione precedente:
struct Persona {
nome: String,
cognome: String
eta: u8
}
Abbiamo 3 campi con i relativi nomi e tipi associati. E' una struct classica tramite la quale abbiamo definito il nostro tipo "Persona". Come tutti i tipi resta sulla carta fintanto che non effettuiamo una istanziazione. In pratica dobbiamo creare una variabile che abbia per tipo la struttura che abbiamo definito precedentemente. Quindi basandoci sulla struct precedentemente definita vediamo un esempio di base:
Esempio 15.1
struct Persona {
nome: String,
cognome: String,
eta: u8
}
fn main() {
let p01 = Persona {
nome: String::from("Mario"),
cognome: String::from("Rossi"),
eta: 50,
};
print!("{} {} {}",p01.nome, p01.cognome, p01.eta)
}
la variabile p01 è creata sulla struct Persona e quindi ne è una istanza. Di seguito troviamo l'inizializzazione dei campi che, avrete notato, avviene tramite l'operatore : (due punti) e non per via di = come forse ci si poteva aspettare. L'accesso ai campi stessi è possibile grazie all'operatore . (punto). la cosiddetta dot notation. Affinchè i campi inizializzati possano essere successivamente variati è necessario che l'istanza sia dichiarata mut. L'istanziazione diventerebbe pertanto:
let mut p01 = Persona {
e questo permette di scrivere successivamente
istruzioni come:p01.nome = "Luigi".to_string();
Non è possibile invece dichiarare solo alcuni campi come mutabili. L'istanza, se è mutabile, lo è in tutti i suoi campi.
Una importante e idiomatica possibilità che ci viene offerta è la cosiddetta field init shorthand che è una piccola scorciatoia sintattica che rende più pulita la costruzione di una struct quando hai variabili locali con lo stesso nome dei campi della struct. In pratica, se un campo della struct ha lo stesso nome di una variabile locale, possiamo scrivere solo il nome del campo invece di campo: campo. Ad esempil, avendo definito una struct Persona come sopra potremmo creare una istanza basandoci su variabili definite:
let nome = String::from("Mario");
let cognome = String::from("Rossi");
let eta = 50;
let p = Persona {
nome: nome,
cognome: cognome,
eta: eta,
};quindi abbiamo 3 variabili locali con i nomi coincidenti con quelli dei campi interni alla struct. Possiamo fare come nel programma che segue:
Esempio 15.2
struct Persona {
nome: String,
cognome: String,
eta: u32,
}
fn main() {
let nome = String::from("Mario");
let cognome = String::from("Rossi");
let eta = 50;
let p = Persona { nome, cognome, eta };
println!("{} {} ha {} anni", p.nome, p.cognome, p.eta);
}
Il compilatore provvede all'abbinamento corretto in base ai nomi. In questo modo la definizione dell'istanza sia molto più compatta e leggibile.
E' possibile
fornire una inizializzazione diciamo di default per i campi, di modo che
questi siano già inizializzati quando creiamo una istanza? La risposta breve
è no in linea di massima ma in realtà qualcosa si può fare:
1) Se i tipi che
caratterizzano tutti i campi ammettono un valore di default
tale valore può essere richiamato:
Esempio 15.3
fn main() {
#[derive(Debug, Default)]
struct Configurazione {
larghezza: u32, // default: 0
altezza: u32, // default: 0
titolo: String, // default: ""
visibile: bool, // default: false
}
let c = Configurazione::default();
println!("{:?}", c);
// Configurazione { larghezza: 0, altezza: 0, titolo: "", visibile: false
}
In questo caso tutti i tipi previsti nella struct ammettono un valore di default e l'istanza la richiama. In questo caso avremo quindi:
| Configurazione { larghezza: 0, altezza: 0, titolo: "", visibile: false } |
2) Esiste un altro sistema che ritroviamo anche sul sito ufficiale. Anticipiamo l'uso di una funzione, come sapete dall'inizio la keyword che identifica le funzioni è fn:
Esempio 15.4
struct Punto {
x: i32,
y: i32,
z: i32,
}
fn inizializzaPunto(y: i32) -> Punto {
Punto { x: 0, y, z: 0 }
}
fn main() {
let p = InizializzaPunto(5);
println!("Punto: x={}, y={}, z={}", p.x, p.y, p.z);
}
Abbiamo definito una funzione che esprime come valore di ritorno una struct. Internamente prende la struct di nome Punto e la presenta con due campi inizializzati ed un terzo, precisamente il parametro y, che invece è libero. Il richiamo della funzione passa il parametro 5 alla struct viene inizializzata con quel valore insieme agli altri due preimpostati che restano fissi. Ovviamente possiamo usare la funzione quante volte vogliamo per produrre istanze con valori y diversi a seconda delle nostre necessità. Può essere comodo in alcuni casi.
Il nostro linguaggio
permette alcuni sistemi per limitare la necessità di digitazione quando si
lavora sulle struct. Vediamo un esempio:
Esempio 15.5
struct Persona {
nome: String,
cognome: String,
anni: i32
}
fn main(){
let p1 = Persona {
nome: String::from("Mario"),
cognome: String::from("Rossi"),
anni: 25
};
let p2 = Persona {
nome: p1.nome,
cognome: p1.cognome,
anni: 28
};
let p3 = Persona {
nome: String::from("Giuseppe"),
..p2
};
println!("p3: {} {} {}", p3.nome, p3.cognome, p3.anni);
}
con output:
| p3: Giuseppe Rossi 28 |
questo perchè:
- definiamo una variabile p1 con una certa inizializzazione dei campi
- definiamo una variabile p2 che riprende i valori di p1 tranne il campo "anni"
- definiamo una variabile p3 che modifica il nome ma poi riprende tutti i campi di p2
in casi complessi questo può risparmiare un po' di battute sulla tastiera.Abbiamo introdotto l'operatore .. (struct update) che in questo caso significa qualcosa come "tutto il resto è uguale ai corrispondenti campi di p2". Non è però tutto oro quello che luccica. Come sempre le stringenti regole di Rust saltano fuori a tempo debito. Vediamo ad esempio il seguente codice:
struct Test {
stringa: String,
}
fn main(){
let t1 = Test {
stringa: String::from("Ciao")
};
let t2 = Test {
stringa:
t1.stringa,
};
println!("t1: {}", t1.stringa);
}
se provate a compilare vi viene fuori:
error[E0382]: borrow of moved value:
`t1.stringa`
--> r223.rs:12:24
|
10 |
stringa: t1.stringa,
| ---------- value moved here
11 | };
12 | println!("t1: {}", t1.stringa);
| ^^^^^^^^^^ value borrowed here after
move
|
= note: move occurs because `t1.stringa`
has type `String`, which does not implement the `Copy` trait
per la stringa t1, che non supporta copy passa di proprietà e va a t2 quindi t1 non può più usarla. La soluzione è evidentemente usare clone() e quindi possiamo modificare:
stringa: t1.stringa;
instringa: t1.stringa.clone();
e allora tutto torna a funzionare. In pratica i procedimenti dell'esempio 15.2 effettuano move o copy in dipendenza dal tipo di dato, non siamo di fronte ad una deep copy in nessuna parte.
Già che ci siamo quindi vediamo il problema di creare una deep copy di una struct. La strada maestra, laddove in particolare i campi non supporti il trait Copy. Una prima soluzione prevede l'uso della cosidetta macro di derivazione o derve macro nella sua dizione originale che si presenta così:
#[derive(Copy, Clone)]
questa istruzione si compone di 3 passi:
-
#(Cancelletto): Indica a Rust che quello che segue è un attributo, ovvero dei metadati che forniscono istruzioni speciali al compilatore. -
derive: È la macro predefinita di Rust che dice al compilatore: "Prendi la struct (o l'enum) che si trova qui sotto e genera automaticamente del codice per me". -
(Copy, Clone): Sono i Tratti (Trait) che stiamo chiedendo a Rust di implementare automaticamente. Copy è un sotto-trait di Clone ovvero non si può implementare Copy su qualcosa che non implementa anche Clone. Quindi sarebbe un errore indicare solo Copy.
Questa è la premessa. Vediamo quindi l'esempio:
Esempio 15.6
#[derive(Clone, Debug)]
struct Dati {
x: i32,
d: String,
}
fn main() {
let dati1 = Dati {
x: 10,
d: String::from("Hello"),
};
println!("Dati1: {:?}", dati1);
let mut dati2 = dati1.clone();
println!("Dati2: {:?}", dati2);
dati2.d = String::from("World");
println!("Dati1: {:?}", dati1);
println!("Dati2: {:?}", dati2);
}
che ci darà il seguente output:
| Dati1: Dati { x: 10, d: "Hello" } Dati2: Dati { x: 10, d: "Hello" } Dati1: Dati { x: 10, d: "Hello" } Dati2: Dati { x: 10, d: "World" } |
dimostrando quindi l'indipendenza delle due strutture.
Oppure, usando
il procedimento del passaggio dei dati:
let dati1 = Dati {
x: 10,
d: String::from("Hello"),
};
println!("Dati1: {:?}", dati1);
let mut dati2 = Dati {
d:
"World".to_string(),
..dati1.clone()
};
println!("Dati1: {:?}",
dati1);
println!("Dati2: {:?}", dati2);
Da notare che l'istruzione di clonazione (evidenziata in rosso) deve sempre essere l'ultima all'interno della definizione della struct.
Possiamo destrutturare le struct? Certo anche se vedremo poco più avanti in questo paragrafo un sistema più immediato, a mio avviso, se proprio vi serve questo tipo di procedimento. Vediamo comunque l'esempio:
Esempio 15.7
struct P {
x1: i32,
y1: f32,
}
fn main() {
let p = P {
x1: 1,
y1: 2.1
};
let P {
x1: x2,
y1: y2
} = p;
println!("{}, {}", x2 * 2, y2);
}
da cui ricaviamo:
| 2, 2.1 |
Quindi abbiamo istanziato una struct e successivamente abbiamo ridefinito due variabili, x2 e y2 alle quali abbiamo abbinato i valori dell'istanza precedentemente definita. Forse un po' macchinoso ma funziona. Vediamo
Parliamo adesso delle tuple-struct.
Qual è la loro utilità ovvero, che problema affrontano e risolvono? Un tupl "normale", come abbiamo visto non è altro che un raggruppamento di valori.
let t01 = (1,2);
let t02 = (3,4);
dal punto di vista della tipologia t01 e t02 sono assolutamente identici ovvero:
(i32, i32)
(i32, i32)
e quindi sono anche interscambiabili. Questo può dare origine a qualche problema ad esempio potrebbero essere usate una al posto dell'altra e per il compilatore questo andrebbe bene pur se semanticamente sarebbe scorretto. Le struct tuple servono ad evitarer queste possibili ambiguità. Infatti se scriviamo:
struct s01 (i32, i32);
struct s02 (i32, i32);
e poi istianziamo
is01 = s01(1,2);
is02 = s02(3,4);
a questo punto le istanze non sono confondibili tra di loro. E' la differenza che passa tra un tipo strutturale, le tuple ed uno nominale, le tuple struct dove è il nome che fa la differenza. In generale le struct sono quindi tipi nominali, è il nome che caratterizza il tipo
Vi sono altri punti in comune tra tuple e tuple struct ad esempio, l'accesso ai campi avviene tramute indice numerico, nell'esempio precedente
is01.0 è il valore 1is02.1 è il valore 4
ma vi sono anche altre differenze, ad esempio le tuple implementano automaticamente i trait come Debug, Clone e altri mentre le tuple struct non lo fanno e bisogna procedere a mano. Le tuple struct però ammettono funzioni al loro interno, come vedremo, le tuple semplici non lo posso fare. Le tuple struct sono anche più leggibili come firma di funzioni ma le funzioni, lo so le dobbiamo ancora vedere.
Proseguendo la condivisione delle caratteristiche tra tuple e tuple struct non possiamo non citare la destrutturazione:
Esempio 15.8
fn main() {
struct Punto(f64, f64);
let Punto(x, y) = Punto(3.0, 4.0);
println!("x={x}, y={y}");
}
con output:
| x=3, y=4 |
che risulta forse più semplice di quanto visto nelle'esempio relativo alle struct normali. Vediamo ancora un esempio di destrutturazione:
Esempio 15.9
struct S {
a: String,
b: i32,
c: f32,
}
fn main() {
let s = S {
a: "ciao".into(),
b: 10,
c: 3.14,
};
let S { a, b, c } = s;
println!("a: {}, b: {}, c: {}", a, b, c);
}
e qui abbiamo:
| a: ciao, b: 10, c: 3.14 |
I campi possono essere rinominati, ad esempio sostituiamolet S {a, b,
c} = s;
conlet S {a:aa, b:bb, c:cc} = s;
e le tre variabili si
chiameranno aa, bb, cc
Un uso non raro che si può avere con le tuple struct è quello del newtype pattern. Si tratta di wrappare un tipo primitivo attribuendogli una semantica diciamo "di proprietà":
Esempio 15.10
fn main() {
struct Metri(f64);
struct Secondi(f64);
let m = Metri(10.0);
let s: Secondi = m; // ERRORE di compilazione
}
Questo programma non compila. Attribuire un valore f64 ad un'altra variabile f64 non dovrebbe essere un problema ma qui abbiamo appunto inscatolato un f64 attribuendogli un tipo custom che non coincide con quello naturale ma è, appunto, personalizzato. Cipossono altri esempi pratici di creazione di tipi per specifici compiti:
struct Username(String);
struct Password(String); Unit-like struct
Sono decisamente interessanti. Niente corpo con graffe, nulla. La loro occupazione in memoria è zero.
Esempio 15.11
fn main() {
use std::mem;
struct Vuota;
println!("{}",
mem::size_of::());
}
Qui avremo 0 come occupazione di memoria. Questo tipo di struct si
istanzia senza parentesi (sarebbero inutili)let _v = Vuota;
Il loro uso è avanzato e direi piuttosto particolare ma non è oggetto di questo paragrafo di base.
Ptima delle conclusione vediamo un paio di aspetti che mi hanno messo un
po' in confusione nel senso che a mio avviso vanno chiariti con sezioni di
approfondimento.
Il primo aspetto che voglio dettagliare è quello del
partial move. Questo meccanismo ci permette di
spostare solo alcuni campi di una struct lasciando gli
altri ancora validi. Vediamo come funziona.
esempio 15.12
struct S {
a: String,
b: i32,
}
fn main() {
let s = S {
a: "ciao".into(),
b: 10,
};
let x = s.a; // move
println!("{}", s.b); // OK: b è ancora valido
println!("{}", x); // OK: b è ancora valido
// println!("{:?}", s.a); // ERRORE: struct parzialmente mossa
}
Anche in questo caso il problema è dato da quegli elementi che non implementano copy. Come si vede b, che è un intero non crea problemi mentre la stringa, come detto e ridetto non implementa Copy, viene mossa all'esterno ed assegnata ad x. Il campo b è ancora utilizzabile via istanza mentre se provaste ad eliminare la riga commentata il compilatore si esprimerebbe come di consueto.
error[E0382]: borrow of moved value: `s.a`
Stessa cosa se si ricorresse ad una destrutturazione. La variabile non Copy assegnata non può più essere utilizzata insieme ad una istanza della struct,
Per le struct il discorso è ancora lungo, bisogna vedere le funzioni all'interno delle struct, che avvicina queste agli oggetti di altri linguaggi, bisognerà analizzare le struct come parametri e valori di ritorno di una funzione e una volta definito il tipo Option vedremo un esempio in cui la struct può avere un campo, o più campi nulli e poi anche sarà interessante vedere la collaborazione con il pattern matching in particolare per quanto riguarda il discrso della destrutturazione e infine studiare anche il fondamentale concetto di lifetime legato alle struct. Non vi basta? Parleremo anche di builder pattern ... insomma non finisce certo qui.