Esta es la versión en blog de mis charlas en Google Open Source Live:
https://youtu.be/nr8EpUO9jhw?si=jlWTapr6NM6isLgt&embedable=true
y GopherCon 2021:
https://youtu.be/Pae9EeCdy8?si=M-87Eisb2nU1qmJ&embedable=true
\ La versión Go 1.18 añade una nueva característica importante al lenguaje: soporte para programación genérica. En este artículo no voy a describir qué son los genéricos ni cómo usarlos. Este artículo trata sobre cuándo usar genéricos en código Go, y cuándo no usarlos.
\ Para ser claro, proporcionaré directrices generales, no reglas estrictas. Usa tu propio criterio. Pero si no estás seguro, recomiendo usar las directrices discutidas aquí.
Comencemos con una directriz general para programar en Go: escribe programas Go escribiendo código, no definiendo tipos. Cuando se trata de genéricos, si comienzas a escribir tu programa definiendo restricciones de parámetros de tipo, probablemente estés en el camino equivocado. Comienza escribiendo funciones. Es fácil añadir parámetros de tipo más tarde cuando esté claro que serán útiles.
Dicho esto, veamos casos en los que los parámetros de tipo pueden ser útiles.
Un caso es cuando se escriben funciones que operan con los tipos especiales de contenedores que están definidos por el lenguaje: slices, maps y channels. Si una función tiene parámetros con esos tipos, y el código de la función no hace suposiciones particulares sobre los tipos de elementos, entonces puede ser útil usar un parámetro de tipo.
\ Por ejemplo, aquí hay una función que devuelve un slice de todas las claves en un map de cualquier tipo:
// MapKeys returns a slice of all the keys in m. // The keys are not returned in any particular order. func MapKeys[Key comparable, Val any](m map[Key]Val) []Key { s := make([]Key, 0, len(m)) for k := range m { s = append(s, k) } return s }
\ Este código no asume nada sobre el tipo de clave del map, y no usa el tipo de valor del map en absoluto. Funciona para cualquier tipo de map. Eso lo convierte en un buen candidato para usar parámetros de tipo.
\ La alternativa a los parámetros de tipo para este tipo de función es típicamente usar reflexión, pero ese es un modelo de programación más incómodo, no se comprueba estáticamente en tiempo de compilación, y a menudo es más lento en tiempo de ejecución.
Otro caso donde los parámetros de tipo pueden ser útiles es para estructuras de datos de propósito general. Una estructura de datos de propósito general es algo como un slice o map, pero que no está integrado en el lenguaje, como una lista enlazada o un árbol binario.
\ Hoy en día, los programas que necesitan tales estructuras de datos típicamente hacen una de dos cosas: escribirlas con un tipo de elemento específico, o usar un tipo de interfaz. Reemplazar un tipo de elemento específico con un parámetro de tipo puede producir una estructura de datos más general que puede ser utilizada en otras partes del programa, o por otros programas. Reemplazar un tipo de interfaz con un parámetro de tipo puede permitir que los datos se almacenen de manera más eficiente, ahorrando recursos de memoria; también puede permitir que el código evite aserciones de tipo, y sea completamente comprobado en tiempo de compilación.
\ Por ejemplo, aquí hay parte de cómo podría verse una estructura de datos de árbol binario usando parámetros de tipo:
// Tree is a binary tree. type Tree[T any] struct { cmp func(T, T) int root *node[T] } // A node in a Tree. type node[T any] struct { left, right *node[T] val T } // find returns a pointer to the node containing val, // or, if val is not present, a pointer to where it // would be placed if added. func (bt *Tree[T]) find(val T) **node[T] { pl := &bt.root for *pl != nil { switch cmp := bt.cmp(val, (*pl).val); { case cmp < 0: pl = &(*pl).left case cmp > 0: pl = &(*pl).right default: return pl } } return pl } // Insert inserts val into bt if not already there, // and reports whether it was inserted. func (bt *Tree[T]) Insert(val T) bool { pl := bt.find(val) if *pl != nil { return false } *pl = &node[T]{val: val} return true }
\ Cada nodo en el árbol contiene un valor del parámetro de tipo T
. Cuando el árbol se instancia con un argumento de tipo particular, los valores de ese tipo se almacenarán directamente en los nodos. No se almacenarán como tipos de interfaz.
\ Este es un uso razonable de parámetros de tipo porque la estructura de datos Tree
, incluyendo el código en los métodos, es en gran parte independiente del tipo de elemento T
.
\ La estructura de datos Tree
necesita saber cómo comparar valores del tipo de elemento T
; usa una función de comparación pasada para eso. Puedes ver esto en la cuarta línea del método find
, en la llamada a bt.cmp
. Aparte de eso, el parámetro de tipo no importa en absoluto.
El ejemplo de Tree
ilustra otra directriz general: cuando necesites algo como una función de comparación, prefiere una función a un método.
\ Podríamos haber definido el tipo Tree
de tal manera que el tipo de elemento requiera tener un método Compare
o Less
. Esto se haría escribiendo una restricción que requiera el método, lo que significa que cualquier argumento de tipo utilizado para instanciar el tipo Tree
necesitaría tener ese método.
\ Una consecuencia sería que cualquiera que quiera usar Tree
con un tipo de datos simple como int
tendría que definir su propio tipo entero y escribir su propio método de comparación. Si definimos Tree
para tomar una función de comparación, como en el código mostrado arriba, entonces es fácil pasar la función deseada. Es tan fácil escribir esa función de comparación como lo es escribir un método.
\ Si el tipo de elemento Tree
resulta tener ya un método Compare
, entonces podemos simplemente usar una expresión de método como ElementType.Compare
como la función de comparación.
\ Para decirlo de otra manera, es mucho más simple convertir un método en una función que añadir un método a un tipo. Así que para tipos de datos de propósito general, prefiere una función en lugar de escribir una restricción que requiera un método.
Otro caso donde los parámetros de tipo pueden ser útiles es cuando diferentes tipos necesitan implementar algún método común, y las implementaciones para los diferentes tipos son todas iguales.
\ Por ejemplo, considera la interfaz sort.Interface
de la biblioteca estándar. Requiere que un tipo implemente tres métodos: Len
, Swap
, y Less
.
\ Aquí hay un ejemplo de un tipo genérico SliceFn
que implementa sort.Interface
para cualquier tipo de slice:
// SliceFn implements sort.Interface for a slice of T. type SliceFn[T any] struct { s []T less func(T, T) bool } func (s SliceFn[T]) Len() int { return len(s.s) } func (s SliceFn[T]) Swap(i, j int) { s.s[i], s.s[j] = s.s[j], s.s[i] } func (s SliceFn[T]) Less(i, j int) bool { return s.less(s.s[i], s.s[j]) }
\ Para cualquier tipo de slice, los métodos Len
y Swap
son exactamente iguales. El método Less
requiere una comparación, que es la parte Fn
del nombre SliceFn
. Como con el ejemplo anterior de Tree
, pasaremos una función cuando creemos un SliceFn
.
\ Así es como usar SliceFn
para ordenar cualquier slice usando una función de comparación:
// SortFn sorts s in place using a comparison function. func SortFn[T any](s []T, less func(T, T) bool) { sort.Sort(SliceFn[T]{s, less}) }
\ Esto es similar a la función de la biblioteca estándar sort.Slice
, pero la función de comparación está escrita usando valores en lugar de índices de slice.
\ Usar parámetros de tipo para este tipo de código es apropiado porque los métodos se ven exactamente iguales para todos los tipos de slice.
\ (Debo mencionar que Go 1.19–no 1.18–probablemente incluirá una función genérica para ordenar un slice usando una función de comparación, y esa función genérica probablemente no usará sort.Interface
. Ver propuesta #47619. Pero el punto general sigue siendo cierto incluso si este ejemplo específico probablemente no será útil: es razonable usar parámetros de tipo cuando necesitas implementar métodos que se ven iguales para todos los tipos relevantes.)
Ahora hablemos del otro lado de la cuestión: cuándo no usar parámetros de tipo.
Como todos sabemos, Go tiene tipos de interfaz. Los tipos de interfaz permiten un tipo de programación genérica.
\ Por ejemplo, la interfaz ampliamente utilizada io.Reader
proporciona un mecanismo genérico para leer datos de cualquier valor que contenga información (por ejemplo, un archivo) o que produzca información (por ejemplo, un generador de números aleatorios). Si todo lo que necesitas hacer con un valor de algún tipo es llamar a un método en ese valor, usa un tipo de interfaz, no un parámetro de tipo. io.Reader
es fácil de leer, eficiente y efectivo. No hay necesidad de usar un parámetro de tipo para leer datos de un valor llamando al método Read
.
\ Por ejemplo, podría ser tentador cambiar la primera firma de función aquí, que usa solo un tipo de interfaz, a la segunda versión, que usa un parámetro de tipo.
func ReadSome(r io.Reader) ([]byte, error) func ReadSome[T io.Reader](r T) ([]byte, error)
\ No hagas ese tipo de cambio. Omitir el parámetro de tipo hace que la función sea más fácil de escribir, más fácil de leer, y el tiempo de ejecución probablemente será el mismo.
\ Vale la pena enfatizar el último punto. Aunque es posible implementar genéricos de varias maneras diferentes, y las implementaciones cambiarán y mejorarán con el tiempo, la implementación utilizada en Go 1.18 en muchos casos tratará los valores cuyo tipo es un parámetro de tipo de manera muy similar a los valores cuyo tipo es un tipo de interfaz. Lo que esto significa es que usar un parámetro de tipo generalmente no será más rápido que usar un tipo de interfaz. Así que no cambies de tipos de interfaz a parámetros de tipo solo por velocidad, porque probablemente no se ejecutará más rápido.
\
Al decidir si usar un parámetro de tipo o un tipo de interfaz, considera la implementación de los métodos. Anteriormente dijimos que si la implementación de un método es la misma para todos los tipos, usa un parámetro de tipo. Inversamente, si la implementación es diferente para cada tipo, entonces usa un tipo de interfaz y escribe diferentes implementaciones de métodos, no uses un parámetro de tipo.
\ Por ejemplo, la implementación de Read
desde un archivo no se parece en nada a la implementación de Read
desde un generador de números aleatorios. Eso significa que deberíamos escribir dos métodos Read
diferentes, y usar un tipo de interfaz como io.Reader
.
Go tiene reflexión en tiempo de ejecución. La reflexión permite un tipo de programación genérica, en el sentido de que te permite escribir código que funciona con cualquier tipo.
\ Si alguna operación tiene que soportar incluso tipos que no tienen métodos (por lo que los tipos de interfaz no ayudan), y si la operación es diferente para cada tipo (por lo que los parámetros de tipo no son apropiados), usa reflexión.
\ Un ejemplo de esto es el paquete encoding/json. No queremos requerir que cada tipo que codificamos tenga un método MarshalJSON
, así que no podemos usar tipos de interfaz. Pero codificar un tipo de interfaz no se parece en nada a