Una Introducción agradable a Haskell
anterior siguiente inicio


7  Entrada/Salida

El sistema de entrada/salida (E/S) de Haskell es funcional puro, y además, proporciona toda la expresividad que presentan los lenguajes de programación imperativos convencionales. En los lenguajes imperativos, los programas están formados por acciones que examinan y modifican el estado actual del sistema. Algunas acciones típicas son la lectura y modificación de variables globales, la escritura en ficheros, la lectura de datos y el manejo de ventanas en entornos gráficos. Haskell permite utilizar acciones de este tipo, aunque están claramente separadas del núcleo puramente funcional del lenguaje.

El sistema de E/S de Haskell está basado en un fundamento matemático que puede asustar a primera vista: las mónadas. Sin embargo, no es necesario conocer la teoría de mónadas subyacente para programar usando el sistema de E/S. Más bien, las mónadas son simplemente una estructura conceptual en las que las E/S encaja. No es necesario conocer la teoría de mónadas para trabajar con operaciones de E/S en Haskell del mismo modo que no es necesario conocer la teoría de grupos para trabajar con operaciones aritméticas simples. Una explicación en profundidad de las mónadas puede encontrarse en la sección 9.

Los operadores monádicos en los que el sistema de E/S está basado son usados también para otros propósitos; profundizaremos en las mónadas posteriormente. Por ahora, evitaremos el término mónada y nos concentraremos en el uso del sistema de E/S. Es mejor pensar en la mónada de E/S como un tipo abstracto.

Las acciones son definidas, pero no invocadas, al nivel de la máquina de evaluación que dirige la ejecución de un programa Haskell. La evaluación de la definición de una acción no hace que la acción sea realizada. Más bien, la ejecución de acciones es efectuada en un nivel distinto a la evaluación de expresiones que hemos considerado hasta este momento.

Las acciones son atómicas, como el caso de las definidas como primitivas del sistema, o se obtienen de la composición secuencial de otras acciones. La mónada de E/S contiene primitivas que permiten construir acciones compuestas, un proceso similar al que realizan algunos lenguajes imperativos al unir sentencias de un modo secuencial usando ";". Esta mónada actúa como un pegamento que une las acciones que forman parte de un programa.

7.1  Operaciones de E/S básicas

Cada acción de E/S devuelve un valor. A nivel del sistema de tipos, el valor devuelto es anotado con un tipo IO. Esto distingue las acciones de otros valores. Por ejemplo, el tipo de la función getChar es:

getChar    ::   IO Char

El tipo IO Char indica que getChar, cuando sea ejecutado, realizará una acción que devolverá un carácter. Las acciones cuyo valor devuelto no es interesante usan el tipo unitario (). Por ejemplo, la función putChar:

putChar    ::    Char -> IO ()

toma un carácter como argumento pero no devuelve nada útil. El tipo unitario es similar al tipo void en otros lenguajes.

Las acciones son combinadas secuencialmente usando un operador cuyo nombre es un tanto críptico: >>= (o "bind"). En vez de usar este operador directamente, usaremos una sintáxis más clara, la notación do, para ocultar el uso de los operadores de secuenciación gracias al uso de una sintaxis que recuerda a la de los lenguajes imperativos convencionales. La notación do puede ser traducida, de un modo trivial, a funciones Haskell normales, como se describe en §3.14.

La palabra clave do inicia una secuencia de sentencias que serán ejecutadas en orden. Una sentencia es o bien una acción, o un patrón asociado al resultado de una acción usando <-, o una definición local definida mediante let. La notación do usa la indentación del programa del mismo modo que las definiciones locales introducidas con let o where, de modo que podemos omitir las llaves y los puntos y coma usando un sangrado adecuado. El siguiente ejemplo es un programa que lee y escribe un carácter:

main :: IO ()
main = do c <- getChar
          putChar c

El uso del nombre main es importante: main es la expresión principal de un programa Haskell (de un modo similar a la función main de un programa en C), y debe tener un tipo IOm, usualmente IO (). (El nombre main es especial tan solo en el módulo Main; hablaremos de los módulos posteriormente). Este programa ejecuta dos acciones en secuencia: primero lee un carácter del teclado, ligando el resultado con la variable c, y a continuación imprime el carácter. A diferencia de las expresiones let para las que las variables introducidas están en el ámbito de todas las definiciones, las variables definidas con <- solo están en el ámbito de las sentencias posteriores.

Todavía falta algo. Podemos ejecutar acciones y examinar sus resultados usando la notación do, pero ¿cómo devolvemos un valor desde una secuencia de acciones? Por ejemplo, considérese la función ready que lee un carácter y devuelve True si dicho carácter es `y':

ready   :: IO Bool
ready   =  do c <- getChar
              c == 'y'  -- Mal !!!

La definición anterior no es correcta porque la segunda sentencia es un valor booleano, y no una acción. Es necesario crear una acción que devuelva un booleano como resultado. La función return hace precisamente esto:

return ::   a -> IO a

La función return completa el conjunto de primitivas. La última línea de la definición de ready debería ser return (c == 'y').

Ahora podemos ver ejemplos de E/S más complicados. En primer lugar, la función getLine:

getLine     :: IO String
getLine     =  do c <- getChar
                  if c == '\n'
                       then return ""
                       else do l <- getLine
                               return (c:l)

Obsérvese el segundo do en la parte else. Cada do inicia una secuencia de sentencias. Cualquier otra construcción que forme parte de la función , como if en el ejemplo, debe usar un nuevo do para iniciar otras secuencias de acciones.

La función return da entrada a un valor en el dominio de las acciones de E/S. Pero, ¿qué pasa con la dirección inversa? ¿es posible invocar alguna acción de E/S desde una expresión normal ? Por ejemplo, ¿cómo podemos expresar x + print y en una expresión de modo que y sea imprimida al evaluar la expresión? La respuesta es que esto no es posible. No es posible adentrarse en el universo imperativo en mitad de código funcional puro. Cualquier valor `infectado' por acciones imperativas debe ser etiquetado como tal (usando el tipo IO). Una función como

f    ::  Int -> Int -> Int

no puede realizar ninguna E/S ya que IO no forma parte del tipo devuelto. Este hecho es habitualmente desconsolador para programadores acostumbrados a colocar sentencias tipo print liberalmente a lo largo del código de un programa durante la fase de depuración. Realmente, existen funciones peligrosas (rompen el carácter puro del lenguaje) para estos casos, aunque es mejor reservar el uso de éstas a programadores avanzados. Los paquetes de depuración suelen hacer un uso liberal de estas "funciones prohibidas" de un modo totalmente seguro.

7.2  Programando con acciones

Las acciones de E/S son valores normales en Haskell: pueden ser pasados como argumentos a funciones, pueden formar parte de estructuras, y en general, ser usados como cualquier otro valor. Considérese esta lista de acciones:

todoList :: [IO ()]

todoList = [putChar 'a',
            do putChar 'b'
               putChar 'c',
            do c <- getChar
               putChar c]

Esta lista no ejecuta ninguna acción - simplemente las almacena. Para enlazar estas acciones y dar lugar a una única acción, es necesaria una función como sequence_:

sequence_        :: [IO ()] -> IO ()
sequence_ []     =  return ()
sequence_ (a:as) =  do a
                       sequence_ as

Esta definción puede ser simplificada si observamos que la expresión do x;y es traducida a x >> y. Este patrón de recursión es el definido por la función foldr; una definición mejor de sequence_ es:

sequence_        :: [IO ()] -> IO ()
sequence_        =  foldr (>>) (return ())

La notación do es útil pero en este caso el operador monádico subyacente , >>, es más apropiado. El conocimiento de los operadores utilizados para traducir la notación do puede ser muy útil para el programador.

La función sequence_ puede ser usada para construir putStr a partir de putChar:

putStr                  :: String -> IO ()
putStr s                =  sequence_ (map putChar s)

Una de las diferencias entre Haskell y la programación imperativa convencional puede verse en la definición de putStr. En un lenguaje imperativo, la aplicación de una versión imperativa de putChar sobre una cadena de caracteres daría lugar a que ésta se imprimiese. En Haskell, la función map no produce efecto alguno. Simplemente crea una lista de acciones, una por cada carácter en la cadena. La operación de plegado en la función sequence usa el operador >> para combinar todas las acciones individuales dando lugar a una acción única. La expresión return () usada es totalmente necesaria -- foldr necesita una acción nula al final de la cadena de acciones que crea (¡sobre todo si la cadena de caracteres está vacía!).

El Prelude y las bibliotecas del lenguaje contienen varias funciones útiles para combinar acciones de E/S. La mayoría de estas funciones están genérizadas a mónadas arbitrarias; cualquier función que incluya el contexto Monad m => en su tipo puede ser usada con el tipo IO (ya que éste es una instancia de la clase Monad).

7.3  Tratamiento de excepciones

Hasta ahora, hemos evitado el tratamiento de excepciones en las operaciones de E/S. Pero, ¿qué ocurriría si getChar se encontrase el final de un fichero? (Usaremos el término error para denotar el valor _|_: un error no recuperable como la no terminación o un error de patrones. Las excepciones, por otro lado, pueden ser capturadas y tratadas dentro de la mónada de E/S.) Un mecanismo de tratamiento, parecido al de Standard ML, es usado para tratar excepciones tales como "fichero no existente". No se usa ninguna sintaxis o semántica especial; el tratamiento de excepciones es parte de la definición de las operaciones de E/S.

Los errores son codificados usando un tipo de datos denominado IOError. Este tipo representa todas las posibles excepciones que pueden ocurrir al ejecutar operaciones de la mónada de E/S. El tipo es abstracto: los constructores no están disponibles para el usuario. Algunos predicados permiten inspeccionar datos del tipo IOError. Por ejemplo, la función

isEOFError       :: IOError -> Bool

determina si el error que toma como argumento fue causado por una condición de final de fichero. Al ser el tipo abstracto, los diseñadores del lenguaje pueden añadir nuevos tipos de errores al sistema sin notofocar el cambio en la implementación del tipo.

Un manejador de excepciones tiene como tipo IOError -> IO a. La función catch asocia un manejador de excepciones con una acción o un conjunto de éstas:

catch                     :: IO a -> (IOError -> IO a) -> IO a

Los argumentos de catch son una acción y el correspondiente manejador. Si la acción concluye sin error, el resultado de ésta es devuelto sin invocar al manejador. En otro caso, si se produce un error, éste es pasado al manejador como un valor de tipo IOError y la acción asociada con el manejador es invocada. Por ejemplo, la siguiente versión de getChar devuelve el carácter correspondiente al salto de línea cuando se produce un error:

getChar'                :: IO Char
getChar'                = getChar `catch` (\e -> return '\n')

Esto es bastante tosco ya que el manejador trata cualquier error del mismo modo. Si solo se quieren tratar los errores provocados por el final de un fichero, el error debe ser examinado:

getChar'           :: IO Char
getChar'           =  getChar `catch` eofHandler where
  eofHandler e = if isEofError e then return '\n' else ioError e

La función ioError eleva una excepción, que podrá ser tratada por otro manejador. El tipo de ioError es

ioError            :: IOError -> IO a

Es parecida a return pero hace que el control se transfiera al manejador de excepciones en vez de a la siguiente operación de E/S. El uso anidado de catch está permitido, y da lugar a manejadores de excepciones anidados.

Usando getChar', podemos redefinir getLine como ejemplo del uso de manejadores anidados:

getLine'        :: IO String
getLine'        = catch getLine'' (\err -> return ("Error: " ++ show err)) where
                   getLine'' = do c <- getChar'
                                  if c == '\n' then return ""
                                               else do l <- getLine'
                                                       return (c:l)

El manejador de excepciones anidado permite que getChar' trate los errores de fin de fichero, mientras que otros errores hacen que getLine' devuelva una cadena de caracteres comenzado por "Error: ".

Haskell define un manejador de excepciones por defecto en el nivel más externo de un programa cuyo comportamiento consiste en imprimir un mensaje con la excepción producida y finalizar el programa.

7.4  Ficheros, Canales y Manejadores

Aparte del uso de la mónada de E/S y del mecanismo de excepciones, el conjunto de operaciones de E/S proporcionadas por Haskell es muy parecido al de otros lenguajes. Muchas de estas operaciones están definidas en la biblioteca IO y por ello deben importarse explícitamente para ser usadas (los módulos y la importación de elementos son estudiados en la sección 11). Además, muchas de estas funciones son descritas en el informe de las bibliotecas del lenguaje en vez de en el informe del lenguaje.

La apertura de un fichero permite obtener un manejador (handle), con tipo Handle, que puede ser usado para operaciones de E/S. Al cerrar el manejador, se cierra el fichero asociado:

type FilePath         =  String  -- nombres de ficheros
openFile              :: FilePath -> IOMode -> IO Handle
hClose                :: Handle -> IO ()
data IOMode           =  ReadMode | WriteMode | AppendMode | ReadWriteMode

Los manejadores también pueden ser asociados con canales (channels): puertos de comunicación que no están conectados directamente con ficheros. Algunos manejadores de ficheros están predefinidos, como por ejemplo stdin (la entrada estándar), stdout (la salida estándar), y stderr (el canal de errores estándar). Dos operaciones de E/S a nivel de caracteres son hGetChar y hPutChar, que toman un manejador como argumento. La función getChar usada previamente puede ser definida del siguiente modo:

getChar                = hGetChar stdin

Haskell también permite leer el contenido completo de un fichero o canal como una cadena de caracteres:

getContents            :: Handle -> String

Puede parecer que getContents debe leer inmediatamente todo el contenido del fichero o canal, dando lugar a un rendimiento pobre desde el punto de vista del espacio de memoria y tiempo utilizados. Sin embargo, esto no es lo que ocurre. Esto se debe a que getContents devuelve una lista de caracteres "perezosa" (recuérdese que las cadenas de caracteres son listas de caracteres en Haskell), cuyos elementos son leídos del fichero "bajo demanda" (del mismo modo que son generadas las listas normales). Puede esperarse que las implementaciones de esta función lean uno por uno los caracteres del fichero según son necesitados para proseguir el cómputo realizado con la cadena.

En el siguiente ejemplo, se copia el contenido de un fichero en otro:
 

main = do fromHandle <- getAndOpenFile "Copy from: " ReadMode
          toHandle   <- getAndOpenFile "Copy to: " WriteMode
          contents   <- getContents fromHandle
          hPutStr toHandle contents
          hClose toHandle
          putStr "Done."

getAndOpenFile          :: String -> IOMode -> IO Handle
getAndOpenFile prompt mode =
    do putStr prompt
       name <- getLine
       catch (openFile mode name)
             (\_ -> do putStrLn "(Cannot open "++ name ++ "\n")

                       getAndOpenFile prompt mode)
        

Al usar la función perezosa getContents, no es necesario leer todo el contenido del fichero en memoria de una vez. Si hPutStr utilizase un buffer para escribir la cadena en bloques de tamaño fijo, solo uno de estos bloques de caracteres ha de permanecer en memoria simultáneamente. El fichero de entrada es cerrado de modo implícito cuando el último carácter es leído.

7.5  Haskell y la programación imperativa

La programación de operaciones de E/S puede dar lugar a la siguiente reflexión: el estilo utilizado se parece sospechosamente al de la programación imperativa. Por ejemplo, la función getLine:

getLine         = do c <- getChar
                     if c == '\n'
                          then return ""
                          else do l <- getLine
                                  return (c:l)

guarda una similitud estrecha con el siguiente código imperativo (que no está escrito en ningún lenguaje concreto) :
 

function getLine() {
  c := getChar();
  if c == `\n` then return ""
               else {l := getLine();
                     return c:l}}

Así, después de todo, ¿ ha reinventado Haskell simplemente la rueda imperativa ?

En cierto sentido, la respuesta es sí. La mónada de E/S constituye un pequeño sub-lenguaje imperativo dentro de Haskell, de modo que la componente de E/S de un programa puede parecer similar al código de un lenguaje imperativo habitual. Pero existe una diferencia importante: no es necesaria una semántica especial para ello. Concretamente, el razonamiento ecuacional no deja de ser válido. La apariencia imperativa del código monádico no denigra el aspecto funcional de Haskell. Un programador funcional experto debe ser capaz de minimizar la componente imperativa de un programa, usando únicamente la mónada de E/S en una parte mínima del programa. La mónada claramente separa las componentes imperativa y funcional del programa. Por el contrario, los lenguajes imperativos con subconjuntos funcionales no establecen una barrera bien definida entre la parte funcional pura y la parte imperativa del programa.


Una Introducción agradable a Haskell
anterior siguiente inicio