Salta al contenuto principale

Interfacce Go in Pratica: Polimorfismo, Composizione e Testing

·8 minuti
Indice dei contenuti
Le interfacce Go sono implicite. Non dichiari che un tipo implementa un’interfaccia. Se un tipo ha i metodi giusti, soddisfa l’interfaccia. Questa singola scelta di design rende le interfacce Go piu flessibili, piu componibili e piu potenti per il testing rispetto alle implementazioni di interfacce esplicite in Java o C#.

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:

shapes/shapes.go
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:

shapes/shapes.go (continued)
// 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:

main.go
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.

io interfaces
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:

io_example.go
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.

Suggerimento

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:

io composition
// 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:

composed interfaces
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:

http/client.go
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:

http/client_test.go
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.

any vs generics
// 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]any per 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
Avviso

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
Il producer di dati raramente conosce tutti i modi in cui verra consumato. Definisci le interfacce dove le usi, non dove le implementi. Un package 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
Un’interfaccia con quindici metodi e quasi impossibile da mockare completamente e segnala che troppa responsabilita e concentrata in un posto. Preferisci interfacce piccole e focalizzate. Se una funzione ha bisogno solo di tre dei quindici metodi di un’interfaccia, definisci una nuova interfaccia a tre metodi e accetta quella.
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
Non definire un’interfaccia finche non hai almeno due tipi concreti che devono soddisfarla, o finche non hai bisogno di mockare la dipendenza in un test. Le interfacce premature aggiungono indirezione senza benefici. Inizia con tipi concreti ed estrai un’interfaccia quando la necessita diventa chiara.
Importante

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.

Articoli correlati