Usare i metodi in Go
Un metodo in Go è un tipo speciale di funzione con una differenza semplice: è necessario includere un parametro aggiuntivo prima del nome della funzione. Questo parametro extra è noto come ricevitore.
I metodi sono utili quando si vogliono raggruppare le funzioni e associarle a un tipo personalizzato. Questo approccio in Go è simile alla creazione di una classe in altri linguaggi di programmazione, in quanto consente di implementare determinate funzionalità dal modello di programmazione orientata a oggetti, ad esempio incorporamento, overload e incapsulamento.
Per capire per quali motivi i metodi sono importanti in Go, si inizierà dalla procedura per dichiararli.
Dichiarare i metodi
Finora, gli struct sono stati usati solo come un altro tipo personalizzato che è possibile creare in Go. In questo modulo si apprenderà che, aggiungendo i metodi, è possibile aggiungere comportamenti agli struct creati.
La sintassi per dichiarare un metodo è simile alla seguente:
func (variable type) MethodName(parameters ...) {
// method functionality
}
Tuttavia, prima di poter dichiarare un metodo, è necessario creare uno struct. Si supponga di voler creare un pacchetto geometry e, come parte di tale pacchetto, si decide di creare uno struct per un triangolo denominato triangle
. Si vuole quindi usare un metodo per calcolare il perimetro di tale triangolo. È possibile rappresentarlo in Go come segue:
type triangle struct {
size int
}
func (t triangle) perimeter() int {
return t.size * 3
}
Lo struct è simile a uno normale, ma la funzione perimeter()
ha un parametro aggiuntivo di tipo triangle
prima del nome della funzione. Ricevitore significa che quando si usa lo struct, è possibile chiamare la funzione come segue:
func main() {
t := triangle{3}
fmt.Println("Perimeter:", t.perimeter())
}
Se si tenta di chiamare la funzione perimeter()
come di consueto, non funzionerà perché la firma della funzione indica che è necessario un ricevitore. L'unico modo per chiamare questo metodo è dichiarare prima uno struct, che consente di accedere al metodo. È possibile anche avere lo stesso nome per un metodo, purché appartenga a uno strutturato diverso. Ad esempio, è possibile dichiarare uno struct square
con una funzione perimeter()
, come segue:
package main
import "fmt"
type triangle struct {
size int
}
type square struct {
size int
}
func (t triangle) perimeter() int {
return t.size * 3
}
func (s square) perimeter() int {
return s.size * 4
}
func main() {
t := triangle{3}
s := square{4}
fmt.Println("Perimeter (triangle):", t.perimeter())
fmt.Println("Perimeter (square):", s.perimeter())
}
Quando si esegue il codice precedente, si noti che non è presente alcun errore e si ottiene l'output seguente:
Perimeter (triangle): 9
Perimeter (square): 16
Dalle due chiamate alla funzione perimeter()
, il compilatore determina la funzione da chiamare in base al tipo di ricevitore. Questo comportamento consente di mantenere coerenza e nomi brevi nelle funzioni tra i vari pacchetti ed evita di includere il nome del pacchetto come prefisso. Parleremo del perché questo comportamento può essere importante durante la presentazione delle interfacce nella prossima unità.
Puntatori nei metodi
In alcuni casi un metodo deve aggiornare una variabile. Oppure, se l'argomento del metodo è troppo grande, è consigliabile evitare di copiarlo. In questi casi, è necessario usare i puntatori per passare l'indirizzo di una variabile. In un modulo precedente, in cui sono stati presentati i puntatori, si è visto che ogni volta che si chiama una funzione in Go, Go crea una copia di ogni valore di argomento per usarlo.
Lo stesso comportamento è presente quando è necessario aggiornare la variabile del ricevitore in un metodo. Si immagini, ad esempio, di voler creare un nuovo metodo per raddoppiare le dimensioni del triangolo. È necessario usare un puntatore nella variabile del ricevitore, come indicato di seguito:
func (t *triangle) doubleSize() {
t.size *= 2
}
È possibile dimostrare che il metodo funziona come segue:
func main() {
t := triangle{3}
t.doubleSize()
fmt.Println("Size:", t.size)
fmt.Println("Perimeter:", t.perimeter())
}
Con l'esecuzione del codice precedente si ottiene questo output:
Size: 6
Perimeter: 18
Non è necessario un puntatore nella variabile del ricevitore quando il metodo accede semplicemente alle informazioni del ricevitore. Tuttavia, secondo le convenzioni di Go, se uno struct ha un metodo con un ricevitore puntatore, tutti gli altri metodi di tale struct devono avere un ricevitore puntatore. Anche se non è strettamente necessario per un metodo dello struct.
Dichiarare metodi per altri tipi
Un aspetto fondamentale dei metodi è definirli per qualsiasi tipo e non solo per i tipi personalizzati, come gli struct. Tuttavia, non è possibile definire uno struct da un tipo che appartiene a un altro pacchetto. Pertanto, non è possibile creare un metodo per un tipo di base, ad esempio string
.
Tuttavia, è possibile usare un trucco per creare un tipo personalizzato da un tipo di base e quindi usarlo come se fosse il tipo di base. Si immagini, ad esempio, di voler creare un metodo per trasformare una stringa da lettere minuscole a maiuscole. È possibile scrivere codice simile al seguente:
package main
import (
"fmt"
"strings"
)
type upperstring string
func (s upperstring) Upper() string {
return strings.ToUpper(string(s))
}
func main() {
s := upperstring("Learning Go!")
fmt.Println(s)
fmt.Println(s.Upper())
}
Con l'esecuzione del codice precedente si ottiene questo output:
Learning Go!
LEARNING GO!
Si noti che è possibile usare il nuovo oggetto s
come se si trattasse di una stringa quando si stampa per la prima volta il relativo valore. Quindi, quando si chiama il metodo Upper
, s
stampa tutte le lettere maiuscole di tipo stringa.
Incorporare i metodi
In un modulo precedente si è appreso che è possibile usare una proprietà in uno struct e incorporare la stessa proprietà in un altro struct. Ovvero è possibile riutilizzare le proprietà di uno struct per evitare la ripetizione e garantire la coerenza nella codebase. Un'idea simile si applica ai metodi. È possibile chiamare i metodi dello struct incorporato anche se il ricevitore è diverso.
Si immagini, ad esempio, di voler creare un nuovo struct per il triangolo con la logica per includere un colore. Si vuole anche continuare a usare lo struct per il triangolo dichiarato in precedenza. Lo struct per il triangolo colorato sarà quindi simile al seguente:
type coloredTriangle struct {
triangle
color string
}
Si potrebbe quindi inizializzare lo struct coloredTriangle
e chiamare il metodo perimeter()
dallo struct triangle
(e anche accedere ai relativi campi), come indicato di seguito:
func main() {
t := coloredTriangle{triangle{3}, "blue"}
fmt.Println("Size:", t.size)
fmt.Println("Perimeter", t.perimeter())
}
Procedere e includere le modifiche precedenti nel programma per vedere come funziona l'incorporamento. Quando si esegue il programma con un metodo main()
come il precedente, si dovrebbe ottenere l'output seguente:
Size: 3
Perimeter 9
Se si ha familiarità con un linguaggio OOP, ad esempio Java o C++, si potrebbe pensare che lo struct triangle
sia simile a una classe di base e che coloredTriangle
sia una sottoclasse (ereditarietà), ma ciò non è corretto. In realtà, il compilatore Go promuove il metodo perimeter()
creando un metodo wrapper, simile al seguente:
func (t coloredTriangle) perimeter() int {
return t.triangle.perimeter()
}
Si noti che il ricevitore è coloredTriangle
, che chiama il metodo perimeter()
dal campo del triangolo. La buona notizia è che non è necessario creare il metodo precedente. Si potrebbe farlo, ma se ne occupa Go dietro le quinte. L'esempio precedente è stato incluso solo a scopo di apprendimento.
Overload dei metodi
Torniamo all'esempio triangle
illustrato in precedenza. Cosa accade se si vuole modificare l'implementazione del metodo perimeter()
nello struct coloredTriangle
? Non è possibile avere due funzioni con lo stesso nome. Tuttavia, poiché i metodi necessitano di un parametro aggiuntivo (il ricevitore), è possibile avere un metodo con lo stesso nome purché sia specifico per il ricevitore da usare. L'uso di questa distinzione è il modo in cui si esegue l'overload dei metodi.
In altre parole, è possibile scrivere il metodo wrapper appena descritto se si vuole modificarne il comportamento. Se il perimetro di un triangolo colorato è il doppio del perimetro di un triangolo normale, il codice sarà simile al seguente:
func (t coloredTriangle) perimeter() int {
return t.size * 3 * 2
}
A questo punto, senza modificare nient'altro nel metodo main()
scritto in precedenza, il codice sarà simile al seguente:
func main() {
t := coloredTriangle{triangle{3}, "blue"}
fmt.Println("Size:", t.size)
fmt.Println("Perimeter", t.perimeter())
}
Quando si esegue questo codice, si ottiene un output diverso:
Size: 3
Perimeter 18
Tuttavia, se è ancora necessario chiamare il metodo perimeter()
dallo struct triangle
, è possibile accedervi in modo esplicito, come indicato di seguito:
func main() {
t := coloredTriangle{triangle{3}, "blue"}
fmt.Println("Size:", t.size)
fmt.Println("Perimeter (colored)", t.perimeter())
fmt.Println("Perimeter (normal)", t.triangle.perimeter())
}
Quando si esegue questo codice, si dovrebbe ottenere l'output seguente:
Size: 3
Perimeter (colored) 18
Perimeter (normal) 9
Come probabilmente si è notato, in Go è possibile eseguire l'override di un metodo e continuare ad accedere a quello originale, se necessario.
Incapsulamento nei metodi
L'incapsulamento indica che un metodo non è accessibile per il chiamante (client) di un oggetto. In genere, in altri linguaggi di programmazione, si usano le parole chiave private
o public
prima del nome del metodo. In Go è necessario usare solo un identificatore con iniziale maiuscola per rendere pubblico un metodo e un identificatore con iniziale minuscola per renderlo privato.
L'incapsulamento in Go ha effetto solo tra pacchetti diversi. In altre parole, è possibile nascondere solo i dettagli di implementazione da un altro pacchetto, non dal pacchetto stesso.
Per provare, creare un nuovo pacchetto geometry
e spostarvi lo struct del triangolo, come segue:
package geometry
type Triangle struct {
size int
}
func (t *Triangle) doubleSize() {
t.size *= 2
}
func (t *Triangle) SetSize(size int) {
t.size = size
}
func (t *Triangle) Perimeter() int {
t.doubleSize()
return t.size * 3
}
È possibile usare il pacchetto precedente, come indicato di seguito:
func main() {
t := geometry.Triangle{}
t.SetSize(3)
fmt.Println("Perimeter", t.Perimeter())
}
Dovrebbe essere visualizzato l'output seguente:
Perimeter 18
Se si tenta di chiamare il campo size
o il metodo doubleSize()
dalla funzione main()
, il programma andrà in panico, come segue:
func main() {
t := geometry.Triangle{}
t.SetSize(3)
fmt.Println("Size", t.size)
fmt.Println("Perimeter", t.Perimeter())
}
Quando si esegue il codice precedente, viene restituito l'errore seguente:
./main.go:12:23: t.size undefined (cannot refer to unexported field or method size)