Go 1.27 y los métodos genéricos: ocho años esperando
6 min de lectura

Go 1.27 y los métodos genéricos: ocho años esperando

1182 palabras

Llevo escribiendo sobre Go en este blog desde 2015. En aquel artículo de “Qué me gusta de Go” hablaba de la simplicidad, de la compilación rápida, de lo bien que sienta un lenguaje que no te obliga a memorizar veinte formas de hacer lo mismo. Y luego llegó Go 1.18 con los genéricos, en 2022, y con ellos una asimetría que ha incomodado a mucha gente durante años: podías escribir tipos genéricos y funciones genéricas, pero no métodos genéricos.

El artículo de Mark Freeman en el blog oficial de Go, publicado el 26 de agosto, cierra ese capítulo. Go 1.27 añade parámetros de tipo a los métodos de tipos concretos.

La asimetría que molestaba

Go 1.18 resolvió el problema clásico: antes tenías que escribir una lista enlazada para enteros y otra para cadenas.

// antes de Go 1.18
type ListOfInts struct {
    elem int
    next *ListOfInts
}

type ListOfStrings struct {
    elem string
    next *ListOfStrings
}

// después de Go 1.18
type List[E any] struct {
    elem E
    next *List[E]
}

Lo mismo con las funciones: SortInts y SortStrings se convirtieron en un único Sort[E cmp.Ordered]. Perfecto.

Pero los métodos se quedaron fuera. La propuesta original de genéricos razonaba que, como los métodos genéricos en interfaces son difíciles de implementar de forma eficiente, tampoco tenía sentido añadir parámetros de tipo a los métodos concretos. En esa visión, los métodos son fundamentalmente el mecanismo para implementar interfaces, así que si una mitad no puede existir, la otra tampoco.

Dónde dolía en la práctica

El ejemplo del artículo es muy claro. Tienes tu List[E] y quieres transformarla en una lista de otro tipo. Puedes escribir un método específico:

func (List[E]) ToString(f func(E) string) List[string] { /* ... */ }

Y funciona bien para casos concretos:

fmt.Println(NewList(1, 2, 3).ToString(strconv.Itoa)) // [1 2 3]

El problema es que parametrizar List te permite generalizar el tipo de origen, pero no el de destino, porque el destino depende de la transformación que apliques. Si solo tienes un tipo de destino, vale. Si tienes muchos, necesitas una función genérica:

// después de Go 1.18
func MapList[E, R any](l List[E], f func(E) R) List[R] { /* ... */ }

Y aquí aparecen dos inconvenientes. El primero es de organización: MapList vive en el ámbito del paquete, y si tienes varios tipos de datos que soportan operaciones de mapeo, el paquete se llena de funciones sueltas. El segundo es de legibilidad: las llamadas encadenadas hay que escribirlas de dentro hacia afuera.

fmt.Println(MapList(MapList(NewList(0, 2, 4), add(2)), divideBy(2))) // [1 2 3]

Cualquiera que haya escrito una cadena de tres o cuatro transformaciones sabe lo que es leer eso.

Lo que trae Go 1.27

// después de Go 1.27
func (List[E]) Map[R any](f func(E) R) List[R] { /* ... */ }

Compacto, expresivo y con ámbito local al tipo. Y las llamadas encadenadas se leen de izquierda a derecha, como uno esperaría:

fmt.Println(NewList(0, 2, 4).Map(add(2)).Map(divideBy(2))) // [1 2 3]

Si por lo que sea prefieres la forma “de dentro hacia afuera”, las expresiones de método siguen funcionando con métodos genéricos:

f := List[int].Map[int]
fmt.Println(f(f(NewList(0, 2, 4), add(2)), divideBy(2))) // [1 2 3]

Como el resto de genéricos en Go, los métodos genéricos deben instanciarse —explícita o implícitamente— antes de usarse.

El cambio de mentalidad

Lo que me parece más interesante del artículo no es la sintaxis, sino el razonamiento. El equipo de Go ha desacoplado dos conceptos que antes iban juntos: los métodos como mecanismo para implementar interfaces, y los métodos como herramienta de organización del código.

Un método concreto genérico no puede ayudar a implementar una interfaz, eso sigue siendo cierto. Pero sí puede ser útil para organizar código. Y esa segunda utilidad es suficiente por sí misma.

El artículo lo ilustra con un caso sutil:

type I interface {
    M()
}

type T struct{}                   // un struct, no una interfaz
func (T) M[P any]() { /* ... */ } // un método *concreto* genérico

Cualquier instanciación de T.M produce una firma idéntica a la de I.M —ambas son func M()—. Pero eso no significa que T implemente I. La implementación de interfaces es una propiedad del tipo, no de un método concreto: T no declara T.M[int], declara su contrapartida genérica sin instanciar T.M.

Por qué no hay métodos genéricos en interfaces

Esta es la parte que explica ocho años de espera, y merece la pena entenderla porque dice mucho sobre cómo compila Go.

Un valor de interfaz es una caja que puede contener un valor de cualquier tipo que la implemente. Cuando llamas a i.M(), en un programa sencillo es evidente que la llamada va a parar a T.M. Pero en programas reales esa relación cruza fronteras de paquete y es imposible de deducir en el caso general.

// -- paquete main --
import "p"

func main() {
    p.F(T{})
}

type T struct{}
func (T) M() { /* ... */ }

// -- paquete p --
func F(i I) {
    i.M()
}

type I interface {
    M()
}

Los dos paquetes se compilan por separado, así que main no sabe qué hace p.F con T{}. La solución actual del compilador es sencilla: genera código para todos los métodos no genéricos de un tipo en su declaración o instanciación. Así, haga lo que haga p.F, el código existirá en tiempo de ejecución.

Ahora imagina que I.M fuera genérico:

// -- paquete p --
func F(i I) {
    i.M[int]()
}

type I interface {
    M[P any]()
}

Para cubrir cualquier uso posible de T{} dentro de p.F, el compilador tendría que instanciar T.M con todos los argumentos de tipo posibles. Eso es inviable con el enfoque de Go, que genera código específico para cada instanciación.

Existe una alternativa: si los argumentos de tipo se “encajonaran” —pasándolos como valores de sus interfaces de restricción—, todas las instanciaciones compartirían el mismo código. Evitarías la explosión de instanciaciones, pero a cambio pagarías el coste de llamadas indirectas, incluso en las llamadas directas a métodos ya instanciados. Es exactamente el tipo de trade-off que Go lleva años negándose a aceptar, y me parece coherente.

Mi lectura

Go sigue siendo el lenguaje que me gustó en 2015 por razones que no han cambiado: prefiere resolver el 90% del problema con una solución que no tiene coste oculto, antes que resolver el 100% pagando en rendimiento o complejidad. Los métodos genéricos son un buen ejemplo. No tenemos la versión completa de la característica, tenemos la mitad que se puede implementar bien.

Y esa mitad es la que más se usa en el día a día. Escribir Map, Filter o Reduce como métodos de tus propios tipos, con encadenamiento legible, cubre la inmensa mayoría de los casos en los que uno echaba de menos los métodos genéricos. Los métodos genéricos en interfaces son un problema de diseño de compiladores que quizá se resuelva algún día, o quizá no.

Ocho años entre la propuesta de genéricos y esta pieza que faltaba. En un ecosistema donde cada seis meses aparece un lenguaje que promete resolverlo todo, esa cadencia lenta y deliberada sigue siendo, para mí, una de las mejores cosas de Go.

Comentarios

Últimas Entradas

6 min

1122 palabras

I’ve been writing about Go on this blog since 2015. Back in that “What I like about Go” article I talked about simplicity, fast compilation, and how good it feels to use a language that doesn’t force you to memorize twenty ways of doing the same thing. Then Go 1.18 arrived with generics in 2022, and with them an asymmetry that has bothered a lot of people for years: you could write generic types and generic functions, but not generic methods.

5 min

1031 palabras

Hace unos días me topé con un artículo que me dejó literalmente con la boca abierta. No es que sea nuevo - en realidad es de enero de 2026, pero bueno, a veces llega tarde a mi radar - pero la combinación de tecnologías y la historia detrás me parecen fascinantes.

Se trata de TinyEMU-Go: un emulador de RISC-V escrito completamente en Go, portado desde C usando Claude. Y lo mejor: puedes ejecutar un Linux completo con un solo comando.

2 min

349 palabras

A few days ago I came across an article that literally left me with my mouth open. It’s about TinyEMU-Go: a RISC-V emulator written entirely in Go, ported from C using Claude. And the best part: you can run a complete Linux with a single command.

The Command Line That Gave Me Envy

go run github.com/jtolio/tinyemu-go/temubox/example@2c8151233c2d

And boom, you have a complete Linux running. No special permissions, no containers, no weird dependencies. A pure static Go binary.

5 min

863 palabras

goimg1910I’ve always liked to see and try different technologies, and within these, of course, programming languages.

An example of this was the intensive use I gave at the time, at Arrakis, to Rebol, an interpreted cross-platform language, with everything you could need to perform great data cleaning scripts, in a “strange” syntax, but beautiful in its approach.

The case of Go was a bit different, because just like what happened to me with Angular, at the time (several years ago) I tried to give it a chance, but all the information and examples I found were “very small” blocks of code, I didn’t see in that (at a bird’s eye view) that it was finished, it seemed like a more academic/conceptual language than something for real use.