Desde la versión 1.18 del lenguaje de programación Go, es posible utilizar tipos de datos genéricos. En esta entrada, aprenderás cómo escribir una función que soporte estos tipos de datos.
Problemática
Imagina que tienes una lista de números enteros ([]int32
) y deseas encontrar el
elemento más grande dentro de esa lista. Este es un problema de programación clásico.
Se podría considerar incluso como el abreboca al mundo de la algoritmia. El problema
surge cuando tu lista ahora es de números enteros sin signo ([]uint32
por ejemplo).
Seguramente tu función tenga este aspecto:
package main
import "fmt"
func largest(elems []int32) int32 {
largest := int32(0)
for _, elem := range elems {
if elem > largest {
largest = elem
}
}
return largest
}
func main() {
x := []int32{76, 51, 94, 16, 74, 101, 12, 1999, 1}
result := largest(x)
fmt.Printf("El elemento más grande es: %d\n", result)
}
Es una buena primera versión, no hay necesidad de algo más sofisticado. Sin embargo, ¿Qué tendrías que hacer para que tu función funcione además con otros tipos de datos?. Tendrías que duplicar tu función y cambiar los tipos de datos para hacerla funcionar. Ahora tienes dos funciones que hacen lo mismo pero para diferentes tipos de datos; en otras palabras, ahora tienes código duplicado y si algo detestamos los programadores es tener código duplicado.
Genéricos al Rescate
En vez de escribir dos funciones diferentes, podemos aprovechar el nuevo feature
de Go 1.18: tipos de datos genéricos. Si reescribimos la función de manera
genérica, obtendrías algo así:
package main
import (
"fmt"
"golang.org/x/exp/constraints"
)
func largest[T constraints.Ordered](elems []T) (largest T) {
if len(elems) == 0 {
return
}
largest = elems[0]
for _, elem := range elems {
if elem > largest {
largest = elem
}
}
return
}
func main() {
x := []int32{76, 51, 94, 16, 74, 101, 12, 1999, 1}
result := largest(x)
fmt.Printf("El elemento más grande es: %d\n", result)
}
No te asustes. Parece como si fuese un nuevo lenguaje de programación pero te juro que sigue siendo Go. Vamos a entender qué sucedió paso por paso:
-
Apareció un nuevo paquete llamado
constraints
. Este paquete contiene una colección de, valga la redundancia,constraints
orestricciones
, si lo quieres pensar en español. Estas restricciones no son más que uniones; las uniones son colecciones de tipos de datos que poseen características en común. Por ejemplo, las restricciones de tipo Ordered son elementos que se pueden ordenar (como los números). -
La firma de la función ahora tiene elementos adicionales; ahora dentro de esta estamos especificando a través de la sintaxis
[T constraints.Ordered]
que los parámetros de esta función pueden ser de cualquier tipo que se pueda ordenar. Si quieres conocer todas las restricciones que el paqueteconstraints
ofrece, te recomiendo leer la documentación del paquete. -
Dentro la firma de la función, también se específica que el retorno de la misma es un único elemento de tipo
T
, dondeT
es un tipo de dato que cumple con la condición de que puede ser ordenado. -
Por último, en vez de declarar una variable de retorno dentro de la función, utilizo las
named returns
de Go los cuales me permiten declarar variables en la firma de la función. Esto es porque en caso de que el arregloelems
está vacío, quiero que el compilador de Go se encargue de asignarle un valor a este retorno.
Conclusión
Hay una gran controversia en la comunidad de Go
debido a cómo fueron implementados los tipos de datos genéricos en el lenguaje. Entre
estas controversias está lo complicado que es implementar un constraint
existente en
tipos de datos definidos por el programador. Dicha tarea es considerada por algunos como
la prueba irrefutable de que el feature
de los genéricos está incompleta en el lenguaje.
Esto se debe a que si se quiere implementar una función largest
como la del ejemplo para
un tipo de dato definido por el programador, este tendría que crear un nuevo constraint
únicamente
para ese tipo de dato y volvería al principio de este artículo: tendría exactamente el
mismo código para dos tipos de datos diferentes. Esto ha hecho que bastantes programadores
de la comunidad se mantengan usando interface
y rechazan el uso de generics
ya que
piensan que su implementación está incompleta y agrega complejidad innecesaria a los programas.
Desde el lado positivo, los tipos de datos genéricos benefician a la mantenibilidad de un programa a lo largo del tiempo. El principal argumento para esta afirmación es que bases de código más pequeñas son más fáciles de mantener.
Puede que en un principio den miedo por su sintaxis. Es como si se definiera un arreglo
dentro de la firma de una función, pero no es así. En este artículo se exploró un caso de uso
bastante simple; sin embargo los tipos de datos genéricos permiten incluso combinar
y crear nuestros propios constraints
, algo así:
type NumeroEnteroPositivo interface {
~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uint
}
De esta sintaxis no se habló en este artículo, pero tal vez escriba una entrada para
explicar cómo podemos definir nuestros propios constraints
e incluso sobre cómo
permitir el uso de más de un constraint
dentro de la misma función.