Na aula anterior, você aprendeu os fundamentos dos genéricos em Go: como declarar funções e tipos com parâmetros de tipo, e como usar constraints básicas como any. Agora vamos aprofundar, explorando constraints customizadas, o uso do operador comparable, a inferência de tipos e as limitações que você deve conhecer para escrever código genérico eficaz e idiomático.

Esses conceitos são essenciais para criar bibliotecas reutilizáveis e para entender como o Go equilibra expressividade com simplicidade. Vamos ver na prática como definir constraints que reflitam as operações necessárias para suas funções, e como o compilador deduz tipos automaticamente na maioria dos casos, reduzindo a verbosidade sem sacrificar a segurança de tipos.

Constraints customizadas

Em Go, uma constraint é uma interface que define o conjunto de tipos que podem ser usados como argumento de tipo. A constraint any é equivalente a interface{} e aceita qualquer tipo. No entanto, para funções que precisam executar operações específicas (como comparação, aritmética, ou métodos), você deve definir constraints customizadas que restrinjam os tipos aceitos, garantindo que as operações sejam válidas.

Uma constraint customizada é simplesmente uma interface que pode conter métodos, ou termos de união (usando o operador |), ou ambos. Os termos de união permitem especificar um conjunto exato de tipos que satisfazem a constraint. Por exemplo, se você quiser uma função que some dois números, pode definir uma constraint que inclua apenas os tipos numéricos básicos.

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

func Sum[T Number](a, b T) T {
    return a + b
}

Nesse exemplo, a constraint Number define uma união de tipos inteiros e de ponto flutuante. A função Sum só pode ser chamada com um desses tipos, e o compilador garante que a operação + é válida. Você também pode combinar métodos com termos de união, mas isso é menos comum. Por exemplo, você pode definir uma constraint que exige um método String() e também restringe a um conjunto de tipos.

As constraints customizadas são fundamentais para criar funções genéricas que realizam operações aritméticas, comparações, ou que exigem métodos específicos. Elas permitem que o código seja reutilizável e seguro, pois o compilador verifica em tempo de compilação se os tipos satisfazem as restrições.

comparable

A constraint comparable é uma interface pré-definida que inclui todos os tipos comparáveis com os operadores == e !=. Isso inclui tipos básicos como int, string, ponteiros, canais, e structs cujos campos são todos comparáveis. Ela é útil para funções genéricas que precisam verificar igualdade ou usar mapas e conjuntos.

func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

func main() {
    fmt.Println(Contains([]int{1, 2, 3}, 2)) // true
    fmt.Println(Contains([]string{"a", "b"}, "c")) // false
}

Neste exemplo, a função Contains usa comparable para poder comparar cada elemento com o alvo usando ==. Sem essa constraint, o compilador não permitiria a comparação, pois o tipo genérico T poderia ser um tipo não comparável, como uma slice ou um map.

É importante notar que comparable é uma constraint especial, pois não pode ser usada como uma interface normal (você não pode definir um tipo que implemente comparable explicitamente). Ela é tratada pelo compilador de forma especial. Além disso, a partir do Go 1.20, você também pode usar comparable em tipos genéricos, como em type Set[T comparable] map[T]struct{}.

Inferência de tipo

Uma das grandes vantagens dos genéricos em Go é a inferência de tipos. Na maioria das chamadas de função, você não precisa especificar explicitamente os argumentos de tipo; o compilador os deduz a partir dos argumentos passados. Isso torna o código mais limpo e legível.

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

func main() {
    nums := []int{1, 2, 3}
    doubled := Map(nums, func(n int) int { return n * 2 })
    fmt.Println(doubled) // [2 4 6]
}

Nesse exemplo, não precisamos escrever Map[int, int]; o compilador infere que T é int e U também é int a partir do tipo de nums e do retorno da função anônima. A inferência funciona na maioria dos casos, mas há situações em que ela não é possível, por exemplo, quando o tipo aparece apenas no retorno da função. Nesses casos, você deve especificar os tipos explicitamente.

func New[T any]() *T {
    return new(T)
}

func main() {
    p := New[int]() // necessário especificar o tipo, pois não há argumentos para inferir
    *p = 10
}

Quando você chama uma função genérica sem argumentos, o compilador não pode inferir o tipo, então você precisa fornecê-lo. Isso é comum em funções construtoras ou em funções que retornam um tipo genérico sem receber argumentos desse tipo.

Limitações

Embora os genéricos em Go sejam poderosos, eles têm algumas limitações importantes que você deve conhecer. Primeiro, você não pode usar operadores aritméticos diretamente em variáveis de tipo genérico, a menos que a constraint inclua os tipos que suportam esses operadores. Por exemplo, func Add[T any](a, b T) T { return a + b } não compila, pois any não garante suporte a +. Você precisa definir uma constraint como Number que inclua os tipos numéricos.

Segundo, não é possível sobrecarregar funções ou métodos baseados em tipos genéricos. Ou seja, você não pode ter duas funções com o mesmo nome e parâmetros de tipo diferentes. Isso é uma decisão de design do Go para manter a simplicidade.

Terceiro, métodos em tipos genéricos não podem ter parâmetros de tipo adicionais. Por exemplo, você pode definir type List[T any] struct { ... } e métodos como func (l *List[T]) Push(v T), mas não pode definir um método com um parâmetro de tipo extra, como func (l *List[T]) Map[U any](f func(T) U) *List[U]. Em vez disso, você deve usar funções genéricas. Essa limitação foi uma escolha para simplificar a implementação e a inferência.

Por fim, a inferência de tipos pode falhar em casos mais complexos, como quando há múltiplas constraints ou quando o tipo é usado apenas em closures. Nesses casos, você pode precisar especificar os tipos explicitamente, o que reduz um pouco a elegância, mas ainda é aceitável.

Boas práticas

Ao usar genéricos em Go, siga estas boas práticas para escrever código claro e eficiente:

  • Use any apenas quando não houver necessidade de operações específicas; caso contrário, defina constraints que reflitam as operações necessárias.
  • Prefira constraints customizadas a usar interface{} com type assertions, pois elas são mais seguras e auto-documentadas.
  • Aproveite a inferência de tipos para reduzir a verbosidade, mas não hesite em especificar os tipos quando a inferência não for possível.
  • Evite usar genéricos em excesso; se uma função só funciona com um tipo específico, não a torne genérica.
  • Documente suas constraints com comentários para explicar quais tipos são esperados e por quê.

Referências

Exercícios

  1. Defina uma constraint customizada chamada Numeric que inclua os tipos int, float64 e complex128. Escreva uma função genérica Abs que retorne o valor absoluto de um número, mas para complexos retorne a magnitude. (Dica: use math.Abs para float64 e math.Abs para complexos? Não existe, use cmplx.Abs.)

    ✓ Resposta:
    import "math"
    import "math/cmplx"
    
    type Numeric interface {
        int | float64 | complex128
    }
    
    func Abs[T Numeric](n T) T {
        switch v := any(n).(type) {
        case int:
            if v < 0 { return T(-v) }
            return n
        case float64:
            return T(math.Abs(v))
        case complex128:
            return T(cmplx.Abs(v))
        }
        return n
    }
    
  2. Escreva uma função genérica IndexOf que retorna o índice da primeira ocorrência de um valor em um slice, ou -1 se não encontrado. Use a constraint comparable.

    ✓ Resposta:
    func IndexOf[T comparable](slice []T, val T) int {
        for i, v := range slice {
            if v == val {
                return i
            }
        }
        return -1
    }
    
  3. Explique por que a seguinte função não compila: func Max[T any](a, b T) T { if a > b { return a }; return b } e como corrigi-la.

    ✓ Resposta: A função não compila porque any não garante que T suporte o operador >. Para corrigir, defina uma constraint que restrinja a tipos ordenáveis, como Ordered (ex.: int | float64 | string).
  4. Dado o tipo type Pair[K, V any] struct { Key K; Value V }, escreva um método GetKey que retorne a chave e um método SetValue que defina o valor.

    ✓ Resposta:
    type Pair[K, V any] struct {
        Key K
        Value V
    }
    
    func (p Pair[K, V]) GetKey() K {
        return p.Key
    }
    
    func (p *Pair[K, V]) SetValue(v V) {
        p.Value = v
    }
    
  5. Qual é uma limitação dos genéricos em Go que afeta a criação de métodos em tipos genéricos? Dê um exemplo do que não é permitido.

    ✓ Resposta: Métodos em tipos genéricos não podem ter parâmetros de tipo adicionais. Por exemplo, você não pode definir func (l *List[T]) Map[U any](f func(T) U) *List[U]. Isso é uma limitação da linguagem.