Javier Valencia Javier Valencia
Go generics en la práctica: cuándo sí y cuándo no

Go generics en la práctica: cuándo sí y cuándo no

Javier Valencia · · 5 min de lectura · 8 visitas · Desarrollo
go generics desarrollo tutorial buenas-practicas

Los generics llegaron a Go en la versión 1.18, marzo de 2022. Llevamos más de tres años con ellos en producción y, visto con perspectiva, creo que la comunidad ha reaccionado de una forma bastante sana: hubo una oleada inicial de uso excesivo, un rebote de "mejor no usarlos nunca", y ahora estamos en un punto intermedio más razonable. Este post es mi intento de destilar ese punto intermedio: cuándo los generics aportan y cuándo sobran.

Por qué Go tardó tanto en incorporarlos

Go generics en la práctica: cuándo sí y cuándo no

Go nació en 2007 con una filosofía clara: simplicidad sobre abstracción. El equipo original consideraba que los generics, tal como estaban implementados en Java o C++, añadían complejidad sin justificarla. Durante diez años la respuesta oficial fue "si necesitas generics, estás haciendo algo mal: usa interfaces, code generation o directamente copia el código".

Esta postura fue cambiando a medida que se acumulaban casos donde las interfaces no llegaban. Cualquiera que haya escrito una función Min o Max en Go antes de 1.18 sabe de qué hablo: o escribes una versión por tipo, o metes any y reflect, o usas un generador de código. Ninguna de las tres es buena. El propio equipo de Go acabó reconociendo que había un hueco real y se puso a trabajar en una propuesta que encajara con el resto del lenguaje.

La propuesta actual: constraints y type parameters

Los generics en Go se basan en dos conceptos: type parameters y constraints. Un type parameter es un marcador genérico (por convención, T, K, V). Una constraint limita qué tipos pueden sustituirlo.

La función Min en generics queda así:

func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

constraints.Ordered viene del paquete golang.org/x/exp/constraints (ahora también hay uno estándar más limitado en cmp). Incluye todos los tipos comparables con <: ints, floats, strings.

La constraint puede ser una interfaz cualquiera, una unión de tipos, o una combinación:

type Number interface {
    int | int32 | int64 | float32 | float64
}

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

La sintaxis con | permite unir tipos concretos. El ~ (tilde) que a veces verás en ejemplos más avanzados permite aceptar también tipos derivados: ~int acepta int pero también type UserID int. Sin el ~ esas definiciones quedarían fuera y te llevarías una sorpresa.

Dónde sí uso generics

Go generics en la práctica: cuándo sí y cuándo no

Hay tres casos donde los uso sin dudar:

Estructuras de datos genéricas

Colas, pilas, conjuntos, árboles, cachés. Cualquier cosa que almacene elementos de un tipo arbitrario. Antes usabas interface{} y casteabas al sacar. Ahora:

type Set[T comparable] struct {
    items map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
    return &Set[T]{items: make(map[T]struct{})}
}

func (s *Set[T]) Add(item T) {
    s.items[item] = struct{}{}
}

func (s *Set[T]) Contains(item T) bool {
    _, ok := s.items[item]
    return ok
}

El consumidor obtiene type safety y autocompletado en el IDE. No hay castings innecesarios ni errores en tiempo de ejecución del tipo "expected string, got int".

Utilidades sobre slices y maps

El paquete slices de la stdlib (añadido en 1.21) es un buen ejemplo de donde brillan los generics. slices.Contains, slices.Index, slices.Sort... todo tipado y sin reflect.

Antes tenías que escribir tu propio Contains para cada tipo, o usar reflect con un impacto de rendimiento brutal. Ahora:

if slices.Contains(emails, "[email protected]") {
    // ...
}

Sin boilerplate, sin performance hit. Igual con maps.Keys, maps.Values, maps.Copy. Son funciones que querías tener desde Go 1.0 y que por fin están ahí.

Patrones funcionales básicos

Map, Filter, Reduce sobre colecciones. Es uno de los casos donde Go siempre sufrió comparado con Python, Ruby o JavaScript.

func Map[T, U any](slice []T, fn func(T) U) []U {
    result := make([]U, len(slice))
    for i, v := range slice {
        result[i] = fn(v)
    }
    return result
}

names := Map(users, func(u User) string { return u.Name })

Es código claro, seguro en tipos y sin ceremonia. La alternativa con un for explícito también es válida (y a veces más idiomática en Go), pero cuando tienes pipelines de transformaciones encadenadas el estilo funcional gana en legibilidad.

Dónde no los uso

Aquí es donde me he ido volviendo conservador con el tiempo. Casos donde técnicamente podrías usar generics pero el resultado es peor que la alternativa simple.

Cuando una interfaz llega

Si tu función solo necesita que el argumento tenga un método concreto, una interfaz es más simple:

// Bien: interfaz
type Writer interface {
    Write(p []byte) (int, error)
}

func LogTo(w Writer, msg string) { ... }

// Mal: generic por forzar
func LogTo[T Writer](w T, msg string) { ... }

El generic no añade nada salvo complejidad en la signatura. Las interfaces son el mecanismo idiomático de Go para polimorfismo; no lo tires por la borda solo porque tengas una herramienta nueva.

Para ahorrarte dos funciones

He visto código así en más de una revisión:

func Process[T int | string](value T) T {
    switch v := any(value).(type) {
    case int:
        // lógica para int
    case string:
        // lógica para string
    }
    return value
}

Si dentro haces type switch sobre T, no estás usando generics: estás reimplementando interface{} con más pasos y perdiendo type safety en el proceso. Dos funciones concretas son mejores que una genérica con un switch dentro.

En APIs públicas sin un patrón claro

Un type parameter en la signatura de una función exportada obliga a todos tus usuarios a pensar en ello. Si no tienes un caso de uso claro y estable, deja el parámetro como any o define una interfaz y espera a ver qué necesita la gente.

Revisar y simplificar una API pública es caro; añadir parámetros genéricos por si acaso es una invitación a arrepentirte en seis meses.

El coste que no se habla

Go generics en la práctica: cuándo sí y cuándo no

Los generics en Go tienen un coste que casi nadie menciona: el código genérico se compila más despacio y, según el caso, se ejecuta más despacio que la versión especializada.

El compilador usa una estrategia híbrida (GC shapes) que genera una implementación por "forma" de tipo, no una por tipo concreto. Esto ahorra tamaño de binario, pero introduce niveles de indirección que el JIT de un lenguaje dinámico no tiene, ni la monomorfización total de C++.

En la mayoría de código esto no importa. En un hot path que procesa millones de elementos, sí. Si tu función es crítica, mídela. En mis benchmarks he visto desde empates hasta un 20% más lento con generics frente a una versión manual por tipo concreto. En un servicio web que hace un millar de peticiones por segundo, eso puede marcar la diferencia entre un nodo y dos.

Patrones útiles que he encontrado

Dos patrones que en otros lenguajes son idiomáticos y que en Go con generics se vuelven viables:

Optional[T]

type Optional[T any] struct {
    value T
    ok    bool
}

func Some[T any](v T) Optional[T] { return Optional[T]{value: v, ok: true} }
func None[T any]() Optional[T]    { return Optional[T]{} }

func (o Optional[T]) Get() (T, bool) { return o.value, o.ok }

En Go lo idiomático sigue siendo devolver (T, bool) directamente, pero Optional[T] tiene sentido cuando quieres guardar "quizás un valor" dentro de un struct más grande sin usar punteros.

Result[T]

type Result[T any] struct {
    value T
    err   error
}

func (r Result[T]) Unwrap() (T, error) { return r.value, r.err }

De nuevo, menos idiomático que (T, error) pero útil en canales: chan Result[Response] es más expresivo que dos canales paralelos.

Mi regla mental

Antes de escribir una función genérica me hago tres preguntas:

  1. ¿Necesito que esto funcione con dos o más tipos no relacionados?
  2. ¿La lógica es exactamente la misma para todos esos tipos?
  3. ¿Hay una interfaz que ya capture lo que necesito?

Si la respuesta a las dos primeras es sí y a la tercera es no, uso generics. Si no, uso interfaces o duplico el código. La duplicación, dentro de unos límites, es más barata que la mala abstracción.

Lo que he aprendido

Llevo tres años con generics en el código. Mi sensación es que los uso bastante menos de lo que esperaba al principio. En cambio, en el código que sí los usa, son un alivio: Set, Map, slices.Contains son piezas pequeñas que aparecen todo el tiempo y que antes eran fricción constante.

Go ha hecho lo que le caracteriza: añadir una feature grande de forma conservadora, con una implementación no óptima pero tratable, y dejar que la comunidad descubra cómo usarla bien. Tres años después, creo que la conclusión es la misma que con casi todo en Go: úsalo cuando aporte algo concreto, ignóralo el resto del tiempo, y no te sientas culpable por escribir código simple.