Original article: Generics in Go Explained with Code Examples

Se propuso incluir genéricos en el lenguaje de programación Go hace unos años, y finalmente se aceptó la propuesta formal en el 2021. La versión de G0 1.18 en marzo de 2022 fue el que incluyó, por primera vez, soporte para genéricos.

Pero, ¿cómo se verá afectado Go por los genéricos? ¿Cambiará nuestra manera de escribir el código?

Para poder responder a estas preguntas, tenemos que analizar detalladamente cómo funciona la programación genérica. Afortunadamente, el equipo de Go nos ha proporcionado un compilador de web donde podemos experimentar con genéricos.

Pero, ¿qué es lo que realmente cambia con la introducción de genéricos en Go?

image-316
Photo by Annie Spratt / Unsplash

Los genéricos permiten que nuestras funciones o estructuras de datos acepten varios tipos, que se definen en su forma genérica.

Para entender bien lo que significa esta frase, veamos un caso muy simple:

Imaginemos que tienes que hacer una función que tome un fragmento y la imprima. Así, podrías escribir este tipo de función:

func Print(s []string) {
	for _, v := range s {
		fmt.Print(v)
	}
}

Sencillo, ¿verdad? Y, ¿qué pasaría si quisiéramos que ese fragmento sea un entero? Para esto, tendrías que crear un nuevo método:

func Print(s []int) {
	for _, v := range s {
		fmt.Print(v)
	}
}

Estas soluciones podrían parecer redundantes, ya que solo estamos cambiando el parámetro. Sin embargo, así es como lo solucionamos ahora mismo en Go sin tener que recurrir a convertirlo en alguna interfaz.

Ahora con los genéricos, nos permitirán declarar nuestras funciones de esta manera:

func Print[T any](s []T) {
	for _, v := range s {
		fmt.Print(v)
	}
}

En la función de arriba, estamos declarando dos cosas:

  1. Por un lado, tenemos T, que es el tipo de la palabra clave any (esta palabra clave se define específicamente como parte de un genérico, lo que indica cualquier tipo)
  2. Y nuestro parámetro, donde tenemos la variable s cuyo tipo es una fragmento de T.

Ahora podremos hacer una llamada a nuestro método de esta manera:

func main() {
	Print([]string{"Hello, ", "playground\n"})
	Print([]int{1,2,3})
}

Un método para cualquier tipo de variable: genial, ¿verdad?

Esta es solo una de las implementaciones muy básicas que hay para genéricos. Aun así, ya tiene buena pinta.

Exploremos un poco más y veamos hasta dónde nos pueden llevar los genéricos.

Limitaciones de los genéricos

image-317
Photo by Nick Tiemeyer / Unsplash

Hemos visto lo que se puede hacer con los genéricos. Nos permiten especificar una función que puede aceptar cualquier tipo de parámetro.

Pero el ejemplo de antes era muy simple. Existen limitaciones al uso de los genéricos. Imprimir, por ejemplo, es bastante sencillo, ya que Go puede imprimir cualquier tipo de variable que se le agregue.

¿Pero qué pasa si queremos hacer algo más complejo? Vamos a imaginar que hemos definido nuestros propios métodos para una estructura y queremos hacer una llamada:

package main

import (
	"fmt"
)

type worker string

func (w worker) Work(){
	fmt.Printf("%s is working\n", w)
}


func DoWork[T any](things []T) {
    for _, v := range things {
        v.Work()
    }
}

func main() {
	var a,b,c worker
	a = "A"
	b = "B"
	c = "C"
	DoWork([]worker{a,b,c})	
}

Y esto devolvería lo siguiente:

type checking failed for main
prog.go2:25:11: v.Work undefined (type bound for T has no method Work)

No se ejecuta porque la fragmento procesada dentro de la función es de tipo any y no implementa el método Work, lo que hace que no se ejecute.

Podríamos hacer que funcionara usando una interfaz:

package main

import (
	"fmt"
)

type Person interface {
    Work()
}

type worker string

func (w worker) Work(){
	fmt.Printf("%s is working\n", w)
}

func DoWork[T Person](things []T) {
    for _, v := range things {
        v.Work()
    }
}

func main() {
	var a,b,c worker
	a = "A"
	b = "B"
	c = "C"
	DoWork([]worker{a,b,c})
}

Imprimiría lo siguiente:

A is working
B is working
C is working

Funcionaría bien con la interfaz, pero también lo haría con una interfaz sin el genérico:

package main

import (
	"fmt"
)

type Person interface {
    Work()
}

type worker string

func (w worker) Work(){
	fmt.Printf("%s is working\n", w)
}

func DoWorkInterface(things []Person) {
    for _, v := range things {
        v.Work()
    }
}

func main() {
	var d,e,f worker
	d = "D"
	e = "E"
	f = "F"
	DoWorkInterface([]Person{d,e,f})
}

Imprimiría lo siguiente:

D is working
E is working
F is working

El uso de los genéricos solo añade un extra de lógica a nuestro código. Cuando usar solo la interfaz sea suficiente, no habría ningún motivo para añadir genéricos al código.

Los genéricos aún se encuentran en sus primeras fases de desarrollo y todavía tienen límites a la hora de realizar procesamientos complejos.

Jugando con las restricciones

image-318
Photo by Paulo Brandao / Unsplash

Antes, usamos el tipo any para nuestra restricción genérica. Además de ese tipo, hay algunas otras restricciones que podemos usar.

Una de las restricciones es comparable. Vamos a ver cómo funciona:

func Equal[T comparable](a, b T) bool {
    return a == b
}

func main() {
	Equal("a","a")
}

Aparte de eso, también podemos intentar crear nuestra propia restricción de la siguiente manera:

package main

import(
	"fmt"
)

type Number interface {
    type int, float64
}

func MultiplyTen[T Number](a T) T{
	return a*10
}

func main() {
	fmt.Println(MultiplyTen(10))
	fmt.Println(MultiplyTen(5.55))
}

Y me parece bastante bueno: podemos tener una función para una expresión matemática simple. Por lo general, terminaremos haciendo dos funciones para incorporarlo o usaremos la reflexión, por lo que solo escribiremos una función.

Si bien todo esto es bastante interesante, aún tenemos que experimentar un poco para crear nuestras propias restricciones. Todavía es pronto para conocer todas las limitaciones de los genéricos. Y debemos tener cuidado de no abusar de ellos y usarlos solo si estamos realmente seguros de que sean necesarios.

Otras formas de usar los genéricos

image-319
Photo by Marcelo Franchi / Unsplash

Además de usar los genéricos como parte de una función, también puedes declararlos como variables de la siguiente manera:

type GenericSlice[T any] []T

Y puedes usar esto como un parámetro en una función o puedes crear un método de ese tipo:

func (g GenericSlice[T]) Print() {
	for _, v := range g {
		fmt.Println(v)
	}
}

func Print [T any](g GenericSlice[T]) {
	for _, v := range g {
		fmt.Println(v)
	}
}

func main() {
	g := GenericSlice[int]{1,2,3}
	
	g.Print() //1 2 3
	Print(g) //1 2 3
}

El uso cambiaría según tus necesidades. Diría que todavía tenemos que experimentar más con genéricos para ver qué casos de uso funcionan mejor.

Mi opinión sobre los genéricos

Los genéricos están todavía en sus fases iniciales, pero estoy bastante impresionado con la forma en la que se gestionan. No hace falta saber muchos términos complicados ni muchas librerías para implementar genéricos, y esta simplicidad es lo que hace que sean geniales.

Hay varios casos de uso en los que ya puedo intuir que usar genéricos será mejor, como por ejemplo en el caso con el método de multiplicación. Una cosa que a lo mejor confunde a la gente es que los genéricos podrían ser un sustituto para el uso de interfaces (tanto el tipo de interfaz{} como la implementación de la interfaz).

Mi consejo es no pensar en los genéricos como un sustituto de nada. Los genéricos son simplemente una herramienta más al alcance de los desarrolladores. Por otro lado, aunque los genéricos te parezcan elegantes y geniales, y quieras usarlos en cada bloque de tu código, te aconsejo que no los uses en exceso; úsalos cuando realmente los necesites y no los metas en todos los sitios en donde puedan encajar.

Eso es todo. Gracias por leer mi artículo, y espero sinceramente que los genéricos te puedan resultar útiles.

¡Qué disfrutéis de los genéricos!