16 KiB
Introduzione alle Generics in Go
Dalla versione 1.18 del Go sono state aggiunte le generics.
Le generics ci permettono di scrivere codice indipendente dai tipi specifici che utilizza.
In particolare le tre novità sono
-
Ora sia funzioni che tipi possono prendere dei tipi come parametri (type parameters)
-
In un modo ristretto le interfacce ora possono essere utilizzare per definire "insiemi di tipi"
-
Un minimo di type inference che ci permette di omettere i type parameters quando si riescono a dedurre dal contesto.
Il problema
Uno degli esempi più lampanti della necessità di aggiungere le generics al Go è che bisogna scrivere ogni volta implementazioni di Min(x, y)
per ogni tipo numerico che vogliamo utilizzare
func MinInt(x, y int) int {
if x < y {
return x
}
return y
}
func MinInt32(x, y int32) int32 {
if x < y {
return x
}
return y
}
func MinInt64(x, y int64) int64 {
if x < y {
return x
}
return y
}
func MinFloat32(x, y float32) float32 {
if x < y {
return x
}
return y
}
func MinFloat64(x, y float64) float64 {
if x < y {
return x
}
return y
}
Notiamo che l'implementazione è sempre la stessa ma cambia solo la segnatura della funzione. Dal Go 1.18 però possiamo scrivere
import "golang.org/x/exp/constraints"
func Min[T constraints.Ordered](x, y T) T {
if x < y {
return x
}
return y
}
che possiamo usare ad esempio con Min[int64](2, 5)
oppure Min[float32](2.71, 3.14)
. In particolare dopo aver passato i type parameters possiamo usarla come una qualunque altra funzione, ovvero quanto segue è codice legale
shortMin := Min[int16] // func(int16, int16) int16
Struct generiche
Finalmente ora possiamo anche definire strutture dati generiche come un albero con valori su ogni nodo.
type Tree[T interface{}] struct {
Left, Right *BinaryTree[T]
Value T
}
In realtà invece di dover scrivere ogni volta interface{}
è stato aggiunto l'alias any
.
O anche con valori solo sulle foglie, in particolare vediamo ora come possiamo anche definire dei metodi su tipi con type parameters.
type BinaryTree[T any] interface{
Has(value T) bool
}
type Leaf[T any] struct {
value T
}
func (l Leaf[T]) Has(value T) {
return l.value == value
}
type Branch[T any] struct {
Left, Right BinaryTree[T]
}
func (b Branch[T]) Has(value T) {
return b.Left.Has(value) || b.Right.Has(value)
}
Giusto per precisare, quando scriviamo Leaf[T any]
stiamo introducendo un type parameter che possiamo usare a destra nella definizione del tipo, ed anche quando scriviamo un metodo func (l Leaf[T]) ...
stiamo reintroducendo la variabile T che infatti possiamo usare a destra. Infatti ad esempio
func Zero[T any]() T {
var zero T
return zero
}
è una funzione ben definita, anzi è anche abbastanza utile quando si vuole ritornare un valore "vuoto" per un certo tipo ma il tipo ci arriva attraverso una generics.
Type sets
Tornando all'esempio di prima della funzione Min
, abbiamo visto che ogni tipo ha bisogno di un type constraint anche se questo è semplicemente any
.
Possiamo definire un type constraint utilizzando o una classica interfaccia del Go oppure usando un type set come nel caso di Min
, vediamo com'è definito in particolare Ordered
nella libreria standard (per la precisione per ora è nel pacchetto golang.org/x/exp
che contiene il codice ancora considerato sperimentale)
type Ordered interface {
Integer | Float | ~string
}
type Float interface {
~float32 | ~float64
}
type Integer interface {
Signed | Unsigned
}
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
type Unsigned interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr
}
Qui definiamo delle interfacce con delle union di vari tipi, al momento ci sono alcune restrizioni riguardo quali tipi sono ammessi per farne l'unione
-
Tutti i tipi primitivi come
string
,int
,rune
, ... -
Un tipo primitivo preceduto da tilde semplicemente intende che rilassiamo il vincolo a tutti i tipi alias a quel tipo, quindi ad esempio
float64
non ammetterebbetype Liter float64
mentre
~float64
sì in quanto il tipoLiter
è solo un alias perfloat64
. -
Altre interfacce rappresentanti un "constraint" ed in particolare solo interfacce senza metodi (per ora è stata aggiunta questa restrizione per evitare alcuni problemi teorici di complessità dell'inferenza dei constraint)
Type inference
La type inference usa un algoritmo relativamente semplice unidirezionale, se ad esempio abbiamo una chiamata ad una funzione generica, se riusciamo a dedurre i type parameters solo dagli argomenti della funzione allora possiamo omettere i type parameters nella chiamata della funzione. Ad esempio questo non si può applicare alla funzione Zero
di prima
func Zero[T any]() T {
var zero T
return zero
}
In questo caso serve esplicitare il type parameter anche se in realtà si potrebbe dedurre dal contesto (in questo casa stiamo già specificando il tipo del risultato ma questo poterebbe l'algoritmo di inferenza ad essere bidirezionale che complicherebbe molto le cose)
var x int = Zero[int]()
Quando usare le generics?
Container
In Go in realtà esistono già da sempre alcune strutture dati "generiche" ovvero
-
[n]T
Array di
n
elementi per il tipoT
-
[]T
Slice per il tipo
T
-
map[K]V
Mappe con chiavi
K
e valoriV
-
chan T
Canali per elementi di tipo
T
Prima delle generics c'è sempre stato il problema che non era possibile definire algoritmi generici per questi tipi di container, ora invece possiamo ed infatti alcune di questi sono "già in prova"
-
Il modulo
golang.org/x/exp/slices
già offre-
func Index[E comparable](s []E, v E) int
-
func Equal[E comparable](s1, s2 []E) bool
-
func Sort[E constraints.Ordered](x []E)
-
func SortFunc[E any](x []E, less func(a, b E) bool)
-
...
-
-
Il modulo
golang.org/x/exp/maps
già offre-
func Keys[M ~map[K]V, K comparable, V any](m M) []K
-
func Values[M ~map[K]V, K comparable, V any](m M) []V
-
...
-
Però ora che abbiamo le generics possiamo noi stessi definire tipi container generici, ad esempio c'è già il modulo https://github.com/zyedidia/generic (1k stelle su GitHub) con varie strutture dati generiche come
-
mapset
-
multimap
-
stack
(con un'interfaccia più simpatica rispetto al modo idiomatico del Go) -
queue
-
cache
(basato sumap[K]V
con una taglia massima che rimuove gli elementi usando la strategia LRU) -
bimap
-
hashmap
(implementazione alternativa dimap[K]V
con supporto per copy-on-write)
Metodi generici
Consideriamo la seguente struttura dati generica
package option
type Option[T any] struct{
present bool
value T
}
func Some[T any](value T) Option[T] {
return Option{ true, value }
}
func None[T any]() Option[T] {
return Option{ present: false }
}
func (o Option[T]) Map(f func(T) T) Option[T] {
if !o.present {
return o
}
return Some(f(o.value))
}
double := func (v int) int {
return v * 2
}
o1 := option.Some[int](10)
o2 := o1.Map(double) // Option[int]{ present: true, value: 20 }
o3 := option.None[int]()
o4 := o3.Map(double) // Option[int]{ present: false }
Questo sembrerebbe un buon utilizzo delle generics per introdurre il tipo Option[T]
già molto usato in molti linguaggi funzionali e non come Rust che ha deciso di integrarli proprio a livello del linguaggio stesso.
Solo che al momento non è possibile introdurre generics nelle funzioni quindi la seguente funzione sarebbe illegale
func (Option[T]) MapToOther[S any](f func(T) S) Option[s] {
if !o.present {
return o
}
return Some(f(o.value))
}
Il compilatore del Go per compilare codice con delle generics utilizza una tecnica chiamata monomorfizzazione ovvero per ogni utilizzo di una funzione generica, vede quali sono i type parameter utilizzati e specializza quella funzione o tipo al caso particolare.
Nel caso di strutture dati ad esempio
package option
// se da qualche parte utilizziamo "Option[int]" allora vengono generate queste strutture
type Option__int struct{
present bool
value int
}
func Some__int(value int) Option__int {
return Option{ true, value }
}
func None__int() Option__int {
return Option{ present: false }
}
// se da qualche parte utilizziamo "Option[int]" allora vengono generate queste strutture
type Option__string struct{
present bool
value string
}
func Some__string(value string) Option__string {
return Option{ true, value }
}
func None__string() Option__string {
return Option{ present: false }
}
questo ha il vantaggio di creare specializzazioni per ogni caso specifico senza fare uso di puntatori (quindi il codice rimane abbastanza performante) però al prezzo di grandezza del binario generato.
In go i metodi su tipi hanno l'obbiettivo di poter verificare una qualche interfaccia e quindi nascondere le implementazioni specifiche dei singoli tipi, con questo principio in mente dovrebbe poter essere possibile definire
type Processor interface {
Process[T any](v T) T
}
solo che a questo punto una funzione (non di per sé generica) potrebbe fare le chiamate
func Example(v Processor) {
fmt.Println(v.Process[int](3))
fmt.Println(v.Process[string]("5"))
fmt.Println(v.Process[[]string]([]string{"a", "b"}))
}
e se ad esempio abbiamo un tipo
type Foo struct{}
func (Foo) Process[T any](value T) T {
return value
}
che porterebbe a vari problemi sul come generare il codice per questo tipo in quanto fino a runtime non sarebbero note quali chiamate generiche vengono instanziate a meno di non fare dell'analisi statica molto elaborata.
Quando non usare le generics?
Ci potrebbe venire in mente di scrivere una funzione per leggere tutto da un io.Reader
aggiungendo il vincolo io.Reader
al type parameter
func ReadSome[T io.Reader](r T) ([]byte, error)
in questo caso però potevamo già scrivere
func ReadSome(r io.Reader) ([]byte, error)
senza dover usare generics in quanto già le interfacce ci permettono di scrivere codice generico. Inoltre al momento l'implementazione delle generics monomorfizza solo per non-"pointer types" e genera un'unica implementazione se il receiver è un pointer type in quanto già il puntatore contiene dati sul tipo passato in input e non aggiungere specializzazioni non migliorerebbe la performance.
Se le implementazioni differiscono
Se vogliamo scrive del codice generico per più tipo chi possiamo chiedere se l'implementazione è la stessa per tutti i tipi o se differisce, la regolare generale è che se l'implementazione è la stessa allora va bene usare un type parameter altrimenti, se le implementazioni differiscono, come detto prima conviene usare semplicemente delle interfacce.
Reflection
Ci sono alcuni casi in cui vorremmo implementare una qualche operazione per tipi che non possono avere metodi (ad esempio per dei tipi primitivi) e tale operazione è diversa per ogni tipo. In questo caso conviene usare la reflection invece di interfacce o generics. Ad esempio encoding/json
funziona in questo modo.
Utilizzi interessanti delle generics
Le generics possono essere utilizzate anche solo per rendere il codice più sicuro dal punto di vista dai tipi (e per fare meno conversioni a runtime), ad esempio quando definiamo una struct generica nessuno ci obbliga ad utilizzare effettivamente il type parameter che introduciamo.
type DatabaseTable[T any] struct {
Table string
IdKey string
GetIdPtr func(entry T) *string
}
func (t DatabaseTable[T]) RefForId(id string) DatabaseRef[T] {
return DatabaseRef[T]{id}
}
func (t DatabaseTable[T]) RefForValue(v T) DatabaseRef[T] {
return t.RefForId(*t.GetIdPtr(v))
}
type DatabaseRef[T any] struct{ Id string }
func DatabaseRead[T any](db Database, table DatabaseTable[T], ref DatabaseRef[T]) (*T, error) {
query := fmt.Sprintf(`SELECT * FROM %s WHERE %s = ?`, table.Table, table.IdKey)
result := db.Get(query, ref.Id)
var value T
if err := result.Scan(&value); err != nil {
return nil, err
}
return &value, nil
}
più eventualmente anche altre funzioni per creare, modificare ed eliminare i dati nel database
// create un'entrata nel database (generando un nuovo id)
func DatabaseCreate[T any](db Database, table DatabaseTable[T], value *T) (DatabaseRef[T], error)
// create un'entrata nel database inserendo il valore fornito
func DatabaseWrite[T any](db Database, table DatabaseTable[T], value *T) (DatabaseRef[T], error)
// aggiorna un'entrata nel db usando la ref ed il valore passato
func DatabaseUpdate[T any](db Database, table DatabaseTable[T], DatabaseRef[T], value *T) error
// elimina un'entrata nel db usando la ref fornita
func DatabaseDelete[T any](db Database, table DatabaseTable[T], DatabaseRef[T]) error
Questo ci permette di creare delle reference tipate che permettono di rendere type safe l'API per interagire con il nostro database
type User struct {
Username string
FirstName string
LastName string
}
// rappresenta una tabella tipata con una primary key specifica
var UsersTable = DatabaseTable[User]{
Table: "users",
IdKey: "username",
GetIdPtr: func(u User) *string {
return &u.Username
},
}
che potremo utilizzare ad esempio
// user1 :: *User
user1 := &User{"j.smith", "John", "Smith"}
// ref1 :: DatabaseRef[User]
ref1, _ := DatabaseWrite(db, UsersTable, u1)
// user2 :: *User
user2, _ := DatabaseRead(db, UsersTable, ref1)
Inoltre potenzialmente potremmo creare queste DatabaseRef[T]
solo se l'entrata corrispondente esiste nel database, in questo modo renderemmo l'API di questa libreria completamente type safe e rendendola anche privata potremmo anche rendere tali istanze costruibili solo interagendo con il database.
Algebraic Data Types
In molti linguaggi funzionali sono presenti i cosiddetti algebraic data types che permettono di codificare molte strutture dati in modo molto semplice e generico. Inoltre se il linguaggio è tipato permettono anche di creare delle interfacce di libreria molto pulite e sicure come ad esempio nel caso di option.
Option[T]
Vediamo meglio questo esempio, consideriamo la definizione type Option[T] = Some(T) | None
(pseudo-codice? forse è valido in Scala boh) può essere codificato in Go come segue
type private struct{}
type Option[T any] interface {
isOption(private)
Match(
caseSome func(value T),
caseNone func(),
)
}
type some[T any] struct{ Value T }
func (some[T]) isOption(private) {}
func Some[T any](value T) Option[T] {
return some{ value }
}
func (v some[T]) Match(caseSome func(value T), caseNone func()) {
caseSome(v.Value)
}
type none[T any] struct{}
func (none[T]) isOption(private) {}
func None[T any]() Option[T] {
return none{}
}
func (v none[T]) Match(caseSome func(value T), caseNone func()) {
caseNone()
}
Possiamo così costruire istanze di Option[T]
a piacimento usando le funzioni pubbliche Some[T](value)
e None[T]()
ma in quanto l'interfaccia ha un metodo che usa un tipo privato non possiamo definire tipi al di fuori che la rispettano.
E se ci viene passata un'istanza di Option
l'unica cosa che possiamo fare è chiamare la funzione match e valutare i vari casi.
Either[A, B]
o anche type Either[A, B] = Left A | Right B
type Either[A, B any] interface {
Match(
caseLeft func(value A),
caseRight func(value B),
)
}
type Left[A, B any] struct{ Value A }
func (v Left[A, B]) Match(
caseLeft func(value A),
caseRight func(value B),
) {
caseLeft(v.Value)
}
type Right[A, B any] struct{ Value B }
func (v Right[A, B]) Match(
caseLeft func(value A),
caseRight func(value B),
) {
caseRight(v.Value)
}