Sobre la pureza de las funciones, ¿están realmente desacoplados los sistema tal como idealizamos?

Sobre la pureza de las funciones, ¿están realmente desacoplados los sistema tal como idealizamos?
Sin comentarios Facebook Twitter Flipboard E-mail

Salvo en programas funciones sencillas, estar seguro, comprender las interrelaciones entre las partes resulta muy complicado. Idealizamos nuestros sistemas y pensamos que están desacoplados pero realmente éstos son porosos y con frecuencia los efectos fluyen entre ellos sin que nos demos cuenta... hasta que es tarde. Esta porosidad puede resultar en una interrelación no deseada (un bug) o en un enraizamiento en que el desacople ha desaparecido. Existen muchas técnicas que pretenden mantener el aislamiento pero la mayoría son patrones o guías cuyo única garantía reside en la entereza del equipo de desarrollo para seguirlas. Existen sin embargo, lenguajes que garantizan firmemente el aislamiento de las partes, con sus ventajas y sus inconvenientes.

Funciones puras, funciones impuras

Una función, o es pura, o es impura. Y es pura cuando cumple las dos siguientes condiciones:

  1. independientemente de la situación cambiante del Universo, si se le entregan los mismos valores en sus parámetros devolverá, exactamente y siempre, el mismo valor resultante.

  2. cuando es invocada, la función no produce absolutamente (e idealmente) ningún efecto en la situación del Universo.

Lo primero significa que el comportamiento de la función no depende en absoluto de cualquier valor, configuración, parametrización, estado, ... exterior a la propia función y sus parámetros explícitos de entrada.

Lo segundo significa que el único efecto detectable sobre dicha función es el valor que devuelva como resultado, la función no realizará ni interactuará en absoluto con el "exterior". La puntualización de "idealmente" ha sido añadida únicamente porque vivimos en un mundo físico en el que realizar cualquier operación (ej. 3 + 7) requiere energía, tiempo de procesamiento de alguna CPU, etc...

Una función impura podría ser:


// invoca al Universo para obtener un estado aleatorio
int aleatorio_con_paridad(int paridad) {
    return rand() & paridad;
}

Otra pura podría ser:


// obtiene el valor máximo desde una posición de memoria hasta centinela
int maximo(int *x) {
    int m = *x;
    while(*++x) if(*x > m) m = *x;
    return m;
}

¿Seguiría siendo la función pura si las posiciones de memoria involucradas cambian caprichosa y constantemente sus valores?, ¿es el puntero el argumento o es el conjunto de valores?.

Implicaciones de la pureza de una función

Obviamente existen funciones impuras muchísimo más simples y fáciles de comprender que otras puras pero, en general, en una función pura será mucho más fácil comprender las interacciones con otras partes, básicamente porque sólo con ver su firma, somos capaces de percibir todo el alcance posible de dicha función, mientras que en una función impura nunca podemos asegurar que existan relaciones ocultas.

Podría poner ejemplos sencillos como que una función pura nunca nos sorprenderá llenándonos el disco de entradas de log, que siempre será thread-safe, que puede ser memoizada, etc... sí, con sólo saber que una función es pura podemos, sin mayor información, asegurar todo lo anterior. Pero en la práctica no es tan sencillo.

Para mí las implicaciones prácticas de la pureza de una función son:

  1. en funciones "sencillas" (ej. algoritmos completos como Floyd–Warshall, clustering, ...), la seguridad total de todo lo comentado anteriormente: sin efectos, paralelizable, distribuíble, memoizable, auto-testeable, ...

  2. en funciones programas complejos, la seguridad total de que el contexto de ejecución, aunque potencialmente complejo, es conocido, pues está perfectamente acotado por la firma de la función.

Por ejemplo, si tienes un sistema de facturación, con diversos archivos de configuración, accesos a bases de datos, direcciones de servicios web, etc... podrías tener una función tarea como:


consolidarInventario :: FacturaSis Bool
consolidarInventario = ...

Aunque la pureza de nuestra función ha quedado diluida en la ingente cantidad de cosas que se puede hacer dentro de nuestra biblioteca FacturaSis, sigue siendo pura y si nosotros conocemos bien nuestro sistema FacturaSis nada de lo que haya podido codificar cualquier programador dentro de esa tarea escapa a mi conocimiento. Sí, quizás lo haya codificado mal y al fin y al cabo dicha tarea borre todos los datos ¡pero soy consciente de ello!. Podríamos perfectamente añadir una limitación como


consolidarInventario :: FacturaSis ReadOnly ConsolidaciónDeInventario
consolidarInventario = ...

Y ahora estoy seguro que ese código, haga lo que haga, no tendrá ningún efecto sobre, por ejemplo, el estado persistente.

Otros ejemplos más sencillos de cómo la pureza de una función nos permite conocer propiedades que, de ser impura no podríamos son:


miConstante :: Num a => a

Una función con la firma de miConstante únicamente puede generar un valor que siempre será el mismo y en la que toda la información necesaria para generarlo está codificada en el propio cuerpo. No hay ninguna otra posibilidad. Por ejemplo:


pi :: Floating a => a
pi = sqrt 12 * sum [(-3)**(-k) / (2*k+1) | k <- fromIntegral <$> [0..100]]

Otro ejemplo sencillo de cómo puede deducirse sin lugar a dudas el comportamiento de una función sólo con la firma es:


const :: a -> b -> a
const u v = ...

La función anterior no puede hacer nada con el parámetro v y tampoco tiene información de cómo construir datos del tipo a, por lo que lo único que puede hacer esa función es devolver el valor de u.

¿Cómo puede ser útil un lenguaje que sólo admita funciones puras?

Si una función es pura, significa que no puede interactuar con el mundo exterior, ¿cómo entonces las funciones puras solicitan y muestran datos a los usuarios?.

Si recuerdas el ejemplo de la función máximo anterior, ¿sigue siendo pura aunque las posiciones de memoria que accesa cambien caprichosamente?, la respuesta es que sí, de forma similar, piensa en la siguiente función pura:


teletipo :: String -> String
teletipo entrada = ... salida ...

Si nuestra función teletipo procesa toda la cadena de entrada para generar la salida, entonces no hay interacción posible con el usuario pero, ¿y si nuestra función va generando la salida a medida que va consumiendo la entrada?. En lenguajes como Python, C#, Clojure, ... podemos usar secuencias perezosas, entonces podrías pensar en la función teletipo como


teletipo :: LazyStringSequence -> LazyStringSequence
teletipo entrada = ... salida ...

En que el usuario ahora puede escribir parte de la entrada e ir leyendo la salida, cerrando el "bucle interactivo" que buscábamos ¡y teletipo sigue siendo pura!.

Funciones puras en la práctica

Las propiedades que poseen las funciones puras aportan un conocimiento y seguridad mucho mayor que si no lo fueran, además, permiten al compilador o recolector de memoria deducir comportamientos que en otros lenguajes no es posible (por ejemplo en Android, un objeto puede ser liberado de la memoria ¡cuando se espera una llamada después que realice una acción!), por contra, introducir efectos y acoplar comportamientos entre las partes se hace más difícil, seguramente porque el nivel de abstracción requerido para conseguirlo está más alejado de nuestra intuición, o quizás, porque somos perezosos y preferimos no tener las ataduras que impone una función pura y poder escribir, en cualquier lugar:


....
printf("Hello World!\n");
....

Comentarios cerrados
Inicio