Bem-vindo à aula sobre genéricos em Go! Nesta aula, vamos explorar um dos recursos mais aguardados da linguagem, introduzido na versão 1.18. Os genéricos permitem escrever funções e tipos que podem operar com diferentes tipos, sem sacrificar a segurança de tipos nem a performance. Isso abre um leque de possibilidades para criar código mais reutilizável e expressivo.

Antes dos genéricos, os programadores Go frequentemente recorriam a técnicas como `interface{}` (vazio) e reflexão, que eram menos seguras e mais lentas. Com os genéricos, você pode definir funções que funcionam com qualquer tipo que satisfaça certas restrições, mantendo a verificação de tipos em tempo de compilação. Vamos mergulhar nos conceitos fundamentais.

Type parameters

Type parameters (parâmetros de tipo) são a base dos genéricos em Go. Eles permitem que você defina funções e tipos que podem ser parametrizados por outros tipos. Na definição, você especifica uma lista de parâmetros de tipo entre colchetes `[]`. Por exemplo, uma função que encontra o maior elemento de um slice pode ser genérica, aceitando qualquer tipo que seja comparável.

Vamos ver um exemplo simples: uma função que retorna o primeiro elemento de um slice. Sem genéricos, teríamos que usar `interface{}` e fazer uma conversão de tipo. Com genéricos, podemos escrever:

package main

import "fmt"

// First retorna o primeiro elemento de um slice de qualquer tipo T.
func First[T any](s []T) T {
    return s[0]
}

func main() {
    ints := []int{10, 20, 30}
    fmt.Println(First(ints)) // 10

    strs := []string{"a", "b", "c"}
    fmt.Println(First(strs)) // "a"
}

No código acima, `[T any]` define um parâmetro de tipo `T` que pode ser qualquer tipo (o `any` é um alias para `interface{}`). A função `First` pode ser chamada com slices de qualquer tipo, e o compilador garante que o retorno será do mesmo tipo do elemento do slice. Isso é muito mais seguro do que usar `interface{}` e fazer type assertion.

Além de funções, também podemos definir tipos genéricos, como uma estrutura de dados (ex.: uma pilha). Por exemplo:

package main

// Stack é uma pilha genérica.
type Stack[T any] struct {
    items []T
}

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
    }
    last := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return last, true
}

func main() {
    stack := Stack[int]{}
    stack.Push(1)
    stack.Push(2)
    if v, ok := stack.Pop(); ok {
        fmt.Println(v) // 2
    }
}

Note que quando você tem um tipo genérico, precisa especificar o tipo ao instanciá-lo (ex.: `Stack[int]`). Isso permite criar uma pilha para qualquer tipo de dado.

Constraints

Constraints (restrições) definem o conjunto de tipos que podem ser usados como argumentos para um parâmetro de tipo. Elas são essenciais para garantir que as operações realizadas dentro da função genérica sejam válidas para todos os tipos aceitos. Sem restrições, você estaria limitado a operações que todos os tipos suportam (como atribuição e chamada de métodos da interface vazia).

Em Go, as restrições são interfaces. Uma interface pode ser usada como uma constraint se contiver métodos ou tipos específicos. Por exemplo, a constraint `constraints.Ordered` (definida no pacote `golang.org/x/exp/constraints`) inclui tipos que suportam operadores de comparação como `<`, `>`, `<=`, `>=`. Isso é útil para funções que precisam comparar valores.

Vamos criar uma função genérica que retorna o maior valor de um slice, usando a constraint `Ordered`:

package main

import (
    "fmt"
    "golang.org/x/exp/constraints"
)

// Max retorna o maior elemento de um slice.
// A constraint Ordered garante que podemos usar operadores de comparação.
func Max[T constraints.Ordered](s []T) T {
    if len(s) == 0 {
        var zero T
        return zero
    }
    max := s[0]
    for _, v := range s[1:] {
        if v > max {
            max = v
        }
    }
    return max
}

func main() {
    ints := []int{3, 1, 4, 1, 5}
    fmt.Println(Max(ints)) // 5

    floats := []float64{2.7, 3.1, 1.5}
    fmt.Println(Max(floats)) // 3.1
}

Além de usar interfaces pré-definidas, você pode criar suas próprias constraints. Por exemplo, suponha que você queira uma função que funcione com tipos que tenham um método `String() string`. Você pode definir uma interface assim:

package main

import (
    "fmt"
    "strconv"
)

// Stringer é uma constraint que exige um método String() string.
type Stringer interface {
    String() string
}

// PrintSlice imprime cada elemento de um slice usando String().
func PrintSlice[T Stringer](s []T) {
    for _, v := range s {
        fmt.Println(v.String())
    }
}

type MyInt int

func (m MyInt) String() string {
    return strconv.Itoa(int(m))
}

func main() {
    nums := []MyInt{1, 2, 3}
    PrintSlice(nums) // imprime 1, 2, 3
}

Note que a constraint `Stringer` é uma interface comum, mas usada como parâmetro de tipo. Isso permite que a função `PrintSlice` seja chamada com qualquer tipo que implemente `String()`.

Outra característica importante é o tipo `any` (que é um alias para `interface{}`). Quando você usa `any`, está dizendo que qualquer tipo é aceito, mas então você não pode fazer muitas operações além de atribuições e chamadas de métodos da interface vazia. Por isso, é recomendável usar constraints específicas sempre que possível.

Funções genéricas

Funções genéricas são funções que possuem parâmetros de tipo. Elas são declaradas com uma lista de parâmetros de tipo entre colchetes, antes dos parâmetros regulares. Isso permite que a função seja usada com diferentes tipos, mantendo a segurança de tipos.

Já vimos exemplos simples, mas vamos explorar um caso mais complexo: uma função que transforma um slice de um tipo em um slice de outro tipo, usando uma função de mapeamento. Isso é semelhante ao `map` em outras linguagens.

package main

import (
    "fmt"
    "strconv"
)

// Map aplica uma função f a cada elemento de um 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
}

func main() {
    ints := []int{1, 2, 3}
    // Converte cada int para string.
    strs := Map(ints, strconv.Itoa)
    fmt.Println(strs) // [1 2 3]

    // Dobra cada int.
    doubled := Map(ints, func(x int) int { return x * 2 })
    fmt.Println(doubled) // [2 4 6]
}

Neste exemplo, temos dois parâmetros de tipo: `T` para o tipo de entrada e `U` para o tipo de saída. A função `Map` aceita um slice de `T` e uma função que converte `T` em `U`, retornando um slice de `U`. Isso é extremamente reutilizável e seguro.

Outra aplicação comum é criar funções que operam sobre slices de qualquer tipo, como `Filter` (filtra elementos que satisfazem uma condição) ou `Reduce` (acumula valores). Vamos ver um exemplo de `Filter`:

package main

import "fmt"

// Filter retorna um novo slice contendo apenas os elementos que satisfazem a condição f.
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
}

func main() {
    nums := []int{1, 2, 3, 4, 5}
    evens := Filter(nums, func(x int) bool { return x%2 == 0 })
    fmt.Println(evens) // [2 4]
}

Essas funções genéricas são um grande avanço em relação a escrever as mesmas funções para cada tipo específico ou usar reflexão.

É importante notar que o compilador Go infere os tipos na maioria das vezes, então você não precisa especificar os parâmetros de tipo na chamada, como fizemos acima. Se houver ambiguidade, você pode especificá-los explicitamente: `Map[int, string](ints, strconv.Itoa)`.

Quando usar

Genéricos são uma ferramenta poderosa, mas devem ser usados com discernimento. Eles são ideais quando você tem lógica que pode ser aplicada a vários tipos, mas que exige operações que não estão disponíveis em `interface{}`. Por exemplo, funções de coleção (`Map`, `Filter`, `Reduce`), estruturas de dados como pilhas, filas, árvores, ou qualquer algoritmo que precise comparar ou ordenar valores.

No entanto, existem situações em que genéricos não são a melhor escolha:

  • Quando a lógica é específica para um único tipo, não há necessidade de generalizar.
  • Quando você precisa de operações que só alguns tipos suportam, mas não é possível expressar isso com constraints (por exemplo, operações aritméticas com números, pois Go não tem uma constraint nativa para isso, mas você pode usar `constraints.Ordered` para comparações).
  • Quando a complexidade adicional não é justificada. Se você tem apenas dois ou três tipos, pode ser mais simples duplicar código ou usar interfaces comuns.

Outra consideração é a performance. Embora os genéricos em Go sejam implementados de forma eficiente (via monomorfização, ou seja, o compilador gera código específico para cada tipo), o uso excessivo pode aumentar o tempo de compilação e o tamanho do binário. Portanto, use genéricos quando o benefício de reutilização for claro.

Um bom exemplo de uso é a biblioteca padrão: a partir da versão 1.18, algumas funções no pacote `slices` e `maps` (novos pacotes experimentais) usam genéricos. Por exemplo, `slices.Index` e `slices.Contains` são genéricos.

Em resumo, genéricos são excelentes para criar bibliotecas e componentes reutilizáveis. Para código de aplicação, avalie se a abstração vale a pena.

Boas práticas e observações finais

Ao trabalhar com genéricos em Go, algumas boas práticas podem ajudar:

  • Use constraints específicas em vez de `any` quando precisar de operações. Isso torna o código mais seguro e documenta os requisitos.
  • Prefira usar funções da biblioteca padrão quando possível. Por exemplo, em vez de escrever sua própria função `Map`, considere se já existe uma solução (mas muitas vezes você precisará criar a sua).
  • Mantenha os nomes dos parâmetros de tipo curtos e significativos, como `T`, `U`, `K`, `V` (para chave/valor).
  • Lembre-se de que você não pode usar métodos de um tipo específico a menos que a constraint os exija. Se precisar chamar um método, defina uma interface que inclua esse método.

Além disso, vale a pena explorar o pacote golang.org/x/exp/constraints, que oferece constraints úteis como `Ordered`, `Signed`, `Unsigned`, `Integer`, `Float`, etc. Embora seja experimental, muitos projetos o utilizam.

Por fim, pratique! Escreva funções genéricas para problemas do dia a dia e você verá como elas tornam o código mais elegante e DRY (Don't Repeat Yourself).

Referências

Exercícios

  1. Exercício 1: Escreva uma função genérica chamada `Reverse` que inverte um slice de qualquer tipo e retorna o slice invertido (não modifique o slice original). Teste com `[]int` e `[]string`.

    ✓ Resposta:
    func Reverse[T any](s []T) []T {
        result := make([]T, len(s))
        for i, v := range s {
            result[len(s)-1-i] = v
        }
        return result
    }
    
    // Teste
    func main() {
        ints := []int{1, 2, 3}
        fmt.Println(Reverse(ints)) // [3 2 1]
        strs := []string{"a", "b", "c"}
        fmt.Println(Reverse(strs)) // [c b a]
    }
    
  2. Exercício 2: Crie uma constraint chamada `Numeric` que aceite apenas tipos numéricos (int, int32, int64, float32, float64). Dica: use uma união de tipos (ex.: `type Numeric interface { ~int | ~int32 | ~int64 | ~float32 | ~float64 }`). Escreva uma função `Sum` que some todos os elementos de um slice de um tipo que satisfaça `Numeric`.

    ✓ Resposta:
    type Numeric interface {
        ~int | ~int32 | ~int64 | ~float32 | ~float64
    }
    
    func Sum[T Numeric](s []T) T {
        var total T
        for _, v := range s {
            total += v
        }
        return total
    }
    
    // Teste
    func main() {
        ints := []int{1, 2, 3}
        fmt.Println(Sum(ints)) // 6
        floats := []float64{1.5, 2.5}
        fmt.Println(Sum(floats)) // 4.0
    }
    
  3. Exercício 3: Defina um tipo genérico `Pair[K, V any]` que armazena uma chave e um valor. Escreva um método `GetKey()` e `GetValue()`. Instancie com `Pair[string, int]` e use os métodos.

    ✓ Resposta:
    type Pair[K, V any] struct {
        key   K
        value V
    }
    
    func NewPair[K, V any](key K, value V) Pair[K, V] {
        return Pair[K, V]{key: key, value: value}
    }
    
    func (p Pair[K, V]) GetKey() K {
        return p.key
    }
    
    func (p Pair[K, V]) GetValue() V {
        return p.value
    }
    
    // Teste
    func main() {
        pair := NewPair("idade", 30)
        fmt.Println(pair.GetKey())   // "idade"
        fmt.Println(pair.GetValue()) // 30
    }
    
  4. Exercício 4: Escreva uma função genérica `Find` que recebe um slice e um valor, e retorna o índice da primeira ocorrência do valor, ou -1 se não encontrado. Use a constraint `comparable` (que já existe como constraint, mas você pode usar `any`? Não, precisa de comparação, então use `comparable`). Teste com `[]int` e `[]string`.

    ✓ Resposta:
    func Find[T comparable](s []T, val T) int {
        for i, v := range s {
            if v == val {
                return i
            }
        }
        return -1
    }
    
    // Teste
    func main() {
        ints := []int{1, 2, 3}
        fmt.Println(Find(ints, 2)) // 1
        strs := []string{"a", "b", "c"}
        fmt.Println(Find(strs, "z")) // -1
    }
    
  5. Exercício 5: Crie uma função genérica `MergeMaps` que recebe dois mapas com a mesma chave e valor (ambos comparáveis? Na verdade, valor pode ser qualquer tipo, mas para simplificar, use `any`). A função deve retornar um novo mapa com todas as chaves de ambos, e se houver conflito, o valor do segundo mapa deve prevalecer. Dica: use `maps.Clone`? Mas você pode implementar manualmente.

    ✓ Resposta:
    func MergeMaps[K comparable, V any](m1, m2 map[K]V) map[K]V {
        result := make(map[K]V, len(m1)+len(m2))
        for k, v := range m1 {
            result[k] = v
        }
        for k, v := range m2 {
            result[k] = v
        }
        return result
    }
    
    // Teste
    func main() {
        m1 := map[string]int{"a": 1, "b": 2}
        m2 := map[string]int{"b": 20, "c": 3}
        merged := MergeMaps(m1, m2)
        fmt.Println(merged) // map[a:1 b:20 c:3]
    }