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:

  1. Apareció un nuevo paquete llamado constraints. Este paquete contiene una colección de, valga la redundancia, constraints o restricciones, 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).

  2. 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 paquete constraints ofrece, te recomiendo leer la documentación del paquete.

  3. 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, donde T es un tipo de dato que cumple con la condición de que puede ser ordenado.

  4. 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 arreglo elems 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.

Bibliografía