A continuación veremos algunos conceptos referentes a la programación 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:
Estas no son las únicas ideas de programación funcional, pero sí proporcionan una base sólida de ello.
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.
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.
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.
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:
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.
Una función puede catalogarse como pura si cumple las siguientes dos condiciones:
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.
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:
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.
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.
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.
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
En este ejemplo demostrativo de recursión se calcula el interés compuesto recursivamente.
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.
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
.
En este caso, la llamada a fact(k-1, k*result)
, sí es lo último en ejecutarse.
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.
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.
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).
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.
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.
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.
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).
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.
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).
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.
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
También son funciones que pueden tomar o devolver otras funciones y se pueden almacenar como variables.
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.
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".
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
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.
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.
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.
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.
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:
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.
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
}
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
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.
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
).
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]
}
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.
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")
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.
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.
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]
}
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)
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.
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).
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:
case class Person(name: String, age: Int)
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
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.
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.
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
}
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
}
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.
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.