Este es el primer tutorial de la serie Introducción a la Programación Funcional. A lo largo del curso, se concocerán los fundamentos de dicho paradigma y, al usar Haskell como nuestro lenguaje, obtendremos las bases para usar Haskell en producción o el conocimiento necesario para usar librerías funcionales en lenguajes tradicionales (Java, C#, Javascript, etc.) y ser prolífico en lenguajes más funcionales como F#, Scala, etcétera. Los temas que se abarcarán en el curso son:
- Un primer vistazo a Haskell
- Funciones y tipos de datos algebraicos
- Algo sobre listas y todo sobre funciones
- Azúcar sintáctico y otros artilugios
- Clases de tipos
- Mónadas
- Testeo
En cursos subsecuentes de Haskell, iremos más allá de las bases y abordaremos todo lo necesario para usar Haskell en producción; desde crear APIs, páginas web dinámicas, multithreading hasta hacer simulaciones sobre un modelo matemático.
En cursos subsequentes de programación funcional, veremos como podemos usar otros lenguajes funcionales como F# para crear aplicaciones móviles nativas.
Si no tienes mucha experiencia con la programación funcional, tienes mucho que aprender y por lo tanto tomaré como responsabilidad ser lo más sucinto posible. Mi objetivo es que puedas trabajar expresándote de manera formal y elegante.
Si algo no queda del todo claro al leerlo, seguramente es por diseño y será aclarado más adelante en el mismo tutorial o en tutoriales siguientes. Si crees que algo no está bien, puedes mandarme un correo a [email protected].
Para realizar los ejercicios, puedes crear una cuenta en fpcomplete y así poder ir utilizando los templates preparados para los tutoriales.
Reconomiento
La mayoría de la información fue tomada de documentos publicados en Internet y de algunos libros: The Haskell School Of Expression por Paul Hudak y Automata, Computability and Complexity: Theory and Applications por Elaine Rich. Mi labor ha sido simplemente la de recabar y sintetizar la información; quizás lo único que hacemos todos en realidad. Otras cosas las recuerdo de mis tiempos de estudiante en el ITESM; particularmente la clase de Lenguajes de Programación, con Santiago Conant y Matemáticas Discretas, con Alejandra González.
¿Por qué estudiar programación funcional pura?
¿Qué es ser funcional?
Un programa es algo que recibe una entrada y computa una salida. Por ejemplo, un programa que calcula la edad promedio de una familia recibiría como entrada los datos de la familia y produciría como salida la edad promedio.
Este sencillo programa se puede modelar como una función avg
en Haskell. Si modelamos la edad como un entero (Int
), la función tendría como entrada una lista de edades ([Int]
) y como salida una edad promedio (Int
). La signatura de esta función sería avg :: [Int] -> Int
.
Un lenguaje funcional puro es aquel cuyas aplicaciones son una composición de funciones puras. La interacción entre el software y el mundo real se hace entonces, en el caso de Haskell, mediante el uso del I/O monad (IO ()
) que veremos a detalle más adelante en el tutorial dedicado a los mónadas.
En Haskell, la ejecución de un programa es la ejecución de su función main :: IO ()
y todas las subllamadas que deriven de esta. La función main es de tipo IO ()
y por lo tanto puede interactuar con el mundo real (base de datos, interfáz humano-computadora, etc.).
¿Qué es ser puro?
Un lenguaje puro (pure) es aquel cuyas expresiones
- siempre se evaluan al mismo resultado dados los mismos argumentos. Es decir, su resultado no depende de un entorno global, e.g. variables estáticas.
- no causan efectos secundarios al ser evaluadas.
- son inmutables, o sea, que el significado de una expresion no puede cambiar en tiempo de ejecución.
Esto nos obliga a separar claramente el estado del comportamiento, lo cual es bueno. Por otro lado, mantener el estado de manera eficiente es el principal reto de programar en Haskell, pero hay librerias que nos harán el trabajo sencillo.
¿Qué es ser fuertemente tipado?
Un lenguaje fuertemente tipado (strongly typed) es aquel cuyos tipos no cambian en tiempo de ejecución. Esto implica que con sólo ver los tipos de una función, ya podemos saber mucho de su intención.
En Haskell, declarar los tipos de una función es casi siempre opcional, pues el compilador (al menos GHC) inferirá todos los tipos posibles de inferir. Esto nos ahorrará mucha escritura sin perder los beneficios de un lenguaje fuertemente tipado. A partir de ahora, omitiré la declaracón de la signatura (declaración de tipos o "firma") de las funciones y valores cuando sea apropiado.
Ser fuertemente tipado nos evita errores de tipo en tiempo de ejecución pues el compilador los detecta en tiempo de compilación. A continuación, declararemos una funcion que recibe un Int
, pero le pasaremos un String
para provocar un type error en tiempo de compilación.
increaseOne :: Int -> Int
increaseOne x = x + 1
main = do
putStr "increaseOne \"¡Hola!\" = "
putStrLn (show (increaseOne "¡Hola!"))
>El error mencionará un problema con un [Char]
; El tipo String
en Haskell es un sinónimo para una lista de caracteres o en otras palabras, en Haskell, String
y [Char]
son sinónimos.
Siempre es mejor detectar un error en nuestro código en tiempo de compilación que en tiempo de ejecución.
¿Qué es ser flojo?
Un lenguaje flojo (lazy) es aquel cuyas expresiones no son evaluadas hasta ser necesario. Esto implica que podemos pasar funciones en los parámetros y estas podrían nunca ser evaluadas.
En este ejemplo, los parámetros first y second sólo se evaluan cuando es necesario. Cuando llamamos la función f
con el tercer parámetro {True
|False
}, el {primer|segundo} parámetro es devuelto y el
{segundo|primero} nunca es evaluado. Esto nos permite componer funciones de manera eficiente.
f first second True = first
f first second False = second
main =
do
putStr "f (putStrLn \"hello\") (putStrLn \"goodbye\") True = "
f (putStrLn "hello") (putStrLn "goodbye") True
putStr "f (putStrLn \"hello\") (putStrLn \"goodbye\") False = "
f (putStrLn "hello") (putStrLn "goodbye") False
Ser flojo también facilita trabajar con Streams, es decir, series infinitas de datos, pues, al ser flojo, se pueden expresar datos infinitos sin tener que calcularlos. Por ejemplo, podemos expresar una lista infinita de enteros y sólo usar los primeros 3 elementos:
infinite_list = [1, 2 ..] -- [1, 2, 3, etc...]
main = putStrLn (show (take 3 infinite_list))
Si Haskell no fuera flojo, antes de usar la lista se tendría que calcular toda esta y, al ser esta infinita, tomaría una infinidad de tiempo.
¿Cómo es la abstracción en un lenguaje funcional?
Básicamente, la abstracción consiste en parametrizar una función. A la función increaseOne
se le puede parametrizar el valor 1
y llamarse entonces increaseN
, como lo haremos a continuación.
{-
Antes:
increaseOne :: Int -> Int
increaseOne x = x + 1
-}
increaseN :: Int -> Int -> Int
increaseN x n = x + n
main =
do
putStr "increaseN 1 2 = "
putStrLn (show (increaseN 1 2))
Podemos notar que ahora la función increaseN
recibe dos argumentos en vez de uno y por lo tanto el tipo de la función cambió de ser Int -> Int
(recibe un Int
y produce un Int
) a Int -> Int -> Int
(recibe dos Int
s y produce un Int
).
Técnicamente, todas las funciones en Haskell reciben un solo argumento y producen un solo valor, ya sea otra función o un valor sencillo. En el siguiente tutorial veremos el tema de partial application con más detalle; para leer más, visita Haskel.org - Partial application.
Para continuar con el proceso de abstracción, podríamos abstraer la suma de la función increaseN
y llamarla binOpApp
(aplicación de un operador binario o en inglés "binary operator application") de la siguiente manera.
binOpApp :: Int -> Int -> (Int -> Int -> Int) -> Int
binOpApp x y binOp = binOp x y
main =
do
putStr "binopApp 1 2 (+) = "
putStrLn (show (binOpApp 1 2 (+)))
putStr "binOpApp 1 2 (-) = "
putStrLn (show (binOpApp 1 2 (-)))
Lo más destacable es que el tipo de la función binOpApp
nos indica que su tercer parámetro es una funcion que recibe dos Int
s y produce un Int
.
¿Cómo es la refactorización en un lenguaje funcional?
Existen varias refactorizaciones válidas (semantic preserving) en Haskell. Algunos ejemplos son:
Movimiento del orden de parámetros. Podemos modificar
binOpApp
para quebinOp
sea su primer parámetro.
binOpApp :: (Int -> Int -> Int) -> Int -> Int -> Int
binOpApp binOp x y = binOp x y
main =
do
putStr "binOpApp (+) 1 2 = "
putStrLn (show (binOpApp (+) 1 2))
putStr "binOpApp (-) 1 2 = "
putStrLn (show (binOpApp (-) 1 2))
Después podemos hacer eta reduction al remover la
x
y lay
en ambos lados:
binOpApp :: (Int -> Int -> Int) -> Int -> Int -> Int
binOpApp binOp = binOp
main =
do
putStr "binOpApp (+) 1 2 = "
putStrLn (show (binOpApp (+) 1 2))
putStr "binOpApp (-) 1 2 = "
putStrLn (show (binOpApp (-) 1 2))
En esta definición de
binOpApp
, la signatura de su tipo es crucial para su funcionamiento; si no fuera por la signatura, podría ser ambiguo el número de parámetros que recibe y por lo tanto su significado dependería de comobinOpApp
sea usada y de lo que el compilador infiera de dicho uso. Al menos en este ejemplo, el código funciona sin la signatura debinOpApp
.
Ahora debe de quedar claro que binOpApp
es completamente redundante y que se ha utilizado simplemente como herramienta pedagógica.
Otras refactorizaciones (más no todas) son:
- la introducción de lambdas (funciones anónimas) (lambda abstraction)
- renombramiento de variables (alpha conversion)
- la aplicación de un valor a una función (beta reduction)
Lo especial de Haskell es que su sistema de tipos nos facilita mucho las refactorizaciones por detectar muchos errores en tiempo de compilación; algunos dicen que dado un programa que funciona, una refactorización que compila probablemente sea correcta. Otros simplemente dicen que si el código compila, probablemente funciona. Why Haskell just works.