Interning en Go con unique.Make

En Go 1.23 incorporaron al lenguaje un paquete llamado unique, con el que se puede hacer interning, que es una optimización para ahorrar memoria evitando que se dupliquen valores en la memoria del programa mediante el uso de valores canónicos.

En Go 1.23 se introduce el paquete unique. Este paquete permite interactuar con lo que se conoce como valores canónicos, que son valores que sólo se repiten en memoria una única vez y que se reutilizan allá donde haga falta.

Por lo general, esto contrasta con la forma de operar de Go, donde si declaras un valor, como un número, una cadena de caracteres o incluso una estructura, se reserva espacio en memoria para ese valor, pero si usas varias veces el mismo valor, se declaran múltiples veces, uno para cada valor separado.

Por ejemplo, en Go si escribes el siguiente código:

cad1 := "hola"
cad2 := "mundo"

En memoria habría que reservar espacio para almacenar cada una de las runas de la cadena de caracteres hola, así como espacio para cada una de las runas de la cadena de caracteres mundo. Esto es lógico porque son cadenas de caracteres diferentes, pero no es muy distinto de lo que ocurre si asignas a varias variables la misma cadena de caracteres.

cad1 := "hola"
cad2 := "hola"

En este caso, aunque podemos comprobar que cad1 == cad2, en memoria se guardará por separado cada copia de la cadena de caracteres.

Aunque no es un problema en la inmensa mayoría de los programas, en algunos casos las restricciones de memoria nos pueden invitar a solucionar este problema para que no ocurra. Por ejemplo, imagina que tienes que interactuar con un array de 3000 elementos de cadenas de caracteres que se van a repetir. O, por poner un ejemplo más realista, que tu servidor web tiene que trabajar con un array de 10000 estructuras que originalmente eran objetos JSON, donde uno de los campos va a tener un valor que se va a repetir múltiples veces. En este caso estaremos desperdiciando mucha memoria en guardar la misma cadena de caracteres en memoria miles de veces.

Cuando usamos valores canónicos, una cadena de caracteres existe en memoria sólamente una vez. Si esa cadena de caracteres se emplea en más ocasiones, entonces lo que se asigna únicamente es una referencia a esa cadena de caracteres única. De este modo, en memoria hay una cadena de caracteres que puede ser apuntada 1000 o 2000 veces, tantas veces como nos haga falta.

Esto es precisamente lo que hace el paquete unique. Tiene un método llamado Make, que se usa del siguiente modo:

cad1 := unique.Make("hola")
cad2 := unique.Make("mundo")

La declaración de Make se define del siguiente modo:

func Make[T comparable](value T) Handle[T]

Es decir, acepta un parámetro de tipo T, que es un genérico. Mientras sea comparable, se puede aceptar como parámetro. Por lo que puedes pasarle como parámetro a Make un valor de tipo int, string, e incluso un struct.

El retorno de esta función es de tipo unique.Handle[T]. Un handle representa el identificador global de un valor. Imaginalo como un puntero, pero más intenso.

La función Make funciona más o menos del siguiente modo:

  • La primera vez que le pases a Make un valor que no haya visto previamente, lo envolverá en un Handle, y registrará ese Handle en una tabla global de valores. Por ejemplo, la primera vez que llames a unique.Make(5), envolverá ese 5 en un Handle y asociará el valor 5 con ese Handle.
  • Cada vez que llames a Make con un valor nuevo, esto se repetirá. Así que si haces unique.Make(5), unique.Make(7) y unique.Make(11), la tabla global de valores asociará el 5 con su handle, el 7 con su handle y el 11 con su handle.
  • Sin embargo, si tratas de llamar a Make con un valor que previamente ha visto y que forma parte de su tabla de valores, en vez de crear un nuevo handle, te devolverá una referencia al mismo. De este modo, tú obtienes como resultado un handle que ya está en memoria y que por lo tanto se está reutilizando, pero que estás usando varias veces, ahorrando memoria.

Es posible que te queden algunas preguntas. Por ejemplo, si alguna vez usaste un map como si fuese una cache de valores memoizados, posiblemente estés preguntándote si es lo mismo. El principio es parecido, pero la implementación de unique tiene algunas ventajas.

Por ejemplo, la más importante es que es compatible con la concurrencia de Go por defecto. Eso significa que puedes llamar de forma segura a unique.Make desde varias gorutinas y la función se comportará correctamente. No digo que no puedas hacerlo en código, pero si lo fabricases tú, tendrías que asegurarte.

Además, las tablas de valores únicos de unique se llevan bastante bien con el recolector de basura de Go. Es capaz de saber cuándo un valor canónico ya no se está usando, porque todas las variables que apuntan al mismo han desaparecido de scope. Es posible que llamases a unique.Make para fabricar una variable local en una función. Una vez la función termine, ese valor único puede que ya no esté siendo apuntado por ninguna variable, y el recolector de basura podría eliminar de memoria ese handle, al menos hasta que vuelva a ser llamada la función Make con el mismo valor.

En conclusión, una cosa que hay que tener en cuenta es que este paquete no vale para todas las situaciones. Y que aunque es un recurso que puede ser de mucha utilidad cuando los requisitos de memoria sean importantes, también tiene como desventaja que llamar a Make va a tomar algo más de tiempo (del orden de nanosegundos) que simplemente crear un valor duplicado en memoria. El sistema de valores canónicos es algo que por lo general se va a tratar como una optimización, salvo que tengas un programa en el que el consumo de memoria sea un requisito esencial, o donde sepas que vas a tratar con posiblemente miles de valores que se puedan repetir.

Lista de reproducción
  1. 1
    Funciones iteradoras
    10 minutos
  2. 2
    Funciones All y Collect
    8 minutos
  3. 3
    Cómo usar pull-iterators
    7 minutos
  4. 4
    Interning en Go con unique.Make
    8 minutos