Nesta aula, vamos mergulhar no uso de genéricos em Go em situações do mundo real. Os genéricos foram introduzidos na versão 1.18 e desde então se tornaram uma ferramenta poderosa para escrever código reutilizável e type-safe. Vamos explorar como criar bibliotecas utilitárias, definir type sets, reconhecer limitações práticas e aplicar tudo isso em exemplos concretos.

Se você já conhece a sintaxe básica de genéricos, esta aula vai elevá-lo a um nível mais avançado, mostrando como pensar em termos de abstrações e como evitar armadilhas comuns. Prepare-se para ver padrões que você pode usar imediatamente em seus projetos.

Bibliotecas utilitárias

Uma das aplicações mais comuns de genéricos é a criação de bibliotecas utilitárias que operam sobre coleções de tipos variados. Antes dos genéricos, você precisaria escrever funções separadas para cada tipo ou usar interface{} com type assertions, o que era verboso e propenso a erros. Com genéricos, podemos escrever funções como Map, Filter e Reduce que funcionam com qualquer tipo, mantendo a segurança de tipos.

Vamos criar uma pequena biblioteca de utilitários para slices. Aqui está um exemplo de como implementar Map e Filter:

package utils

// Map aplica uma função f a cada elemento do slice e retorna um novo slice com os resultados.
func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

// Filter retorna um novo slice contendo apenas os elementos que satisfazem o predicado.
func Filter[T any](s []T, f func(T) bool) []T {
    var result []T
    for _, v := range s {
        if f(v) {
            result = append(result, v)
        }
    }
    return result
}

Essas funções são genéricas e podem ser usadas com qualquer tipo, desde inteiros até structs personalizados. Por exemplo:

package main

import (
    "fmt"
    "utils"
)

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

    even := utils.Filter(nums, func(x int) bool { return x%2 == 0 })
    fmt.Println(even) // [2 4]
}

Além de Map e Filter, você pode criar funções como Reduce, Contains, Unique, etc. Essas funções são particularmente úteis em projetos que lidam com coleções de dados, como processamento de listas de usuários, produtos ou eventos. Ao criar sua própria biblioteca de utilitários, você pode padronizar operações comuns e reduzir a duplicação de código.

Type sets

Em Go, um type set é uma interface que especifica um conjunto de tipos que podem ser usados como argumentos de tipo. Isso é feito usando a sintaxe de união de tipos dentro de uma interface. Por exemplo:

type Number interface {
    ~int | ~float64
}

Aqui, ~int significa qualquer tipo cujo tipo subjacente é int (incluindo int, int8, int16, etc.), e ~float64 inclui float64 e tipos derivados. Isso permite que você restrinja os genéricos a um conjunto específico de tipos, garantindo que as operações sejam válidas.

Vamos ver um exemplo prático: uma função que calcula a soma de uma lista de números. Sem type sets, teríamos que usar interface{} e fazer type assertions. Com type sets, podemos fazer:

package main

import "fmt"

type Number interface {
    ~int | ~float64
}

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

func main() {
    ints := []int{1, 2, 3}
    floats := []float64{1.5, 2.5, 3.0}
    fmt.Println(Sum(ints))   // 6
    fmt.Println(Sum(floats)) // 7.0
}

Neste exemplo, Number é um type set que restringe T a tipos que são int ou float64 (ou seus derivados). Isso permite que a função use o operador +=, que é válido para esses tipos.

Type sets podem ser mais complexos, combinando múltiplos tipos e até mesmo interfaces. Por exemplo, você pode definir um type set que aceita qualquer tipo que implemente uma interface específica e também alguns tipos básicos. No entanto, é importante lembrar que type sets têm limitações: eles não podem incluir tipos de referência (como slices, maps, channels) diretamente, e a sintaxe de união só é permitida dentro de interfaces.

Limitações práticas

Apesar do poder dos genéricos, há várias limitações práticas que você deve conhecer para evitar frustrações ao desenvolver. Uma das principais é que os genéricos em Go não suportam métodos genéricos. Ou seja, você não pode definir um método em um tipo que tenha seus próprios parâmetros de tipo. Isso significa que, se você quiser criar um método que funcione com tipos variados, você precisa usar funções genéricas em vez de métodos.

Outra limitação é que você não pode usar operadores aritméticos com tipos genéricos a menos que restrinja o type set a tipos numéricos, como fizemos com Number. Mas mesmo assim, operadores como + só funcionam para tipos que suportam essa operação. Para tipos mais complexos, você precisará de funções de callback.

Além disso, o desempenho dos genéricos pode ser uma preocupação em alguns cenários. O compilador gera código especializado para cada instância de tipo, o que pode aumentar o tamanho do binário. Em contrapartida, o uso de interface{} com type assertions pode ser mais lento em tempo de execução devido ao boxing/unboxing. Você deve avaliar o trade-off entre tamanho do código e desempenho.

Finalmente, a inferência de tipos nem sempre é perfeita. Em alguns casos, você precisará especificar explicitamente os parâmetros de tipo, especialmente quando a inferência não é óbvia. Por exemplo, ao chamar uma função genérica com múltiplos parâmetros de tipo que não podem ser inferidos a partir dos argumentos.

Exemplos

Vamos consolidar o que aprendemos com dois exemplos mais complexos que combinam bibliotecas utilitárias, type sets e lidam com limitações.

Exemplo 1: Uma função genérica para encontrar o valor máximo em um slice, usando um comparador personalizado. Vamos usar um type set para aceitar qualquer tipo comparável? Não, comparável não é suficiente, pois precisamos de uma função de comparação. Vamos usar um callback.

package main

import "fmt"

// Max retorna o elemento máximo de um slice usando a função less para comparar.
func Max[T any](s []T, less func(a, b T) bool) T {
    if len(s) == 0 {
        var zero T
        return zero
    }
    max := s[0]
    for _, v := range s[1:] {
        if less(max, v) {
            max = v
        }
    }
    return max
}

func main() {
    nums := []int{3, 1, 4, 1, 5}
    maxNum := Max(nums, func(a, b int) bool { return a < b })
    fmt.Println(maxNum) // 5

    words := []string{"apple", "banana", "cherry"}
    maxWord := Max(words, func(a, b string) bool { return len(a) < len(b) })
    fmt.Println(maxWord) // cherry
}

Exemplo 2: Uma função que retorna os elementos únicos de um slice, usando um mapa para rastrear ocorrências. Aqui, precisamos que o tipo T seja comparável para poder usá-lo como chave de mapa. Podemos usar a restrição comparable.

package main

import "fmt"

// Unique retorna um novo slice com elementos únicos, preservando a ordem.
func Unique[T comparable](s []T) []T {
    seen := make(map[T]bool)
    result := make([]T, 0, len(s))
    for _, v := range s {
        if !seen[v] {
            seen[v] = true
            result = append(result, v)
        }
    }
    return result
}

func main() {
    nums := []int{1, 2, 2, 3, 3, 3}
    uniqueNums := Unique(nums)
    fmt.Println(uniqueNums) // [1 2 3]

    words := []string{"go", "go", "java", "rust", "java"}
    uniqueWords := Unique(words)
    fmt.Println(uniqueWords) // [go java rust]
}

Esses exemplos mostram como usar genéricos para criar funções flexíveis e seguras. Você pode combinar essas ideias para construir bibliotecas mais amplas, como processamento de coleções, algoritmos de ordenação e muito mais.

Boas práticas e observações finais

Ao usar genéricos em Go, é importante seguir algumas boas práticas para manter o código limpo e eficiente. Primeiro, use genéricos quando houver uma necessidade real de abstração; não os use indiscriminadamente, pois podem tornar o código mais complexo. Segundo, defina type sets claros e restritos, evitando aceitar tipos inesperados que possam causar erros em tempo de execução. Terceiro, documente suas funções genéricas com exemplos, pois a assinatura pode ser intimidadora para outros desenvolvedores.

Além disso, lembre-se de que nem tudo precisa ser genérico. Às vezes, usar uma interface específica é mais simples e adequado. Avalie o trade-off entre flexibilidade e complexidade. Por fim, acompanhe as mudanças na linguagem: os genéricos ainda estão evoluindo e novas funcionalidades podem surgir em futuras versões.

Exercícios

  1. Implemente uma função genérica Reduce que recebe um slice, um valor inicial e uma função de redução, e retorna o resultado acumulado. Teste com um slice de inteiros para somar todos os elementos.

    ✓ Resposta:
    func Reduce[T, U any](s []T, initial U, f func(U, T) U) U {
        acc := initial
        for _, v := range s {
            acc = f(acc, v)
        }
        return acc
    }
    
    // Teste
    func main() {
        nums := []int{1, 2, 3, 4}
        sum := Reduce(nums, 0, func(acc int, v int) int { return acc + v })
        fmt.Println(sum) // 10
    }
  2. Crie um type set chamado Signed que aceite todos os tipos de inteiros com sinal (int, int8, int16, int32, int64) e uma função Abs que retorne o valor absoluto de um número. Use o type set.

    ✓ Resposta:
    type Signed interface {
        ~int | ~int8 | ~int16 | ~int32 | ~int64
    }
    
    func Abs[T Signed](x T) T {
        if x < 0 {
            return -x
        }
        return x
    }
    
    func main() {
        fmt.Println(Abs(-5))   // 5
        fmt.Println(Abs(int8(-8))) // 8
    }
  3. Explique por que não é possível ter métodos genéricos em Go e como contornar isso usando funções genéricas.

    ✓ Resposta: Em Go, métodos não podem ter parâmetros de tipo próprios; apenas o tipo receptor pode ser parametrizado. Isso ocorre porque a especificação da linguagem não permite que métodos introduzam novos parâmetros de tipo. Para contornar, você pode definir funções genéricas que tomam o tipo como parâmetro, em vez de métodos. Por exemplo, em vez de um método func (s MySlice[T]) Map(...), você define uma função func Map[T any](s MySlice[T], f func(T) T) MySlice[T].
  4. 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 restrição comparable.

    ✓ Resposta:
    func IndexOf[T comparable](s []T, val T) int {
        for i, v := range s {
            if v == val {
                return i
            }
        }
        return -1
    }
    
    func main() {
        nums := []int{10, 20, 30}
        fmt.Println(IndexOf(nums, 20)) // 1
        fmt.Println(IndexOf(nums, 40)) // -1
    }
  5. Considere a seguinte função genérica que tenta usar o operador + com um tipo T sem restrição. Corrija o código adicionando um type set adequado.

    func Add[T any](a, b T) T {
        return a + b
    }

    ✓ Resposta: O código não compila porque o operador + não é definido para todos os tipos. Você deve restringir T a tipos numéricos. Por exemplo:
    type Number interface {
        ~int | ~float64
    }
    
    func Add[T Number](a, b T) T {
        return a + b
    }
    
    func main() {
        fmt.Println(Add(1, 2))       // 3
        fmt.Println(Add(1.5, 2.5))   // 4.0
    }

Referências