« Aprendizaje por refue… « || Inicio || » Programación Funciona… »

Haskell: el Lenguaje Funcional

Última modificación: 14 de Septiembre de 2016, y ha tenido 2614 vistas

Etiquetas utilizadas: || || ||

¿Qué son los lenguajes de programación funcionales? Hemos visto en entradas anteriores (ésta o ésta) que no hay una respuesta sencilla a esta pregunta, pero podríamos intentar resumirlo marcando inicialmente las diferencias con los lenguajes de programación más clásicos, los imperativos. Los programas escritos en lenguajes de programación imperativos (como C, C++, Java, Python, etc.) consisten en una secuencia de acciones que le dicen explícitamente al ordenador cómo realizar una tarea, paso a paso. En cambio, los lenguajes de programación funcionales trabajan de forma diferente, y en lugar de realizar acciones en secuencia, evalúan expresiones. Por supuesto, cada día es más difícil establecer líneas claras de diferenciación entre ambos tipos porque, en los últimos años, tras los éxitos demostrados por el paradigma funcional, vemos que los lenguajes considerados clásicamente como imperativos se enriquecen con propiedades que provienen de éste. Por ello, si estás acostumbrado a programar en las versiones últimas de lenguajes como Python o Java, verás que muchas de las ideas que se muestran a continuación las puedes incorporar (algunas veces con más esfuerzo que otras) sin tener que cambiar de lenguaje.

Y es que, al igual que tras la aparición de paradigmas como el de orientacion a objetos, casi todos los lenguajes fueron incorporando en mayor o menor medida capacidades de estas nuevas ideas, el gran éxito de la programación funcional no radica en su capacidad para hacer que los programadores migren a nuevos lenguajes, sino que los lenguajes antiguos se actualicen para incorporar una nueva forma de resolver problemas por medio de estructuras de programación nuevas y más potentes. Algunos de estos cambios afectan a la estructura en que se dividen los problemas para atacarlos, otros afectan a la forma en que los compiladores e intérpretes actúan en la capa oculta para optimizar el rendimiento cuando ajecutan programas escritos en ellos, otros a la forma en que podemos crear y acceder a las estructuras de datos que nuestros programas van a manipular.

Como continuación de otras entradas en las que se muestran características generales de la programación funcional (que a veces parecen mágicas para los programadores que aprendimos en paradigmas más clásicos), en esta entrada vamos a aterrizar un poco más y ver algunas de las propiedades del gran representante de la programación funcional, Haskell, que se ha convertido (sobre todo en su implementación GHC) en el referente de todo lo que un lenguaje funcional puro debe y puede ser. No te preocupes, no hace falta que sepas Haskell para entender este texto, sigue leyendo y descubre qué novedades puedes encontrar si decides entrar por la senda funcional.

Por cierto, mucho de lo que hay aquí está extraído de Por qué Haskell importa, que a su vez está basado en el famoso artículo Why Functional Programming Matters de John Hughes.

Más que añadir librerías

Cuando se programa, hay tres puntos fundamentales que afectan a los resultados obtenidos:

  1. la capacidad del programador,
  2. el manejo de recursos que proporciona el lenguaje, y
  3. las capacidades de secuenciación que incorpora.

El primero de los puntos no será tratado aquí, y nos situaremos siempre en el deseable (y utópico) contexto de que somos el mejor programador posible, tanto en conocimientos como en interés y capacidad.

Históricamente, el manejo de recursos (es decir, de qué forma el compilador o intérprete aloja registros y gestiona la memoria) ha sido objeto de un profundo análisis y atención, y la mayoría de los lenguajes nuevos (ya sean imperativos o funcionales) implementan sistemas de recolección de basura y de gestión de la memoria que son muy eficientes para minimizar los efectos de las capacidades limitadas de que disponemos en el mundo real, de esta forma, se deja al programador concentrarse en el algoritmo en lugar de tener que estar pendiente de la tarea mecánica de la gestión de memoria. Por supuesto, sigue habiendo aplicaciones, las llamadas de bajo nivel, que requieren de un conocimiento exhaustivo de los recursos de la máquina y de cómo acceder a ellos para extraer el alto rendimiento que estas aplicaciones necesitan, pero incluso estas tareas de más bajo nivel cada vez son más accesibles, y de forma eficiente, con lenguajes que se encargan de la gestión de recursos de forma automática.

Sin embargo, las capacidades de secuenciación que ofrecían los lenguajes, aunque también tuvieron algún proceso de análisis y abstracción, no llegaron al mismo nivel de profundidad, y se consideraban como partes inherentes, y más inamovibles, que la gestión de los recursos. En cierta forma, lo que se ha hecho en los lenguajes imperativos ha sido potenciar el lenguaje por medio de nuevas instrucciones y librerías estándar que facilitan tareas repetitivas. Por ejemplo, la mayoría de los lenguajes imperativos proporcionan comandos especiales para construir varios tipos de bucles ligeramente diferentes entre sí, pero que esencialmente se gestionan de formas muy similares, la existencia de estas variantes no proporciona nuevas herramientas conceptuales para la resolución de problemas (las soluciones que se pueden escribir con ellas son fundamentalmente equivalentes y, por tanto, no afectan a la aparición de nuevos enfoques de resolución). Esta (casi) identificación entre los lenguajes imperativos y la tarea de secuenciar comandos que debe ejecutar el procesador significa que los lenguajes imperativos nunca podŕan elevarse más allá de la tarea básica de secuenciar y, en consecuencia, nunca podrán llegar al mismo nivel de abstracción que presentan los lenguajes funcionales (recordemos aquí que las últimas versiones de muchos lenguajes clásicamente imperativos ofrecen capacidades funcionales, por lo que actualmente se presentan como lenguajes que hibridan ambos paradigmas, consiguiendo algunas ventajas adicionales, pero sin la capacidad inherente que proporcionan los lenguajes funcionales puros... y es que la compatibilidad hacia atrás que deben cumplir para no dejar obsoletos los programas escritos en versiones anteriores del lenguaje es un lastre que cada día pesa más en cualquier desarrollo, sobre todo para aquellos lenguajes de mayor éxito).

Esto no debe resultarnos extraño, ya que ocurre en todas las ramas del conocimiento, por lo que este inmovilismo es fácilmente reconocible en áreas tan supuestamente abiertas a nuevas ideas como son las matemáticas o la física, donde los métodos de resolución no cambian sustancialmente a lo largo del tiempo, y la introducción de nuevos paradigmas para resolver los problemas se ve dificultada por la costumbre de los investigadores de trabajar de formas ya conocidas y valoradas por el resto de la comunidad... exactamente igual a los programadores, que se sienten más cómodos y respaldados si siguen trabajando con herramientas conceptualmente antiguas pero reconocidas por la comunidad en la que se mueven.

Una diferencia fundamental que vemos en los lenguajes funcionales en general, y en Haskell como representante destacado en particular, es que la secuenciación es eliminada, y sólo se preocupa por lo que el programa calcula, no cuándo ni cómo se calcula, lo que genera lenguajes más flexibles y fáciles de usar. De alguna forma, los lenguajes funcionales se presentan como la puerta de entrada para la resolución de un problema (que tendrá una dificultad inherente) sin añadir dificultades adicionales.

¿Pero porqué se llaman funcionales a este tipo de lenguajes?

Pues la razón principal es porque las funciones juegan un rol central en los lenguajes de programación funcional. Las funciones se consideran valores, al mismo nivel que los tipos enteros o cadenas en cualquier lenguaje. Por ello, al igual que es habitual que en todos los lenguajes una función reciba datos de entrada (de tipo entero, flotante, cadena, etc) y devuelva datos (de los mismos tipos), en los lenguajes funcionales una función puede recibir como dato de entrada una función y devolver otra función como salida, que puede ser construida a partir de sus entradas y por operaciones entre funciones, como la composición.

Esta capacidad nos proporciona métodos más potentes para construir y combinar los diversos módulos de los que se compone un programa. Por ejemplo, emulando la forma de operar sobre funciones que habitualmente se usa en matemáticas, podríamos definir una función, derivada, que tomase como entrada una función de números reales, y devolviera otra función que calcula su derivada numérica. A este tipo de funciones que operan entre funciones se les llama funciones de orden superior.

Para ver un ejemplo sencillo, tengamos en cuenta la siguiente función,

numDe p xs = length (filter p xs)

que recibe como datos de entrada un predicado (es decir, una función que devuelve verdadero/falso), p, y una lista de datos, xs. Esencialmente, esta función hace uso de filter (que es una función de orden superior que ya viene definida en el sistema) para filtrar de xs únicamente los elementos que verifican p, y posteriormente cuenta la longitud de la lista filtrada (por medio de la función length, que no es de orden superior, ya que opera sobre datos normales, no sobre funciones).

Así, numDe es una función de orden superior, ya que parte de su funcionalidad es tratar sobre funciones que recibe como argumentos. A partir de ella podemos definir otras funciones, como por ejemplo:

numDePares xs = numDe even xs

que hace uso de la función anterior y del predicado even para contar el número de pares que contiene una lista. Gracias a que estamos trabajando con lenguajes funcionales, podríamos abstraer mejor esta tarea y escribir la función anterior de la siguiente forma, más directa y compacta. Teniendo en cuenta que, en general, la aplicación de funciones trabaja así:

f arg1 arg2 = (f arg1) arg2

es decir, que aplicar una función a dos argumentos realmente lo que hace es aplicar la función al primer argumento, y obtiene una función que recibe un solo parámetro y que se aplica sobre el segundo, podríamos haber escrito directamente.

numDePares = numDe even

Si queremos escribir una función que calcula ahora cuántos elementos positivos tiene una lista, podríamos escribir

numDePositivos = numDe (>=0)

(ya que la función >= se comporta de la siguiente forma: (>= x y) = (x >= y), por tanto (>=y) recibe un dato de entrada, x, y el resultado es cierto si x>=y).

Por supuesto, estos ejemplos son muy sencillos, pero es fácil extrapoler la misma idea para construir funciones más especializadas que hagan tareas mucho más complejas. Por ejemplo, podríamos escribir una función para recorrer un árbol binario auto-balanceado y tomar parte de la funcionalidad como parámetro (por ejemplo, la función de comparación), lo que permitiría recorrer un árbol sobre cualquier tipo de datos, simplemente proporcionando la función de comparación que sea necesaria. De esta forma, nos podemos centrar en el esfuerzo de asegurar que la función general es correcta, y podremos deducir más fácilmente que todas las funciones especializadas a partir de ella serán también correctas (además, ahorramos escritura de código y, consecuentemente, reducimos el número de errores de codificación).

Esta metodología, por supuesto, también es realizable en algunos lenguajes imperativos. Por ejemplo, en algunos lenguajes orientados a objetos se puede proporcionar un objeto comparador para árboles y otras estructuras de datos. La diferencia es que la forma funcional es mucho más intuitiva y elegante, ya que tener que crear un tipo de dato distinto sólo para comparar otros tipos y después tener que pasar un objeto de este tipo como comparador no es una forma muy elegante de hacerlo.

Otra característica central: la ausencia de efectos colaterales

Otro concepto central en los lenguajes funcionales es que el resultado de una función está determinado, únicamente, por su entrada, es decir, no hay efectos colaterales. Pero la pureza de esta idea va más allá de las funciones, en un lenguaje funcional puro (como Haskell) las variables no modifican su valor, son inmutables. Sí, puede sonar extraño que una variable no pueda variar, sobre todo si provenimos de la programación imperativa (donde precisamente la mayor parte del código consiste en cambiar el "contenido" de las variables que intervienen), pero es lo habitual cuando trabajamos con el concepto matemático de variable, que se llama así porque es la parte de la función que puede recibir distintos valores, no porque se modifique durante el cálculo de la función. Veremos que esta inmutabilidad es realmente muy natural y que además ofrece más ventajas que inconvenientes si aprendemos a sacarle partido.

Una variable en un lenguaje como Haskell es un nombre al que se le da un valor; y no, como en los lenguajes imperartivos, una abstracción de algún concepto de bajo nivel como una celda de memoria. Si se ven las variables como atajos para valores (exactamente igual que en matemáticas), tiene sentido que no se permitan las modificaciones de las variables. Por ejemplo, es indeseable que "4 = 5" sea una asignación válida en ningún lenguaje de programación; por lo tanto, es extraño que "x = 4; x = 5" esté permitido a lo largo de una ejecución. En los lenguajes imperativos se introduce el concepto de estado para poder manipular ese cambio temporal de las variables, pero introduce una complejidad que hace practicamente imposible, por ejemplo, validar si el comportamiento de un programa es correcto. Esta inmutabilidad propia de los paradigmas funcionales es lo que se denomina integridad referencial.

Eliminar efectos colaterales, algo que se deduce de esta pureza referencial, permite que las expresiones sean evaluadas en cualquier orden. Una función debe ser absolutamente determinista, y devolver siempre el mismo resultado si recibe la misma entrada, y no hay excepciones. Este determinismo elimina toda una clase de errores que se encuentran comúnmente en programas imperativos. De hecho, se podría argumentar que la mayoría de los errores en sistemas grandes provienen de efectos colaterales (si no son causados directamente por ellos, son causados por un diseño que se basa en el uso de efectos colaterales para obtener sus resultados). Por lo tanto, a priori, la ausencia de estos efectos puede significar que los programas funcionales tienden a tener muchos menos errores que los imperativos. En consecuencia, siempre y cuando seamos capaces de resolver los mismos problemas sin hacer uso de estos efectos, ya habremos ganado con el cambio de paradigma.

Una cuestión de tamaño

Como se puede ver en los ejemplos vistos más arriba, los lenguajes funcionales son más intuitivos y ofrecen más formas y formás más fáciles de hacer las cosas, y como resultado los programas funcionales tienden a ser más cortos (usualmente entre 2 y 10 veces más cortos que sus equivalentes imperativos). Además, la semántica está más cercana al problema que se quiere resolver que en una versión imperativa, lo que facilita verificar la corrección de una función y, como no permiten efectos colaterales, se producen menos errores durante la codificación. Por todo ello, los programas en lenguajes funcionales puros, como Haskell, son más fáciles de escribir, más robustos y más fáciles de mantener.

¿Qué puede ofrecer Haskell al programador?

Antes de continuar debemos aclarar que lo que entendemos por programador debe ser más amplio que la concepción habitual de un técnico que ha aprendido algunas técnicas de programación en diversos lenguajes clásicos. Una visión, más moderna, flexible y potente, pasa por considerar la programación como una herramienta general para la resolución de problemas de todo tipo, independientemente de la especialidad a la que estén adscritos, el programador o los problemas.

Debido a que las matemáticas engloba el conjunto de herramientas que se han ido construyendo para formalizar los métodos generales de resolución de problemas, no debe resultar extraño que, si buscamos un lenguaje de programación potente, éste se inspire fuertemente en teorías matemáticas, haga uso de ellas, y se convierta en una herramienta formal más de esta disciplina. En este sentido, el programador que buscamos se asemeja más al creativo que combina herramientas formales para resolver problemas de manera verificable y eficiente, que al técnico que únicamente proyecta patrones aprendidos para resolver de forma mediocre, aunque profesional, los problemas que se le presentan.

Dicho esto, y habiendo aclarado ya que estamos entrando en una nueva religión, vamos a pasar a ver qué buenas características puede ofrecer Haskell a este nuevo programador. 

Haskell es un lenguaje moderno, de propósito general, desarrollado para incorporar el conocimiento colectivo de la comunidad de programación funcional en un lenguaje elegante, potente y general, en el que queremos destacar las siguientes propiedades principales:

Puro

A diferencia de otros lenguajes de programación funcional Haskell es puro respecto de la integridad referencial. No permite ningún efecto colateral. Probablemente, sea su característica más importante, y lo que le convierte en el referente actual de la programación funcional.

Perezoso

Otra característica de Haskell es que es perezoso (dicho más técnicamente, es no-estricto). Esto significa que no se evalúa nada mientras no sea necesario. Así, por ejemplo, se puede definir una lista infinita de primos (por ejemplo, por recursión) sin caer en un cálculo infinito que bloquea la máquina. Sólo los elementos de esta lista que sean realmente usados serán construidos en ese momento, y es que se debe ver en este caso la definición como un patrón de generación de los elementos que contiene, no como el conjunto en sí mismo, lo que permite dar algunas soluciones muy elegantes para muchos problemas.

A partir de esta capacidad, un patrón típico de resolución de un problema sería definir una lista de todas las posibles soluciones y luego filtrar las que no verifican las condiciones deseadas. La lista resultante contendrá, únicamente, las soluciones válidas. La evaluación perezosa hace que esta operación se haga de forma limpia. Si solo se necesita una solución, simplemente se puede tomar el primer elemento de la lista resultante, y la evaluación perezosa (o no estricta) nos asegurará que nada más será evaluado innecesariamente.

Un Tipo duro

Junto a las propiedades anteriores, Haskell es además fuertemente tipado, lo que significa que es imposible convertir, si no es explícitamente con funciones de conversión, entre tipos de datos distintos, por lo que no podremos convertir sin querer un tipo Double a un tipo Int, o seguir un puntero nulo, lo que conlleva también tener menos errores. Esta característica puede ser dolorosa, por las operaciones adicionales que conlleva, en los raros casos en que uno necesita convertir entre tipos de forma explícita antes de hacer alguna operación, pero en la práctica es algo que sucede tan pocas veces que arrastrar un sistema flexible de tipos puede convertirse más en una molestia que en una ayuda y, de hecho, forzar cada conversión explícitamente puede ayudar a resaltar problemas en el código. En los lenguajes donde se hacen conversiones de forma transparente surgen con frecuencia problemas cuando el compilador no sabe exactamente cuándo hacer las conversiones y cuándo no.

Haskell no es el único lenguaje fuertemente tipado, pero a diferencia de la mayoría de ellos, en Haskell estos tipos son inferidos automáticamente, lo que quiere decir que rara vez se tienen que declarar los tipos que intervienen en las funciones (salvo como método de documentación, que siempre es deseable). Haskell mirará cómo se usan las variables e inferirá qué tipo debería tener la variable, posteriormente hará un chequeo de todos los tipos que intervienen para asegurarse de que todos los tipos inferidos coinciden (en caso de que el programador no haya declarado los tipos que usa, el compilador no dará un error, pero sí lanzará un aviso para que se tenga en cuenta que la verificación de tipos ha sido inferida).

En Python existe la noción de duck typing, que dice "si camina y habla como un pato, entonces es un pato". Podríamos decir que Haskell tiene una mejora de esta idea: si un valor camina y habla como un pato, entonces será considerado un pato a través de la inferencia de tipos; pero, a diferencia de Python, el compilador también atrapará los errores si después intenta comportarse como un mono. El tipado fuerte proporciona beneficios directos, se capturan errores en tiempo de compilación, en vez de en tiempo de ejecución, y además sin las molestias que acarrea esta labor en otros lenguajes. 

Además, Haskell siempre inferirá el tipo más general posible para una variable. Por tanto, si se escribe una función de ordenamiento sin una declaración de tipos explícita, Haskell se asegurará de que la función acepte cualquier otro valor que pueda ser ordenado. Para ello, proporciona un sistema de tipos muy elaborado, y que supera con creces los sistemas de tipos ofrecidos por el resto de lenguajes habituales.

Si intentáramos obtener este resultado con un lenguaje orientado a objetos, para tener polimorfismo tendríamos que usar alguna clase base para después declarar las variables como instancias de subclases de esta clase principal, lo que requiere mucho trabajo extra y declaraciones ridículamente complicadas sólo para declarar la existencia de una variable. Además, habría que realizar muchas conversiones de tipo a través de conversiones explícitas (casts), lo que desde luego no supone una solución ni elegante ni cómoda.

Si se quiere escribir una función polimórfica en uno de estos lenguajes orientados a objetos, probablemente haya que declarar los parámetros como objetos de una clase base global (como Object en Java), que le permite al programador pasar cualquier cosa a la función, incluso objetos que no tiene sentido pasarle. El resultado es que la mayoría de las funciones que se escriben en estos lenguajes no son generales, sólo funcionan con un pequeño puñado de tipos de datos, y pasa el control de errores de tiempo de compilación a tiempo de ejecución, por lo que en sistemas grandes donde algunas de las funcionalidades se usan raramente, estos errores pueden no ser vistos hasta que causen un error fatal en el peor momento posible.

Elegante

Otra propiedad de Haskell que es muy importante, aun cuando no signifique mucho en términos de estabilidad o rendimiento, es la elegancia, lo que significa en pocas palabras que las cosas funcionan como te lo imaginas y además siguen un criterio que se agrada nuestros conceptos estéticos de belleza.

Para ilustrar la elegancia de Haskell vamos a mostrar un pequeño ejemplo haciendo uso del algoritmo QuickSort, que es simple y muy conocido, y mostraremos dos versiones, una escrita en C++ (lenguaje imperativo de alto rendimiento), y otra escrita en Haskell. Ambas versiones sólo van a usar primitivas y estructuras estándar de cada uno de los lenguajes, sin hacer uso de módulos adicionales. Haremos uso de listas en Haskell y de arrays en C++. Ambas versiones se harán polimórficas (algo automático en Haskell, y por medio de templates en C++) y reflejarán el mismo algoritmo recursivo, cuya descripción podría ser:

"Para ordenar una lista, ubica la cabeza entre la lista ordenada de todos los elementos menores a ella y la lista ordenada de todos los elementos mayores a ella".

Debe indicarse que no intenta hacerse una comparación definitiva entre los lenguajes, sino únicamente mostrar la elegancia de Haskell frente a una implementación conocida por muchos programadores.

template <typename T>
void qsort (T *result, T *list, int n)
{
  if (n == 0) return;
  T *smallerList, *largerList;
  smallerList = new T[n];
  largerList = new T[n]; 
  T pivot = list[0];
  int numSmaller=0, numLarger=0; 
  for (int i = 1; i < n; i++)
    if (list[i] < pivot)
       smallerList[numSmaller++] = list[i];
    else 
       largerList[numLarger++] = list[i];

  qsort(smallerList,smallerList,numSmaller); 
  qsort(largerList,largerList,numLarger);

  int pos = 0; 
  for ( int i = 0; i < numSmaller; i++)
      result[pos++] = smallerList[i];

  result[pos++] = pivot;

  for ( int i = 0; i < numLarger; i++)
      result[pos++] = largerList[i];

  delete [] smallerList;
  delete [] largerList;
};

No vamos a explicar este código, sino únicamente ponerlo en comparación con el equivalente en Haskell:

qsort [] = []
qsort (x:xs) = qsort menores ++ [x] ++ qsort mayores
  where menores = filter (<x) xs
        mayores = filter (>=x) xs

Sí explicaremos la solución dada en Haskell. La función se llama qsort y toma una lista como entrada. Debemos tener en cuenta para interpretar el código anterior que en Haskell las funciones definidias por el usuario tienen más precedencia que cualquier otra cosa (salvo que se diga la contrario), por lo que f 5 * 2 = (f 5) * 2. La función se ha definido por lo que se llama ajuste de patrones (pattern matching), lo que significa que el sistema probará el argumento pasado a la función con los patrones de arriba a abajo, y aplicará el primer patrón que se ajuste a los datos recibidos. Por ello, la primera definición se ajusta a [], que en Haskell es la lista vacía (una lista formada por 1, 2 y 3 sería [1,2,3] así que tiene sentido que la lista vacía sean sólo dos corchetes), y cuya ordenación de devolver la lista vacía. El segundo patrón de definición se ajusta a una lista con al menos un elemento (el operador (:) simplemente pone un elemento en la cabeza de una lista, por lo que 0 : [1,2,3] = [0,1,2,3]), el patrón (x:xs) se cumple cuando se recibe una lista con cabeza x y cola xs (que debe ser una lista, ya sea vacía o no), por lo que representa una lista que tiene, al menos, un elemento. Como necesitaremos usar la cabeza de la lista después, podemos extraerla muy elegantemente mediante la expresión del patrón usado (aunque también podríamos haber usado como patrón una sola variable, que representaría la lista completa, y luego usar la función head para obtener la cabeza de la lista, pero es mucho menos elegante ya que se identificaría con cualquier dato recibido, lista o no). En este caso, si tenemos una lista no vacía que empieza por un elemento, x, la lista ordenada completa se consigue ordenando de forma recursiva, por una parte, todos los elementos menores a x y poniéndolos delante de x, y por otra parte, ordenando todos los elementos mayores a x y poniéndolos después de la aparición de x. Para conseguir la concatenación de estas listas ya ordenadas usamos el operador ++. Como el elemento x no es una lista, el operador ++ no funciona sobre él, por lo que lo convertimos en lista unitaria poniéndolo entre corchetes (es sencillo, [x] es la lista formada por x). ¿Cómo se calculan las listas menores y mayores? Debido a que no importa la secuenciación en Haskell, se definen debajo de la función usando la notación where (que permite usar los parámetros que recibe la función que estamos definiendo en sus definiciones). Por lo demás, se parece mucho a las funciones definidas al principio de esta entrada, por lo que no daremos más detalles.

Observa que, aunque ambas implementaciones siguen fielmente la descripción informal del algoritmo recursivo que vimos anteriormente,  la definición formal de la función de Haskell se asemeja mucho mucho más fielmente al algoritmo original, siendo casi una reescritura del mismo usando un lenguaje formal... exactamente lo que ha estado haciendo el lenguaje de las matemáticas desde hace siglos. Por esto, decimos que Haskell tiene una brecha semántica menor que otros lenguajes.

Modular

Un concepto central en informática es la modularidad. En el artículo Why Functional Programming Matters, John Hughes dice:

Los lenguajes que intenten mejorar la productividad deben permitir la programación modular. Pero no son suficientes nuevas reglas y mecanismos de compilación, la modularidad implica algo más que módulos. Nuestra capacidad para descomponer problemas en partes depende directamente de nuestra capacidad para unir posteriormente las soluciones de cada parte. Por ello, para facilitar la programación modular, un lenguaje debe proporcionar un buen pegamento. Los lenguajes de programación funcional proporcionan dos nuevos tipos de pegamento: las funciones de orden superior y la evaluación perezosa.

Veloz

Algunos programadores de C++ pueden afirmar que la versión de QuickSort en C++ es probablemente un poco más rápida que la versión en Haskell, y puede ser cierto. Sin embargo, para la mayoría de las aplicaciones esta diferencia de velocidad es tan pequeña que llega a ser insignificante. Por ejemplo, en Computer Language Shootout se muestran algunas comparativas en las que Haskell presenta resultados favorables respecto a la mayoría de los lenguajes considerados rápidos. Aunque estas comparativas no prueban nada sobre los rendimientos en el mundo real, sí muestran que Haskell no es tan lento como algunas personas piensan. Actualmente, está de media en la 2ª posición, sólo ligeramente detrás de C (con C++ bastante más lejos).

A estos buenos resultados debemos añadir algo quizás de mayor importancia. Hay una vieja regla empírica en programación llamada la regla del 80/20, que dice que el 80% del tiempo se gasta en el 20% del código. La consecuencia de esta regla es que una función cualquiera de un sistema tiene una importancia mínima cuando se trata de optimizar la velocidad, y suele haber un pequeño conjunto de funciones que sí son suficientemente significativas a la hora de optimizar los resultados globales. Y si estamos especialmente preocupados por la velocidad, siempre se podría escribir este pequeño conjunto de funciones en C (usando la excelente interfaz para funciones foráneas de Haskell) y se utilizan desde el resto de funciones escritas en Haskell.

Como norma general, es preferible avanzar en niveles de abstracción más altos, como muestra Haskell, con el fin de cambiar la velocidad de las aplicaciones por mejoras en la productividad, estabilidad y mantenibilidad. Como el tiempo de los programadores es casi siempre más caro que el tiempo de CPU, no se escriben más aplicaciones en ensamblador por la misma razón por la que no deberíamos escribir más aplicaciones en C, simplemente requieren mucho más tiempo para su generación y (las pocas veces que es posible) verificación.

Finalmente, debe tenerse en cuenta que puede dar más beneficios optimizar algoritmos que optimizar el código. Si se puede desarrollar una aplicación en Haskell en la décima parte de lo que tomaría desarrollarla en C (la experiencia dice que es así), se puede utilizar el tiempo ganado en el desarrollo para analizar e implementar nuevos algoritmos que hagan que la solución completa sea mucho más robusta y potente. Por ello, en el "mundo real", donde no tenemos infinita cantidad de tiempo para programar nuestras aplicaciones, programar en Haskell suele ser a menudo mucho más beneficioso que programar en C.

Resumen

A partir de todo lo que hemos leído podemos resumir las características de Haskell de la siguiente forma:

  • Puro. No hay efectos colaterales.
  • Fuertemente tipado. No puede haber uso dudoso de tipos.
  • Conciso. Los programas son mucho más cortos, lo que permite entenderlos y verificarlos con más facilidad.
  • Alto nivel. Los programas de Haskell casi siempre se escriben exactamente igual (o muy cercanos) a la descripción informal del algoritmo, lo que facilita verificar que la función hace lo que el algoritmo dice. Al codificar en un nivel de abstracción superior, dejando los detalles para el compilador, se deja menos espacio para que se cuelen errores.
  • Gestión de Memoria. No hay que preocuparse por los punteros, el Garbage Collector de Haskell se ocupa de todo, y lo hace de una forma extremadamente eficiente (tanto, que su funcionamiento está siendo copiado en los administradores de memoria de muchos otros compiladores de otros lenguajes). El programador debe preocuparse únicamente de la implementación del algoritmo, no de cómo gestionar la memoria.
  • Modular. Haskell ofrece más formas, y más potentes, de combinar programas a partir de módulos ya desarrollados. De esta manera, se generan programas más modulares. Desde el punto de vista de la corrección, es un punto muy positivo, ya que frecuentemente se puede probar por inducción la correción de funciones modulares (si la combinación es correcta, combinar dos funciones correctas da como resultado una función correcta).

Pero hay un detalle más, la mayoría de las personas que trabajan con lenguajes funcionales concuerdan en que se acaba pensando de forma distinta al resolver problemas en un lenguaje funcional, el problema se subdivide en funciones más y más pequeñas (fácilmente verificables), y luego se combinan de varias maneras para obtener el resultado final.

Entonces, todo esto nos debe llevar a hacernos una pregunta fundamental: ¿por qué no es tan popular Haskell como otros lenguajes de programación?

En primer lugar, si el sistema operativo está escrito en C (o en algún otro lenguaje de programación imperativo) es muy probable que sea más fácil utilizar en una primera aproximación un lenguaje imperativo para interactuar con él, aunque esta decisión acarree problemas interminables más adelante.

Además, otra posible razón es que los lenguajes de programación muy raramente se consideran herramientas intercambiables (a pesar de que lo son). Para la mayoría de la gente su lenguaje de programación preferido es similar a una religión; es difícil creer que puede existir otro lenguaje mejor y más rápido. Paul Graham escribió un documento llamado Ganándole a los promedios en el que cuenta sus experiencias utilizando Lisp (otro lenguaje de programación funcional) en una empresa nueva. Graham utiliza una analogía que llama La paradoja de Blub: suponiendo que el lenguaje de programación preferido de una persona sea "Blub" (un lenguaje de programación ficticio de potencia media), esta persona generalmente sólo podrá identificar lenguajes de programación con menor potencia que "Blub". El programador entonces examina COBOL y piensa "¿como puede alguien programar en COBOL, si no soporta X?" (tomando como X un característica que sí está presente en "Blub"). Sin embargo, esta persona tiene dificultades para juzgar lenguajes que están por encima de "Blub" en la escala. Si examina estos lenguajes, le parecerán "raros" porque el programador está "pensando en Blub" y no tiene posibilidad de comprender características avanzadas de lenguajes de programación más poderosos. Lo que lleva a la conclusión de que para poder comparar todos los lenguajes uno debe estar en la cima de la escala. Los lenguajes funcionales, casi por definición, están más cerca de la cima que los imperativos, lo que limita el campo de visión de un programador más convencional.

En definitiva, Haskell no se utiliza más a menudo porque la gente siente que "su" lenguaje hace "todo lo necesario"; y no se equivocan, ya que están pensando en "Blub". Pero los lenguajes de programación no son solamente tecnología, también son una forma de pensar; es muy difícil comprender la utilidad de Haskell si no se piensa en Haskell.

« Aprendizaje por refue… « || Inicio || » Programación Funciona… »