Python 3
Tu pasaporte a sabelotodo de Python. Desde las bases hasta la creación de tu primer programa, este curso...
En este post abordamos el paradigma de la programación funcional, explicando sus características, ventajas y desventajas y lenguajes que lo implementan.
Actualmente existen dos modelos de computación que fueron presentados en la década de los 30. Por un lado, tenemos el cálculo lambda, definido por Alonzo Church en 1932 y por el otro la Maquina de Turing, presentada por Alan Turing en 1937.
Ambos modelos son universales ya que permiten computar cualquier cosa computable, de hecho, es posible implementar uno dentro del otro y viceversa.
La máquina de Turing, por un lado, define una serie de axiomas fácilmente trasladables al hardware:
En esto fue en lo que se basó John von Neumann cuando en 1945 presentó su arquitectura de computación electrónica.
Más tarde llegaría lo que todos conocemos como lenguaje ensamblador que no era otra cosa que implementar el modelo de Turing en la arquitectura de Neumann de una forma que fuese entendida por humanos.
Por el otro lado, el cálculo lambda se compone de 3 elementos que nos obligan a pensar más en abstracto:
Solo con funciones y una forma de guardar la información (variables) podremos ir haciendo uso de la composición y la recursividad para computar cualquier cosa computable.
El primer lenguaje en implementar este modelo fue LISP y hoy por hoy es considerado por muchos el lenguaje de programación funcional por excelencia.
La programación funcional (PF) es un paradigma de programación al igual que la programación orientada a objetos (POO). La PF se basa en cálculo lambda y concretamente en composición de funciones puras para modelar las soluciones de software. En cambio, la POO está más ligada a la programación imperativa y mutable (listado de instrucciones que se van ejecutando) que tienen mucha más relación con el modelo mental de Turing que hemos comentado.
El desarrollo de software va de crear soluciones a problemas pequeños y después componerlos para solucionar un problema mayor. Es por eso que un modelo basado en funciones y en composición de las mismas como únicas herramientas para crear programas, nos brinda una forma muy elocuente de crear software.
Planteemos por ejemplo el problema de querer incrementar un numero por 1. Podemos enfocar el problema creando una función que resuelva directamente el problema:
const inc = x => x + 1;
O podemos pensar en solucionar primero el problema de sumar dos números y después crear nuestra función inc mediante una composición:
const add = x => y => x + y;
const inc = add(1);
Nuestra función add toma un valor X y devuelve una función que toma otro valor Y. Finalmente devuelve la suma de los dos números.
La función inc es solo una composición (en este caso usando aplicación parcial) de add.
Si mañana tenemos que crear más funciones ‘inc’, simplemente tendremos que seguir especlializando a la función add:
const inc2 = add(2);
const inc3 = add(3);
Además, podemos crear funciones completamente nuevas componiendo varias ya existentes:
const head = arr => arr[0];
const splitBySpace = str => str.split(' ');
const firstWord = compose(head, splitBySpace);
const toUpperCase = str => str.toUpperCase();
const toUpperCaseFirstWord = compose(toUpperCase, firstWord);
toUpperCaseFirstWord('Hello World') // HELLO
Hemos definido una serie de funciones y después las hemos ido componiendo para crear funciones nuevas.
Cuando hablamos de composición nos referimos simplemente a usar el resultado de una función como ‘input’ de otra. Siempre que los tipos coincidan (es decir, mi función A devuelve el tipo X y mi función B recibe el tipo X) podremos componerlas.
A lo largo de las implementaciones de los modelos anteriormente comentados, cada paradigma fue definiendo una serie de peculiaridades. Así las características que definen al paradigma funcional hoy por hoy son las siguientes:
Como el modelo de cálculo lambda carecía de “cinta” para conservar el estado del programa, este se tenía que ir regenerando a través de la composición de funciones y la recursividad.
Un buen ejemplo de cómo podemos crear cualquier cosa usando funciones es la lógica combinatoria, una variante del cálculo lambda que provee un set limitado de funciones combinadoras:
// funciones combinadoras
const I = x => x;
const K = x => y => x;
const V = x => y => z => z(x)(y);
// implementación de una tupla
const first = I;
const second = K(I);
const tuple = V;
const myTuple = tuple('Hello')('World');
myTuple(first); // 'Hello'
myTuple(second); // 'World'
Cumpliendo las características arriba comentadas hemos implementado una tupla básica usando solo funciones.
Los combinadores son excelentes ejemplos de programación funcional y están presentes hoy en día más de lo que creemos. Por ejemplo, la famosa aceleradora de startups de capital riesgo estadounidense Y combinator, toma el nombre el combinador ‘Y’ de lógica combinatoria.
Además, según la implementación, el paradigma de programación funcional también se suele asociar a lo que en teoría de programación se conoce como “lazy evaluation” o evaluación perezosa. Esta estrategia de evaluación que implementan lenguajes como Haskell consiste en no evaluar ninguna expresión hasta que el valor se necesite realmente. Así podemos definir estructuras de datos infinitas que por lo general hacen más sencilla la implementación de ciertos algoritmos.
Por ejemplo, si quisiéramos representar en una lista todo el conjunto de los números naturales usando Haskell, podríamos hacer esto:
n = [0..] -- lista infinita representando todos los numeros naturales
doubles = map (*2) n -- doblamos todos los numeros naturales
Al ser evaluado perezosamente, haskell no generará la lista hasta que realmente hagamos algo con ella:
main = print (take 10 doubles) -- [0,2,4,6,8,10,12,14,16,18]
En el ejemplo anterior estamos cogiendo los 10 primeros números de un conjunto infinito e imprimiéndolos por pantalla. Esto es posible porque los 10 primeros números naturales duplicados son una determinación en contraposición con la lista del doble de todos los números naturales que sería una indeterminación, representable para pensar en abstracto, pero no evaluable (obviamente).
Para que esta comparación sea justa, tenemos que tener claro que estos paradigmas son dos enfoques distintos para abordar los mismos problemas y que no son mutuamente excluyentes, es decir podemos por ejemplo modelar nuestra capa de datos con objetos o estructuras y después definir comportamiento con composición de funciones, o podemos aprovecharnos de la inmutabilidad sin renunciar a modelos de herencia de funcionalidad.
Para ver las diferencias entre un enfoque y otro, vamos a trabajar sobre mismo ejemplo: Queremos definir a una Persona que se componga de nombre, apellido y edad. A partir de ahí queremos tener varias personas y realizar sobre ellas las siguientes operaciones:
Para simplificar a la hora de resolver este problema con programación funcional, vamos a definir algunas premisas:
Empecemos por tanto definiendo una función para crear Personas. Para este caso usaré JavaScript un lenguaje multiparadigma entre los que se encuentra el funcional:
export const Person = ({ name, lastname, age }) => ({ name, lastname, age });
Ahora definimos operaciones para actualizar el nombre y el apellido. Como hemos dicho que todo es inmutable, no podemos modificar una persona dada, sino que tenemos que crear una nueva con la información actualizada:
export const updateAge = R.curry((age, person) => Person({ ...person, age }));
export const updateName = R.curry((name, person) => Person({ ...person, name }));
export const updateLastname = R.curry((lastname, person) => Person({ ...person, lastname }));
// creamos nuevos objetos persona cada vez que actualizamos
Como hemos dicho que las funciones solo pueden tener un parámetro he usado una técnica conocida como “curried functions” para hacer que en lugar de tener una función de “arity N”, tengamos una función de un parámetro que devuelve una función que toma el siguiente parámetro y así sucesivamente.
Una vez tenemos las funciones para actualizar las propiedades, podemos mediante composición de funciones crear una nueva para actualizar el nombre y el apellido de una vez:
export const rename = (name, lastname) =>
R.compose(updateName(name), updateLastname(lastname));
Finalmente definimos una función para que una persona pueda cumplir años, esto es, incrementar su edad en 1:
export const age = (person) => person.age; // función para obtener la edad dada una persona
const inc = (x) => x + 1; // función para incremantar un numero en 1
const incAge = R.compose(inc, age); // función para incrementar la edad de una persona en 1
export const turnsYears = R.converge(updateAge, [incAge, R.identity]) // función que dada una persona devuelve una nueva persona con la edad incrementada en 1
Ahora vamos a implementar este mismo ejemplo con programación orientada a objetos. Las ideas principales que están detrás de este paradigma las proporcionó Alan Kay (el que acuñó el término) en una lista de correo en 2003:
Es decir, gestionar y ocultar el estado de los objetos y comunicar unos con otros mediante mensajes. Enlaces tardíos (late binding) hace referencia a los mecanismos por lo que se debería de poder ampliar o modificar el comportamiento de los objetos en runtime.
Para está implementación de nuestro ejemplo en POO, vamos a usar C++:
struct Person
{
std::string name;
std::string lastname;
unsigned int age;
Person() : name(""), lastname(""), age(0)
{
}
Person(const std::string& name, const std::string& lastname, unsigned int age) :
name(name),
lastname(lastname),
age(age)
{}
Person(const Person& person) :
name(person.name),
lastname(person.lastname),
age(person.age)
{}
void rename(const std::string& new_name)
{
name = new_name;
}
void rename(const std::string& new_name, const std::string& new_lastname)
{
name = new_name;
lastname = new_lastname;
}
void update_age(unsigned int new_age)
{
age = new_age;
}
};
De entrada, el código es un poco más verboso que JavaScript, pero esto es mayoritariamente por los tipos. Al final hemos definido una clase Person que aúna las propiedades de dominio name, lastname y age, y el comportamiento: rename y update_age. La funcionalidad de actualizar el nombre y el apellido (que disponíamos de dos funciones en JS) se han implementado mediante sobrecarga del método rename.
Finalmente lo ponemos en acción:
#include <iostream>
#include "oop.hpp"
int main()
{
Person lisa("Lisa", "Simpson", 13);
Person homer(lisa);
homer.rename("Homer");
homer.update_age(40);
std::cout << lisa << std::endl << homer << std::endl;
}
Como resumen general: con la programación orientada a objetos definimos “que hace el programa” con una serie de instrucciones listadas de principio a fin y con la programación funcional se define “que es el programa” como una serie de características que son resueltas.
Llegados a este punto creo que es fácil intuir muchas de las ventajas y potenciales inconvenientes de la programación funcional, pero es importante recalcar que muchas de las características que se suelen asociar a este paradigma pueden no estar implementados en todos los lenguajes que se han hecho eco de ser funcionales. Por ejemplo, la inmutabilidad es una característica implícita en la programación funcional que algunos lenguajes como JavaScript no implementan de forma nativa. Sin embargo, sí que es relativamente fácil implementar composición de funciones y evaluación perezosa.
Por tanto, muchas de las ventajas que se suelen asignar al paradigma funcional son más potenciales que estrictamente pertenecientes al modelo, ya que dependiendo del lenguaje y de su uso podemos tener más o menos ventajas.
En términos generales las ventajas de la programación funcional son las siguientes:
En contraposición, los principales inconvenientes que se suelen asociar al modelo de programación funcional son los siguientes:
En resumen, nada es perfecto. No obstante, a la larga un enfoque de programación funcional suele dar más alegrías que frustraciones y si no termina de convenceros, seguro que el haber visto un enfoque distinto os brinda nuevas ideas para extrapolar a la POO.
Podemos dividirlos en dos categorías: Los lenguajes puramente funcionales y los lenguajes multiparadigma que implementan un subconjunto de funcionalidades de la programación funcional.
Por ejemplo, Rust es un lenguaje que, entre muchos otros paradigmas, implementa el funcional:
#![allow(dead_code)]
#[derive(Debug, Default, Clone)]
struct Person {
name: String,
lastname: String,
age: usize,
}
impl Person {
fn new(name: String, lastname: String, age: usize) -> Self {
Self {
name,
lastname,
age,
}
}
fn firstname(&self) -> &str {
&self.name
}
fn lastname(&self) -> &str {
&self.lastname
}
fn age(&self) -> usize {
self.age
}
fn update_age(self, age: usize) -> Self {
Self { age, ..self }
}
fn update_name(self, name: String) -> Self {
Self { name, ..self }
}
fn update_lastname(self, lastname: String) -> Self {
Self { lastname, ..self }
}
}
fn main() {
let lisa = Person::new("Lisa".to_string(), "Simpson".to_string(), 14);
println!("{lisa:?}");
let homer = lisa
.clone()
.rename("Homer".to_string(), "Simpson".to_string());
println!("{homer:?}");
let marge = Person::update_name(homer, "Marge".to_string());
println!("{marge:?}");
let marge_name = Person::firstname(&marge);
println!("{marge_name}");
// composición usando un patrón donde devolvemos la instancia de un objeto y enlazamos llamadas
let maggie = Person::default()
.update_name("Maggie".to_string())
.update_lastname("Simpson".to_string());
println!("{maggie:?}");
// crear funciones on the fly con closures
let update_person_age =
move |person: Person| move |age: usize| Person::modify_age(person, |_| age);
let marge = update_person_age(marge)(30);
println!("{marge:?}");
// es divertido, pero no rusty-style ni fácil de leer
}
Otros ejemplos de lenguajes de programación funcional puros son los siguientes:
Y algunos lenguajes que tienen una o más características de la programación funcional:
Como podéis ver el mundo de la programación funcional es amplio y no es excluyente de ser usado junto con otros enfoques como la POO. La clave está en encontrar el equilibrio entre ambos puntos de vista y usarlos para nuestro beneficio. Así muchas desventajas de la programación funcional (como la curva de aprendizaje) se anulan al combinarlos con lenguajes históricamente orientados a objetos pero que pueden apoyarse en utilidades y patrones funcionales.
No hace falta aprender Haskell (aunque aprenderlo puede ayudar a entender conceptos) para obtener muchos de los beneficios, ya que como hemos visto es posible aplicar el paradigma a lenguajes como C++, Python o JavaScript.
También te puede interesar
Tu pasaporte a sabelotodo de Python. Desde las bases hasta la creación de tu primer programa, este curso...
Si has oído hablar de de RUST y quieres saber más sobre este lenguaje de programación, en este artículo te contamos qué...