Lenguajes de Programación

Qué es la programación funcional y sus características

En este post abordamos el paradigma de la programación funcional, explicando sus características, ventajas y desventajas y lenguajes que lo implementan.

Publicado el 09 de Septiembre de 2022
Compartir

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:

  • Una cinta infinita que iremos modificando (estado).
  • Una lista de instrucciones que se ejecutarán y modificarán el estado (programa).

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:

  • Variables.
  • Funciones.
  • Aplicación de funciones.

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.

Conviértete en un Backend Developer
Domina los lenguajes de programación más demandados. Accede a cursos, talleres y laboratorios para crear proyectos con Java, Python, PHP, Microsoft .NET y más
Comenzar gratis ahora

Qué es la programación funcional

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.

Características de la programación funcional

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:

  1. No hay estado global.
  2. Todas las funciones son puras: Dado un mismo input siempre devolvemos el mismo output.
  3. Todos los valores son inmutables: Lo único que podemos hacer es generar nuevos valores.
  4. No hay bucles: La iteración se realiza usando recursividad.

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).

Imagen 0 en Qué es la programación funcional y sus características

Programación funcional vs orientada a objetos

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:

  1. Crear personas.
  2. Modificar el nombre y el apellido.
  3. Hacer que la persona cumpla años.

Para simplificar a la hora de resolver este problema con programación funcional, vamos a definir algunas premisas:

  1. La información se almacena en variables inmutables.
  2. Solo podemos usar funciones unarias (de un solo parámetro).
  3. La iteración se implementa con recursividad.
  4. Es posibles utilizar estructuras de control (como ifs) siempre y cuando estas sean expresiones (evaluables a un valor).
  5. Se pueden usar estructuras de datos (como objetos) siempre que solo guarden información de dominio y no comportamiento.
  6. Todas las funciones han de ser puras. Esto es, una función siempre ha de generar el mismo valor dado el mismo parámetro.

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:

  1. Encapsulación.
  2. Comunicación de objetos mediante mensajes.
  3. Enlaces tardíos.

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.

Ventajas y desventajas de la programación funcional

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:

  • Pureza. En algunos lenguajes está implementada por defecto, de tal forma que no se permiten expresiones con side-effects.
  • Inmutabilidad. Al no permitir modificar nada todo se vuelve más robusto, safe-thread y determinista. No hay estados globales mutables.
  • Evaluación perezosa. No se evalúa nada hasta que se requiere el valor. Habilita la posibilidad de usar estructuras de datos infinitas.
  • La composición es más natural de entender. Es más fácil de entender un coche con comportamiento de movimiento que un coche herede de vehículo motorizado. Puede que tenga sentido en código, pero en la vida real los objetos inanimados no pueden heredar características de otros directamente.
  • El testing es más sencillo. Al ser todo puro e inmutable el test unitario es muy sencillo de hacer.

En contraposición, los principales inconvenientes que se suelen asociar al modelo de programación funcional son los siguientes:

  • Curva de aprendizaje elevada. Sobre todo, si se viene de POO. El cambio de enfoque cuesta.
  • Los lenguajes puramente funcionales suelen requerir recursividad. Es fácil de usar, pero difícil de entender.
  • Los ordenadores no se basan en un enfoque funcional. Al implementar el modelo de Neumann que se base en tener un estado que se va mutando a través de instrucciones.
  • Mayor dificultad para pensar en tener pequeñas funciones que después vamos combinando.
  • En lenguajes puramente NO funcionales, la optimización puede ser mala.

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.

Lenguajes funcionales

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 
}

Mejora las habilidades de tus desarrolladores
Acelera la formación tecnológica de tus equipos con OpenWebinars. Desarrolla tu estrategia de atracción, fidelización y crecimiento de tus profesionales con el menor esfuerzo.
Solicitar más información

Otros ejemplos de lenguajes de programación funcional puros son los siguientes:

  • Haskell
  • Lisp/Scheme
  • Scala
  • Elm
  • Elixir

Y algunos lenguajes que tienen una o más características de la programación funcional:

  • Python
  • C++
  • JavaScript
  • Rust

Conclusión

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.


Compartir este post

También te puede interesar...

Python 3

Curso de Python: Aprende a programar en Python 3

10 horas y 16 minutos · Curso

Tu pasaporte a sabelotodo de Python. Desde las bases hasta la creación de tu primer programa, este curso potenciará tu comprensión de uno de los …

  • Lenguajes de programación
Tecnología

Qué es Rust

17 Febrero 2020 Yanina Muradas
Artículos
Ver todos