Le interfacce sono il meccanismo che Go usa per esprimere il polimorfismo, per disaccoppiare i consumer dai producer, e per rendere il codice testabile senza framework pesanti. Questo post tratta i pattern che contano nei codebase reali: polimorfismo con slice di interfacce, il design di io.Reader/io.Writer, composizione di interfacce, interfacce per i mock nei test, e il confine tra any e i generics.
Interfaccia di base e polimorfismo#
Definisci un’interfaccia che descrive il comportamento, non l’identita:
package shapes
import "math"
// Shape describes any two-dimensional figure that has an area.
type Shape interface {
Area() float64
Perimeter() float64
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius }
type Rectangle struct {
Width, Height float64
}
func (r Rectangle) Area() float64 { return r.Width * r.Height }
func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) }
type Triangle struct {
Base, Height, A, B, C float64 // A, B, C sono le lunghezze dei lati
}
func (t Triangle) Area() float64 { return 0.5 * t.Base * t.Height }
func (t Triangle) Perimeter() float64 { return t.A + t.B + t.C }Ora scrivi una funzione che funziona su qualsiasi Shape senza conoscere il tipo concreto:
// TotalArea sums the area of all shapes. It works for any mix of types.
func TotalArea(shapes []Shape) float64 {
var total float64
for _, s := range shapes {
total += s.Area()
}
return total
}
// LargestShape returns the shape with the greatest area.
func LargestShape(shapes []Shape) Shape {
if len(shapes) == 0 {
return nil
}
largest := shapes[0]
for _, s := range shapes[1:] {
if s.Area() > largest.Area() {
largest = s
}
}
return largest
}Utilizzo:
shapes := []shapes.Shape{
shapes.Circle{Radius: 5},
shapes.Rectangle{Width: 10, Height: 3},
shapes.Triangle{Base: 6, Height: 4, A: 5, B: 5, C: 6},
}
fmt.Printf("Total area: %.2f\n", shapes.TotalArea(shapes))
fmt.Printf("Largest: %+v\n", shapes.LargestShape(shapes))Circle, Rectangle e Triangle non dichiarano mai di implementare Shape. Il compilatore controlla il method set al call site. Aggiungere un nuovo tipo di shape non richiede modifiche a TotalArea o LargestShape.
Il pattern io.Reader e io.Writer#
io.Reader e io.Writer sono le interfacce piu importanti nella libreria standard di Go. Sono anche la migliore illustrazione del perche le interfacce piccole sono potenti.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}Qualsiasi tipo che implementa Read e un io.Reader. Questo include file, connessioni di rete, corpo delle risposte HTTP, buffer di byte, stream gzip e stringhe. Per questo motivo puoi scrivere una sola funzione che funziona per tutti loro:
package main
import (
"compress/gzip"
"io"
"net/http"
"os"
"strings"
)
// countBytes conta i byte da qualsiasi Reader. Non importa se
// i dati provengono da un file, un HTTP body, una stringa o un socket di rete.
func countBytes(r io.Reader) (int64, error) {
return io.Copy(io.Discard, r)
}
func main() {
// Da un file
f, _ := os.Open("data.txt")
defer f.Close()
n, _ := countBytes(f)
fmt.Printf("file: %d bytes\n", n)
// Dal corpo di una risposta HTTP
resp, _ := http.Get("https://example.com")
defer resp.Body.Close()
n, _ = countBytes(resp.Body)
fmt.Printf("http: %d bytes\n", n)
// Da una stringa
n, _ = countBytes(strings.NewReader("hello world"))
fmt.Printf("string: %d bytes\n", n)
// Da una sorgente compressa gzip (decomprime in modo trasparente)
gz, _ := gzip.NewReader(strings.NewReader(compressedData))
n, _ = countBytes(gz)
fmt.Printf("gzip: %d bytes\n", n)
}Scrivere su qualsiasi destinazione funziona allo stesso modo con io.Writer. os.Stdout, os.File, http.ResponseWriter, bytes.Buffer e gzip.Writer implementano tutti io.Writer. Una funzione che accetta io.Writer funziona con tutti loro.
Se ti trovi a scrivere una funzione che accetta *os.File o *bytes.Buffer come parametro per I/O, riconsideralo. Accetta invece io.Reader o io.Writer. La funzione diventa immediatamente piu generica e piu facile da testare (passa un bytes.Buffer nei test invece di creare un file reale).
Composizione di interfacce#
Le interfacce Go si compongono tramite embedding. Costruisci interfacce complesse da interfacce focalizzate e single-purpose:
// Queste sono definite nella libreria standard.
type ReadWriter interface {
Reader
Writer
}
type ReadCloser interface {
Reader
Closer
}
type ReadWriteCloser interface {
Reader
Writer
Closer
}Nel tuo codice:
package storage
type Reader interface {
Get(ctx context.Context, key string) ([]byte, error)
}
type Writer interface {
Put(ctx context.Context, key string, value []byte) error
Delete(ctx context.Context, key string) error
}
type ReadWriter interface {
Reader
Writer
}
// Le funzioni che devono solo leggere accettano Reader.
// Le funzioni che devono scrivere accettano Writer.
// Le funzioni che necessitano di entrambe accettano ReadWriter.
// Questo rende le dipendenze esplicite e riduce la superficie dei mock.
func exportData(ctx context.Context, r storage.Reader, keys []string) ([]byte, error) {
// ...
}Le interfacce composte ti permettono di esprimere le esatte capacita di cui una funzione ha bisogno, niente di piu. Una funzione che accetta un’interfaccia grande con venti metodi e piu difficile da mockare e piu difficile da ragionare rispetto a una che accetta un’interfaccia a due metodi.
Interfacce per la testabilita#
Il motivo piu pratico per definire interfacce in Go e rendere il codice testabile senza dipendenze esterne. Definisci una thin interface su qualsiasi dipendenza con effetti esterni:
package http
import "net/http"
// HTTPClient abstracts net/http.Client for testing.
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// APIClient usa HTTPClient, non *http.Client direttamente.
type APIClient struct {
base string
client HTTPClient
}
func NewAPIClient(base string, client HTTPClient) *APIClient {
return &APIClient{base: base, client: client}
}
func (c *APIClient) GetUser(ctx context.Context, id string) (*User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.base+"/users/"+id, nil)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var user User
return &user, json.NewDecoder(resp.Body).Decode(&user)
}Nel test, inietta un fake:
type fakeHTTPClient struct {
resp *http.Response
err error
}
func (f *fakeHTTPClient) Do(_ *http.Request) (*http.Response, error) {
return f.resp, f.err
}
func TestGetUser_Success(t *testing.T) {
body := `{"id":"42","name":"Alice"}`
fake := &fakeHTTPClient{
resp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
},
}
client := NewAPIClient("https://api.example.com", fake)
user, err := client.GetUser(context.Background(), "42")
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("name = %q; want Alice", user.Name)
}
}Nessuna vera chiamata HTTP. Nessun test server. Veloce, deterministico e completamente isolato.
Empty interface (any) e generics#
any (l’alias per interface{}) significa “accetto qualsiasi tipo”. Disabilita il type system per quel valore. Ogni operazione su un any richiede una type assertion a runtime.
// SBAGLIATO: perde le informazioni sul tipo, richiede assertion al call site
func printAll(items []any) {
for _, item := range items {
fmt.Println(item)
}
}
// MEGLIO: se conosci il tipo dell'elemento a compile time, usa i generics
func printAll[T any](items []T) {
for _, item := range items {
fmt.Println(item)
}
}- Il tipo e genuinamente sconosciuto a compile time (unmarshaling JSON, codice basato su reflection)
- Stai scrivendo un container che deve contenere tipi eterogenei (
map[string]anyper JSON) - Stai implementando un componente infrastrutturale di basso livello che deve funzionare con tipi arbitrari e i generics non sono sufficientemente espressivi
- Stai scrivendo una struttura dati riutilizzabile (stack, queue, set, cache)
- Stai scrivendo un algoritmo che funziona in modo identico per piu tipi (sort, map, filter)
- Vuoi la type safety a compile time senza fare boxing in
any
any nella firma di una funzione e quasi sempre un code smell. Significa che la funzione non puo essere verificata dal type checker al call site. Prima di usare any, chiediti se i generics, un tipo concreto o un’interfaccia piu specifica sarebbero piu adatti.
Errori comuni#
Definire le interfacce al producer invece che al consumer
database dovrebbe esportare tipi concreti. Il service layer che usa il database dovrebbe definire l’interfaccia di cui ha bisogno. Questo mantiene l’interfaccia piccola e corrispondente all’utilizzo reale.Interfacce enormi
Non verificare la soddisfazione dell'interfaccia a compile time
Se *MyStruct dovrebbe implementare MyInterface, aggiungi questa riga al package:
var _ MyInterface = (*MyStruct)(nil)Questa e un’asserzione a compile time. Se MyStruct manca di un metodo, il build fallisce immediatamente invece che a runtime quando il codice viene chiamato per la prima volta. Posizionala vicino alla dichiarazione del tipo.
Definire le interfacce troppo presto
Accetta interfacce, restituisci struct. Questa e la linea guida piu importante per le interfacce Go. Quando una funzione accetta un’interfaccia, i chiamanti possono passare qualsiasi implementazione, inclusi i fake per i test. Quando una funzione restituisce un’interfaccia, i chiamanti perdono le informazioni sul tipo e non possono accedere ai metodi specifici del tipo concreto. Restituisci la struct concreta. Lascia che i chiamanti decidano a quale interfaccia assegnarla.
Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architetture cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.