Golang: Generics (Parte 13)

·7 min de leitura

Golang: Generics (Parte 13)

1. Introdução: O que são Generics e por que são úteis em Go?

Generics, ou programação genérica, permitem que você escreva funções e tipos que operam com tipos de dados arbitrários, em vez de tipos específicos. Antes da introdução dos generics no Go 1.18, a linguagem dependia de interfaces vazias (interface{}) e reflexão para alcançar um comportamento polimórfico, o que frequentemente resultava em código menos seguro em termos de tipo, mais verboso e com desempenho inferior devido à necessidade de asserções de tipo e conversões em tempo de execução.

Os generics resolvem esses problemas, permitindo que os desenvolvedores escrevam código mais reutilizável, seguro em termos de tipo e eficiente. Eles permitem que você defina algoritmos e estruturas de dados que funcionam com qualquer tipo, desde que esse tipo satisfaça certas restrições.

2. Parâmetros de Tipo: Como definir funções e tipos com parâmetros de tipo

Para usar generics, você define parâmetros de tipo em funções ou tipos. Os parâmetros de tipo são listados entre colchetes [] após o nome da função ou tipo.

Exemplo de Função Genérica:

package main

import "fmt"

// PrintSlice imprime todos os elementos de um slice de qualquer tipo.
func PrintSlice[T any](s []T) {
	for _, v := range s {
		fmt.Print(v, " ")
	}
	fmt.Println()
}

func main() {
	PrintSlice([]int{1, 2, 3})
	PrintSlice([]string{"hello", "world"})
	PrintSlice([]float64{3.14, 2.71})
}

Neste exemplo, [T any] declara um parâmetro de tipo T que pode ser qualquer tipo (devido à restrição any). A função PrintSlice pode agora operar em slices de inteiros, strings, floats, ou qualquer outro tipo.

3. Restrições de Tipo: Entendendo interfaces como restrições, incluindo comparable e restrições personalizadas

Parâmetros de tipo podem ter restrições, que são interfaces que definem o conjunto de operações permitidas para o tipo. Isso garante que o código genérico só possa ser usado com tipos que suportam as operações necessárias.

A Restrição any:

any é um alias para interface{}, significando que o parâmetro de tipo pode ser de qualquer tipo. É a restrição mais permissiva.

A Restrição comparable:

comparable é uma interface pré-declarada que permite o uso de operadores de comparação (== e !=) em tipos. Isso é útil para funções que precisam comparar valores, como encontrar um elemento em um slice.

package main

import "fmt"

// Index retorna o índice da primeira ocorrência de x em s, ou -1 se não encontrado.
func Index[T comparable](s []T, x T) int {
	for i, v := range s {
		if v == x {
			return i
		}
	}
	return -1
}

func main() {
	fmt.Println(Index([]int{1, 2, 3}, 2))    // Saída: 1
	fmt.Println(Index([]string{"a", "b", "c"}, "d")) // Saída: -1
}

Restrições Personalizadas:

Você pode definir suas próprias interfaces para usar como restrições de tipo. Isso é poderoso para definir comportamentos específicos que os tipos devem ter.

package main

import "fmt"

type Stringer interface {
	String() string
}

// Concatena elementos que implementam a interface Stringer.
func Concat[T Stringer](s []T) string {
	out := ""
	for _, v := range s {
		out += v.String()
	}
	return out
}

type MyInt int

func (m MyInt) String() string {
	return fmt.Sprintf("MyInt(%d)", m)
}

func main() {
	nums := []MyInt{1, 2, 3}
	fmt.Println(Concat(nums)) // Saída: MyInt(1)MyInt(2)MyInt(3)
}

4. Funções Genéricas: Prática de escrita

Funções genéricas são a aplicação mais comum de generics. Elas permitem que você escreva algoritmos que funcionam independentemente do tipo de dados que estão processando.

Exemplo: Função Map

package main

import "fmt"

// Map aplica uma função 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 {
	res := make([]U, len(s))
	for i, v := range s {
		res[i] = f(v)
	}
	return res
}

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

	words := []string{"hello", "world"}
	upper := Map(words, func(s string) string { return s + "!" })
	fmt.Println(upper) // Saída: [hello! world!]
}

5. Tipos Genéricos: Definindo structs e interfaces genéricas

Além de funções, você também pode definir tipos genéricos, como structs e interfaces.

Struct Genérica:

package main

import "fmt"

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

// Push adiciona um elemento à pilha.
func (s *Stack[T]) Push(item T) {
	s.data = append(s.data, item)
}

// Pop remove e retorna o elemento do topo da pilha.
func (s *Stack[T]) Pop() (T, bool) {
	if len(s.data) == 0 {
		var zero T // Retorna o valor zero do tipo T
		return zero, false
	}
	lastIndex := len(s.data) - 1
	item := s.data[lastIndex]
	s.data = s.data[:lastIndex]
	return item, true
}

func main() {
	intStack := Stack[int]{}
	intStack.Push(10)
	intStack.Push(20)
	fmt.Println(intStack.Pop()) // Saída: 20 true
	fmt.Println(intStack.Pop()) // Saída: 10 true
	fmt.Println(intStack.Pop()) // Saída: 0 false

	stringStack := Stack[string]{}
	stringStack.Push("Go")
	stringStack.Push("Generics")
	fmt.Println(stringStack.Pop()) // Saída: Generics true
}

Interface Genérica:

Interfaces genéricas são úteis para definir contratos que operam com tipos genéricos.

package main

import "fmt"

type Container[T any] interface {
	Add(T)
	Get() T
}

// MyContainer implementa a interface Container para um tipo específico.
type MyContainer[T any] struct {
	value T
}

func (mc *MyContainer[T]) Add(item T) {
	mc.value = item
}

func (mc *MyContainer[T]) Get() T {
	return mc.value
}

func main() {
	var intContainer Container[int] = &MyContainer[int]{}
	intContainer.Add(42)
	fmt.Println(intContainer.Get()) // Saída: 42

	var stringContainer Container[string] = &MyContainer[string]{}
	stringContainer.Add("Hello Generics")
	fmt.Println(stringContainer.Get()) // Saída: Hello Generics
}

6. Casos de Uso Comuns: Cenários onde generics se destacam

Generics são particularmente úteis em cenários onde você precisa de código que opere de forma consistente em diferentes tipos, sem sacrificar a segurança de tipo ou o desempenho:

  • Estruturas de Dados: Implementações de listas, pilhas, filas, árvores, etc., que podem armazenar qualquer tipo de elemento.
  • Algoritmos: Funções de busca, ordenação, filtragem, mapeamento que podem ser aplicadas a coleções de qualquer tipo.
  • Coleções: Funções utilitárias para manipular slices, maps e outras coleções.
  • Operações de I/O: Funções que leem ou escrevem dados de/para diferentes fontes, onde o tipo de dado lido/escrito pode variar.

7. Melhores Práticas e Considerações: Dicas para uso eficaz e potenciais armadilhas

  • Use com Moderação: Embora poderosos, generics não devem ser usados em excesso. Se uma função ou tipo pode ser escrito de forma clara e eficiente sem generics, prefira a simplicidade.
  • Restrições Claras: Sempre que possível, use restrições de tipo específicas em vez de any. Isso torna o código mais seguro em termos de tipo e mais fácil de entender, pois as operações permitidas são explícitas.
  • Legibilidade: Certifique-se de que o uso de generics não torne seu código mais difícil de ler ou depurar. Nomes de parâmetros de tipo devem ser concisos e descritivos (por exemplo, T para Tipo, K para Chave, V para Valor).
  • Desempenho: Em geral, o compilador Go otimiza o código genérico para ter um desempenho comparável ao código não genérico. No entanto, em casos muito específicos, o uso excessivo de reflexão dentro de funções genéricas pode ter um impacto.
  • Compatibilidade: Lembre-se que generics foram introduzidos no Go 1.18. Certifique-se de que seu ambiente de desenvolvimento e implantação suporta esta versão ou superior.

Generics são uma adição valiosa ao Go, permitindo a criação de bibliotecas mais robustas e reutilizáveis, e simplificando muitos padrões de código que antes eram complexos ou inseguros em termos de tipo. Ao entender seus fundamentos e aplicá-los criteriosamente, você pode escrever código Go mais eficaz e elegante.