Generics: type parameters em Go 1.18+

1. Introdução aos Generics em Go

Generics representam uma das maiores adições à linguagem Go desde sua criação. Introduzidos oficialmente no Go 1.18, os type parameters permitem escrever funções e tipos que podem operar com diferentes tipos concretos sem sacrificar a segurança de tipos em tempo de compilação.

Antes do Go 1.18, desenvolvedores enfrentavam um dilema constante: duplicar código para cada tipo específico ou recorrer a interface{} com type assertions, perdendo a verificação estática de tipos. Generics resolvem esse problema permitindo que você escreva código parametrizado por tipos, mantendo a type safety e eliminando redundâncias.

// Antes: duplicação para cada tipo
func SomaInts(a, b int) int { return a + b }
func SomaFloats(a, b float64) float64 { return a + b }

// Depois: uma única função genérica
func Soma[T int | float64](a, b T) T { return a + b }

2. Sintaxe Básica de Type Parameters

A sintaxe de type parameters utiliza colchetes [] para declarar parâmetros de tipo antes dos parâmetros tradicionais da função.

// Função genérica simples
func Primeiro[T any](slice []T) T {
    return slice[0]
}

// Múltiplos type parameters
func Converter[T, U any](entrada T, saida *U) error {
    // implementação
    return nil
}

// Tipo genérico (struct)
type Pilha[T any] struct {
    elementos []T
}

func (p *Pilha[T) Empurrar(item T) {
    p.elementos = append(p.elementos, item)
}

func (p *Pilha[T]) Topo() T {
    if len(p.elementos) == 0 {
        var zero T
        return zero
    }
    return p.elementos[len(p.elementos)-1]
}

A inferência de tipos permite que você omita os type parameters na chamada quando o compilador pode deduzi-los:

numeros := []int{1, 2, 3}
primeiro := Primeiro(numeros) // Inferido como int

// Especificação explícita quando necessário
primeiro = Primeiro[int](numeros)

3. Constraints: Restringindo Type Parameters

Constraints definem quais tipos são permitidos como argumentos para um type parameter. A constraint any é equivalente a interface{} e aceita qualquer tipo.

// Constraint personalizada
type Numerico interface {
    int | int8 | int16 | int32 | int64 |
    uint | uint8 | uint16 | uint32 | uint64 |
    float32 | float64
}

func SomaSlice[T Numerico](slice []T) T {
    var total T
    for _, v := range slice {
        total += v
    }
    return total
}

Constraints podem incluir métodos, permitindo que você chame métodos nos parâmetros genéricos:

type Stringer interface {
    String() string
}

func ImprimirLista[T Stringer](itens []T) {
    for _, item := range itens {
        fmt.Println(item.String())
    }
}

4. Uso de ~ e Tipos Aproximados em Constraints

O operador ~ permite que uma constraint aceite tipos cujo tipo subjacente corresponde ao especificado. Isso é crucial para trabalhar com tipos personalizados baseados em tipos primitivos.

type MeuInt int

type InteiroAproximado interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64
}

func Dobro[T InteiroAproximado](valor T) T {
    return valor * 2
}

func main() {
    var x MeuInt = 5
    fmt.Println(Dobro(x)) // Funciona graças ao ~int
}

Sem o ~, a função Dobro não aceitaria MeuInt, mesmo que seu tipo subjacente seja int. Use com moderação: tipos aproximados podem tornar suas constraints mais flexíveis, mas também mais complexas de entender.

5. Generics com Slices, Maps e Canais

Generics brilham em estruturas de dados e operações utilitárias:

// Filter genérico para slices
func Filter[T any](slice []T, predicate func(T) bool) []T {
    resultado := make([]T, 0)
    for _, v := range slice {
        if predicate(v) {
            resultado = append(resultado, v)
        }
    }
    return resultado
}

// Map genérico
func Map[T, U any](slice []T, transform func(T) U) []U {
    resultado := make([]U, len(slice))
    for i, v := range slice {
        resultado[i] = transform(v)
    }
    return resultado
}

// Operações em maps
func Chaves[K comparable, V any](m map[K]V) []K {
    chaves := make([]K, 0, len(m))
    for k := range m {
        chaves = append(chaves, k)
    }
    return chaves
}

// Canal genérico com fan-in
func Merge[T any](canais ...<-chan T) <-chan T {
    saida := make(chan T)
    for _, c := range canais {
        go func(ch <-chan T) {
            for v := range ch {
                saida <- v
            }
        }(c)
    }
    return saida
}

6. Type Inference e Limitações Práticas

O compilador Go realiza inferência de tipos na maioria dos casos, mas existem situações onde você precisa especificar explicitamente os type parameters:

// Inferência funciona
nums := []int{1, 2, 3}
dobrados := Map(nums, func(x int) int { return x * 2 })

// Inferência falha - precisa especificar
resultado := Map[int, string](nums, strconv.Itoa)

// Limitação: operadores aritméticos em tipos genéricos
func Soma[T any](a, b T) T {
    // return a + b // ERRO: operador + não definido para T
    // Solução: usar constraint com operadores
}

Limitações importantes:
- Sem variância: slices de tipos relacionados não são automaticamente compatíveis
- Sem operadores em constraints: você não pode definir constraints que exijam operadores como + ou *
- Sem especialização: não é possível fornecer implementações diferentes para tipos específicos

7. Comparação com Alternativas Pré-1.18

Antes dos generics, as soluções principais eram:

// Abordagem com interface{} (type safety perdida)
func SomaInterface(a, b interface{}) interface{} {
    switch v := a.(type) {
    case int:
        return v + b.(int)
    case float64:
        return v + b.(float64)
    }
    return nil
}

// Abordagem com reflection (lenta e verbosa)
func SomaReflection(a, b interface{}) interface{} {
    va := reflect.ValueOf(a)
    vb := reflect.ValueOf(b)
    // implementação complexa...
}

Com generics, você ganha:
- Verificação em tempo de compilação: erros de tipo capturados antes da execução
- Performance: sem overhead de reflection ou type assertions
- Código mais limpo: sem switch/case para cada tipo
- Documentação automática: os tipos são explicitamente declarados

8. Boas Práticas e Padrões com Generics

Use generics quando:
- Você precisa de algoritmos que funcionam independentemente do tipo (sort, filter, map)
- Você está criando estruturas de dados reutilizáveis (pilha, fila, árvore)
- Você quer evitar duplicação de código para tipos similares

Evite generics quando:
- O código se torna mais complexo que a duplicação simples
- Você está lidando com apenas um ou dois tipos específicos
- A interface do problema já é bem modelada com interfaces tradicionais

// Bom uso: estrutura de dados genérica
type ArvoreBinaria[T comparable] struct {
    valor T
    esq   *ArvoreBinaria[T]
    dir   *ArvoreBinaria[T]
}

// Evitar: complexidade desnecessária
type Resultado[T any] struct {
    Valor T
    Erro  error
}
// Prefira retornar (T, error) diretamente

Testes com generics seguem o padrão normal, mas você pode usar type inference para simplificar:

func TestPilha(t *testing.T) {
    pilha := new(Pilha[int])
    pilha.Empurrar(1)
    pilha.Empurrar(2)

    if topo := pilha.Topo(); topo != 2 {
        t.Errorf("esperado 2, obtido %d", topo)
    }
}

Documente suas funções genéricas explicando quais constraints são necessárias e por quê. Use nomes de type parameters descritivos (T, K, V, E) seguindo as convenções da comunidade.

Generics em Go foram projetados para serem simples e pragmáticos. Eles resolvem problemas reais de reuso de código sem introduzir a complexidade de templates ou metaprogramação encontrada em outras linguagens. Use-os com sabedoria e seu código Go será mais expressivo, seguro e reutilizável.

Referências