Metodi
I metodi (method) sono simili alle funzioni: le dichiariamo con la parola
chiave fn
e un nome, possono avere parametri e un valore di ritorno, e
contengono del codice che viene eseguito quando il metodo viene chiamato da
un’altra parte. Diversamente dalle funzioni, i metodi sono definiti nel contesto
di una struct (o di un’enum o di un trait object, che tratteremo nel
Capitolo 6 e Capitolo 18, rispettivamente), e il loro primo parametro è sempre self
, che
rappresenta l’istanza della struct su cui il metodo viene chiamato.
Sintassi dei Metodi
Trasformiamo la funzione area
che prende un’istanza di Rettangolo
come
parametro rendendola invece un metodo definito sulla struct Rettangolo
, come
mostrato nel Listato 5-13.
#[derive(Debug)] struct Rettangolo { larghezza: u32, altezza: u32, } impl Rettangolo { fn area(&self) -> u32 { self.larghezza * self.altezza } } fn main() { let rettangolo1 = Rettangolo { larghezza: 30, altezza: 50, }; println!( "L'area del rettangolo è di {} pixel quadrati.", rettangolo1.area() ); }
area
nella struct Rettangolo
Per definire la funzione nel contesto di Rettangolo
, iniziamo un blocco impl
(implementazione) per Rettangolo
. Tutto ciò che sta dentro questo blocco
impl
sarà associato al type Rettangolo
. Poi spostiamo la funzione area
all’interno delle parentesi graffe dell’impl
e cambiamo il primo (e in questo
caso, unico) parametro in self
nella firma e ovunque nel corpo. In main
,
dove chiamavamo la funzione area
passando rettangolo1
come argomento,
possiamo invece usare la sintassi dei metodi per chiamare il metodo area
sull’istanza di Rettangolo
. La sintassi del metodo va dopo un’istanza:
aggiungiamo un punto seguito dal nome del metodo, parentesi tonde ed eventuali
argomenti.
Nella firma di area
usiamo &self
invece di rettangolo: &Rettangolo
. Il
&self
è in realtà l’abbreviazione di self: &Self
. All’interno di un blocco
impl
, il type Self
è un alias del type per cui il blocco impl
è stato
scritto. I metodi devono avere un parametro chiamato self
di type Self
come primo parametro, quindi Rust permette di abbreviare questo con soltanto il
nome self
nella prima posizione dei parametri. Nota che dobbiamo comunque
usare &
davanti alla forma abbreviata self
per indicare che questo metodo
prende in prestito l’istanza Self
, esattamente come facevamo con rettangolo: &Rettangolo
. I metodi possono prendere la ownership di self
, prendere un
reference immutabile a self
, come abbiamo fatto qui, oppure prendere un
reference mutabile a self
, proprio come possono fare con qualsiasi altro
parametro.
Qui abbiamo scelto &self
per lo stesso motivo per cui abbiamo usato
&Rettangolo
nella versione precedente: non serve che prendiamo la ownership,
vogliamo solo leggere i dati nella struct, non modificarli. Se volessimo
modificare l’istanza su cui chiamiamo il metodo come parte di ciò che il metodo
fa, useremmo &mut self
come primo parametro. Avere un metodo che prende la
ownership dell’istanza usando semplicemente self
come primo parametro è
raro; questa tecnica è solitamente usata quando il metodo trasforma self
in
qualcos’altro e si vuole impedire al chiamante di usare l’istanza originale dopo
la trasformazione.
La ragione principale per usare i metodi invece delle funzioni, oltre a fornire
la sintassi dei metodi e a non dover ripetere il type di self
in ogni firma
dei metodi, è per organizzazione. Abbiamo messo tutte le cose che possiamo fare
con un’istanza di un type in un unico blocco impl
invece di costringere chi
dovrà in futuro leggere o manutenere il nostro codice a cercare le funzionalità
di Rettangolo
in vari posti nella libreria che forniamo.
Nota che possiamo scegliere di dare a un metodo lo stesso nome di uno dei campi
della struct. Per esempio, possiamo definire un metodo su Rettangolo
che si
chiama anch’esso larghezza
:
#[derive(Debug)] struct Rettangolo { larghezza: u32, altezza: u32, } impl Rettangolo { fn larghezza(&self) -> bool { self.larghezza > 0 } } fn main() { let rettangolo1 = Rettangolo { larghezza: 30, altezza: 50, }; if rettangolo1.larghezza() { println!("La larghezza del rettangolo è > 0; è {}", rettangolo1.larghezza); } }
Qui scegliamo di fare in modo che il metodo larghezza
ritorni true
se il
valore nel campo larghezza
dell’istanza è maggiore di 0
e false
se il
valore è 0
: possiamo usare un campo all’interno di un metodo con lo stesso
nome per qualunque scopo. In main
, quando seguiamo rettangolo1.larghezza
con
le parentesi tonde, Rust sa che si intende il metodo larghezza
. Quando non
usiamo le parentesi tonde, Rust sa che intendiamo il campo larghezza
.
Spesso, ma non sempre, quando diamo a un metodo lo stesso nome di un campo vogliamo che esso ritorni soltanto il valore del campo e non faccia altro. I metodi di questo tipo sono chiamati getter (metodi di incapsulamento) e Rust non li implementa automaticamente per i campi della struct come fanno alcuni altri linguaggi di programmazione. I getter sono utili perché puoi rendere il campo privato ma il metodo pubblico, abilitando così accesso in sola lettura a quel campo come parte dell’API pubblica del type. Discuteremo cosa sono pubblico e privato e come designare un campo o un metodo come pubblico o privato nel Capitolo 7.
Dov’è l’Operatore ->
?
In C e C++ si usano due operatori diversi per accedere ai membri: si usa .
quando si lavora direttamente con un oggetto, e ->
quando si lavora con un
puntatore all’oggetto e prima bisogna de-referenziarlo. In C++, questi
operatori possono essere usati per chiamare i metodi; in C, sono usati solo
per accedere ai campi delle struct. In altre parole, se oggetto
è un
puntatore, oggetto->qualcosa()
è simile a (*oggetto).qualcosa()
.
Rust non ha un equivalente dell’operatore ->
; invece, Rust ha una
funzionalità chiamata referenziamento e de-referenziamento automatico
(automatic referencing and dereferencing). Chiamare i metodi è uno dei pochi
posti in Rust che implementa questa funzionalità.
Ecco come funziona: quando chiami un metodo con oggetto.qualcosa()
, Rust
aggiunge automaticamente &
, &mut
, o *
affinché oggetto
corrisponda
alla firma del metodo. In altre parole, i seguenti sono equivalenti:
#![allow(unused)] fn main() { #[derive(Debug,Copy,Clone)] struct Punto { x: f64, y: f64, } impl Punto { fn distanza(&self, altro: &Punto) -> f64 { let x_quad = f64::powi(altro.x - self.x, 2); let y_quad = f64::powi(altro.y - self.y, 2); f64::sqrt(x_quad + y_quad) } } let p1 = Punto { x: 0.0, y: 0.0 }; let p2 = Punto { x: 5.0, y: 6.5 }; p1.distanza(&p2); (&p1).distanza(&p2); }
Il primo sembra molto più pulito. Questo comportamento di referencing
automatico funziona perché i metodi hanno un receiver (recettore) chiaro,
il type di self
. Dato il receiver e il nome di un metodo, Rust può
determinare in modo definitivo se il metodo sta leggendo (&self
), mutando
(&mut self
), o consumando (self
). Il fatto che Rust renda implicito il
borrowing per i receiver dei metodi è una parte importante per rendere
l’ownership ergonomica nella pratica.
Metodi con Più Parametri
Esercitiamoci ad usare i metodi implementando un secondo metodo sulla struct
Rettangolo
. Questa volta vogliamo che un’istanza di Rettangolo
prenda
un’altra istanza di Rettangolo
e ritorni true
se la seconda Rettangolo
può
entrare completamente dentro self
(la prima Rettangolo
); altrimenti dovrebbe
ritornare false
. Cioè, una volta definito il metodo può_contenere
, dovremmo
poter scrivere il programma mostrato nel Listato 5-14.
fn main() {
let rettangolo1 = Rettangolo {
larghezza: 30,
altezza: 50,
};
let rettangolo2 = Rettangolo {
larghezza: 10,
altezza: 40,
};
let rettangolo3 = Rettangolo {
larghezza: 60,
altezza: 45,
};
println!("Può rettangolo1 contenere rettangolo2? {}", rettangolo1.può_contenere(&rettangolo2));
println!("Può rettangolo1 contenere rettangolo3? {}", rettangolo1.può_contenere(&rettangolo3));
}
può_contenere
ancora da scrivereL’output atteso sarà il seguente perché entrambe le dimensioni di rettangolo2
sono più piccole delle dimensioni di rettangolo1
, ma rettangolo3
è più larga
di rettangolo1
:
Può rettangolo1 contenere rettangolo2? true
Può rettangolo1 contenere rettangolo3? false
Sappiamo che vogliamo definire un metodo, quindi sarà all’interno del blocco
impl Rettangolo
. Il nome del metodo sarà può_contenere
, e prenderà un
reference immutabile di un’altra Rettangolo
come parametro. Possiamo dedurre
il type del parametro osservando il codice che chiama il metodo:
rettangolo1.può_contenere(&rettangolo2)
passa &rettangolo2
, che è un
reference immutabile di rettangolo2
, un’istanza di Rettangolo
. Questo ha
senso perché abbiamo solo bisogno di leggere rettangolo2
(invece di
modificarlo, il che richiederebbe un reference mutabile), e vogliamo che
main
mantenga l’ownership di rettangolo2
così da poterlo usare di nuovo
dopo la chiamata a può_contenere
. Il valore di ritorno di può_contenere
sarà
un Booleano, e l’implementazione verificherà se la larghezza e l’altezza di
self
sono maggiori rispetto alla larghezza e all’altezza dell’altra
Rettangolo
, rispettivamente. Aggiungiamo il nuovo metodo può_contenere
al
blocco impl
del Listato 5-13, come mostrato nel Listato 5-15.
#[derive(Debug)] struct Rettangolo { larghezza: u32, altezza: u32, } impl Rettangolo { fn area(&self) -> u32 { self.larghezza * self.altezza } fn può_contenere(&self, altro: &Rettangolo) -> bool { self.larghezza > altro.larghezza && self.altezza > altro.altezza } } fn main() { let rettangolo1 = Rettangolo { larghezza: 30, altezza: 50, }; let rettangolo2 = Rettangolo { larghezza: 10, altezza: 40, }; let rettangolo3 = Rettangolo { larghezza: 60, altezza: 45, }; println!("Può rettangolo1 contenere rettangolo2? {}", rettangolo1.può_contenere(&rettangolo2)); println!("Può rettangolo1 contenere rettangolo3? {}", rettangolo1.può_contenere(&rettangolo3)); }
può_contenere
in Rettangolo
che riceve un’altra istanza di Rettangolo
come parametroQuando eseguiamo questo codice con la funzione main
del Listato 5-14,
otterremo l’output desiderato. I metodi possono prendere parametri multipli che
aggiungiamo alla firma dopo il parametro self
, e quei parametri funzionano
proprio come i parametri nelle funzioni.
Funzioni Associate
Tutte le funzioni definite all’interno di un blocco impl
sono chiamate
funzioni associate (associated functions) perché sono associate al type
nominato dopo la parola impl
. Possiamo definire funzioni associate che non
hanno self
come primo parametro (e quindi non sono metodi) perché non hanno
bisogno di un’istanza del type per svolgere il loro compito. Ne abbiamo già
usata una: la funzione String::from
implementata sul type String
.
Le funzioni associate che non sono metodi sono spesso usate come costruttori
che ritornano una nuova istanza della struct. Spesso si chiamano new
perché
new
non è una parola chiave e non è incorporata nel linguaggio. Per esempio,
potremmo decidere di fornire una funzione associata chiamata quadrato
che
prende un parametro di dimensione e lo usa sia come larghezza sia come altezza,
rendendo più semplice creare un Rettangolo
quadrato invece di dover
specificare lo stesso valore due volte:
File: src/main.rs
#[derive(Debug)] struct Rettangolo { larghezza: u32, altezza: u32, } impl Rettangolo { fn quadrato(dimensione: u32) -> Self { Self { larghezza: dimensione, altezza: dimensione, } } } fn main() { let quad = Rectangle::quadrato(3); }
La parola chiave Self
nel type di ritorno e nel corpo della funzione è un
alias per il type che appare dopo la parola chiave impl
, che in questo caso
è Rettangolo
.
Per chiamare questa funzione associata, usiamo la sintassi ::
con il nome
della struct; let quad = Rettangolo::quadrato(3);
è un esempio. Questa
funzione è organizzata nel namespace della struct: la sintassi ::
è usata
sia per le funzioni associate sia per i namespace creati dai moduli. Parleremo
più approfonditamente dei moduli nel Capitolo 7.
Blocchi impl
Multipli
A ogni struct è permesso avere più blocchi impl
. Per esempio, il Listato
5-15 è equivalente al codice mostrato nel Listato 5-16, che ha ognuno dei metodi
nel proprio blocco impl
.
#[derive(Debug)] struct Rettangolo { larghezza: u32, altezza: u32, } impl Rettangolo { fn area(&self) -> u32 { self.larghezza * self.altezza } } impl Rettangolo { fn può_contenere(&self, altro: &Rectangle) -> bool { self.larghezza > altro.larghezza && self.altezza > altro.altezza } } fn main() { let rettangolo1 = Rettangolo { larghezza: 30, altezza: 50, }; let rettangolo2 = Rettangolo { larghezza: 10, altezza: 40, }; let rettangolo3 = Rettangolo { larghezza: 60, altezza: 45, }; println!("Può rettangolo1 contenere rettangolo2? {}", rettangolo1.può_contenere(&rettangolo2)); println!("Può rettangolo1 contenere rettangolo3? {}", rettangolo1.può_contenere(&rettangolo3)); }
impl
Non c’è motivo di separare questi metodi in più blocchi impl
in questo caso,
ma questa è una sintassi valida. Vedremo un caso in cui più blocchi impl
sono
utili nel Capitolo 10, dove discuteremo i type generici e i trait.
Riepilogo
Le struct ti permettono di creare type personalizzati significativi per il
tuo dominio. Usando le struct, puoi mantenere pezzi di dati correlati tra loro
e dare un nome a ciascun pezzo per rendere il codice chiaro. Nei blocchi impl
puoi definire funzioni associate al tuo type, e i metodi sono un tipo di
funzione associata che ti permette di specificare il comportamento che le
istanze delle tue struct hanno.
Ma le struct non sono l’unico modo per creare type personalizzati: passiamo ad un altro type di Rust, le enumerazioni, per aggiungere un altro strumento alla tua cassetta degli attrezzi.