Nesta aula, vamos mergulhar no mundo das coleções genéricas em Go. Desde a versão 1.18, Go suporta generics, permitindo que você escreva funções e estruturas de dados que trabalham com qualquer tipo, sem abrir mão da segurança de tipos. Vamos explorar como implementar estruturas genéricas, como slices e maps, e como isso promove o reuso de código.

As coleções genéricas são fundamentais para criar bibliotecas e algoritmos que funcionam com diversos tipos, evitando duplicação de código e mantendo a clareza. Veremos na prática como definir tipos genéricos, usar restrições (constraints) e aproveitar os pacotes padrão que já oferecem suporte a genéricos.

Implementando estruturas genéricas

Para criar uma estrutura genérica, usamos a sintaxe de parâmetros de tipo entre colchetes. Por exemplo, uma pilha (stack) genérica pode ser definida assim:

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
}

O parâmetro T é um tipo genérico que pode ser qualquer tipo. A restrição any é um alias para interface{}, permitindo qualquer tipo. Isso nos permite usar a mesma pilha para inteiros, strings, structs, etc.

Podemos instanciar a pilha com diferentes tipos:

func main() {
    intStack := Stack[int]{}
    intStack.Push(1)
    intStack.Push(2)
    fmt.Println(intStack.Pop()) // 2 true

    stringStack := Stack[string]{}
    stringStack.Push("hello")
    fmt.Println(stringStack.Pop()) // hello true
}

Além de any, podemos definir restrições mais específicas. Por exemplo, para uma função que soma elementos de um slice, precisamos de tipos que suportem + e -. A interface Number pode ser definida com tipos numéricos:

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

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

Com isso, podemos chamar Sum com um slice de inteiros ou floats, e o compilador garante que apenas tipos numéricos sejam usados.

slices e maps (pacotes)

O pacote slices foi adicionado na versão 1.21 e oferece funções genéricas para operações comuns em slices, como Contains, Index, Sort e Filter. Exemplo:

import "slices"

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

    fmt.Println(slices.Contains(numbers, 4)) // true
}

O pacote maps oferece funções genéricas para mapas, como Keys, Values e Clone:

import "maps"

func main() {
    m := map[string]int{"a": 1, "b": 2}
    keys := maps.Keys(m)
    fmt.Println(keys) // [a b]

    clone := maps.Clone(m)
    clone["c"] = 3
    fmt.Println(m) // map[a:1 b:2]
    fmt.Println(clone) // map[a:1 b:2 c:3]
}

Esses pacotes eliminam a necessidade de escrever funções repetitivas para cada tipo de slice ou map, promovendo coesão e eficiência.

Reuso

Com generics, escrevemos código uma vez e reutilizamos para muitos tipos, reduzindo duplicação e erros. Por exemplo, uma função de filtro genérica pode ser usada para filtrar qualquer slice:

func Filter[T any](items []T, predicate func(T) bool) []T {
    result := make([]T, 0)
    for _, item := range items {
        if predicate(item) {
            result = append(result, item)
        }
    }
    return result
}

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

    words := []string{"apple", "banana", "cherry"}
    long := Filter(words, func(s string) bool { return len(s) > 5 })
    fmt.Println(long) // [banana cherry]
}

Isso é muito mais eficiente do que criar versões específicas para cada tipo. Além disso, a segurança de tipos é mantida: se você tentar usar uma função de comparação que não aceita o tipo, o compilador acusa erro.

Exemplos

Vamos consolidar com um exemplo completo: uma lista simples genérica com métodos básicos.

package main

import (
    "fmt"
    "slices"
)

type List[T comparable] struct {
    items []T
}

func (l *List[T]) Add(item T) {
    l.items = append(l.items, item)
}

func (l *List[T]) Remove(item T) bool {
    idx := slices.Index(l.items, item)
    if idx == -1 {
        return false
    }
    l.items = append(l.items[:idx], l.items[idx+1:]...)
    return true
}

func (l *List[T]) Contains(item T) bool {
    return slices.Contains(l.items, item)
}

func (l *List[T]) Len() int {
    return len(l.items)
}

func main() {
    list := List[string]{}
    list.Add("Go")
    list.Add("Rust")
    list.Add("Python")
    fmt.Println(list.Contains("Go")) // true
    fmt.Println(list.Remove("Rust")) // true
    fmt.Println(list.Len())           // 2
}

Note que usamos comparable para que slices.Index funcione, pois ele exige comparação de igualdade. Isso demonstra como combinar restrições e pacotes genéricos.

Boas práticas

1. Use any apenas quando realmente não houver restrições; caso contrário, defina interfaces específicas.
2. Prefira funções genéricas a duplicar código para cada tipo.
3. Aproveite os pacotes slices e maps para operações comuns.
4. Lembre-se de que generics têm custo de performance mínimo, mas podem aumentar o tempo de compilação.
5. Documente suas restrições para que outros desenvolvedores entendam quais tipos são aceitos.

Referências

Exercícios

  1. Implemente uma função genérica Map que recebe um slice de T e uma função que converte T em U, retornando um slice de U.

    ✓ Resposta:
    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
    }
  2. Crie uma função genérica Reduce que acumula os valores de um slice usando uma função de acumulação.

    ✓ 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
    }
  3. Implemente um tipo Set[T comparable] que armazena elementos únicos, usando um mapa interno.

    ✓ Resposta:
    type Set[T comparable] struct {
        m map[T]struct{}
    }
    
    func NewSet[T comparable]() *Set[T] {
        return &Set[T]{m: make(map[T]struct{})}
    }
    
    func (s *Set[T]) Add(v T) {
        s.m[v] = struct{}{}
    }
    
    func (s *Set[T]) Remove(v T) {
        delete(s.m, v)
    }
    
    func (s *Set[T]) Contains(v T) bool {
        _, ok := s.m[v]
        return ok
    }
  4. Use o pacote slices para escrever uma função que retorna os elementos únicos de um slice, mantendo a ordem original.

    ✓ Resposta:
    func Unique[T comparable](s []T) []T {
        seen := make(map[T]bool)
        var result []T
        for _, v := range s {
            if !seen[v] {
                seen[v] = true
                result = append(result, v)
            }
        }
        return result
    }
  5. Escreva um exemplo de uso de uma função genérica que ordena um slice de qualquer tipo ordenável (use constraints.Ordered).

    ✓ Resposta:
    import "sort"
    
    type Ordered interface {
        ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 | ~string
    }
    
    func SortSlice[T Ordered](s []T) {
        sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
    }