golang-generics-nerdearla

command module
v0.0.0-...-9e590fc Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 27, 2023 License: MIT Imports: 2 Imported by: 0

README

golang-generics-nerdearla

Este repositorio contiene el código que vamos a usar en el workshop de Golang Generics de Nerdearla 2023, dictado por Agustín Luques y Nicolás Del Piano.

Contenido

  1. Introducción
  2. Sistema de Tipos de Go
  3. Generics
    1. Generics en Go
    2. Notación
    3. Funciones Genéricas
    4. Tipos Genéricos
  4. Constraints
  5. Inferencia de Tipos
  6. Encadenamiento de Tipos (Type Chaining)
  7. Generics Múltiples
  8. Interfaces versus Generics
  9. Reflection versus Generics
  10. Estado del Arte

Introducción

Go ha ido evolucionando desde su versión inicial 1.0 hasta la más reciente 1.21 (Go releases).

Uno de los grandes cambios introducidos últimamente en la version 1.18 es la implementación de generics (tipos genéricos). La propuesta oficial fue conocida como type parameters.

Pero... ¿para qué nos sirve esto?

Sistema de Tipos de Go

Un repaso rápido del sistema de tipos de Go:

Tipos Básicos

Los tipos básicos built-in que podemos encontrar en el lenguaje son:

Tipos numéricos:

  • int8, uint8 (byte), int16, uint16, int32 (rune), uint32, int64, uint64, int, uint, uintptr
  • float32, float64
  • complex64, complex128

Booleanos:

  • bool

Strings:

  • string

Cada uno de estos tipos pueden ser usados en código Go sin importar ningún paquete externo.

Zero Values

Cada tipo tiene un "valor cero" (zero value), el cual puede ser pensado como el valor por defecto del tipo, es decir, si no inicializamos la variable con un valor.

  • El valor cero de un tipo booleano es false
  • El valor cero de un tipo numérico es 0 (el tamaño en memoria puede variar de acuerdo al tipo)
  • El valor cero de un tipo string es ""
Tipos Compuestos

Go admite los siguientes tipos compuestos:

  • Punteros *T
  • Struct struct{}
  • Funciones func(){} – las funciones son first-class types en Go
  • Tipos contenedores:
    • Arreglos [n]T – un arreglo de longitud fija
    • Slices []T – una lista de longitud variable
    • Maps map[T1]T2 – arreglos asociativos o hash-tables
    • Channels channel T – concurrencia
    • Interfaces interface{} – polimorfismo

Generics

Go es un lenguaje de tipado estático, por lo tanto el chequeo de tipos de las variables, funciones y parámetros se da en tiempo de compilación. Los tipos básicos junto con sus construcciones con maps, slices y channels, y las funciones asociadas como len, cap, o make, aceptan y retornan valores de diferentes tipos:

arrayOfInts := []int{1,2,3}
arrayOfStrings := []string{"1","2","3"}

fmt.Println("El tamaño del arreglo de enteros es: " + len(arrayOfInts))
// Imprime: "El tamaño del arreglo de enteros es: 3"

fmt.Println("El tamaño del arreglo de strings es: " + len(arrayOfStrings))
// Imprime: "El tamaño del arreglo de strings es: 3"

Lo cual nos dice que tenemos soporte de genéricos para tipos built-in con las funciones ya definidas en el lenguaje.

¿Pero qué pasa con los tipos y funciones que definimos nosotros como programadores?

Generics en Go

Los tipos genéricos (o también llamados parámetros de tipo) en Go nos permiten parametrizar el tipo de datos de los argumentos de una función para mantenerlos lo más abstractos que se puedan y poder definir funciones más genéricas, evitando la repetición de código.

Consideremos un caso de uso bastante simple. Tenemos una lista de precios y queremos conocer la suma total:

type Precio int // Precio en centavos de alguna moneda

precios := []Precio{1, 2, 1000, 50}

Una solución probable es:

func calcularTotalPrecios(precios []Precio) Precio {
  total := 0

  for _, precio := range precios {
    total += precio
  }

  return total
}

Ahora bien, supongamos que tenemos otro tipo Distancia y queremos obtener la distancia total en un slice:

type Distancia int // Distancia en metros

distancias := []Distancia{100, 2000, 50}

Volvemos a definir la misma funcion, pero para el tipo Distancia:

func calcularTotalDistancias(distancias []Distancia) Distancia {
  total := 0

  for _, distancia := range distancias {
    total += distancia
  }

  return total
}

Notamos un patrón que se repite, dado un T con ciertas propiedades:

func calcularTotal[T ???](t []T) T {
  var total T = 0

  for _, element := range t {
    total += element
  }

  return total
}

Si reemplazamos ??? por la interfaz Sumable definida como:

type Sumable interface {
  ~int
}

Podemos directamente usar la función genérica calcularTotal gratis en los slices de ambos tipos Precio y Distancia:

calcularTotal(precios)
calcularTotal(distancias)

💥 reduciendo así la repetición de código.

Notación
// Define la función f con el parámetro de tipo T
func f[T any](t T) {
  // ...
}

// Llama a la función f especificando el parámetro int
f[int](10)

// Define una estructura con parámetro de tipo T
type e[T any] struct {
  t T
}

// Instancia la estructura especificando el parámetro de tipo int
val := s[int]{t: 1}

// Define una interface con el parámetro de tipo T
type i[T any] interface {
  HacerAlgo(t T) T
}
Funciones Genéricas

Uno de los grandes problemas que teníamos en Go era la definición de funciones que hacian escencialmente lo mismo para distintos tipos, pero por una limitación del lenguaje, no podíamos generalizarla y terminábamos copiando y pegando la definición de Map en todos los lugares que necesitábamos.

Con generics, definir funciones generales se facilita bastante:

// Map convierte []T1 a []T2 usando una función de mapeo.
// Esta función tiene dos parámetros de tipo, T1 and T2.
// Esto funciona con slices de cualquier tipo (especificado por "any").
func Map[T1, T2 any](s []T1, f func(T1) T2) []T2 {
    r := make([]T2, len(s))
    for i, v := range s {
        r[i] = f(v)
    }
    return r
}

words := []string{"hello", "world"}
lengths := Map[string, int](words, func(word string) int {
  return len(word)
})
fmt.Println(lengths) // [5 5]
Tipos Genéricos

Hay otro lugar donde vamos a ver parámetros de tipos, y es en definiciones de tipos o interfaces.

Por ejemplo, si queremos definir la estructura Tupla que contiene dos elementos del mismo tipo:

type Tupla[T any] struct {
  t1 T
  t2 T
}

tupla := Tupla{t1: 1, t2: 2}

También podemos crear interfaces que contengan parámetros de tipos:

type Sumable[T any] interface {
  fmt.Stringer
  Sumar(T) T
}

type Entero int

func (e Entero) String() string {
  return fmt.Sprintf("%d", e)
}

func (e Entero) Sumar(b Entero) Entero {
  return e + b
}

func Suma[T Sumable[T]](a, b T) T {
	return a.Sumar(b)
}

// Entero satisface la interfaz genérica Sumable
fmt.Println(Suma[Entero](1, 100)) // 101

Constraints

Habrán notado en el ejemplo de la sección Generics en Go la siguiente línea en la función calcularTotal:

func calcularTotal[T Sumable](t []T)  T {

¿Qué es ese Sumable ahí?

Lo que le estamos diciendo al compilador con [T Sumable], es que el T que le estamos pasando satisface la interfaz Sumable, lo que significa que podemos usar el operador + en elementos del tipo T.

Claramente int es Sumable.

Pero veámoslo con un ejemplo más concreto:

type Sumable[T any] interface {
  Sumar(b T) T
}

Ahora, redefinamos calcularTotal como:

func calcularTotal[T Sumable[T]](t []T) T {
  var total T // Usa el zero-value de T

  for _, e := range t {
    total = total.Sumar(e)
  }

  return total
}

Sabiendo que nuestro T satisface la interfaz Sumable, podemos utilizar la función Sumar en elementos de ese tipo.

Constraints existentes
any

Recién vimos el ejemplo de una constraint hecha por nosotros [T Sumable], pero si vemos cómo definimos nuestra interfaz:

type Sumable[T any] interface {
  Sumar(b T) T
}

encontramos la keyword any. any no es mas que un alias a nuestro querido interface{} (el tipo que abarca todos los tipos en Go).

Sería como el tipo "bottom", no nos dice nada, no nos aporta mucha información más que podemos meter cualquier tipo ahí.

Supongamos que queremos redefinir calcularTotal usando any:

func calcularTotal[T any](t []T) T {
  var total T

  for _, e := range t {
    total += e
  }

  return total
}

El compilador va a tirar el siguiente error:

invalid operation: operator + not defined on total (variable of type T constrained by any)

El punto débil de usar any como constraint es que no podemos asumir nada sobre el tipo: puede ser cualquiera, lo cual nos limita a la hora de definir una función.

Las operaciones permitidas para variables de tipo any son las siguientes:

  • Declaraciones de variables de ese tipo
  • Asignaciones
  • Usos en parámetros y valores de retorno
  • Obtención de la dirección de esas variables
  • Conversión o asignación de los valores de esos tipos al tipo interface{}
  • Conversión a un valor del tipo T a T
  • Uso de type assertion para convertir un valor de tipo interface{} al tipo que se quiera
  • Uso de type como case en un switch de tipos
  • Definición y uso de tipos compuestos que usan esos tipos, como []T
  • Uso de tipo en funciones predeclaradas como new
comparable

La nueva keyword comparable se introdujo en la versión de Go 1.18 y sirve para especificar tipos que pueden compararse, esto es, pueden usar los operadores == y !=.

Casi todos los tipos built-in implementan la interfaz comparable (booleanos, números, strings, punteros, canales, interfaces, arreglos de tipos comparables, etc).

Hay que tener en cuenta que solo puede usarse como una constraint en generics, y no como un tipo de una variable:

var x comparable // error: cannot use type comparable outside a type constraint: interface is (or embeds) comparable
constraints package

The Go team created a package of constraints (constraints) that can be imported and used for the most generic of contraint types. One important constraint is constraints.Ordered, which allows the use of the

El equipo de Go definió un paquete de restricciones (constraints) que se pueden importar y usar en la gran mayoría de los tipos con restricciones. Una restricción importante es constraints.Ordered, que permite el uso de los operadores <, <=, > y >=.

type Ordered interface {
        Integer | Float | ~string
}
Underlying Types

Habrás notado la notación ~T en los ejemplos anteriores.

La tilde ~ significa que el tipo T es de tipo T, o es equivalente a T.

Por ejemplo, si definimos:

type Precio int

Sabemos que "por debajo" Precio es lo mismo que un int: debería poder hacer las mismas cosas.

Entonces:

func SumarMil[T ~int](v T) T {
  return v + 1_000
}

var p Precio = 100

fmt.Println(SumarMil(p)) // Imprime 1100

¿Qué pasaría si no usaramos la tilde?

func SumarMil[T int](v T) T {
  return v + 1_000
}

var p Precio = 100

fmt.Println(SumarMil(p))
// Precio does not satisfy int (possibly missing ~ for int in int)
Unions

También podemos definir uniones de tipos, que representan la unión de los conjuntos de valores que puede aceptar un tipo T, por ejemplo:

func Resta[T int | uint](a, b T) T {
	return a - b
}

var a uint = 10
var b uint = 2

Resta(a,b) // 8

Una notación conveniente es usar interfaces para expresar union types, por ejemplo:

type Integer interface {
  int | uint
}

func Resta[T Integer](a, b T) T {
  return a - b
}
Combinaciones

Podemos combinar todas las constraints vistas hasta este momento en una interfaz:

type ValorMoneda interface {
	~int | ~int64
}

type Moneda interface {
	ValorMoneda
	ISO4127Code() string
	Decimal() int
}

type ARS int64

func ImprimirBalance[T Moneda](m T) {
	balance := float64(m) / math.Pow10(m.Decimal())
	fmt.Printf("%.*f %s\n", m.Decimal(), balance, m.ISO4127Code())
}

func (a ARS) ISO4127Code() string {
	return "ARS"
}

func (a ARS) Decimal() int {
	return 2
}
Uso de constantes

Las constantes que usemos dentro de funciones o métodos tienen que satisfacer al valor más general posible de todos los tipos que abarcan el tipo genérico.

Veamos un ejemplo:

// No es válido!
func SumarMil[T Integer](v T) T {
  return v + 1_000
}

fmt.Println(SumarMil(100))

Retorna:

./prog.go:47:13: cannot convert 1_000 (untyped int constant 1000) to type T

ya que el tipo int8 no puede representar ese valor.

Pero:

// Válido!
func SumarCien[T Integer](v T) T {
  return v + 100
}

fmt.Println(SumarCien(100))

Retorna "200" como se esperaría.

Ventajas de usar Constraints

Una de las principales ventajas que nos dan las constraints es que podemos definir la lista de tipos que permitimos en nuestra función en la misma declaración de generics:

func Suma[A Sumable[A]](a, b A) A {
  // Sabemos que podemos sumar a + b y obtendremos un tipo A
}

En general, las constraints nos ayudan a escribir código más legible, testeable y mantenible.

Inferencia de Tipos

Go ofrece inferencia de tipos cuando usamos el operador :=:

x := 1
fmt.Println(reflect.TypeOf(x)) // int

Y también ofrece inferencia con Generics para simplificar las llamadas a funciones:

func Contiene[T comparable](t []T, v T) bool {
  for _, x := range t {
    if x == v {
      return true
    }
  }

  return false
}

listaStrings := []string{"1", "2"}
Contiene(listaStrings, "1") // true

listaInts := []int{1, 2}
Contiene(listaInts, 100) // false

Vemos que podemos omitir los argumentos de tipo para Contiene: Go lo hace por nosotros.

En algunas situaciones sí hay que especificarlo, como por ejemplo, si el tipo genérico se usa en el valor de retorno:

type Integer interface {
    int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64
}

func Convert[T1, T2 Integer](in T1) T2 {
    return T2(in)
}

func main() {
    var a int = 10
    b := Convert[int](a) // error: can't infer the return type
    fmt.Println(b)
}

Encadenamiento de Tipos (Type Chaining)

Type chaning es una técnica que nos permite definir tipos genéricos componiendo tipos definidos en la misma declaración.

Por ejemplo:

func MapToString[L ~[]E, E fmt.Stringer](l L) []string {
  resultado := make([]string, len(l))

  for i, e := range l {
    resultado[i] = e.String()
  }

  return resultado
}

Acá estamos requiriendo que el tipo de L depende del tipo de E que es un fmt.Stringer, por lo tanto estamos encadenando los tipos.

Inferencia de Tipos en Constraints (Constraint Type Inference)

En el ejemplo de arriba, podemos notar como el parámetro de tipo L está definido como ~E[] (un compuesto de E) y el tipo de E es un fmt.Stringer. L puede ser inferido sabiendo el tipo de E cuando se llama a la función MapToString.

El compilador va a determinar el tipo de L cuando MapToString es llamado con el argumento. Por ejemplo, si implementásemos una struct Persona que satisfaga la interfaz fmt.Stringer:

type Persona struct {
  Name string
}

func (p Persona) String() string {
  return fmt.Sprintf("%s", p.Name)
}

MapToString([]Persona{{Name: "Jack Sparrow"}})

El tipo de L será inferido como []Persona.

Generics Múltiples

Go permite especificar parámetros de tipo múltiples, por ejemplo:

func ImprimirValores[A int, B, C any, D ~int](a A, b B, c1, c2 C, d D) {
  fmt.Printf("%v %v %v %v %v", a, b, c1, c2, d)
}

Acá las restricciones van a ser que A es un int, B y C pueden ser cualquier cosa y D de cualquier tipo subyacente que represente un int, pero ademas los parámetros c1 y c2 tienen que ser del mismo tipo:

ImprimirValores(1, 2.0, "3", 4, 5) // error: mismatched types untyped string and untyped int (cannot infer C)

Dado que el tercer argumento c1 es de tipo string y el cuarto c2 de tipo int, pero los type parameters requieren que sean del mismo tipo:

ImprimirValores(1, 2.0, "3", "4", 5) // 1 2 3 4 5

Notar que si especificamos tipos, debemos siempre completar los de la izquierda (no podemos saltearnos argumentos de tipos):

ImprimirValores[int, float32, string](1, 2.0, "3", "4", 5) // 1 2 3 4 5

⚠️ Cuidado con definir varios parámetros de tipo si queremos especificar que sean iguales:

func Iguales[T1, T2 comparable](a T1, b T2) bool {
  return a == b
}

var a int = 1
var b int = 2

Iguales(a, b) // error: invalid operation: a == b (mismatched types T1 and T2)

Una correcta definición sería garantizando que son del mismo tipo:

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

var a int = 1
var b int = 2

Iguales(a, b) // false
Iguales(a, 1) // true

Interfaces versus Generics

Reflection versus Generics

Estado del Arte

En esta sección veremos qué limitaciones tenemos con los Generics en la versión actual de Go 1.21.

❌ Tipos genéricos en alias

Una declaración de alias de tipo no puede tener un parámetro de tipo:

type T[X, Y any] func(X) Y

type A = T[int, string] // OK

type B[X any] = T[X, X] // Error: generic type cannot be alias
❌ Métodos Genéricos

Actualmente los métodos no soportan parámetros de tipos.

Es decir, el siguiente código no compila:

type Moneda struct {
  valor int
}

func (m *Moneda) Valor[T ~int]() T {
  return m.valor // syntax error: method must have no type parameters
}

Existe una issue en GitHub sobre esto.

❌ Embeber Genéricos
type Derived[Base any] struct {
	Base // error: embedded field type cannot be a (pointer to a) type parameter
	x bool
}

Documentation

The Go Gopher

There is no documentation for this package.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL