Génériques en Go : type parameter par l'exemple

1. Vue d’ensemble

À partir de Go 1.18, vous pouvez écrire des fonctions et des types de données avec des type parameter. Grâce à cela, vous n’avez pas besoin de dupliquer la même logique pour int, float64, string, et vous n’avez pas non plus besoin de faire une conversion de type à partir de interface{}.

Cet article part d’un problème initial pour arriver aux fonctions génériques, aux constraint et à la notation ~. Viennent ensuite les types génériques comme Stack[T] et Cache[K, V], comment écrire des tests, ainsi que les erreurs courantes. Après la lecture, vous saurez quand utiliser les génériques et quand une simple interface suffit.

Tous les exemples utilisent Go 1.22 ou supérieur. Le package cmp existe depuis Go 1.21. Votre go.mod minimal :

module example.com/shop

go 1.22

Pas de bibliothèque externe. Tout se trouve dans la standard library.

2. Le problème avant les génériques

Supposons que vous ayez besoin de calculer le nombre total d’articles (int) et le montant total (float64). Avant Go 1.18, vous écriviez généralement deux fonctions identiques :

func SumInts(xs []int) int {
	var total int
	for _, x := range xs {
		total += x
	}
	return total
}

func SumFloats(xs []float64) float64 {
	var total float64
	for _, x := range xs {
		total += x
	}
	return total
}

L’alternative était d’accepter []interface{} puis d’utiliser un type switch. Cela perd la sécurité des types. Les erreurs ne s’affichent qu’à l’exécution, et vous devez faire une conversion de type partout où vous appelez la fonction.

Les génériques résolvent précisément ce problème : écrire la logique une seule fois, et le compilateur vérifie toujours les types pour vous.

3. Première fonction générique

3.1. Syntaxe des type parameter

Les type parameter sont déclarés entre crochets, immédiatement après le nom de la fonction :

package main

import "fmt"

type Number interface {
	~int | ~int64 | ~float64
}

func Sum[T Number](xs []T) T {
	var total T
	for _, x := range xs {
		total += x
	}
	return total
}

func main() {
	quantities := []int{2, 1, 5}
	prices := []float64{120.5, 99.9, 15}

	fmt.Println(Sum(quantities))
	fmt.Println(Sum(prices))
}

Explication partie par partie :

  • [T Number] déclare le type parameter T. T doit satisfaire la constraint Number.
  • Number est une interface contenant un type set : int, int64, float64 et les types dérivés de ceux-ci (le sens de ~ se trouve à la section 4.3).
  • var total T crée la valeur zéro de T, c’est-à-dire 0 ou 0.0.
  • L’opérateur += est valide car tous les types du type set supportent l’addition.

Résultat :

8
235.4

3.2. Inférence de type

Avez-vous remarqué que nous appelons Sum(quantities) plutôt que Sum[int](quantities) ? Le compilateur déduit T automatiquement à partir de l’argument. C’est l’inférence de type.

Vous devez seulement le spécifier explicitement quand le compilateur n’a pas assez d’informations pour déduire, par exemple quand le type parameter n’apparaît que dans le type de retour :

func Zero[T any]() T {
	var z T
	return z
}

func main() {
	fmt.Println(Zero[string]() == "") // true
}

3.3. Plusieurs type parameter : Map et Filter

Voici les deux fonctions utilitaires les plus courantes. Nous les appliquons à une liste de Order :

package main

import "fmt"

type Order struct {
	ID       string
	Customer string
	Total    float64
}

func Map[T, U any](xs []T, f func(T) U) []U {
	out := make([]U, 0, len(xs))
	for _, x := range xs {
		out = append(out, f(x))
	}
	return out
}

func Filter[T any](xs []T, keep func(T) bool) []T {
	var out []T
	for _, x := range xs {
		if keep(x) {
			out = append(out, x)
		}
	}
	return out
}

func main() {
	orders := []Order{
		{"A01", "Lan", 250_000},
		{"A02", "Minh", 1_200_000},
		{"A03", "Lan", 890_000},
	}

	big := Filter(orders, func(o Order) bool { return o.Total >= 500_000 })
	ids := Map(big, func(o Order) string { return o.ID })

	fmt.Println(ids)
}
  • Map[T, U any] possède deux type parameter, donc le type d’entrée et de sortie peuvent être différents.
  • any est un alias de interface{}. Il permet tous les types, mais vous ne pouvez appeler aucun opérateur sur sa valeur en dehors de l’assignation et du passage en argument.
  • Le compilateur déduit T = Order et U = string à partir de la fonction func(o Order) string.

Résultat :

[A02 A03]

4. Constraint : limiter ce que T peut faire

Une constraint détermine ce que vous pouvez faire avec une valeur de type T à l’intérieur de la fonction.

4.1. comparable

comparable est une constraint prédéfinie. Elle permet d’utiliser == et !=, et c’est aussi une condition pour que T soit la clé d’une map :

func Unique[T comparable](xs []T) []T {
	seen := make(map[T]struct{}, len(xs))
	var out []T
	for _, x := range xs {
		if _, ok := seen[x]; !ok {
			seen[x] = struct{}{}
			out = append(out, x)
		}
	}
	return out
}

// Unique([]string{"Lan", "Minh", "Lan"}) => [Lan Minh]

Le struct Order ci-dessus est également comparable, car tous ses champs sont comparables. Un struct contenant une slice, une map ou une fonction n’est pas comparable, et le compilateur vous le signalera.

4.2. cmp.Ordered

Quand vous avez besoin de comparer plus grand ou plus petit, utilisez cmp.Ordered de la standard library :

import "cmp"

func MaxBy[T any, K cmp.Ordered](xs []T, key func(T) K) (T, bool) {
	var best T
	if len(xs) == 0 {
		return best, false
	}
	best = xs[0]
	for _, x := range xs[1:] {
		if key(x) > key(best) {
			best = x
		}
	}
	return best, true
}

// top, _ := MaxBy(orders, func(o Order) float64 { return o.Total })
// top.ID => "A02"
  • K cmp.Ordered permet d’utiliser > sur la valeur clé.
  • La fonction retourne (T, bool) pour gérer la slice vide sans panique.

Attention : Pour les simples slices numériques, vous n’avez pas besoin de l’écrire vous-même. Go 1.21 a déjà les fonctions built-in max/min, et le package slices dispose de slices.Max, slices.Contains, slices.Sort. Vérifiez slices et maps avant d’écrire vos propres utilitaires génériques.

4.3. Notation ~ (tilde)

Dans la pratique, vous définissez souvent vos propres types pour la monnaie :

type VND int64

func main() {
	payments := []VND{150_000, 350_000}
	fmt.Println(Sum(payments)) // 500000
}

Cette ligne compile car il y a ~int64 dans Number. La notation ~int64 signifie « tous les types ayant int64 comme underlying type ». Si vous écrivez int64 sans ~, alors VND ne satisfait pas la constraint, et le compilateur vous le dira :

VND does not satisfy Number (possibly missing ~ for int64 in Number)

Règle simple : quand vous écrivez une constraint pour des types fondamentaux, vous devriez presque toujours utiliser ~.

4.4. Constraint combinée avec des méthodes

Une constraint peut aussi exiger des méthodes, comme une interface habituelle :

type Identifiable interface {
	comparable
	Key() string
}

Un type qui veut satisfaire Identifiable doit être comparable ET avoir une méthode Key() string.

5. Types génériques

5.1. Stack[T]

Les type parameter ne s’utilisent pas seulement pour les fonctions, mais aussi pour les struct :

type Stack[T any] struct {
	items []T
}

func (s *Stack[T]) Push(v T) {
	s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	if len(s.items) == 0 {
		return zero, false
	}
	last := len(s.items) - 1
	v := s.items[last]
	s.items = s.items[:last]
	return v, true
}

func (s *Stack[T]) Len() int { return len(s.items) }

Utilisation :

var history Stack[Order]
history.Push(Order{ID: "A01"})
history.Push(Order{ID: "A02"})

o, _ := history.Pop()
fmt.Println(o.ID, history.Len()) // A02 1
  • Le receiver est écrit *Stack[T], il doit inclure [T].
  • Quand vous déclarez une variable, vous devez spécifier le type : Stack[Order]. Il n’y a pas d’inférence à ce stade.
  • La valeur zéro de Stack[Order] est utilisable immédiatement, car append fonctionne bien sur une slice nil.

5.2. Cache[K, V] thread-safe

Un exemple plus pratique : un cache en mémoire thread-safe pour partager entre Customer et Order.

package cache

import "sync"

type Cache[K comparable, V any] struct {
	mu   sync.RWMutex
	data map[K]V
}

func New[K comparable, V any]() *Cache[K, V] {
	return &Cache[K, V]{data: make(map[K]V)}
}

func (c *Cache[K, V]) Put(key K, value V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}
type Customer struct {
	ID   int
	Name string
}

customers := cache.New[int, Customer]()
customers.Put(1, Customer{ID: 1, Name: "Lan"})

orders := cache.New[string, Order]()
orders.Put("A01", Order{ID: "A01", Total: 250_000})

c, ok := customers.Get(1)
fmt.Println(c.Name, ok) // Lan true

Une même implémentation sert deux types de données. customers.Get retourne exactement Customer, pas besoin de type assertion. Si vous aviez accidentellement mis un Order dans customers, le code ne compilerait pas.

6. Écrire des tests pour du code générique

Go utilise le package testing standard. Le test table-driven fonctionne normalement avec des fonctions génériques. Chaque tableau de test correspond à un type spécifique :

package main

import "testing"

func TestSum(t *testing.T) {
	ints := []struct {
		name string
		in   []int
		want int
	}{
		{"empty", nil, 0},
		{"positive", []int{2, 1, 5}, 8},
		{"mixed", []int{10, -3}, 7},
	}
	for _, tc := range ints {
		t.Run(tc.name, func(t *testing.T) {
			if got := Sum(tc.in); got != tc.want {
				t.Errorf("Sum(%v) = %d, want %d", tc.in, got, tc.want)
			}
		})
	}

	if got := Sum([]VND{150_000, 350_000}); got != 500_000 {
		t.Errorf("Sum VND = %d, want 500000", got)
	}
}

func TestStackPopEmpty(t *testing.T) {
	var s Stack[string]
	v, ok := s.Pop()
	if ok || v != "" {
		t.Fatalf("Pop() on empty = (%q, %v), want (\"\", false)", v, ok)
	}
}

Lancer les tests :

go test ./... -v
=== RUN   TestSum
=== RUN   TestSum/empty
=== RUN   TestSum/positive
=== RUN   TestSum/mixed
--- PASS: TestSum (0.00s)
=== RUN   TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS

Testez au moins un type dérivé comme VND. C’est le moyen le plus rapide de détecter une constraint manquant ~.

7. Erreurs courantes et quand ne pas l’utiliser

Les méthodes ne peuvent pas avoir leurs propres type parameter. Ceci ne compile pas :

// erreur : methods cannot have type parameters
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }

Écrivez-la comme une fonction top-level : func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U].

Vous ne pouvez pas faire un type switch directement sur T. Pour brancher selon le type, vous devez passer par any : switch v := any(x).(type) { ... }. Si vous le faites souvent, les génériques ne sont probablement pas le bon outil.

Une constraint avec un type set ne peut pas être utilisée comme type de variable. var n Number sera une erreur. Une interface contenant | ou ~ ne peut s’utiliser qu’en position de constraint.

comparable avec interface peut paniquer. Si K est un type interface et que la valeur dynamique est une slice, la comparaison ou l’utilisation comme clé map va paniquer à l’exécution.

Note : Les génériques ne sont pas automatiquement plus rapides qu’une interface. Le compilateur Go utilise une technique de « GC shape stenciling » avec un dictionnaire. Différents types pointeur peuvent partager un seul morceau de code, et les appels de méthode via constraint ont toujours un surcoût indirect. Si les performances importent, mesurez avec un benchmark plutôt que de deviner.

Quand NE PAS utiliser les génériques :

  • Quand la logique dépend du comportement (méthodes), pas du type. Par exemple, un PaymentService qui accepte un Notifier avec une méthode Send. Une interface est généralement plus concise et lisible.
  • Quand seulement un type spécifique utilisera la fonction. N’ajoutez pas de génériques « au cas où » ultérieurement.
  • Quand slices, maps, cmp ont déjà une fonction dont vous avez besoin.

Une règle facile à retenir : si vous écrivez le même bout de code pour plusieurs types, et la seule différence est le nom du type, c’est le moment d’utiliser les génériques.

8. Conclusion

Cet article a couvert les points clés des génériques en Go :

  • Déclarer les type parameter [T C] et laisser le compilateur déduire le type.
  • Utiliser les constraint any, comparable, cmp.Ordered, et définir vos propres constraint avec ~ pour supporter les types dérivés comme VND.
  • Construire des types génériques Stack[T] et Cache[K, V] thread-safe.
  • Écrire des tests table-driven pour des fonctions génériques.
  • Identifier les limitations : pas de type parameter dans les méthodes, les constraint ne sont pas des types de variables, et les génériques ne remplacent pas les interface.

Commencez par l’endroit où le code est vraiment dupliqué dans votre projet. Écrivez les génériques avec parcimonie, préférez la standard library, et testez toujours avec au moins deux types différents.

golanggenericstype parameterconstraint