La inmutabilidad es una de las principales características de la programación funcional, además de un poderoso aliado de la programación concurrente. Es también uno de los principales problemas de los programadores cuando intentan aprender el paradigma funcional viniendo del paradigma imperativo. ¿Sabes en qué consiste realmente y por qué funciona tan bien?
Uno de los principales problemas cuando desarrollamos un código en lenguajes con inmutabilidad es el hecho de no poder modificar el contenido de la memoria para un vector (array). Para modificar la información de una lista debe copiarse y modificar el valor específico. En Elixir este concepto lo realiza el compilador y en otros lenguajes como Python se hace solo para ciertos tipos de datos, como las cadenas de texto.
El Ruby un error muy común es pensar que este código retornará en su última línea false
porque las cadenas de a
y b
serán diferentes. Pero no. Ambas cadenas han sido referenciadas y al modificar una de ellas, cambia también para la otra variable:
a = "Hola mundo"
b = a
b[0] = "h"
b == a and a != "Hola mundo"
# true
En Python esto no sucede porque es imposible cambiar el contenido de un elemento de la cadena de texto. Para hacer esto tendríamos que componer una nueva cadena de texto y por tanto, tenemos inmutabilidad ahí. Pero sí sucede con los vectores:
a = [1,2,3,4]
b = a
a[0] = 0
a == b
# True
En Elixir al emplear BEAM tenemos inmutabilidad completa para todas las estructuras de datos. Cuando asignamos un valor de esta forma nos retorna false
porque ambas estructuras son las esperadas:
a = [1, 2, 3, 4]
b = a
a = [0|a]
a == b
# false
Además, Erlang tiene otra característica más. La asignación única. Cada variable queda ligada de forma permanente durante la ejecución de la función al valor asignado inicialmente. Cualquier intento de realizar una nueva asignación se convierte en una acción de concordancia (matching) pudiendo fallar lanzando un error.
Peter Hizalev dijo una vez acerca de estas características:
Los diseñadores de Erlang hicieron una gran compensación desde el principio: inmutabilidad y asignación única (muy poco común en los lenguajes de programación convencionales), impuestas por la VM.
Si pensamos como un programador imperativo, cada espacio de memoria es como un cajón y vamos depositando dentro los elementos necesarios, cambiándolos a nuestro antojo. Se asemeja mucho a cómo funcionan los registros de los procesadores y por ello está muy ligado a cómo funciona el hardware de un ordenador.
Pensar en realizar una iteración al más puro estilo de lenguaje C:
for (int i=0; i<10; i++)
;
Se hace completamente imposible en lenguajes como Erlang. Genera frustración al no poder resolver los problemas de la misma forma.
Pero si volvemos al enfoque iterativo, nos damos cuenta de que hay muchas implicaciones en modificar los espacios de memoria directamente. Peor aún si los modificamos en ejecuciones paralelas o concurrentes. De inmediato necesitamos la intervención de elementos de exclusión mutua (mutex) y estos a su vez generan latencias e interbloqueos potenciales si algo sale mal.
Para resolver estos problemas rápidamente nos damos cuenta de necesitar la no modificación (inmutabilidad) de ciertos datos a través de versiones (como el MVCC en PostgreSQL) para mantener una copia en uso por otros procesos y la copia recientemente modificada que comenzará a emplearse cuando se dé la señal pertinente dejando la copia antigua para ser recolectada por el recolector de basura cuando cese su uso completamente.
En el enfoque funcional además, se llega a la conclusión de acortar estos momentos de almacenaje de información reduciendo el tamaño de las funciones y empleando recursividad con características como tail-recurssion (recursividad de cola) permitiendo liberar esa memoria no necesaria aún más rápido y sin afectar al código en ejecución.
Finalmente, el estado global se prohíbe. Incluso en lenguajes donde se emplea, comienzan a existir mecanismos para no hacer tan obvio su uso o intentar emplearlo lo menos posible.
Como dijo Robert Virding una vez:
Cualquier programa concurrente suficientemente complicado en otro lenguaje contiene una implementación lenta ad-hoc, cargada de errores y especificada informalmente de la mitad de Erlang.
Un código como la ordenación por selección en lenguaje C:
void seleccion(int *v, int s) {
for (int i=0, k=0; i<s-1; i++, k=i) {
for (int j=i+1; j<s; j++) {
if (v[j] < v[k]) {
k = j;
}
}
if (k != i) {
int l = v[k];
v[k] = v[i];
v[i] = l;
}
}
}
O implementado en otro lenguaje como Python (para que no se diga que lo hago feo a propósito):
def seleccion(v):
for i in range(0, len(v)-2):
k = i
for j in range(i+1, len(v)-1):
if v[j] < v[k]:
k = j
if k != i:
v[i], v[k] = v[k], v[i]
En ningún caso necesitamos retornar nada porque la memoria donde se encuentra el vector se va modificando.
Como vemos, este algoritmo se basa en modificar una y otra vez cada posición de memoria. No es el algoritmo más rápido para ordenar, curiosamente el óptimo se basa en divide y vencerás al igual que plantean los algoritmos basados en concurrencia y proponen el uso de inmutabilidad y otros mecanismos muy ligados a la programación funcional. Si sabes a qué algoritmo me refiero deja un comentario abajo.
En enfoque funcional, como no modificamos la cadena original podemos descomponer el problema en diferentes funciones:
seleccion(V) -> seleccion(V, []).
seleccion([], R) -> R;
seleccion([H|T], R) ->
{Max, NewT} = busca_max(T, H, []),
seleccion(NewT, [Max|R]).
busca_max([], Max, R) -> {Max, R};
busca_max([H|T], Max, R) when H < Max ->
busca_max(T, Max, R);
busca_max([H|T], _, R) ->
busca_max(T, H, R).
Como puedes ver en el ejemplo, el problema se secciona en diferentes funciones e incluso estas funciones se minimizan al máximo posible. Esto garantiza un uso de memoria mínimo y la garantía de no modificar el dato original que puede ser referenciado una y otra vez.
Además, fuerza a preparar dos funciones diferentes. Esto incrementa las posibilidades de reutilizar la función de búsqueda de máximo eliminándolo de una lista dada.
¿Pensaste en la inmutabilidad como una desventaja en lugar de una ventaja antes de leer el artículo? ¿Aún la sigues considerando una desventaja? ¿Qué tipo de algoritmo consideras sería imposible realizar? ¿Te gustaría probar Erlang o Elixir? ¡Echa un vistazo a los libros y deja tu comentario!