Una Introducción agradable a Haskell
anterior siguiente
inicio
Pasamos a examinar algunos de los aspectos más avanzados de las declaraciones de tipo.
Una práctica común en programación es definir un tipo cuya representación es idéntica a otro tipo existente pero que tenga una identidad propia para el sistema de tipos. En Haskell, la declaración newtype crea un nuevo tipo a partir de uno existente. Por ejemplo, los números naturales pueden ser representados usando el tipo Integer mediante la siguiente declaración:
newtype Natural = MakeNatural Integer
Esta declaración crea un tipo completamente nuevo, Natural, cuyo único constructor permite almacenar un Integer. El constructor MakeNatural permite convertir un dato de tipo Integer en uno de tipo Natural:
toNatural :: Integer -> Natural
toNatural x | x < 0 = error
"No es posible crear naturales negativos!"
| otherwise = MakeNatural x
fromNatural :: Natural -> Integer
fromNatural (MakeNatural i) = i
La siguiente declaración de instancia convierte al tipo Natural en instancia de la clase Num:
instance Num Natural where
fromInteger = toNatural
x + y = toNatural (fromNatural x +
fromNatural y)
x - y = let r = fromNatural x -
fromNatural y in
if r < 0 then error "Sustracción no natural"
else toNatural r
x * y = toNatural (fromNatural x *
fromNatural y)
Sin esta declaración, el tipo Natural no sería de la clase Num. Las instancias declaradas para el tipo Integer no dan lugar automáticamente a las correspondientes instancias para el tipo Natural. De hecho, el propósito principal de este tipo es introducir una instancia de la clase Num diferente. Esto no habría sido posible si el tipo Natural hubiese sido definido como un sinónimo del tipo Integer.
Todo lo comentado funciona si usamos una declaración data en vez de una declaración newtype. Sin embargo, la declaración data da lugar a un coste extra en la representación de valores del tipo Natural. El uso de newtype evita el nivel extra de indirección (causado por la evaluación perezosa) que la declaración data introduciría. Véase la sección 4.2.3 del informe del lenguaje para una discusión en profundidad de las declaraciones newtype, data, y type. [Excepto por la palabra clave inicial, las declaraciones newtype usan la misma sintaxis que las declaraciones data que poseen un único constructor con un único campo. Esto es consistente, ya que los tipos definidos usando newtype son casi idénticos a los declarados usando una declaración data ordinaria.]
Los campos de un dato pueden ser accedidos tanto por su posición como por su nombre, usando en este último caso, las etiquetas de campo. Considérese el siguiente tipo para definir un punto bidimiensional:
data Point = Pt Float Float
Las dos componentes del punto son el primer y segundo argumento del constructor Pt respectivamente. Una función como
pointx
:: Point -> Float
pointx (Pt x _) = x
puede ser usada para acceder a la primera componente del punto de un modo más descriptivo, pero para estructuras de datos más extensas, resulta pesado definir dichas funciones manualmente.
Los constructores en una declaración data pueden contener nombres de campo colocándolos entre llaves. Estos nombres de campo permiten identificar los componentes de un constructor por su nombre en vez de por su posición. Una definición alternativa del tipo Point es:
data Point = Pt {pointx, pointy :: Float}
Esta declaración de tipo es idéntica a la definición previa del tipo Point. El constructor Pt es el mismo en ambos casos. Sin embargo, esta declaración también introduce dos nombres de campo, pointx y pointy. Estos nombres de campo pueden ser usados como funciones selectoras para extraer un componente de la estructura. Para el ejemplo considerado, los selectores son:
pointx :: Point -> Float
pointy :: Point -> Float
La siguiente función usa estos selectores:
absPoint :: Point -> Float
absPoint p = sqrt (pointx p * pointx p + pointy p * pointy p)
Las etiquetas de campo pueden ser usadas también para construir nuevos valores. La expresión Pt {pointx=1, pointy=2} es idéntica a Pt 1 2. El uso de nombres de campo en la declaración de un constructor de datos no prohibe el modo posicional de acceso a los campos; tanto Pt {pointx=1, pointy=2} como Pt 1 2 son válidos. Es posible omitir algún campo al definir valores usando nombres de campo; los campos ausentes estarán indefinidos.
Los patrones para las etiquetas de campo tienen una sintaxis parecida para el constructor Pt:
absPoint (Pt {pointx = x, pointy = y}) = sqrt (x*x + y+y)
Una función de actualización usa los nombres de campo existentes en una estructura para rellenar los componentes de otra. Si p es un valor de tipo Point, entonces p {pointx=2} es un punto con la misma componente pointy que p pero con el valor 2 en la componente pointx. Esta operación no es una asignación destructiva: la función de actualización simplemente crea una nueva copia del objeto, rellenando los campos especificados con nuevos valores.
[Las llaves usadas junto con las etiquetas de campo son especiales en cierto modo: la sintaxis de Haskell suele permitir que las llaves sean omitidas usando la regla del sangrado (layout rule, descrita en la sección 4.6). Por el contrario, las llaves asociadas con los nombres de campo deben aparecer explícitamente.]
Los nombres de campos no están restringidos a tipos con un constructor único (habitualmente llamados tipos "registro"). En un tipo con constructores múltiples, la selección o la actualización mediante nombres de campo puede dar lugar a un error en tiempo de ejecución. Esto es parecido al comportamiento de la función head al actuar sobre listas vacías.
Las nombres de campo comparten el mismo espacio de nombres que las variables normales y los métodos de clases. Un nombre de campo no puede ser usado en más de un tipo de datos en un mismo ámbito. Sin embargo, dentro de un mismo tipo de datos, el mismo nombre de campo puede ser usado en más de un constructor siempre que el tipo del campo sea el mismo en todos los casos. Por ejemplo, en la siguiente declaración de tipo
data T = C1 {f :: Int, g :: Float}
| C2 {f :: Int, h ::
Bool}
el nombre de campo f aparece en los dos contructores de T. Así, si x tiene tipo T, entonces x {f=5} es válido para valores creados a partir de cualquiera de los constructores de T.
Los nombres de campo no modifican la naturaleza básica de una estrucutura de datos algebraica; son simplemente una sintaxis apropiada que permite acceder a los componentes de una estructura de datos utilizando nombres en vez de posiciones. Hacen más manejable el uso de constructores con varias componentes, ya que los campos pueden ser añadidos o eliminados sin cambiar cada aparición del constructor. Para otros detalles de los nombres de campo y su semántica, véase la sección §4.2.1.
Las estructuras de datos de Haskell son normalmente perezosas: las componentes de éstas no son evaluadas hasta que sea necesario. Esto permite definir estructuras que contienen elementos cuya evaluación, si se produce, puede provocar un error o no terminar. Las estructuras de datos perezosas mejoran la expresividad de Haskell y son una parte fundamental de su estilo de programación.
Internamente, cada componente de una estructura de datos perezosa es envuelta en una estructura usualmente denominada cierre (thunk), que encapsula la computación que producirá el valor. Este cierre no es evaluado hasta que el valor es necesitado; los cierres que contienen errores (_|_) no afectan a otros componentes de la estructura. Por ejemplo, la tupla ('a',_|_) es un valor totalmente legal en Haskell. El caracter 'a' puede ser usado sin ningún problema. La mayoría de los lenguajes de programación son estrictos (strict) en vez de perezosos (lazy); en los lenguajes estrictos, todos los componentes de una estructura son reducidos a sus respectivos valores antes de construir la estructura.
Hay una serie de costos asociados a los cierres: es necesario tiempo para construirlos y para evaluarlos, ocupan espacio en el montículo (heap), y hacen que el recolector de basura (garbage collector) mantenga en memoria otras estructuras que pueden ser necesarias para una posible evaluación posterior del cierre. Para evitar estos costos, los indicadores de estrictez (strictness flags) en las declaraciones data permiten que campos específicos de un constructor sean evaluados inmediatamente, lo cual ofrece la posibilidad al programador de suprimir de un modo selectivo la perezosidad del lenguaje. Un campo marcado con ! en una declaración data es evaluado cuando la estructura es creada en vez de producir la creación de un cierre. Hay varias situaciones en las que el uso de los indicadores de estrictez puede ser adecuado:
Por ejemplo, la biblioteca de números complejos define el tipo Complex como:
data RealFloat a => Complex a = !a :+ !a
[obsérvese el caracter infijo del constructor :+.] Esta definición marca los dos componentes (la parte real e imaginaria) del número complejo como estrictas. Esta representación es más compacta pero hace que los números complejos con una única componente indefinida, 1 :+ _|_ por ejemplo, estén totalmente indefinidos (_|_). Dado que no hay necesidad real de hacer uso de números complejos parcialmente definidos, tiene sentido usar indicadores de estrictez para obtener una representación más eficiente.
Los indicadores de estrictez suelen ser usados para solucionar escapes de memoria (memory leaks): estructuras retenidas en memoria por el recolector de basura que ya no son necesarias para el cómputo restante.
El indicador de estrictez, !, sólo puede aparecer en declaraciones data. No pueden ser usados en otras declaraciones de tipo o en otras definiciones de tipo. No hay un modo de marcar argumentos de funciones como estrictos, aunque este efecto puede ser conseguido usando la función seq y el operador $!. Véase §4.2.1 para más detalles.
Es difícil presentar una guía general para el uso de los
indicadores de estrictez. Deben ser usados con precaución: la
perezosidad es una de las propiedades fundamentales de Haskell y
las anotaciones pueden dar lugar a bucles infinitos difíciles de
detectar o tener consecuencias inesperadas.
Una Introducción agradable a Haskell
anterior siguiente
inicio