Una Introducción agradable a Haskell
anterior siguiente inicio


5  Clases de tipos y Sobrecarga

El sistema de tipos de Haskell posee una característica que lo distingue de otros lenguajes de programación. El tipo de polimorfismo del que hemos tratado hasta ahora es denominado polimorfismo paramétrico. Existe otro tipo de polimorfismo llamado ad hoc o sobrecarga. Estos son algunos ejemplos de polimorfismo ad hoc:

Obsérvese que el comportamiento de estas funciones u operadores sobrecargados suele ser distinto para cada tipo de dato (de hecho, el comportamiento está indefinido o es erróneo en ciertos casos), mientras que en el caso del polimorfismo paramétrico, el comportamiento de la función no depende del tipo (el comportamiento de fringe, por ejemplo, no depende del tipo de los elementos almacenados en las hojas del árbol). En Haskell, las clases de tipos proporcionan un modo estructurado de controlar el polimorfismo ad hoc o sobrecarga.

Empecemos considerando un ejemplo simple pero a la vez importante: la igualdad. Hay muchos tipos para los cuales nos gustaría que la igualdad estuviese definida, y algunos para los que no. Por ejemplo, la determinación de la igualdad de funciones es, en general, considerada no computable, mientras que la comparación de listas es algo habitual. (El tipo de igualdad que estamos considerando es "comparación de valores", y es distinto a la igualdad de punteros que aparece en otros lenguajes, como el operador == de Java. La igualdad de punteros rompe la transparencia referencial y, por tanto, no es apropiada para un lenguaje funcional puro). Como ejemplo práctico, considérese esta definición de la función elem que comprueba la pertenencia a una lista:

x `elem`  []            = False
x `elem` (y:ys)         = x==y || (x `elem` ys)

[Debido a la norma de estilo discutida en la sección 3.1, hemos definido elem de forma infija. == y || son operadores infijos para la igualdad y la disyunción lógica, respectivamente.]

De un modo intuitivo, el tipo de elem "debería" ser: a->[a]->Bool. Pero esto implicaría que == tuviese como tipo a->a->Bool, aunque acabamos de decir que no esperamos que == esté definido para todos los tipos.

Además, como hemos destacado anteriormente, incluso si == estuviese definido para cualquier tipo, el cómputo realizado para comparar dos listas es distinto al realizado al comparar dos enteros. Por todo ello, es de esperar que == esté sobrecargado para realizar estas tareas distintas.

Las clases de tipos resuelven todos estos problemas de un modo adecuado. Permiten declarar qué tipos son instancias de qué clases, y dar definiciones para las operaciones sobrecargadas asociadas con cada clase. Por ejemplo, definamos una clase de tipos conteniendo el operador de igualdad:

class Eq a where
  (==)                  :: a -> a -> Bool

Donde Eq es el nombre de la clase definida, y == es la única operación en la clase. Esta declaración puede ser leída como "un tipo arbitrario a es una instancia de la clase Eq si existe una operación sobrecargada ==, del tipo adecuado, definida para él." (Obsérvese que == solo está definido sobre pares de objetos del mismo tipo.)

La restricción de que un tipo a debe ser una instancia de la clase Eq se escribe Eq a. Así, Eq a no es una expresion de tipo, sino que expresa una restricción sobre un tipo, y se denomina un contexto. Los contextos aparecen al inicio de las expresiones de tipo. Por ejemplo, el resultado de la declaración de clase anterior es asignar el siguiente tipo a ==:

(==)                    :: (Eq a) => a -> a -> Bool

Este tipo debe ser leído del siguiente modo: "Para cada tipo a que es una instancia de la clase Eq, == tiene como tipo a->a->Bool." Este es el tipo que es usado para  == en la definición de elem del ejemplo, y de hecho, la restricción impuesta por el contexto es propagada al tipo principal de elem:

elem                    :: (Eq a) => a -> [a] -> Bool

El tipo anterior debe ser leído como, "Para cada tipo a que sea una instancia de la clase Eq, elem tiene por tipo a->[a]->Bool." Esto es exactamente los que queríamos---expresar que elem no está definido para todos los tipos, sino solo para aquellos que pueden ser comparados con el operador de igualdad.

Todo esto está muy bien, pero ¿cómo especificamos qué tipos son instancias de la clase Eq, y el comportamiento de == para cada uno de ellos?. Esto se consigue con una declaración de instancia. Por ejemplo:

instance Eq Integer where
  x == y                =  integerEq x y

== es denominado un método de la clase. La función integerEq es una primitiva que compara dos enteros, aunque, en general, cualquier expresión válida puede aparecer en la parte derecha de la declaración, como en cualquier otra definición de función. Toda la declaración viene a decir: "El tipo Integer es una instancia de la clase Eq, y esta es la definición del método correspondiente a la operación == para los enteros." Dada esta declaración, podemos comparar enteros de precisión limitada usando ==. De un modo similar:

instance Eq Float where
  x == y                =  floatEq x y

permite comparar números reales usando ==.

Los tipos recursivos definidos previamente, como Tree, también pueden ser tratados:

instance (Eq a) => Eq (Tree a) where
  Leaf a         == Leaf b          =  a == b
  (Branch l1 r1) == (Branch l2 r2)  =  (l1==l2) && (r1==r2)
  _              == _               =  False

Obsérvese el contexto  Eq a  en la primera línea---esto es necesario porque los elementos en las hojas (que tienen tipo a) son comparados en la parte derecha de la segunda línea. La restricción adicional viene a decir esencialmente que podemos determinar la igualdad de árboles cuyos elementos tienen tipo a siempre que podamos determinar la igualdad de datos de tipo a. Si los contextos hubiesen sido omitidos en la declaración de instancia, se habría producido un error de tipo estático (en tiempo de compilación).

El Informe de Haskell, y en especial el Prelude, contiene bastantes ejemplos útiles de clases de tipo. La definición completa de la clase Eq es un poco más extensa:

class  Eq a  where
  (==), (/=)            :: a -> a -> Bool
  x == y                =  not (x /= y)
  x /= y                =  not (x == y)

Éste es un ejemplo de una clase con dos operaciones, una para determinar la igualdad y otra para la desigualdad. También demuestra el uso de los métodos por defecto, en este caso para los operadores /= y ==. Si un método para una operación concreta es omitido en una declaración de instancia, entonces el definido por defecto en la declaración de clase, si existe, es usado. Por ejemplo, las tres instancias de Eq definidas previamente son correctas dada la nueva declaración de la clase Eq. Además, la definición de desigualdad es adecuada para los tipos considerados: la negación lógica de la igualdad.

Haskell también posee una noción de extensión de clases. Por ejemplo, podemos definir una clase Ord que hereda todas las operaciones de Eq, y que además posee operadores de orden y funciones para calcular el máximo y mínimo de dos valores:

class  (Eq a) => Ord a  where
  (<), (<=), (>=), (>)  :: a -> a -> Bool
  max, min              :: a -> a -> a

Obsérvese el contexto en la declaración de clase. Decimos que Eq es una superclase de Ord (recíprocamente, Ord es una subclase de Eq). El significado de la extensión de clases es que cualquier tipo que sea una instancia de Ord debe ser también una instancia de Eq. (En la próxima sección daremos las definición completa de la clase Ord del Prelude.) El compilador producirá un error en caso contrario.

Uno de los beneficios de esta jerarquía de clases es que los contextos de la funciones pueden ser abreviados: una expresión de tipo para una función que usa operaciones tanto de la clase Eq como de Ord puede usar simplemente el contexto (Ord a), en vez de (Eq a, Ord a), ya que Ord "implica" Eq. Más importante es el hecho de que los métodos para operaciones de subclases pueden asumir la existencia de métodos para las operaciones de la superclase. Por ejemplo, la declaración de la clase Ord del Prelude contiene esta definición por defecto para (<):

    x < y               =  x <= y && x /= y

Como ejemplo del uso de la clase Ord, el tipo principal de la función quicksort definida en la sección 2.4.1 es:

quicksort               ::  (Ord a) => [a] -> [a]

De otro modo: quicksort sólo opera con listas de valores ordenables. El tipo de quicksort es debido al uso de los operadores de comparación < y >= en su definición.

Haskell también permite herencia múltiple, ya que una clase puede tener más de una superclase. Los conflictos de nombres son evitados con la restricción de que una operación solo puede ser miembro de una única clase en un mismo ámbito. Por ejemplo, la declaración

class (Eq a, Show a) => C a where ...

crea una clase C que hereda las operaciones de Eq y Show.

Los métodos de clase son tratados como declaraciones globales en Haskell. Comparten el mismo espacio de nombres que las variables normales; así, un mismo nombre no puede ser usado para denotar a la vez un miembro de clase y una variable o métodos en distintas clases.

Los contextos también pueden ser usados en las declaraciones de tipos (data); véase §4.2.1.

Los métodos de clases pueden tener restricciones adicionales sobre cualquier variable de tipo excepto aquella que aparece en la declaración de clase tras el nombre de la clase definida. Por ejemplo, en esta clase:

class C a where
  m :: Show b => a -> b

el método m requiere que el tipo b esté en la clase Show. Sin embargo, no es posible establecer un contexto adicional sobre el tipo a en el método m. Para ello, la restrición debería ser parte del contexto en la declaración de la clase.

Hasta ahora, hemos usado tipos de "primer orden". Por ejemplo, el constructor de tipos Tree ha aparecido siempre seguido de un argumento, como en Tree Integer (un árbol que almacena valores de tipo Integer) or Tree a (representando la familia de tipos de árboles conteniendo valores de tipo a). Pero Tree por sí mismo es un constructor de tipos, y como tal toma un tipo como argumento y devuelve un tipo como resultado. No hay ningún valor en Haskell que tenga como tipo simplemente Tree, pero los "tipos de orden superior" pueden ser usados en las declaraciones de clase.

Para empezar, consideremos la siguiente clase Functor (tomada del Prelude):

class Functor f where
  fmap :: (a -> b) -> f a -> f b

La fución fmap generaliza a la función map usada previamente. Obsérvese que la variable de tipo f está siendo aplicada a otro tipo en f a y f b. Así que podemos esperar que aparezca ligada a un tipo como Tree que puede ser aplicado a un argumento. Una instancia de la clase Functor para el tipo Tree puede ser:

instance Functor Tree where
  fmap f (Leaf x)       = Leaf   (f x)
  fmap f (Branch t1 t2) = Branch (fmap f t1) (fmap f t2)

Esta declaración de instacia indica que Tree, en vez de Tree a, es una instancia de la clase Functor. Esta característica del lenguaje es muy útil, y demuestra la posibilidad de definir tipos contenedores (containers), que implementen funciones como fmap, de modo que ésta funcione de un modo uniforme sobre árboles, listas y otros tipos de datos.

[La aplicación de tipos se escribe del mismo modo que la aplicación de funciones. El tipo T a b es analizado sintácticamente como (T a) b. Algunos tipos como las tuplas, que usan una sintaxis especial, pueden ser escritos de un modo alternativo que permite la currificación. El constructor para los tipos función es (->); Los tipos f -> g y (->) f g son idénticos. De un modo similar, los tipos [a] y [] a son idénticos también. Para las tuplas, los constructores de tipo (del mismo modo que los constructores de datos) son (,), (,,), y así sucesivamente.]

Como ya sabemos, el sistema de tipos detecta los errores de tipo en las expresiones. Pero, ¿qué es lo que ocurre con las expresiones de tipo malformadas ? La expresión (+) 1 2 3 da lugar a un error de tipos ya que (+) toma tan solo dos argumentos. De un modo similar, el tipo Tree Integer Integer debería provocar alguna clase de error ya que el tipo Tree toma tan solo un único argumento. Entonces, ¿Cómo detecta Haskell expresiones de tipo erróneas ? La respuesta es que utiliza un segundo sistema de tipos que asegura la correción de los tipos. Cada tipo tiene un género (kind) asociado que puede ser usado para asegurar que el tipo es usado de modo correcto.

Las expresiones de tipo son clasificadas según sus géneros los cuales pueden tomar una de las siguientes formas:

El constructor de tipos Tree tiene como género *->*; el tipo Tree Integer tiene género *. Los miembros de la clase Functor han de tener todos el género *->*; a partir de la siguiente declaración, el compilador generaría un error de géneros

instance Functor Integer where ...

ya que Integer tiene como género *.

Los géneros no aparecen explícitamente en los programas escritos en Haskell. El compilador infiere los géneros antes de realizar el chequeo de tipos, sin que sea necesario (ni posible) introducir en el programa "declaraciones de género". Los géneros permanecen en un segundo plano excepto cuando se produce un error de género. Los géneros son lo suficientemente simples como para que un compilador pueda producir un mensaje de error descriptivo en caso de que se produzca. Véase §4.1.2 y §4.6 para obtener más información sobre los géneros.

Una perspectiva diferente.

Antes de mostrar más ejemplos de uso de clases, merece la pena resaltar otros dos modos de ver las clases de Haskell. El primero es la analogía con la programación orientada a objetos (POO). Si sustituimos clase por clase de tipos y objeto por tipo en las siguientes afirmaciones sobre POO, obtendremos un resumen válido de qué representan las clases de tipos de Haskell:

"Las clases capturan conjuntos comunes de operaciones. Un objeto concreto puede ser una instancia de una clase, y habrá un método para cada operación. Las clases pueden aparecer jerarquizadas, dando lugar a las nociones de superclase y subclase, y permitiendo la herencia de operaciones/métodos. Un método por defecto puede estar asociado con una operación."

Al contrario que en POO, los tipos no son objetos, y en concreto, no tiene sentido la noción de estado modificable interno de un objeto o tipo. Una ventaja en relación con algunos lenguajes orientados a objetos es que los métodos de Haskell son completamente seguros con respecto a los tipos (type-safe): cualquier intento de aplicar un método a un valor cuyo tipo no esté en la clase requerida será detectado en tiempo de compilación en vez de en tiempo de ejecución. Es decir, los métodos no son buscados en tiempo de ejecución, sino que son simplemente pasados como argumentos a funciones de orden superior.

Una perspectiva diferente es considerar la relación entre el polimorfismo paramétrico y la sobrecarga. Hemos visto que el polimorfismo paramétrico es útil a la hora de definir familias de tipos al cuantificar universalmente sobre todos los tipos. A veces, sin embargo, esa cuantificación universal es demasiado amplia ---nos gustaría cuantificar sobre un conjunto de tipos menor, como, por ejemplo, aquellos tipos para cuyos elementos la igualdad puede ser determinada. Las clases de tipos proporcionan un modo estructurado de hacer esto. De hecho, podemos ver también el polimorfismo paramétrico como una forma de sobrecarga. La sobrecarga se realiza sobre todos los tipos en vez de sobre un conjunto restringido de éstos (sobre una clase de tipos).

Comparación con otros lenguajes.

Las clases de Haskell son similares a las usadas en algunos lenguajes orientados a objetos como C++ y Java. Sin embargo, hay diferencias significativas:


Una Introducción agradable a Haskell
anterior siguiente inicio