viernes, 20 de marzo de 2015

El boom de la Programación Funcional (parte 3/3)

Después de contar cómo fue mi primer acercamiento con la programación funcional y lo importante que me parece aprender algo al respecto (parte 1), y por qué siendo un paradigma tan viejo ha tomado nueva fuerza en los últimos años gracias al auge de la programación paralela principalmente (parte 2), hoy cierro contando muy básicamente cómo se lleva esto a mi lenguaje favorito, C++, que desde 2011 ha facilitado mucho las cosas en este sentido.

Partamos de dos ideas básicas. Primero, recordemos que una función "pura" es una función cuyos resultados dependen únicamente de los argumentos que recibe, y nada más. Es decir, que no usa variables globales, ni estáticas, ni nada que le permita guardar, cambiar, o consultar algún estado externo, algo que no esté explícito en los argumentos. Y además, no genera "side-effects". Esto es, solo calcula y retorna su resultado, pero no modifica nada más fuera de ella, nada compartido con ninguna otra parte del código. Por ejemplo, que no modifica variables globales. Estas premisas son las que facilitan tanto el análisis riguroso (digamos matemático) de los programas funcionales, las que evitan sorpresas, y las que nos permiten usar y componer esas funciones en programas paralelos sin preocuparnos. Por ejemplo, la función "std::rand" utiliza un estado interno (la semilla, ¿les suena "srand(time(NULL))"?), que no está entre sus argumentos, por lo cual no es obvio al leer o escribir una llamada a esta función, y sus efectos tampoco. Peor aún, en cada llamada lo modifica (side-effect), hecho que tampoco es obvio, y complica su uso en programas paralelos.

La segunda idea básica es que para que un lenguaje permita hacer programación funcional, las funciones deben ser ciudadanos de primera clase. Es decir, el programa debe permitir, crear, copiar, asignar, etc... en tiempo de ejecución. Todo lo que hacemos con cualquier otro tipo de dato, pero la clave está en lo de "en tiempo de ejecución". C++ permite desde que nació la creación de funciones, pero no en tiempo de ejecución... es decir, no aparecen funciones nuevas mientras el programa se está ejecutando. ¿O si? Vemos un ejemplo: quiero que el usuario ingrese un entero para luego eliminar de un vector todos los elementos mayores a ese valor. El algoritmo std::remove_if sirve para esto, recibe dos iteradores diciéndole dónde buscar, y una función que usará para saber cuáles eliminar... Entonces, todo lo que hay que hacer es crear una función que reciba un entero (un elemento del vector) y diga si es o no mayor al que ingresó el usuario, que será fijo para todo el vector, y por ello no será argumento de la función. Pero entonces no podemos crear la función hasta no saber qué ingresó el usuario, y eso es en tiempo de ejecución. A menos que...

    vector<int> v = {.......};
    int valor_usuario; 
    cin >> valor_usuario;
    struct Funcion { // magic?
       int valor_fijo;
       Funcion(int x) : valor_fijo(x) {}
       bool operator() (int vi) { return vi>valor_fijo; }
    };
    Funcion func(valor_usuario);
    v.erase( remove_if( v.begin(),v.end(),func ), v.end() );

...a menos que hagamos un functor (o function object). Esto es, un objeto (clase o struct) que tiene el operator() sobrecargado, de forma que puede utilizarse como si fuera una función. Y este objeto puede guardar en sus atributos el valor de referencia que ingresó el usuario, para no tener que recibirlo como argumento cuando haga de función. Todo esto puede hacerse sin problemas desde C++98, solo requiere un detalle importante de parte del algoritmo remove_if. Y es que el algoritmo no reciba una función en su tercer argumento (puntero a función en realidad), sino algo genérico (templates). ¿Porqué?, pues porque un puntero a función obliga a recibir una función, y un functor se parece a una función, se puede invocar como si fuera una función, huele a función, y sabe a función, pero no es una verdadera función para el compilador, sino un objeto, que tiene su propio tipo. Entonces, remove_if debe recibir algo de un tipo genérico (y de hecho lo hace), con la única condición de que pueda usarse "como si fuera" una función con el prototipo esperado (que reciba un int y retorne bool, sino no compila).

El truco es suficiente como para crear estos objetos [ya no tan] mágicos, que dan la ilusión de ser funciones. Es decir, podemos crear la ilusión de estar creando funciones en tiempo de ejecución (una nueva para cada valor_usuario ingresado). Bueno, para que la ilusión sea todavía mejor y no se pinche la burbuja, C++11 agregó una nueva sintaxis para crear estos objetos especiales mucho más fácilmente, en una línea y sin ponerles siquiera nombre. Además, para facilitar su asignación, nos viene genial la palabra auto (que sirve para usar en lugar del tipo al declarar una variable, y dejar entonces que el compilador decida solo el tipo que corresponde, que como acabo de decir, ni tiene nombre). También flexibilizó muchísimo el sistema de templates (no nos vamos a meter en tanto, pero hay muchas cosas nuevas), y agregó en la biblioteca estándar varias plantillas que pueden ser útiles para esto de la programación funcional (ver std::function, std::bind, por ejemplo).

Volviendo al ejemplo, el código ahora luciría más o menos así:
    vector<int> v = {.......};
    int valor_usuario; 
    cin >> valor_usuario;
    auto func = [](int vi){return >valor_usuario;};
    v.erase( remove_if( v.begin(),v.end(),func ), v.end() );

La función se crea al vuelo y su tipo no tiene nombre. Comienza con la lista de variables que vamos a tomar del scope actual para usar en la función (en este caso valor_usuario, pero no decimos nada, "[]", y así nos deja tomar todo, que el compilador adivine). Luego los argumentos (un entero, que será cada valor del vector, lo enviará remove_if), y luego el cuerpo de la función. No hace falta aclarar el tipo de dato que retorna, el compilador lo puede deducir del return. Lo interesante es que ahora da muchísimo menos trabajo crear, asignar, copiar, etc-ar functores, de forma que ya no parecen functores, sino que al ver el código parecen verdaderas funciones "lambda" (así se las conoce en varios lenguajes). La ilusión es mucho más perfecta, y al eliminar toda la parte tediosa el truco se vuelve mucho más simple, útil, y elegante. Como verán no es algo 100% nuevo, con mucho trabajo, esto de las funciones lambda se podía emular en la mayoría de los casos aún antes de C++11. Pero cuando las vi por primera vez pensé que eran mágicas, me llevó un buen rato establecer la relación. Y ese "pensé que eran mágicas" es muy importante. La "ilusión" de la que hablo pesa mucho, y realmente nos facilita las cosas, no solo a nivel de implementación, sino de diseño y análisis también, que es más importante. No olviden que el ejemplo que puse es "de juguete", en la vida real hay casos mucho más complejos e interesantes que antes resultaban impensables.

Si unimos entonces inteligentemente esta posibilidad de hacer cosas en tiempo de ejecución con las funciones, con los conceptos básicos de la programación funcional, podemos de a poco empezar a explorar ese nuevo terreno que nos permitirá generar mejores códigos (más limpios, fáciles de mantener, seguros, etc) y aplicar nuevos patrones de diseño. Pero, ¿estamos haciendo programación funcional? Sí, y no. Sí, porque estamos usando y creando nuevas funciones en tiempo de ejecución, son puras, las pasamos como argumentos a otras funciones, etc, etc, etc. Pero también es orientada a objetos, está la clase vector por ejemplo, con sus atributos, constructores, destructores, y también iterator... Pero es además genérica, hay templates por todos lados. ¿Quién da más? Solo podemos concluir que los paradigmas no son mutuamente excluyentes (vean este excelente post al respecto). Por si todavía no lo saben, lo que estamos haciendo es programación fungenoop. Y eso es algo que me encanta de C++: que me permite combinar todos los paradigmas, sin atarme a ninguno, y sacar lo mejor (o lo peor, dependerá de mi) de cada mundo.

1 comentario:

  1. Hola! Tengo una pregunta que seguramente va a quedar sin contestar porque la entrada es de hace 5 años, pero el paradigma funcional me interesa muchísimo como para no hacerla.

    ¿Tiene alguna opinión formada sobre otros lenguajes del paradigma, tales como Haskell o Clojure?

    P.S.: Actualmente soy alumno de usted, por timidez nomás me mantengo anónimo. Quería decirle que su blog es genial, me divierte muchísimo, y hasta siento que complementa las clases.

    P.S. II: Voy a ver si puedo hacer mi propio interprete de Lisp! :P

    ResponderEliminar