Programación Funcional

Conceptos clave

A continuación veremos algunos conceptos referentes a la programación funcional.



Pensamiento Funcional

El pensamiento funcional es la serie de habilidades e ideas que se aplican para resolver problemas funcionalmente. Dos ideas importantes que debe aprender el programador funcional son:

  1. Distinguir acciones, cálculos y datos
  2. Usar abstracciones de primera clase

Estas no son las únicas ideas de programación funcional, pero sí proporcionan una base sólida de ello.



Distinguir acciones, cálculos y datos

Todo código se separa según las categorías de acciones, cálculos o datos. Estas corresponden a qué tan difícil es entender, testear y reusar el código.

Acciones

Son aquello que depende de cuándo y cuántas veces son llamados. Son funciones con efectos secundarios.

Ejemplo: Leer de una base de datos.

Cálculos

Representan decisiones o planes. Estos no afectan el entorno al ser ejecutados. Son funciones puras.

Ejemplo: Revisar si una dirección de correo es válida.

Datos

Es información registrada sobre eventos, es inmutable y flexible y puede almacenarse, transferirse y usarse de múltiples maneras.

Ejemplo: Ordenes en un restaurante.

Esta perspectiva se puede aplicar a cualquier situación, aunque al principio puede parecer abstracto, a medida que se práctica se mejora.
Es importante tener en cuenta unos detalles finales:

  • Lo que parece una acción puede estar compuesto de otras acciones, cálculos o datos más pequeños.
  • Los cálculos pueden estar compuestos de cálculos y datos más pequeños. Al descomponerlo, este se vuelve un cálculo partido en dos, donde la salida del primero es la entrada para el segundo-
  • Los datos solo están compuestos de más datos. Debido a ello, si se tienen datos, se tiene garantía de cómo se comportarán.

Usar abstracciones de primera clase

Se hace uso de funciones de primera clase, estas son funciones que toman como argumento otras funciones. Existen también valores de primera clase, son aquellos que se pueden pasar a una función, pueden ser retornados por una, se pueden guardar en arreglos, asignar a un atributo de un objeto, se pueden asignar auna variable, entre otros. En cambio, operadores aritméticos, ciclos, sentencias if, bloques try/catch, son ejemplos de valores que no son de primera clase.
La habilidad de identificar valores, funciones, entre otros, que no son de primera clase convertirlos a primera clase proporciona un gran poder para resolver problemas, pues abre las puertas a patrones de programación funcional más sofisticados (Cabe resaltar que son patrones de PF, diferentes a los usualmente usados para POO). Algunas ventajas son que permiten abstraer código, eliminar código duplicado, envolver código en funciones.

Funciones Puras

Una función puede catalogarse como pura si cumple las siguientes dos condiciones:

  • Dados los mismos parámetros de entrada, la función debe retornar siempre el mismo valor de salida, sin importar cuántas veces se llame. Aquí se aplica el concepto de "Transparencial Referencial".
  • La función no debe tener "efectos secundarios", es decir, no debe tener ningún otro efecto en el entorno, aparte del cálculo del resultado objetivo.

Las funciones puras son útiles porque se pueden ejecutar desordenadamente y en paralelo, cuando no hay dependencias de parámetros entre ellas. Algunos ejemplos de estas los podemos ver en las funciones matemáticas y algoritmicas deterministas. Son funciones impuras, por otro lado, los generadores de números aleatorios.

Efectos secundarios

Están referidos a los estados en los que puede estar el código mientras se ejecuta, una de las características ya mencionadas de la programación funcional es que es de tipo declarativo, por lo que no existe la variación de estados, estos efectos secundarios pueden referirse a:

  • Cambiar el valor de cálculos realizados previamente.
  • Entrada - salida de datos estándar o por archivo.
  • Modificar uno de los argumentos de la función.

Ejemplos

Loops

Los loops generan "side effects" debido a que se está cambiando el valor de una variable para iterar y comparar, generando cambios de valor y por lo tanto cambiando la salida de una función o rutina.

Variable aleatorias

Las variables generadas aleatoriamente generan side effects, ya que las funciones evaluadas con un número aleatorio no retornaran el mismo resultado ejecutadas dos veces.

Entrada de datos por consola en ejecución

Los argumentos de entrada de un programa en programación funcional se asignan sólo al principio de este (argumentos), en ejecución está prohibida la asignación de variables, ya que no garantizan que el código ejecutado dos veces sea exactamente igual.



Recursión

En los lenguajes funcionales no existen los ciclos, esto con el objetivo de preservar la inmutabilidad, por ende, la técnica que se usa es reemplazarlos con funciones recursivas. A continuación un ejemplo de la misma función implementada con un ciclo for y con recursión

Haskell

En este ejemplo demostrativo de recursión se calcula el interés compuesto recursivamente.

Java

Recursión de Cola

Dado que las recursiones se manejan guardando la información de los llamados en una pila, es posible que esto produzca un error de 'StackOverFlow', para evitar esto se usa recursión de cola, esta es una función recursiva en la que la llamada a sí misma es la última sentencia que ejecuta la función. Esta técnica evita que se desborde la pila dado que el compilador es capaz de reescribirlo como un búcle.



Recursividad Normal

A pesar de que el llamado recursivo factorial(n-1) aparece al final de la función, esta no es la última acción, pues tras ejecutar esa sentencia aún es necesario realizar la multiplicación por n.

Tail Recursion

En este caso, la llamada a fact(k-1, k*result), sí es lo último en ejecutarse.



Modularidad

Modularidad para hallar la desviación estándar de una lista.


Aplicación en Haskell:


Un problema complejo se puede descomponer en problemas más simples. Por ejemplo en programación funcional una serie de funciones realizadas secuencialmente podrían resolver la mayoría de problemas de programación.



Transparencia referencial

Se dice que una expresión es referencialmente transparente sí se puede reemplazar con su valor correspondiente sin cambiar el comportamiento del programa. Como resultado, la evaluación de una función referencialmente da el mismo valor para los mismos argumentos. Estas funciones se llaman funciones puras. Una expresión que no es referencialmente transparente se llama referencialmente opaca.

En las matemáticas todas las aplicaciones de una función son referencialmente transparentes por definición. Esto no pasa siempre en la programación, donde los términos procedimiento y método se utilizan para evitar connotaciones engañosas.

La importancia de la transparencia referencial es que permite al programador y al compilador razonar sobre el comportamiento del programa como un sistema de reescritura. Esto puede ayudar a probar la corrección, simplificar un algoritmo, ayudar a modificar el código sin romperlo, o optimizar el código mediante memoization, eliminación de subexpresión común, evaluación perezosa o paralelización.

Como la transparencia referencial requiere los mismos resultados para cualquier conjunto dado de entradas en cualquier punto en el tiempo, una expresión referencialmente transparente es por lo tanto determinista.

Ejemplos

  • Se tiene la siguiente función:

    int sumarUno(int x){
    return x + 1;
    }

    Es transparente, porque para cualquier valor de x no tendrá side effects.

  • Pero con una función de tiempo como:

    today();

    No es transparente, porque para un día como hoy retornará 1 de Octubre de 2017, pero mañana no (llamando la función de la misma manera).



Evaluación perezosa

  • Algunos lenguajes funciones no evalúan expresiones que no son usadas. Esto puede reducir el tiempo de ejecución de algunas funciones en tiempo exponencial.
  • Permite declarar infinitos valores, ya que siempre y cuando sólo se use un número finito solo estos se calcularán. Ver ejemplo en aquí
  • Mediante la reducción por grafos, no se re-calculan expresiones o funciones ya evaluadas. Esto reduce el costo computacional.

Inmutabilidad

En general, tener estados mutables vuelve el código más complejo y es causa de errores, ya que dificulta hacer seguimiento a los valores cambiantes, es por eso que en PF se busca tener objetos inmutables. Un objeto inmutable es aquel que no puede ser modificado una vez haya sido creado. Podemos ver a los objeto inmutables como constantes, una vez nosotros asignamos un valor este no podrá ser modificado.
Teniendo en cuenta que un programa en programación funcional es inmutable, en ejecución está prohibida la asignación de variables, ya que no garantizan que el código ejecutado dos veces sea exactamente igual.

Tipos Algebraicos

En los lenguajes funcionales tipados como ML y Haskell ademas de los tipos tradicionales, támbien tenemos la posibilidad de declarar tipos adicionales segun lo necesitemos, los cuales son declarados mediantes constructores y con los cuales tenemos la opcion de declarar listas recursivas, direcciones entre otros ejemplos.

Emparejamiento de Patrones

El emparejamiento de patrones (en inglés Pattern Matching), se refiere a la posibilidad que brindan algunos lenguajes funcionales de definir funciones a trozos o por casos, lo que nos permite tener mayor capacidad expresiva al lenguaje, lo que quiere decir es que nos permite escribir el código más claro, sencillo y conciso.

Aqui definimos una funcion que puede ser vacia o puede ser cons

Composición de funciones

La composición de funciones es la imagen resultado de la aplicación sucesiva de dos o más funciones sobre un mismo elemento x.

En esencia funciona igual a la composición de funciones en matemáticas, así:

La composición de funciones se realiza aplicando dichas funciones en orden de derecha a izquierda, de manera que en (g o f)(x) primero actua la función f y luego la g sobre f(x).

Ejemplos de composición en lenguajes funcionales

Haskell

Siguiendo con el ejemplo de g(x) y f(x), y usando la sintaxis de Haskell, la composición de funciones se excribiría de la siguiente manera: (g.f)(x).

En el siguiente snippet de Haskell se hace una composición de las funciones sumarUno y multDiez. Siguinedo el orden de ejecución de derecha a izquierda, primero se ejecutará multDiez usando como parámetro de 12, acto seguido el resultado de esta función (12*10 = 120) será tomado como parámetro en la función sumarUno, dando como resultado 120 + 1 = 121.


Scala

En el caso de Scala la composición de funciones se hace usando la palabra clave compose, que con el ejemplo inicial quedaría (g compose f)(x).

Estado Compartido

El estado compartido es cualquier variable, objeto o espacio de memoria que exista en un ámbito compartido. Un ámbito compartido incluye el alcance global o ámbitos de cierre. Un ejemplo de esto lo podemos observar en la programación orientada a objetos, donde los objetos se comparten entre ámbitos al agregar propiedades a otros objetos. De la misa manera, el paradigma funcional toma esta propiedad para aplicarla en su ámbito de desarrollo.

Funciones de Orden Superior y de Primera Clase

Funciones de Orden Superior

Son funciones que pueden tomar otras funciones como argumentos o devolverlas como resultado. Estas funciones se pueden almacenar en variables En cálculo , un ejemplo de una función de orden superior es el operador diferencial d / dx , que devuelve la derivada de una función f, recibe una función de parámetro y devuelve otra función, por ejemplo al derivar x^2, devuelve otra función que es 2x


Funciones de Primera Clase

También son funciones que pueden tomar o devolver otras funciones y se pueden almacenar como variables.


¿Cuál es la diferencia?

Ambas se pueden tratar como cualquier otro valor del lenguaje, pero a las de primera clase no se les debe hacer ningún tratamiento especial.

Ejemplo de una función de orden superior que no es de primera clase

Haskell

En este ejemplo demostrativo de haskell la función applyTwice toma una función de números enteros como primer argumento y la aplica dos veces a su segundo argumento. Los paréntesis aclaran que el primer conjunto Int debe leerse en conjunto para significar una función Int en lugar de dos valores Int independientes. En el siguiente snippet se crean algunas funciones de muestra: "double" y "next" para pasar a nuestra función de orden superior "applyTwice".

Ruby

En Ruby no se pueden pasar métodos como parámetros de otros métodos, pero si objetos, por ello cuando se quiera hacer algo similar, se tiene que convertir el método que se quiere pasar a un objeto. Esto se puede realizar gracias al método lambda. Este es un claro ejemplo de una función de orden superior pero no de primera clase, ya que para poder pasar el método "hola" como parámetro del método "metod1", fue necesario hacerle algún tipo de parseo o transformación


JavaScript

En contraste con Ruby, Javascript permite que una función sin tratar pueda ser pasada como parámetro de otra función. Como ocurre en el siguiente ejemplo donde una función notada de manera original (no como una función anónima o lambda) puede ser pasada como paramétro de cualquier otra función, en este caso como parámetro de la función map() de un arreglo.


Ejemplo de una función de orden superior y de primera clase

Haskell


En este caso no fue necesario ningún tipo de parseo o similar para poder pasar un función como parámetro de otra función (pasarle f a g), por ello es de orden superior y de primera clase.

Clausura (Closure)

Una clausura o cerradura es cuando una función tiene la capacidad de recordar la información que estaba en su contexto cuando fue declarada. En algunos casos, como en el de JavaScript, su implementación se contradice con su naturaleza orientada a objetos, pues las referencias encapsuladas en el contexto de la función pueden ser alteradas.

Currificación

Como parte del estilo tácito o “Point-Free”, se suele necesitar acciones adicionales para permitir una mejor coherencia del código. Uno de ellos es la currificación, que consiste en convertir una función de múltiples parámetros en una con un único parámetro, la cual retorna otra función que espera los parámetros restantes de su definición. También existe el proceso inverso de descurrificación.

Ventajas de la Currificación

  • Podemos crear funciones nuevas simplemente pasando nuestras funciones de base con algunos parámetros.
  • Podemos transformar cualquier función que trabaje con un solo elemento en una que trabaje con una lista envolviéndola en un map.
  • Se pueden escribir pequeñas piezas de código que sean más fácil de reutilizar.
  • Es fácil de razonar sobre ellas.
  • Escribir funciones currificadas nos permitirá componer funciones.

JavaScript

Se ejemplificará este concepto clave de currificación haciendo uso de Javascript.

Javascript permite definir las funciones de manera estandar o como funciones anónimas. De la misma manera una función currificada puede estar definida como función estandar o como función anónima.

Al momento de ejecutar una función currifcada se puede hacer de 2 maneras:

  • Ejecutando la función que contiene a las demás n-1 funciones con n listas de argumentos (una lista por cada función) con la siguiente sintáxis: customFunction(argF1)(argF2)...(argFn)
  • Pasar un argumento y recibir la siguiente función en el stack de currificación.

Higher-Kinded Types (HKT)

Los Higher-Kinded Types (HKT) son una característica avanzada en la programación funcional que permite definir tipos que aceptan otros tipos como parámetros. Esto permite una mayor abstracción y reutilización de código en el contexto de la programación funcional.

Definición de HKT

Un Higher-Kinded Type se define como un tipo que toma otro tipo como parámetro. Aquí hay un ejemplo sencillo de cómo se puede definir un HKT:


trait Container[F[_]] {
    def put[A](value: A): F[A]
    def get[A](container: F[A]): A
}
                    

Ejemplo de Uso

Un ejemplo común de HKT es el uso de listas como un contenedor genérico. Aquí hay un ejemplo de cómo usar listas con una clase que implementa el trait Container:


object ListContainer extends Container[List] {
    def put[A](value: A): List[A] = List(value)
    def get[A](container: List[A]): A = container.head
}

val list = ListContainer.put(42)
val value = ListContainer.get(list)  // 42
                    

Ventajas de HKT

Los HKT permiten escribir código más genérico y flexible, facilitando la abstracción sobre diferentes tipos de contenedores y estructuras de datos. También son fundamentales para la definición de estructuras de datos y patrones de diseño más avanzados en muchos lenguajes funcionales.

Ejemplo en Scala:
En Scala, los HKT se utilizan ampliamente para definir estructuras de datos genéricas y patrones de diseño funcionales.

Monads

Una Monad es una abstracción que permite estructurar programas de forma modular. Las Monads permiten encadenar operaciones en contextos computacionales como listas, opciones o futuros. Una Monad debe implementar dos operaciones: flatMap y unit (a menudo llamado pure).

Definición de Monad

Una Monad se define como una clase con los métodos flatMap y unit. Aquí hay un ejemplo de una Monad sencilla:


trait Monad[M[_]] {
    def flatMap[A, B](value: M[A])(func: A => M[B]): M[B]
    def unit[A](value: A): M[A]
}
                    

Concatenación de Métodos

Una de las características más poderosas de las Monads es su capacidad para encadenar operaciones, lo que se conoce como concatenación de métodos. Esto permite construir secuencias de operaciones que se aplican una tras otra de manera limpia y manejable.

Ejemplo de Uso con Option Monad

Considere el caso de la Monad Option, que representa un valor que puede estar presente o no. Aquí hay un ejemplo de cómo usar Option para encadenar operaciones:


val maybeInt: Option[Int] = Some(5)
val result: Option[String] = maybeInt
    .flatMap(x => Some(x * 2))         // Multiplica el valor por 2
    .flatMap(x => Some(x + 3))         // Suma 3 al resultado
    .flatMap(x => Some(x.toString))    // Convierte el resultado a String

// result es Some("13")
                    

Leyes de Monad

Las Monads deben cumplir con tres leyes: identidad izquierda, identidad derecha y asociatividad. Estas leyes aseguran que las Monads se comporten de manera predecible y consistente.

Functor

Un Functor es una abstracción que permite aplicar una función a un valor dentro de un contexto. Los Functors se implementan con el método map. Un Functor debe cumplir con dos leyes: identidad y composición.

Definición de Functor

Un Functor se define como una clase con el método map. Aquí hay un ejemplo de un Functor sencillo:


trait Functor[F[_]] {
    def map[A, B](value: F[A])(func: A => B): F[B]
}
                    

Ejemplo de Uso

Un ejemplo común de un Functor es la clase List. Aquí hay un ejemplo de cómo usar List como un Functor:


val numbers = List(1, 2, 3, 4)
val doubled = numbers.map(_ * 2)
// doubled es List(2, 4, 6, 8)
                    

Leyes de Functor

Los Functors deben cumplir con dos leyes: la ley de identidad (map con la función identidad no cambia el Functor) y la ley de composición (map con la composición de dos funciones es igual a map con la primera función y luego map con la segunda).

Ejemplo en Scala:
En Scala, los Functors son ampliamente utilizados en las colecciones y otros contextos que admiten transformaciones.

ADTs (Algebraic Data Types)

Los Tipos de Datos Algebraicos (ADTs) son una forma de definir tipos de datos compuestos. Los dos ADTs más comunes son los productos (casos de clase) y las sumas (jerarquías selladas).

Definición de ADTs

Un tipo de producto se define utilizando casos de clase, y un tipo de suma se define utilizando una jerarquía sellada. Aquí hay un ejemplo de cada uno:

Tipo de Producto


case class Person(name: String, age: Int)
                    

Tipo de Suma


sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
                    

Ejemplo de Uso

Aquí hay un ejemplo de cómo usar ADTs:


val shape: Shape = Circle(5.0)
shape match {
    case Circle(r) => println(s"Circle with radius $r")
    case Rectangle(w, h) => println(s"Rectangle with width $w and height $h")
}
                    

Ejemplo en Scala:
En Scala, los ADTs son fundamentales para definir estructuras de datos complejas de manera segura y expresiva.

Typeclass

Una Typeclass es un patrón de diseño que permite la adición de nuevas funcionalidades a los tipos existentes sin modificar su código. Las Typeclasses se implementan utilizando traits y la implementación implícita de métodos.

Definición de Typeclass

Una Typeclass se define como un trait con métodos que describen las operaciones soportadas. Aquí hay un ejemplo de una Typeclass:


trait Show[A] {
    def show(value: A): String
}
                    

Implementación de Typeclass

Para implementar una Typeclass para un tipo específico, se proporciona una implementación implícita del trait. Aquí hay un ejemplo:


implicit val intShow: Show[Int] = new Show[Int] {
    def show(value: Int): String = value.toString
}
                    

Uso de Typeclass

Para usar una Typeclass, se usa la resolución implícita. Aquí hay un ejemplo de cómo usar una Typeclass:


def printValue[A](value: A)(implicit s: Show[A]): Unit = {
    println(s.show(value))
}
printValue(123)  // "123"
                    

Ejemplo en Scala:
En Scala, las Typeclasses son un patrón poderoso para la extensibilidad y la abstracción en la programación funcional.

Aplicación Parcial

Consiste en fijar algunos de los argumentos de la función. El proceso es simple: se define una función currificada y se aplica hasta un punto específico, obteniendo una función para la que aún hay argumentos esperados.