Genéricos en Go: type parameters con ejemplos
1. Descripción general
Desde Go 1.18, puedes escribir funciones y tipos de datos con type parameters. Gracias a esto, no tienes que duplicar la misma lógica para int, float64, string, ni tampoco necesitas hacer type assertions desde interface{}.
Este artículo va desde el problema inicial hasta funciones genéricas, constraints y la notación ~. Luego, tipos genéricos como Stack[T] y Cache[K, V], cómo escribir tests, y errores comunes. Después de leerlo, sabrás cuándo usar generics y cuándo una interface común es suficiente.
Todos los ejemplos usan Go 1.22 o superior. El package cmp está disponible desde Go 1.21. El archivo go.mod mínimo:
module example.com/shop
go 1.22
No necesitas librerías externas. Todo en este artículo está en la standard library.
2. El problema antes de tener genéricos
Supón que necesitas calcular la suma de cantidades (int) y la suma de dinero (float64). Antes de Go 1.18, normalmente escribías dos funciones idénticas:
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
}
La otra opción era recibir []interface{} y usar type switch. Esto pierde type safety. Los errores solo aparecen en runtime, y tienes que hacer type assertions en todos los lugares donde llamas la función.
Los genéricos resuelven exactamente esto: escribe la lógica una sola vez, y el compiler sigue verificando los tipos por ti.
3. Tu primera función genérica
3.1. Sintaxis de type parameters
Los type parameters se declaran entre corchetes, justo después del nombre de la función:
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))
}
Explicación de cada parte:
[T Number]declara el type parameterT.Tdebe satisfacer el constraintNumber.Numberes una interface que contiene un type set:int,int64,float64y tipos derivados de ellos (el significado de~está en la sección 4.3).var total Tcrea el zero value deT, es decir,0o0.0.- El operador
+=es válido porque todos los tipos en el type set soportan la suma.
Resultado:
8
235.4
3.2. Inferencia de tipos
¿Notas que llamamos Sum(quantities) en lugar de Sum[int](quantities)? El compiler deduce T automáticamente del argumento. Esto es type inference.
Solo necesitas especificar explícitamente cuando el compiler no tiene suficiente información para deducir, por ejemplo, cuando el type parameter solo aparece en el tipo de retorno:
func Zero[T any]() T {
var z T
return z
}
func main() {
fmt.Println(Zero[string]() == "") // true
}
3.3. Múltiples type parameters: Map y Filter
Estas son dos de las funciones auxiliares más comunes. Las aplicaremos a una lista 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]tiene dos type parameters, por lo que el tipo de entrada y el de salida pueden ser diferentes.anyes un alias deinterface{}. Permite cualquier tipo, pero no puedes llamar a ningún operador sobre ese valor excepto asignación y paso de parámetros.- El compiler deduce
T = OrderyU = stringde la funciónfunc(o Order) string.
Resultado:
[A02 A03]
4. Constraints: limita lo que T puede hacer
Un constraint determina qué puedes hacer con un valor de tipo T dentro de la función.
4.1. comparable
comparable es un constraint incorporado. Permite usar == y !=, y también es la condición para que T pueda ser la clave de un 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]
El struct Order de arriba también es comparable, porque todos sus campos se pueden comparar. Un struct que contiene slice, map o func no satisface comparable, y el compiler reportará un error.
4.2. cmp.Ordered
Cuando necesitas comparaciones de mayor que o menor que, usa 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.Orderedpermite usar>en el valor de la clave.- La función devuelve
(T, bool)para manejar slices vacíos sin necesidad de panic.
Nota: Para slices de números simples, no necesitas escribirlo tú mismo. Go 1.21 ya tiene las funciones built-in
max/min, y el packageslicestieneslices.Max,slices.Contains,slices.Sort. Revisaslicesymapsantes de escribir tus propias utilidades genéricas.
4.3. La notación ~ (tilde)
En un caso real, a menudo defines tipos propios para el dinero:
type VND int64
func main() {
payments := []VND{150_000, 350_000}
fmt.Println(Sum(payments)) // 500000
}
Esta línea compila gracias a ~int64 en Number. La notación ~int64 significa “cualquier tipo cuyo underlying type sea int64”. Si escribes int64 sin ~, entonces VND no satisface el constraint, y el compiler reporta:
VND does not satisfy Number (possibly missing ~ for int64 in Number)
Regla simple: cuando escribes constraints para tipos básicos, casi siempre debes usar ~.
4.4. Constraint que combina métodos
Un constraint también puede requerir métodos, al igual que una interface ordinaria:
type Identifiable interface {
comparable
Key() string
}
Un tipo que quiera satisfacer Identifiable debe ser comparable y tener el método Key() string.
5. Tipos genéricos
5.1. Stack[T]
Los type parameters no solo se usan en funciones sino también en structs:
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) }
Cómo usarlo:
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
- El receiver se escribe como
*Stack[T], debe incluir[T]. - Cuando declares una variable, debes especificar el tipo:
Stack[Order]. No hay inferencia en este caso. - El zero value de
Stack[Order]se puede usar inmediatamente, porqueappendfunciona bien en slices nil.
5.2. Cache[K, V] segura para múltiples goroutines
Un ejemplo más realista: un cache en memoria compartido para Customer y 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
Una implementación que sirve a dos tipos de datos. customers.Get devuelve exactamente Customer, sin necesidad de type assertion. Si accidentalmente intentas Put una Order en customers, el código no compilará.
6. Escribir tests para código genérico
Go usa el package testing estándar. Los tests table-driven funcionan normalmente con funciones genéricas. Cada tabla de test corresponde a un tipo específico:
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)
}
}
Ejecutar los 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
Asegúrate de probar al menos un tipo derivado como VND. Esta es la forma más rápida de detectar si falta ~ en un constraint.
7. Errores comunes y cuándo no usar genéricos
Los métodos no pueden tener type parameters propios. El siguiente código no compila:
// error: methods cannot have type parameters
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }
Escríbelo como una función de nivel superior: func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U].
No hagas type switch directamente sobre T. Para ramificar según el tipo, primero tienes que convertir a any: switch v := any(x).(type) { ... }. Si encuentras que haces esto frecuentemente, probablemente los genéricos no sean la herramienta adecuada.
Un constraint con type set no puede usarse como tipo de variable. var n Number reportará un error. Las interfaces que contienen | o ~ solo se pueden usar como constraints.
comparable con una interface puede hacer panic. Si K es un tipo interface y el valor dinámico dentro es un slice, la comparación o usarlo como clave de map hará panic en runtime.
Nota: Los genéricos no son automáticamente más rápidos que las interfaces. El compiler de Go usa “GC shape stenciling” con un diccionario. Diferentes tipos de punteros pueden compartir el mismo código, y las llamadas a métodos a través de constraints todavía tienen un costo indirecto. Si el rendimiento es importante, mide con benchmarks en lugar de adivinar.
Cuándo NO usar genéricos:
- Cuando la lógica depende del comportamiento (métodos), no del tipo. Por ejemplo,
PaymentServicerecibe unNotifiercon un métodoSend. Las interfaces suelen ser más concisas y legibles. - Cuando solo hay un tipo específico que usa esa función. No hagas genéricos “por si acaso en el futuro los necesites”.
- Cuando
slices,maps,cmpya tienen la función que necesitas.
Una regla fácil de recordar: si escribes el mismo código para varios tipos, y solo difiere en el nombre del tipo, ese es el momento de usar genéricos.
8. Conclusión
Este artículo ha cubierto las partes principales de los genéricos en Go:
- Declarar type parameters
[T C]y dejar que el compiler deduzca el tipo automáticamente. - Usar constraints
any,comparable,cmp.Ordered, y definir tus propios constraints con~para soportar tipos derivados comoVND. - Construir tipos genéricos
Stack[T]yCache[K, V]seguros para múltiples goroutines. - Escribir tests table-driven para funciones genéricas.
- Identificar las limitaciones: los métodos no pueden tener type parameters propios, los constraints no son tipos de variables, y los genéricos no reemplazan las interfaces.
Empieza donde realmente se repite el código en tu proyecto. Escribe genéricos al mínimo necesario, prioriza la standard library, y siempre incluye tests para al menos dos tipos diferentes.