Aterrizando en la programación funcional

¿Por qué la programación funcional suscita tanto interés?, ¿qué la diferencia del principal paradigma imperativo usado en la industria?, ¿en que grado deberías/podrías sacar partido a la programación funcional? Este tipo de cuestiones aparecen de forma recurrente entre aquellos que observan con curiosidad o recelo los movimientos de los lenguajes por incluir características de programación funcional.

Aunque el tema es amplísimo, complejo e inevitablemente propenso al partidismo y subjetividad, intentaré desde mi modesta experiencia apuntar algunas ideas que quizás, te respondan a estas cuestiones.

La programación funcional puesta en práctica

En un sentido ontológico práctico y dejando de lado definiciones formales, yo diría que la programación funcional consiste en determinar, entender y fijar las relaciones existentes entre los objetos que utilizamos al programar.

Por ejemplo, el principio de sustitución de Liskov determina y permite entender una relación existente entre una jerarquía de objetos en la programación orientada a objetos. Esa conexión, encontrada, entendida y fijada por Barbara Liskov es en cierto sentido, un ejercicio de programación funcional.

Es muy posible que todo esto de relaciones, principios, teoremas, ... te parezca demasiado profundo para total, escribir tan sólo algo de código que lea un archivo por aquí, imprima algunas líneas por allá, ... pero parece razonable pensar que la calidad del código que escribamos estará directamente relacionada con el conocimiento que tengamos sobre qué significa escribir código.

Veamos las siguientes líneas de javascript:


var x = ~~cadena;
if( a ) {
  y = f(x, a);
} else {
  y = g(x);
}

Muchos programadores no verán ningún problema con este código y no le darían mayor importancia pero, pensemos por un momento en algunas de las relaciones que no hay ahí:

  • parece que cadena debería ser una secuencia de dígitos quizás precedida por un + o un - pero nada impide que sea un objeto Date, un número, undefined, ...
  • existe una relación obvia entre el chequeo de "nulidad" de a y que se usen las funciones f o g pero nada impide intercambiar las expresiones y evaluar f cuando a es nulo
  • y deberíamos suponer que los comportamientos de f y g están relacionados de alguna forma, aunque nada impide usar ahí cualquier otra función h, i, ... o que hagan y devuelvan cosas diferentes

Así, seamos o no conscientes, existen multitud de relaciones tácitas en el código que escribimos, que forman parte de la implementación aunque el lenguaje no las considere y que podrían ser utilizadas para escribir código más seguro, eficiente y conciso.

Seguramente una de las relaciones más obvias que pueden fijarse cuando programamos son los tipos pues, al escribir int y = g(x) se está poniendo de manifiesto una relación entre y y la función g. Pero la programación funcional establece muchas relaciones que no se limitan al sistema de tipos del lenguaje.

Con la programación funcional, de forma similar al principio de sustitución de Liskov, se busca encontrar, poner de manifiesto todas las relaciones posibles y utilizarlas para escribir código mas eficaz.

En C#, podríamos utilizar la sintaxis de LINQ para enlazar código (fijar relaciones) que requiere cierto valor pero que quizás está o no disponible y la sobrecarga de operadores para seleccionar el primero por la izquierda que sí posea un valor, algo como:


Quizás<int> y = from x in atoi(cadena)
                from r in (from o in notNull(a) select f(x, o)) | g(x)
                select r;

En el código anterior, la función atoi y la función g devuelven quizás un entero (Quizás<int>) y la función notNull devuelve quizás un objeto nulable (Quizás<T>) pero la función f requiere valores y sólo se evalúa cuando notNull(a) contiene un valor. La tubería devuelve el primer valor por la izquierda que sí posea valor (o ninguno si ninguno lo tiene) en expresiones como q1 | q2 | q3. Es la estructura interna codificada (en otro sitio) usando la sintaxis LINQ y operador | la que realiza la lógica de los Quizás y es tan general y reusable como las propias del lenguaje: if, switch, ...

Revisemos de nuevo algunas de las relaciones anteriores:

  • podemos usar x con seguridad, puesto que las computaciones debajo de from x no serán evaluadas si atoi falló. Y no hay otra forma de acceder a x (la compilación fallaría).
  • podemos usar o con seguridad por la misma razón, es imposible acceder a a si es nulo.
  • la relación entre f y g está implícita pues la familia de computaciones admitidas está restringida a las que devuelven Quizás<int>. Y eso, que el tipo de retorno de f y g es diferente.

En lenguajes con sintaxis más adecuadas podría quedar (ej. Haskell):


let y = do  x <- atoi cadena
            (f x <$> notNull a) <|> g x 

La programación funcional busca esas relaciones y define estructuras y estrategias que, como el ejemplo de C#, refinan y aseguran otras previas.

Por tanto, la programación funcional no es hacer un "reduce-map" o usar listas por comprehensión, sino entender y usar relaciones de una forma que, en la práctica, está mostrando algunas ventajas frente a otros paradigmas.

Comparativa de algunas estrategias

El ejemplo anterior muestra como controlar el flujo según el resultado de las operaciones intermedias. Un ejemplo real (en el que he cambiado los nombres de los símbolos para respetar la fuente) transformado a un estilo funcional podría ser la siguiente función:


        bool ShowControl(Request request) {
            IControlFinder finder;

            if (!Api.TryResolveInjection(out finder)) {
                return false;
            }

            var control = finder.FindControl(request);
            if (control == null) {
                return false;
            }

            if (control.ViewData != null) {
                return true;
            }

            var service = Api.ResolveInjection<IViewDataTransformer>();
            var viewData = service.ViewFromData(request);
            control.ViewData = viewData;
            return true;
        }

como en el ejemplo anterior, hay unas cuantas relaciones "no resueltas" como que la inyección de la dependencia a IViewDataTransformer puede fallar o, quizás, la generación de los datos de la vista. También se observan diferentes convenciones para indicar éxito como en Try o los chequeos de null. Quien lo codificó decidió no añadir ahí aserciones como comentábamos en To throw or not to throw o para que sirven las excepciones.

Usando una estilo aplicativo y la notación LINQ anterior podría ser algo como



Quizás<Void> ShowControl(Request request) {
   return from finder  in Api.GetFinder()
          from control in finder.FindControl(request)
          from success in control.ViewData
                        | from service  in Api.GetService()
                          from viewData in service.ViewFromData(request)
                          from result   in control.SetViewData(viewData)
                          select result
          select success;
}

o de forma similar en Haskell algo como


viewFromRequest :: Request -> Control -> API ()
viewFromRequest r c = withService (viewFromData r) >>= setViewData c

showControl :: Request -> API ()
showControl request = do  control <- withFinder $ findControl request
                          viewData control <|> viewFromRequest request control

Otro pequeño ejemplo consiste en cómo se realiza la inyección de dependencias o, mejor dicho, cómo no se realiza ningún tipo de inyección de dependencias.

En el código anterior hemos visto que nuestra Api hace uso de cierto servicio (Api.GetService) para realizar la acción que sea. Con el fin de desacoplar esa acción que necesitamos con quien finalmente realizará la acción se tiene la inyección de dependencias.

Muy por encima, la inyección de dependencias se puede dividir en dos aspectos:

  1. cual es la acción que queremos independizar desacoplar.
  2. de qué forma consigo yo un objeto que me proporcione dicha acción.

La forma más básica (de inversión) es mediante callbacks, cuya firma resuelve el primer punto. Pasarlos como argumento es también la forma más básica de resolver el segundo punto.

Pero pasarlo como argumento requiere ir arrastrándolo "por ahí", por lo que se suele tirar de reflexión para inyectarlo resolverlo en tiempo de ejecución a partir de algún fichero de configuración (ej. Web.config), anotaciones (@Inject), singleton global (ej. Routes.cs), ...

En la programación funcional y usando mónadas, las computaciones se definen dentro de un contexto sobre el que el programador ha decidido abstraer su problema, formalmente, nada que no se haya previsto en ese contexto está permitido realizar (puedes leer sobre ello en Usar mónadas es mucho más fácil de lo que crees, empezando con la programación funcional) y por tanto si nuestra función showControl va a necesitar instanciar un Finder nuestro contexto debe permitirlo. A partir de ahí, cualquier computación dentro de ese contexto tiene disponible esas computaciones definiciones sin necesidad de arrastrar explícitamente el contexto. Muy, muy burdamente sería como que nuestra Api entera consiste en una única clase parcial y la instancia concreta de cierto interface (ej. IHttpProvider) la tenemos accesible mediante algún getter (por hacerlo lazy).

En el ejemplo, los withService "recuperan" una computación permitida en el contexto, cuyas definiciones pueden estar en cualquier sitio (no dentro de una gran clase Api).

Otra estrategia muy usada en la programación funcional es la currificación o (no siendo exactamente lo mismo) aplicación parcial en que cierta función con argumentos es reducida a otra función con menos argumentos. De forma similar a la inyección de dependencias, aquí podemos tener computaciones configurables sin que haya que arrastrar la configuración (o inyectarla).

Por ejemplo, si tenemos cierta función que renderiza un informe, no hace falta pasar explícitamente las propiedades "locales" que tendrá el título (color, tipo de letra, ...) sino que ignoraremos esas propiedades dándolas por asumidas y sólo definiremos lo que queremos abstraer a ese nivel, en el ejemplo, la inversión se produce en el texto que habrá en el título:


renderizaInforme :: (String -> Picture) -> ... -> Picture
renderizaInforme titulo ... = do
  ...
  let tituloPic = titulo "Informe"
  ...
  pictures [ ..., tituloPic, ... ]

desacoplando la lógica de renderizado que sea con las particularidades del título. Posteriormente se podría definir como


data Mostrar = Informe | Menú | ...

interfazGráfica :: Mostrar -> Render ()
interfazGráfica Informe = withRender $ renderizaInforme (textPicture Red Bold)
interfazGráfica Menú = ...

-- donde cierta API gráfica define
data Color = Red | Blue | ...
data FontWeight = Normal | Bold | ...

textPicture :: Color -> FontWeight -> String -> Picture

Exponer aunque fuera sucintamente otros aspectos como la reducción (o fusión) total o parcialmente automática de computaciones enlazadas, la elevación al sistema de tipos de invariantes en nuestra aplicación (por ejemplo el routing en un servicio REST) o que la facilidad de crear DSL permite construir entornos seguros (el contexto del que hablábamos antes) sería largo y seguramente no los sabría exponer adecuadamente, pero espero que estos ejemplos hayan dado una pequeña idea.

Automatizando el principio de sustitución de Liskov

Las estrategias mostradas anteriormente suelen poderse aplicar a cualquier lenguaje, pero ya hemos apuntado la idea de que la programación funcional no es la aplicación de esas estrategias sino la capacidad intrínseca del paradigma para razonar, obtener conocimiento y aprovecharnos de ello.

¿No sería genial que el propio compilador nos advirtiera de que nuestra jerarquía de objetos está incumpliendo el principio de sustitución de Liskov?

Ese es el enfoque que motivan y potencian ciertos lenguajes resultando en herramientas como Proving Type Class Laws for Haskell (vía @Jose_A_Alonso) que permiten de forma automática probar que todas las instancias de cierto interface cumplen las leyes propiedades obligadas por el interface. O como fitspec que de forma automática nos indica si nuestros test de invarianzas (ej. la concatenación de cadenas implica suma de longitudes) cubren el dominio.

Problemas de la programación funcional

Un estilo funcional no está exento de inconvenientes, igual que otros paradigmas sufre del problema de expresividad de forma inversa a como ocurre con la POO por lo que dependiendo del problema podría no ser el enfoque más adecuado. La facilidad con la que pueden definirse estructuras globales (vía DSL, aplicación parcial, ...) supone con frecuencia que debemos "estudiar" el enfoque particular de ciertas apis que al principio puede sorprender, pues parecen otro lenguaje diferente. Por uno u otro motivo, las estructuras que suelen definirse provienen de las matemáticas, lo que supone por un lado una asunción de un conocimiento previo de las mismas (monoide, functor, ...) que quizás no existe y por otro lado un nivel de abstracción mayor, complicando la adopción de ciertas estrategias.

En todo caso, consigue un curioso efecto en el que las abstracciones quedan más desacopladas que con otros paradigmas y a la vez, son las definiciones las que tienden a estar mucho más acopladas llevándonos al dicho de que "si te compila Haskell, seguramente sea correcto".

Ver todos los comentarios en https://www.genbeta.com

VER 0 Comentario

Portada de Genbeta