Generics em Go: type parameter através de exemplos

1. Visão geral

A partir do Go 1.18, você pode escrever funções e tipos com type parameter. Assim, você não precisa duplicar a mesma lógica para int, float64, string, nem fazer conversão a partir de interface{}.

Este artigo vai desde o problema inicial até funções genéricas, constraints e a notação ~. Depois vêm tipos genéricos como Stack[T] e Cache[K, V], como escrever testes e erros comuns. Ao final, você saberá quando usar generics e quando uma interface comum é suficiente.

Todos os exemplos usam Go 1.22 ou superior. O package cmp está disponível desde Go 1.21. Arquivo go.mod mínimo:

module example.com/shop

go 1.22

Nenhuma biblioteca externa necessária. Tudo neste artigo está na standard library.

2. O problema antes dos generics

Suponha que você precise calcular a soma de quantidades (int) e a soma de valores (float64). Antes do Go 1.18, você geralmente escrevia duas funções idênticas:

func SumInts(xs []int) int {
	var total int
	for _, x := range xs {
		total += x
	}
	return total
}

func SumFloats(xs []float64) float64 {
	var total float64
	for _, x := range xs {
		total += x
	}
	return total
}

Outra forma era aceitar []interface{} e usar type switch. Essa abordagem perdia type safety. Erros só apareciam em tempo de execução, e você precisava fazer conversão de tipo em toda parte do código que chamava a função.

Generics resolvem exatamente isso: escreva a lógica uma vez, e o compiler ainda verifica tipos para você.

3. Sua primeira função genérica

3.1. Sintaxe de type parameter

Type parameters são declarados entre colchetes, logo após o nome da função:

package main

import "fmt"

type Number interface {
	~int | ~int64 | ~float64
}

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

func main() {
	quantities := []int{2, 1, 5}
	prices := []float64{120.5, 99.9, 15}

	fmt.Println(Sum(quantities))
	fmt.Println(Sum(prices))
}

Explicando cada parte:

  • [T Number] declara o type parameter T. T deve satisfazer a constraint Number.
  • Number é uma interface que contém um type set: int, int64, float64 e tipos derivados deles (o significado de ~ está na seção 4.3).
  • var total T cria o zero value de T, ou seja, 0 ou 0.0.
  • O operador += é válido porque todos os tipos no type set suportam adição.

Resultado:

8
235.4

3.2. Type inference

Você percebeu que chamamos Sum(quantities) e não Sum[int](quantities)? O compiler infere automaticamente T a partir dos argumentos. Isso é type inference.

Você só precisa especificar explicitamente quando o compiler não tem informações suficientes para inferir, por exemplo quando o type parameter aparece apenas no tipo de retorno:

func Zero[T any]() T {
	var z T
	return z
}

func main() {
	fmt.Println(Zero[string]() == "") // true
}

3.3. Múltiplos type parameters: Map e Filter

Estas são duas das funções utilitárias mais populares. Vamos aplicá-las à lista de Order:

package main

import "fmt"

type Order struct {
	ID       string
	Customer string
	Total    float64
}

func Map[T, U any](xs []T, f func(T) U) []U {
	out := make([]U, 0, len(xs))
	for _, x := range xs {
		out = append(out, f(x))
	}
	return out
}

func Filter[T any](xs []T, keep func(T) bool) []T {
	var out []T
	for _, x := range xs {
		if keep(x) {
			out = append(out, x)
		}
	}
	return out
}

func main() {
	orders := []Order{
		{"A01", "Lan", 250_000},
		{"A02", "Minh", 1_200_000},
		{"A03", "Lan", 890_000},
	}

	big := Filter(orders, func(o Order) bool { return o.Total >= 500_000 })
	ids := Map(big, func(o Order) string { return o.ID })

	fmt.Println(ids)
}
  • Map[T, U any] tem dois type parameters, então o tipo de entrada e saída podem ser diferentes.
  • any é um alias para interface{}. Permite qualquer tipo, mas você não pode chamar operadores nesse valor além de atribuição e passagem adiante.
  • O compiler infere T = Order e U = string a partir da função func(o Order) string.

Resultado:

[A02 A03]

4. Constraints: limitando o que T pode fazer

A constraint determina o que você pode fazer com valores do tipo T dentro da função.

4.1. comparable

comparable é uma constraint pré-definida. Ela permite usar == e !=, e é também a condição para T ser chave de um mapa:

func Unique[T comparable](xs []T) []T {
	seen := make(map[T]struct{}, len(xs))
	var out []T
	for _, x := range xs {
		if _, ok := seen[x]; !ok {
			seen[x] = struct{}{}
			out = append(out, x)
		}
	}
	return out
}

// Unique([]string{"Lan", "Minh", "Lan"}) => [Lan Minh]

O struct Order acima também é comparable, porque todos os seus campos podem ser comparados. Structs contendo slices, maps ou funções não satisfazem comparable, e o compiler reportará um erro.

4.2. cmp.Ordered

Quando você precisa comparar maior ou menor, use cmp.Ordered da standard library:

import "cmp"

func MaxBy[T any, K cmp.Ordered](xs []T, key func(T) K) (T, bool) {
	var best T
	if len(xs) == 0 {
		return best, false
	}
	best = xs[0]
	for _, x := range xs[1:] {
		if key(x) > key(best) {
			best = x
		}
	}
	return best, true
}

// top, _ := MaxBy(orders, func(o Order) float64 { return o.Total })
// top.ID => "A02"
  • K cmp.Ordered permite usar > em valores de chave.
  • A função retorna (T, bool) para lidar com slices vazios sem panicking.

Nota: Para slices numéricos simples, você não precisa escrever. Go 1.21 já tem funções built-in max/min, e o package slices fornece slices.Max, slices.Contains, slices.Sort. Verifique slices e maps antes de escrever seus próprios utilitários genéricos.

4.3. A notação ~ (tilde)

No mundo real, você frequentemente define tipos próprios para valores monetários:

type VND int64

func main() {
	payments := []VND{150_000, 350_000}
	fmt.Println(Sum(payments)) // 500000
}

Esta linha compila graças a ~int64 em Number. A notação ~int64 significa “qualquer tipo cujo underlying type seja int64”. Se você escrever int64 sem ~, então VND não satisfaz a constraint, e o compiler reporta:

VND does not satisfy Number (possibly missing ~ for int64 in Number)

Regra simples: ao escrever constraints para tipos básicos, quase sempre você deve usar ~.

4.4. Constraints combinadas com métodos

Constraints também podem exigir métodos, como interfaces comuns:

type Identifiable interface {
	comparable
	Key() string
}

Um tipo que queira satisfazer Identifiable deve ser comparável e ter um método Key() string.

5. Tipos genéricos

5.1. Stack[T]

Type parameters não são apenas para funções, mas também para structs:

type Stack[T any] struct {
	items []T
}

func (s *Stack[T]) Push(v T) {
	s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	if len(s.items) == 0 {
		return zero, false
	}
	last := len(s.items) - 1
	v := s.items[last]
	s.items = s.items[:last]
	return v, true
}

func (s *Stack[T]) Len() int { return len(s.items) }

Forma de usar:

var history Stack[Order]
history.Push(Order{ID: "A01"})
history.Push(Order{ID: "A02"})

o, _ := history.Pop()
fmt.Println(o.ID, history.Len()) // A02 1
  • O receiver é escrito como *Stack[T], deve incluir [T].
  • Ao declarar uma variável, você deve especificar o tipo: Stack[Order]. Não há inferência aqui.
  • O zero value de Stack[Order] é utilizável imediatamente, porque append funciona bem em slices nil.

5.2. Cache[K, V] seguro para múltiplas goroutines

Um exemplo mais realista: um cache in-memory compartilhado para Customer e Order.

package cache

import "sync"

type Cache[K comparable, V any] struct {
	mu   sync.RWMutex
	data map[K]V
}

func New[K comparable, V any]() *Cache[K, V] {
	return &Cache[K, V]{data: make(map[K]V)}
}

func (c *Cache[K, V]) Put(key K, value V) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

func (c *Cache[K, V]) Get(key K) (V, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}
type Customer struct {
	ID   int
	Name string
}

customers := cache.New[int, Customer]()
customers.Put(1, Customer{ID: 1, Name: "Lan"})

orders := cache.New[string, Order]()
orders.Put("A01", Order{ID: "A01", Total: 250_000})

c, ok := customers.Get(1)
fmt.Println(c.Name, ok) // Lan true

Uma única implementação serve dois tipos de dados. customers.Get retorna exatamente Customer, sem necessidade de type assertion. Se você acidentalmente Put um Order em customers, o código não compilará.

6. Escrevendo testes para código genérico

Go usa o package testing padrão. Table-driven tests funcionam normalmente com funções genéricas. Cada tabela de teste corresponde a um tipo específico:

package main

import "testing"

func TestSum(t *testing.T) {
	ints := []struct {
		name string
		in   []int
		want int
	}{
		{"empty", nil, 0},
		{"positive", []int{2, 1, 5}, 8},
		{"mixed", []int{10, -3}, 7},
	}
	for _, tc := range ints {
		t.Run(tc.name, func(t *testing.T) {
			if got := Sum(tc.in); got != tc.want {
				t.Errorf("Sum(%v) = %d, want %d", tc.in, got, tc.want)
			}
		})
	}

	if got := Sum([]VND{150_000, 350_000}); got != 500_000 {
		t.Errorf("Sum VND = %d, want 500000", got)
	}
}

func TestStackPopEmpty(t *testing.T) {
	var s Stack[string]
	v, ok := s.Pop()
	if ok || v != "" {
		t.Fatalf("Pop() on empty = (%q, %v), want (\"\", false)", v, ok)
	}
}

Executar testes:

go test ./... -v
=== RUN   TestSum
=== RUN   TestSum/empty
=== RUN   TestSum/positive
=== RUN   TestSum/mixed
--- PASS: TestSum (0.00s)
=== RUN   TestStackPopEmpty
--- PASS: TestStackPopEmpty (0.00s)
PASS

Teste pelo menos um tipo derivado como VND. Esta é a forma mais rápida de detectar uma constraint faltando ~.

7. Erros comuns e quando não usar

Métodos não podem ter type parameters próprios. O seguinte não compila:

// erro: methods cannot have type parameters
func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] { ... }

Escreva como uma função de nível superior: func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U].

Não use type switch diretamente em T. Para fazer ramificação por tipo, você deve converter para any primeiro: switch v := any(x).(type) { ... }. Se você se vê fazendo isso frequentemente, generics pode não ser a ferramenta certa.

Constraints com type set não podem ser usadas como tipos de variáveis. var n Number reportará um erro. Interfaces contendo | ou ~ podem ser usadas apenas em posições de constraint.

comparable com interface pode fazer panic. Se K é um tipo interface e o valor dinâmico dentro é um slice, comparação ou uso como chave de mapa fará panic em tempo de execução.

Nota: Generics não são automaticamente mais rápidos que interfaces. O compilador Go usa a técnica “GC shape stenciling” com dicionários. Diferentes tipos de ponteiro podem compartilhar uma versão do código, e chamadas de método através de constraints ainda têm custos indiretos. Se performance importa, meça com benchmark em vez de adivinhar.

Quando NÃO usar generics:

  • Quando a lógica depende de comportamento (métodos), não de tipos. Por exemplo, PaymentService recebe um Notifier com método Send. Interfaces geralmente são mais concisas e legíveis.
  • Quando há apenas um tipo específico usando a função. Não generalize “para o caso em que você possa precisar depois”.
  • Quando slices, maps, cmp já têm a função que você precisa.

Uma regra fácil de lembrar: se você está escrevendo o mesmo código para vários tipos e a única diferença é o nome do tipo, é hora de usar generics.

8. Conclusão

Este artigo cobriu as principais partes de generics em Go:

  • Declarar type parameters [T C] e deixar o compiler inferir tipos.
  • Usar constraints any, comparable, cmp.Ordered e definir constraints próprias com ~ para suportar tipos derivados como VND.
  • Construir tipos genéricos Stack[T] e Cache[K, V] seguros para múltiplas goroutines.
  • Escrever table-driven tests para funções genéricas.
  • Reconhecer limitações: métodos não têm type parameters próprios, constraints não são tipos de variáveis, e generics não substituem interfaces.

Comece de onde há duplicação real de código no seu projeto. Escreva generics no mínimo necessário, priorize a standard library e sempre teste com pelo menos dois tipos diferentes.

golanggenericstype parameterconstraint