Generics en Go: Casos de uso y patrones

Código reutilizable seguro de tipos con generics de Go

Índice

Generics en Go representan una de las características más significativas añadidas desde Go 1.0. Introducidas en Go 1.18, las generics permiten escribir código seguro de tipos, reutilizable que funciona con múltiples tipos sin sacrificar el rendimiento o la claridad del código.

Este artículo explora casos de uso prácticos, patrones comunes y mejores prácticas para aprovechar las generics en tus programas de Go. Si eres nuevo en Go o necesitas un repaso sobre los fundamentos, consulta nuestro Go Cheatsheet para construcciones y sintaxis esenciales del lenguaje.

a-lone-programmer

Entendiendo las Generics en Go

Las generics en Go permiten escribir funciones y tipos parametrizados por parámetros de tipo. Esto elimina la necesidad de duplicar código cuando necesitas la misma lógica para trabajar con diferentes tipos, manteniendo la seguridad de tipo en tiempo de compilación.

Sintaxis Básica

La sintaxis para generics utiliza corchetes cuadrados para declarar parámetros de tipo:

// Función genérica
func Max[T comparable](a, b T) T {
    if a > b {
        return a
    }
    return b
}

// Uso
maxInt := Max(10, 20)
maxString := Max("apple", "banana")

Restricciones de Tipo

Las restricciones de tipo especifican qué tipos pueden usarse con tu código genérico:

  • any: Cualquier tipo (equivalente a interface{})
  • comparable: Tipos que admiten operadores == y !=
  • Restricciones de interfaz personalizadas: Define tus propios requisitos
// Usando una restricción personalizada
type Numeric interface {
    int | int8 | int16 | int32 | int64 | 
    uint | uint8 | uint16 | uint32 | uint64 | 
    float32 | float64
}

func Sum[T Numeric](numbers []T) T {
    var sum T
    for _, n := range numbers {
        sum += n
    }
    return sum
}

Casos de Uso Comunes

1. Estructuras de Datos Genéricas

Uno de los casos de uso más atractivos para las generics es crear estructuras de datos reutilizables:

// Pila genérica
type Stack[T any] struct {
    items []T
}

func NewStack[T any]() *Stack[T] {
    return &Stack[T]{items: make([]T, 0)}
}

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

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

// Uso
intStack := NewStack[int]()
intStack.Push(42)
intStack.Push(100)

stringStack := NewStack[string]()
stringStack.Push("hello")

2. Utilidades para Slices

Las generics facilitan la escritura de funciones reutilizables para manipular slices:

// Función Map
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
}

// Función Filter
func Filter[T any](slice []T, fn func(T) bool) []T {
    var result []T
    for _, v := range slice {
        if fn(v) {
            result = append(result, v)
        }
    }
    return result
}

// Función Reduce
func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U {
    result := initial
    for _, v := range slice {
        result = fn(result, v)
    }
    return result
}

// Uso
numbers := []int{1, 2, 3, 4, 5}
doubled := Map(numbers, func(n int) int { return n * 2 })
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
sum := Reduce(numbers, 0, func(acc, n int) int { return acc + n })

3. Utilidades Genéricas para Mapas

Trabajar con mapas se vuelve más seguro de tipo con generics:

// Obtener claves de un mapa como un slice
func Keys[K comparable, V any](m map[K]V) []K {
    keys := make([]K, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

// Obtener valores de un mapa como un slice
func Values[K comparable, V any](m map[K]V) []V {
    values := make([]V, 0, len(m))
    for _, v := range m {
        values = append(values, v)
    }
    return values
}

// Obtener valor de mapa con valor predeterminado
func GetOrDefault[K comparable, V any](m map[K]V, key K, defaultValue V) V {
    if v, ok := m[key]; ok {
        return v
    }
    return defaultValue
}

4. Patrón Genérico de Opción

El patrón de opción se vuelve más elegante con generics:

type Option[T any] struct {
    value *T
}

func Some[T any](value T) Option[T] {
    return Option[T]{value: &value}
}

func None[T any]() Option[T] {
    return Option[T]{value: nil}
}

func (o Option[T]) IsSome() bool {
    return o.value != nil
}

func (o Option[T]) IsNone() bool {
    return o.value == nil
}

func (o Option[T]) Unwrap() T {
    if o.value == nil {
        panic("intentado desempaquetar valor None")
    }
    return *o.value
}

func (o Option[T]) UnwrapOr(defaultValue T) T {
    if o.value == nil {
        return defaultValue
    }
    return *o.value
}

Patrones Avanzados

Composición de Restricciones

Puedes componer restricciones para crear requisitos más específicos:

type Addable interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64 | string
}

type Multiplicable interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

type Numeric interface {
    Addable
    Multiplicable
}

func Multiply[T Multiplicable](a, b T) T {
    return a * b
}

Interfaces Genéricas

También pueden ser genéricas, permitiendo abstracciones poderosas:

type Repository[T any, ID comparable] interface {
    FindByID(id ID) (T, error)
    Save(entity T) error
    Delete(id ID) error
    FindAll() ([]T, error)
}

// Implementación
type InMemoryRepository[T any, ID comparable] struct {
    data map[ID]T
}

func NewInMemoryRepository[T any, ID comparable]() *InMemoryRepository[T, ID] {
    return &InMemoryRepository[T, ID]{
        data: make(map[ID]T),
    }
}

func (r *InMemoryRepository[T, ID]) FindByID(id ID) (T, error) {
    if entity, ok := r.data[id]; ok {
        return entity, nil
    }
    var zero T
    return zero, fmt.Errorf("entidad no encontrada")
}

Inferencia de Tipos

La inferencia de tipos de Go a menudo permite omitir parámetros de tipo explícitos:

// Inferencia de tipos en acción
numbers := []int{1, 2, 3, 4, 5}

// No es necesario especificar [int] - Go lo infiere
doubled := Map(numbers, func(n int) int { return n * 2 })

// Parámetros de tipo explícitos cuando sea necesario
result := Map[int, string](numbers, strconv.Itoa)

Mejores Prácticas

1. Comienza con lo Simple

No sobreutilices generics. Si una interfaz simple o un tipo concreto funcionaría, prefiera eso para una mejor legibilidad:

// Prefiere esto para casos simples
func Process(items []Processor) {
    for _, item := range items {
        item.Process()
    }
}

// Demasiado genérico - evita
func Process[T Processor](items []T) {
    for _, item := range items {
        item.Process()
    }
}

2. Usa Nombres de Restricciones Significativos

Nombra tus restricciones claramente para comunicar la intención:

// Bueno
type Sortable interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64 | string
}

// Menos claro
type T interface {
    int | string
}

3. Documenta Restricciones Complejas

Cuando las restricciones se vuelven complejas, añade documentación:

// Numeric representa tipos que admiten operaciones aritméticas.
// Esto incluye todos los tipos de enteros y flotantes.
type Numeric interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

4. Considera el Rendimiento

Las generics se compilan a tipos concretos, por lo que no hay sobrecarga en tiempo de ejecución. Sin embargo, sé consciente del tamaño del código si instancias muchas combinaciones de tipos:

// Cada combinación crea código separado
var intStack = NewStack[int]()
var stringStack = NewStack[string]()
var floatStack = NewStack[float64]()

Aplicaciones en el Mundo Real

Constructores de Consultas de Base de Datos

Las generics son especialmente útiles al construir constructores de consultas de base de datos o al trabajar con ORMs. Al trabajar con bases de datos en Go, podrías encontrar útil nuestra comparación de ORMs de Go para PostgreSQL para entender cómo las generics pueden mejorar la seguridad de tipo en operaciones de base de datos.

type QueryBuilder[T any] struct {
    table string
    where []string
}

func (qb *QueryBuilder[T]) Where(condition string) *QueryBuilder[T] {
    qb.where = append(qb.where, condition)
    return qb
}

func (qb *QueryBuilder[T]) Find() ([]T, error) {
    // Implementación
    return nil, nil
}

Gestión de Configuración

Cuando construyes aplicaciones de línea de comandos o sistemas de configuración, las generics pueden ayudar a crear cargadores de configuración seguros de tipo. Si estás construyendo herramientas de línea de comandos, nuestra guía sobre construir aplicaciones de CLI con Cobra & Viper demuestra cómo las generics pueden mejorar la gestión de configuración.

type ConfigLoader[T any] struct {
    path string
}

func (cl *ConfigLoader[T]) Load() (T, error) {
    var config T
    // Cargar y deserializar configuración
    return config, nil
}

Bibliotecas de Utilidad

Las generics destacan al crear bibliotecas de utilidad que funcionan con varios tipos. Por ejemplo, al generar informes o trabajar con diferentes formatos de datos, las generics pueden proporcionar seguridad de tipo. Nuestro artículo sobre generar informes PDF en Go muestra cómo las generics pueden aplicarse a utilidades de generación de informes.

Código Crítico en Rendimiento

En aplicaciones sensibles al rendimiento, como funciones serverless, las generics pueden ayudar a mantener la seguridad de tipo sin sobrecarga en tiempo de ejecución. Al considerar opciones de lenguaje para aplicaciones serverless, entender las características de rendimiento es crucial. Nuestra análisis de rendimiento de AWS Lambda en JavaScript, Python y Golang demuestra cómo el rendimiento de Go, combinado con generics, puede ser ventajoso.

Puntos Comunes de Error

1. Sobrerestricción de Tipos

Evita hacer restricciones demasiado restrictivas cuando no sea necesario:

// Demasiado restrictivo
func Process[T int | string](items []T) { }

// Mejor - más flexible
func Process[T comparable](items []T) { }

2. Ignorar la Inferencia de Tipos

Deja que Go infiera tipos cuando sea posible:

// Tipos explícitos innecesarios
result := Max[int](10, 20)

// Mejor - deja que Go infiera
result := Max(10, 20)

3. Olvidar los Valores Cero

Recuerda que los tipos genéricos tienen valores cero:

func Get[T any](slice []T, index int) (T, bool) {
    if index < 0 || index >= len(slice) {
        var zero T  // Importante: devuelve el valor cero
        return zero, false
    }
    return slice[index], true
}

Conclusión

Las generics en Go ofrecen una forma poderosa de escribir código seguro de tipo, reutilizable sin sacrificar el rendimiento o la legibilidad. Al entender la sintaxis, las restricciones y los patrones comunes, puedes aprovechar las generics para reducir la duplicación de código y mejorar la seguridad de tipo en tus aplicaciones de Go.

Recuerda usar las generics con juicio: no toda situación las requiere. Cuando dudes, prefiere soluciones más simples como interfaces o tipos concretos. Sin embargo, cuando necesitas trabajar con múltiples tipos mientras mantienes la seguridad de tipo, las generics son una herramienta excelente en tu kit de herramientas de Go.

A medida que Go continúa evolucionando, las generics se están convirtiendo en una característica esencial para construir aplicaciones modernas y seguras de tipo. Ya sea que estés construyendo estructuras de datos, bibliotecas de utilidad o abstracciones complejas, las generics pueden ayudarte a escribir código más limpio y mantenible.

Enlaces Útiles y Artículos Relacionados