« Haskell: el Lenguaje … « || Inicio || » Llegan las Jornadas F… »

Programación Funcional Reactiva

Última modificación: 25 de Noviembre de 2016, y ha tenido 5361 vistas

Etiquetas utilizadas: || || ||

Muchas aplicaciones, sobre todo en determinados dominios, requieren un cambio de enfoque a la hora de plantear soluciones, pasando de generar sistemas que son transformacionales (es decir, que presuponen un estado global que define el mundo, y por operaciones sucesivas van transformándolo para llegar a una solución) a generar sistemas reactivos. Este cambio proviene del hecho de que las entradas del sistema no se conocen a priori, sino que van llegando de forma continua durante la ejecución. En consecuencia, se espera que un sistema reactivo interaccione con su entorno, intercalando las entradas y las salidas de forma temporal, lo que quiere decir que dentro de una cantidad de tiempo adecuado (lo que se considere adecuado depende del dominio de aplicación) el sistema devuelva una respuesta en función de las entradas recibidas y continúe con el ciclo de ejecución. En este sentido, algunos sistemas reactivos deben proporcionar una respuesta en tiempo real exacto (en aquellos dominios en los que cumplir los límites temporales es imprescindible), mientras que otros solo proporcionan un tiempo real aproximado (para los dominios en que es deseable, pero no imprescindible, cumplir los requisitos temporales).

Pongamos algunos ejemplos de dominios en los que quede clara la necesidad de trabajar con sistemas reactivos. El primero sería el de un robot móvil, que es un ejemplo típico de sistema híbrido, en el que parte del sistema se corresponde con un modelo continuo (por ejemplo, el nivel de batería, el voltaje de los servo-motores, los sensores de proximidad, etc.) junto con partes que se corresponden con modelos discretos (como serían los propios procesadores que controlan el robot, el sistema de comunicación por paquetería con el que se comunica, etc.). Pero esta hibridación física es solo un aspecto que esconde una composición híbrida también en la lógica del robot, ya que opera con entradas que se correponden con información continua (localización espacial, velocidad a la que va o con la que debe impulsar algún motor), junto a otros bloques de información que son claramente discretos (como es el estado de consecución de sus objetivos, si está moviéndose o no, etc.). 

Un ejemplo completamente distinto pero que también precisaría de un modelado reactivo sería el de un videojuego, en el que las entradas de los controles de juego (que pueden ser continuas, como la posición del ratón, o discretas como saber si se ha pulsado el disparador o no) se vuelven mucho más fáciles cuando se modelan desde una aproximación reactiva y no desde una aproximación de transformación sucesiva de estados.

La Programación Funcional Reactiva (FRP, por sus siglas en inglés) surgió a partir del trabajo de Conal Elliott y Paul Hudak sobre Functional Reactive Animation (Fran). El objetivo de la FRP es permitir toda la potencia de la programación funcional moderna dentro de sistemas que requieren soluciones reactivas. La idea básica consiste en modelar las entradas y las salidas como valores que varían en el tiempo, llamados señales. A partir de estas señales, se describen sistemas combinando funciones de señales (que transforman señales en señales) en redes de procesos de señales. Además, qué se considera una señal depende del dominio de aplicación, y puede variar considerablemente si estamos ante, por ejemplo, la necesidad de realizar una interfaz gráfica de usuario o ante el diseño de un sistema de respuesta para un robot.

Pero antes de comenzar a presentar en qué consiste la Programación Funcional Reactiva, deberíamos plantearnos el porqué puede ser tan benficioso usar la programación reactiva dentro del contexto de la programación funcional.

Por supuesto, aproximarnos a la programación reactiva desde una visión puramente funcional nos proporciona todas las ventajas habituales que este paradigma ofrece, y que hemos visto en entradas anteriores (como aquí o aquí), como son la modularidad, abstracción y facilidad para la verificación y el razonamiento formal. Pero, además, existen aspectos adicionales de la programación reactiva en los que la aproximación funcional puede ser especialmente adecuada.

Algunas aplicaciones habituales de la FRP incluyen el modelado de sistemas físicos o la introducción de la simulación de leyes físicas en sistemas más grandes (por ejemplo, para gobernar el movimiento de algunas entidades en videojuegos). Normalmente, este tipo de leyes se definen por medio de ecuaciones diferenciales, y es mucho más fácil para el programador traducir esas ecuaciones en un código funcional que en un código imperativo.

Una ventaja de la aproximación funcional pura es que hace que el sistema sea mucho más fácil de paralelizar y distribuir. A partir de la red de procesos de señales que comentamos antes, podemos ver un programa FRP como una red que procesa un flujo de información de forma sincronizada, donde cada nodo de la red encapsula un estado local que le permite recordar el pasado, pero que es inaccesible desde fuera del propio nodo. Los tipos de datos funcionales puros son exactamente lo que se necesita para representar este tipo de nodos, ya que al ser inmutables no se pueden modificar desde otras partes del programa (la red). Por tanto, si dos nodos (o sub-redes) están en paralelo en la estructura global de la red, es seguro que se ejecutarán correctamente de forma concurrente, ya que ninguna puede interferir en la ejecución de la otra. Como demuestra el entorno MapReduce de Google, una vez que se establece la imposibilidad de interferencias, es mucho más fácil ejecutar programas extremadamente grandes de forma segura (ya sea en una máquina con multiprocesador, o de forma distribuida). En el entorno de los videojuegos, donde las plataformas tienen ya varios procesadores dedicados a tareas específicas, disponer de herramientas de programación naturalmente paralelizables supone sacar provecho sin esfuerzo de las opciones de hardware que se nos ofrecen.

Aunque, por supuesto, los lenguajes funcionales no son los únicos que proporcionan posibilidades de abstracción, es indudable que los niveles de abstracción que se pueden alcanzar con ellos (por ejemplo, con las funciones de orden superior) superan a los de la mayoría de lenguajes imperativos, y además de forma mucho más sencilla.

Fundamentos de la FRP

Un lenguaje FRP debe tener dos niveles: un primer nivel funcional, y un segundo nivel reactivo. El nivel funcional habitualmente se corresponde con un lenguaje funcional puro (o, al menos, así lo estudiaremos nosotros). Como es habitual que las implementaciones FRP se hagan embebidas sobre lenguajes funcionales ya existentes (por ejemplo, Haskell o Elm), este primer nivel ya viene dado por el lenguaje de programación base.

El nivel reactivo que se sitúa sobre el lenguaje funcional es el que se encargará de manipular las señales de forma adecuada. Para ello, se usan funciones que operarán sobre las señales para construir las redes de flujo a las que hacíamos mención antes. Por tanto, se conforman dos espacios de funciones distintos, uno a cada nivel de abstracción, pero que son interdependientes, ya que por una parte el nivel reactivo se apoya en el nivel funcional para llevar a cabo cálculos puntuales sobre señales y, por otro, algunas de las construcciones reactivas son entidades de orden superior para el nivel funcional. En consecuencia, una vez establecido el lenguaje funcional sobre el que se asentará el nivel reactivo, es necesario dar un conjunto de constructores primitivos reactivos de primera clase, y un conjunto de funciones de orden superior que permiten combinar los constructores básicos para conseguir las redes de procesamiento de señales. El punto clave de las primitivas y los constructores es que únicamente permiten construir redes que se ajustan al modelo conceptual marco que decidamos.

Veremos a continuación los elementos fundamentales en los que se basan todas las variantes de FRP más habituales:

Señales Continuas en el Tiempo

En general, el tiempo se considera continuo en la FRP, y en consecuencia las señales se modelan como funciones que transforman el dominio continuo temporal (el intervalo \(T=(0, +\infty)\)), en un valor de un tipo específico. En algunos marcos reactivos, a las señales continuas se les llama Comportamientos (Behaviors):

\(Signal\ a : T \rightarrow a\)

Este modelo conceptual proporciona un fundamento para una semántica ideal, aunque por supuesto, cualquier implementación digital de una señal de tiempo continuo tendrá que tomar muestras de la señal sobre una sucesión discreta de instantes temporales, lo que lo convierte en una aproximación de la semántica teórica. La ventaja del modelo conceptual es que se abstrae de los detalles de la implementación, por lo que no hace suposiciones acerca de la frecuencia de muestreo, o de cómo se realiza tal muestreo. También evita muchos de los problemas que podemos encontrar al componer subsistemas que tienen frecuencias de muestro diferentes. Implementar la semántica ideal de un FRP de una forma completamente satisfactoria es un reto difícil de alcanzar. Al menos, hemos de pedir que una implementación digital concreta debería converger a la semántica anterior si la frecuencia de muestreo tiende a 0.  Por tanto, esta semántica ideal tiene como objetivo ayudarnos a comprender los programas FRP, aunque sea solo en una primera aproximación.

Un ejemplo de señal puede ser el valor de una caja de texto en un formulario web (que sería de tipo Signal string) o la posición del ratón en la pantalla (que sería de tipo Signal (Int, Int)). Se puede observar que en ambos casos la aplicación tiene información acerca del valor de ambas señales a lo largo del tiempo (en realidad, el tiempo se muestrea de forma discreta en función de la velocidad de proceso del ordenador y de características que pueden depender del sistema operativo y de la sobrecarga del mismo, por eso hay una diferencia entre el modelo conceptual abstracto y la implementación real).

Señales discretas en el Tiempo

Conceptualmente, las señales discretas en el tiempo (también llamadas Eventos) son señales cuyo dominio de definición es, a lo sumo, un subconjunto numerable de instantes temporales. Cada instante del dominio se asocia con un evento que no tiene extensión temporal. La inclusión de este tipo de señales, junto con las operadores sobre ellas y los operadores que permiten mezclarlas con las señales continuas, es lo que hace que muchas de las variantes de FRP sean capaces de manipular sistemas híbridos. Sin embargo, hay diferentes aproximaciones a la naturaleza de estas señales discretas. 

Una posibilidad pasa por hacer la diferenciación entre ambos tipos de señales directamente desde la base, lo que las hace tener diferentes propiedades y facilita un uso específico de ambos tipos con fines de optimización (es lo que se lllama FRP multi-tipo, y cuyo principal representante es la CFRPProgramación Funcional Reactiva Clásica). Otra posibilidad es definir las señales discretas como un subtipo de las continuas restringiendo el dominio de definición (es lo que se llama FRP uni-tipo, y cuyo principal representante es la UFRPProgramación Funcional Reactiva Unaria).

Un ejemplo de evento podría venir dado por la pulsación de uno de los botones del ratón, que es una señal discreta ya que no se produce de forma continua, sino únicamente cuando el usuario pulsa el ratón. Obsérvese que asociado a este evento podríamos haber definido una señal continua de tipo booleano que indicase si un cierto botón ha sido pulsado o no. En este caso, los eventos se producirían exactamente cuando la señal continua pasase de un valor False a un valor True.

Funciones de Señales

Las funciones de señales, como su nombre indica, son conceptualmente funciones que transforman señales en señales:

\(SF\ a\ b : Signal\ a \rightarrow Signal\ b\)

En algunos lenguajes FRP la abstracción reactiva primaria son las funciones de señales, y no las señales en sí, por lo que en estas implementaciones las funciones de señales son entidades de orden superior, y las señales no existen de forma independiente por sí mismas.

Para poder implementar las funciones de señales en sistemas reactivos, debemos añadir la restricción de ser causales temporalmente, lo que significa que sus efectos no pueden preceder a sus causas con respecto al tiempo (el tiempo puede depender del pasado, pero nunca del futuro). En consecuencia, la salida de una función de señales en el tiempo \(t\) está unívocamente determinado por la entrada de la señal y el intervalo temporal \([0,t]\). Normalmente, esta restricción se implementa a nivel de las primitivas y de combinadores que son cerrados bajo causalidad temporal, por lo que la restricción se obtiene automáticamente para todas las funciones reactivas por inducción.

Nota: Existen variantes de FRP en las que no se exige esta restricción de causalidad, por lo que las funciones establecen relaciones formales entre las señales de las que depende, pero no se considera una señal como entrada y otra como salida, ya que no se interpreta como transformación de una señal en otra. En este caso, se le denomina Modelado Híbrido Funcional.

Reusando los ejemplos de señales del apartado anterior, podríamos tener una función de señales que transformase la señal de la posición del ratón en la pantalla en la señal de la caja de texto, de forma que se visualizase en todo momento la posición como el par de coordenadas que lo representa.

Es en estas funciones de señales donde uno puede determinar cuáles serán los operadores primitivos que le permitirán combinar las diversas señales con las que modelará su problema y que permitirán construir redes de procesos de señales que sean más o menos expresivos y potentes.

Por ejemplo, es bastante común encontrarse con los siguientes combinadores (se explican con brevedad porque son similares a las funciones que hay en los lenguajes funcionales):

  • map : (a -> b) -> Signal a -> Signal b. Si tenemos una función que convierte elementos de tipo a en elementos de tipo b, podemos aplicar esa misma función para convertir señales de tipo a en señales de tipo b.
  • filter : (a -> Bool) -> a -> Signal a -> Signal a. Si tenemos un filtro que podemos aplicar sobre elementos de tipo a, podemos aplicar un filtro equivalente a señales de ese tipo.
  • merge : Signal a -> Signal a -> Signal a. Permite combinar dos señales (del mismo tipo) en una señal del mismo tipo. Como las señales se pueden ver como listas temporales de elementos del tipo a, simplemente lo que hace es ir adjuntando a esa lista la señal que llega primero, si ambas llegasen a la vez, se añade primero la que ocupa el lugar izquierdo de la definición.
  • foldp : (a -> s -> s) -> s -> Signal a -> Signal s. Permite aplicar un proceso de plegado a una señal (recuerda que es como una lista ordenada temporalmente) para obtener un valor que depende de toda la histaria de esa señal. El nombre foldp proviene de fold past porque pliega el pasado de la señal. En algunos lenguajes hay un combinador similar llamado reduce.

Cualquiera de los lenguajes de programación funcionales que son capaces de aplicar la metodología reactiva dispone de todos los combinadores anteriores (aunque los nombres pueden variar, todas las funcionalidades se pueden encontrar) y de variantes de las mismas para cubrir diversas necesidades que nos pueden surgir al programar nuestras soluciones, pero la verdadera potencia se consigue por el uso combinado de ellos en lo que antes hemos llamado una red de procesos de señales.

En entradas posteriores veremos ejemplos de cómo usar estas ideas para resolver pequeños problemas haciendo uso del lenguaje Elm.

« Haskell: el Lenguaje … « || Inicio || » Llegan las Jornadas F… »