Durante una década, la respuesta de la comunidad a la necesidad de reutilizar lógica con diferentes tipos era una y la misma: “usa interface{}“. Este enfoque se basaba en la filosofía de diseño de Go: mantener el lenguaje simple y evitar la complejidad de otros lenguajes. Si necesitabas una función que aceptara cualquier cosa, usabas any (antes interface{}). Sin embargo, esta solución tenía un costo alto en type safety y rendimiento. Para trabajar con tipos específicos, te veías obligado a realizar type assertions en tiempo de ejecución, lo que abre la puerta a errores de pánico si la aserción falla.
Los generics [disponible desde Go 1.18] introducen la polimorfismo paramétrico. A diferencia de las interfaces, que definen comportamiento mediante métodos, los generics permiten que una estructura o función trabaje con un tipo que se define en el momento de su uso. Esto permite crear colecciones como pilas, colas o árboles que mantengan la integridad de los tipos sin recurrir a la conversión manual.
El equipo de Go no llegó a los generics por accidente; fue el resultado de un proceso de diseño exhaustivo liderado por Griesemer, Taylor y Cox. El gran reto era evitar los problemas de los templates de C++, que pueden causar una explosión masiva en el tamaño del binario (code bloat) y tiempos de compilación astronómicos, y evitar la complejidad de los higher-kinded types de lenguajes funcionales avanzados. El diseño final optó por un modelo que optimiza tanto la velocidad de compilación como la eficiencia en el runtime, sacrificando algunas comodidades para mantener la simplicidad que define a Go.
package main
import "fmt"
// Stack es una estructura de datos genérica.
// El parámetro de tipo [T any] permite que el Stack trabaje con cualquier tipo,
// pero manteniendo la seguridad de tipos en cada instancia.
type Stack[T any] struct {
elements []T
}
// Push añade un elemento al tope de la pila.
// Al usar T, el compilador garantiza que solo se inserten elementos del mismo tipo.
func (s *Stack[T]) Push(v T) {
s.elements = append(s.elements, v)
}
// Pop extrae el último elemento.
// Retornamos (T, bool) para evitar el uso de tipos zero value ambiguos en errores.
func (s *Stack[T]) Pop() (T, bool) {
if len(s.elements) == 0 {
var zero T // Obtenemos el valor por defecto del tipo T
return zero, false
}
v := s.elements[len(s.elements)-1]
s.elements = s.elements[:len(s.elements)-1]
return v, true
}
// Filter es una función de orden superior genérica.
// Permite filtrar una slice basándose en una condición sin usar interface{}.
func Filter[T any](slice []T, test func(T) bool) []T {
var result []T
for _, v := range slice {
if test(v) {
result = append(result, v)
}
}
return result
}
func main() {
// Ejemplo 1: Estructura de datos type-safe con enteros.
intStack := Stack[int]{}
intStack.Push(42)
intStack.Push(13)
if val, ok := intStack.Pop(); ok {
// No hace falta hacer type assertion: val es directamente un int.
fmt.Printf("Valor de la pila (int): %d\n", val)
}
// Ejemplo 2: Utilidad de colección con strings.
words := []string{"go", "generics", "performance", "type-safe"}
longWords := Filter(words, func(s string) bool {
return len(s) > 5
})
fmt.Printf("Palabras largas: %v\n", longWords)
}
En el ejemplo anterior, observa cómo Stack[T] utiliza un parámetro de tipo [T any]. Cuando instanciamos Stack[int], el compilador genera una implementación optimizada para int. Si intentas hacer intStack.Push("hola"), el compilador te detendrá inmediatamente, algo que con interface{} solo detectarías en runtime.
En la función Filter[T any], vemos la utilidad de los genéricos en funciones puras. Antes de Go 1.18, para que Filter fuera realmente útil, tendrías que haber usado []interface{} y luego convertir cada elemento de vuelta a su tipo original, lo cual es costoso para el procesador y propenso a errores. Aquí, T es un marcador de posición que se resuelve en el momento en que llamas a Filter(words, ...).
El método Pop utiliza un truco importante: var zero T. Como no sabemos qué es T en tiempo de compilación (puede ser un int, un struct complejo o un puntero), declaramos una variable sin inicializar para obtener el “valor cero” de ese tipo y poder retornarlo de forma segura cuando la pila esté vacía.
El error frecuente
Un error muy común al empezar con genéricos es intentar definir métodos genéricos en un tipo que ya es genérico. Por ejemplo, intentar esto:
type Container[T any] struct {
value T
}
// ESTO NO COMPILARÁ
func (c *Container[T]) Compare[U any](other U) bool {
// El compilador de Go no permite que un método introduzca
// nuevos parámetros de tipo que no pertenezcan al receptor.
return false
}
Go prohíbe esto para mantener la simplicidad del runtime y evitar la complejidad que supondría la instanciación de métodos con múltiples parámetros de tipo independientes. Si necesitas una funcionalidad que dependa de un tipo distinto al del receptor, esa lógica debe vivir en una función independiente en el paquete, no en un método de la estructura.
N° 81