Una Introducción agradable a Haskell
anterior siguiente inicio


4  Expresiones por Casos y Comparación de Patrones

Antes vimos varios ejemplos de comparación de patrones en definiciones de funciones- por ejemplo length y fringe. En esta sección revisaremos con detalle el proceso de comparación de patrones (§3.17). (La comparación de patrones de Haskell es diferente a la utilizada en los lenguajes de programación lógicos, como Prolog; en particular, en Haskell puede verse como una comparación "uni-direccional", mientras que en Prolog es "bi-direccional" (via la unificación), con vuelta-atrás implícita en el mecanismo de evaluación.)

Los patrones no son de "primera-categoría;" existe un conjunto fijo de patrones diferentes. Vimos varios ejemplos de patrones como los constructores de datos; las funciones length y fringe anteriores usan tales patrones, la primera usa constructores predefinidos (listas), y la segunda patrones definidos por el usuario (Tree). Por tanto, al comparar patrones se permiten constructores de cualquier tipo, definidos por el usuario o no. Esto incluye tuplas, cadenas, números, caracteres, etc. Por ejemplo, he aquí una función que concuerda tuplas de "constantes":

contrived :: ([a], Char, (Int, Float), String, Bool) -> Bool
contrived    ([],  'b',  (1,   2.0),   "hi",   True) = False

Este ejemplo ilustra que se permite el anidamiento de patrones (de profundidades arbitrarias).

Técnicamente hablando, los parámetros formales (el Informe los llama variables.) son también patrones---para éstos nunca falla la comparación de patrones. Como "efecto lateral" de una comparación con éxito, la variable se instancia al valor comparado. Por esta razón, no se permite que una ecuación tenga variables repetidas como patrones. (propiedad que llamamos linealidad §3.17, §3.3,§4.4.3).

Patrones como las variables, que nunca fallan, se llaman irrefutables, en contraste con los patrones refutables que pueden fallar. El patrón usado en el ejemplo de la función contrived es refutable. Existen otros tres tipos de patrones irrefutables, dos de los cuales introducimos a continuación (el otro lo postergamos hasta la Sección 4.4).

Patrones-nombrados.

Algunas veces es conveniente dar un nombre a un patrón para poder usarlo en la parte derecha de una ecuación. Por ejemplo la función que duplica el primer elemento de una lista podemos escribirla así:

f (x:xs)                = x:x:xs

(recordemos que ":" es asociativo a la izquierda.) Nótese que x:xs aparece como patrón en la parte izquierda y como expresión en la parte derecha. Para mejorar la lectura, desearíamos escribir x:xs una sola vez, lo cual se consigue utilizando un patrón-nombrado como sigue: (Otra ventaja es relativa a la implementación ya que en lugar de reconstruir completamente x:xs se usa el valor comparado.)

f s@(x:xs)             = x:s

Técnicamente hablando, un patrón-nombrado siempre tiene éxito, aunque el patrón que nombre pueda fallar.

Comodines (Wild-cards).

Otra situación corriente es comparar con un valor que realmente no necesitamos. Por ejemplo, las funciones head y tail definidas en la Seción 2.1 pueden reescribirse como:

head (x:_)             = x
tail (_:xs)            = xs

en las cuales hemos "avisado" del hecho de que no necesitamos cierta parte del argumento de entrada. Un comodín puede "emparejarse" con un valor arbitrario, pero en contraste con las variables, no se instancia a nada; por esta razón, se permiten varios comodines en la misma ecuación.

4.1  Semántica de la Comparación de Patrones

Vimos cómo son comparados los patrones individuales, cómo algunos son refutables y cómo otros son irrefutables, etc. Pero, ¿cómo se activa el proceso global? ¿en qué orden se intentan las comparaciones? ¿qué ocurre si ninguna tiene éxito? En esta sección discutimos estas cuestiones.

La comparación de patrones puede fallar, puede tener éxito, o puede divergir. Una comparación exitosa instancia (liga) los parámetros formales del patrón. La divergencia ocurre cuando un valor necesario es evaluado a error (_|_). El proceso de comparación se produce "hacia-abajo y de izquierda a derecha". El fallo de un patrón en algún lugar de una ecuación produce un fallo de toda la ecuación, y entonces se intenta con la siguiente ecuación. Si todas las ecuaciones fallan, el valor de la aplicación de la función es _|_, y entonces se provoca un error en tiempo de ejecución.

Por ejemplo, si comparamos [1,2] con [0,bot], entonces 1 no encaja con 0, y el resultado en un fallo. (Notemos que bot, definido anteriormente, es una variable con valor _|_.) Pero si comparamos [1,2] con [bot,0], entonces la comparación de 1 con bot produce divergencia (i.e. _|_).

Una excepción a este conjunto de reglas es el caso en que los patrones (globales) puedan tener una guarda booleana, como en la siguiente definición de función que implementa una versión abstracta de la función signo:

sign x |  x >  0        =   1
       |  x == 0        =   0
       |  x <  0        =  -1

Nótese que podemos proporcionar una secuencia de guardas para el mismo patrón, y como cón éstos, las guardas son evaluadas hacia abajo, y la primera que se evalue a True produce un éxito en la comparación.

4.2  Un Ejemplo

Las reglas de la comparación de patrones pueden tener efectos delicados sobre el significado de las funciones. Por ejemplo, consideremos la siguiente definición de take:

take  0     _           =  []
take  _     []          =  []
take  n     (x:xs)      =  x : take (n-1) xs

y la siguiente versión ligeramente diferente (donde hemos invertido el orden en las dos primeras ecuaciones):

take1  _     []         =  []
take1  0     _          =  []
take1  n    (x:xs)      =  x : take1 (n-1) xs

Notemos entonces las siguientes reducciones:

take  0 bot => []
take1 0 bot => _|_
take  bot [] => _|_
take1 bot [] => []

Vemos que take está "más definida" con respecto a su segundo argumento, mientras take1 está más definida con respecto a su primer argumento. Es difícil decir cual de las dos definiciones es mejor. Solo diremos que en ciertas aplicaciones podremos diferenciarlas. (El Standard Prelude incluye la definición correspondiente a take.)

4.3  Expresiones Case

La comparación de patrones proporciona una forma de "transferir el control" basada en las propiedades estructurales de un valor. Sin embargo, en varias situaciones no queremos definir una función para este fin, sino solamente especificar cómo realizar la comparación de patrones en la definición de funciones. Las expresiones case de Haskell proporcionan una forma para resolver este problema. En efecto; en el Informe se especifica el significado de la comparación de patrones a través de expresiones primitivas "case". En particular, una definición de función de la forma:

f p11 ... p1k = e1
...
f pn1 ... pnk = en

donde cada pij es un patrón, es equivalente semánticamente a:

f x1 x2 ... xk = case (x1, ..., xk) of

(p11, ..., p1k) -> e1
...
(pn1, ..., pnk) -> en

donde los xi son identificadores nuevos. (Una traslación más general, que incluya construcciones con guardas, puede verse en §4.4.3.) Por ejemplo, la definición de take anterior es equivalente a:

take m ys               = case (m,ys) of
                            (0,_)       ->  []
                            (_,[])      ->  []
                            (n,x:xs)    ->  x : take (n-1) xs

Un aspecto no apuntado anteriormente es que por cuestiones de corrección de tipos, los tipos de los miembros derechos de una expresión "case" deben ser iguales; más exactamente, éstos deben tener el mismo tipo principal.

Las reglas de la comparación de patrones para las expresiones "case" son las mismas que para las definiciones de funciones, de forma que realmente no aportamos nada nuevo, salvo la conveniencia que ofrecen las expresiones "case".

Por el contrario, existe un uso de las expresiones case con una sintaxis especial: las expresiones condicionales. En Haskell, las expresiones condicionales tienen la forma familiar:

if e1 then e2 else e3

que realmente es una forma abreviada de:

case e1 of True  -> e2
  False -> e3

A partir de este convenio, queda claro que e1 debe ser de tipo Bool, mientras que e2 y e3 deben tener el mismo tipo principal (aunque arbitrario). En otras palabras, if_then_else_puede verse como una función de tipo Bool->a->a->a.

4.4  Patrones Perezosos

Existe otra clase de patrones permitidos en Haskell llamados patrones perezosos, y tienen la forma ~pat. Estos son irrefutables: la comparación de un valor v con ~pat siempre tiene éxito, independientemente de la expresión pat. Operacionalmente hablando, si, posteriormente, un identificador de pat es "utilizado" en la parte derecha de la ecuación, éste será instanciado con la subexpresión del valor si la comparación de patrones tiene éxito; en otro caso, se instanciará con el valor _|_.

Los patrones perezosos son útiles en contextos donde aparecen estructuras de datos infinitas definidas recursivamente. Por ejemplo, las listas infinitas (usualmente llamadas streams) permiten describir en forma elegante programas de simulación. Como ejemplo elemental, consideremos la simulación de la interacción entre un proceso servidor server y un proceso cliente client, donde client envía una secuencia de peticiones a server, y server contesta a cada petición con alguna forma de respuesta. Esta situación se muestra en la Figura 2, al igual que para el ejemplo de la sucesión de Fibonacci. (Nótese que client también tiene un mensaje inicial como argumento.)

Client Server Example

Figura 2

Utilizando streams para representar la secuencia de mensajes, el código Haskell correspondiente a éste diagrama es:

reqs                     = client init resps
resps                    = server reqs

Tales ecuaciones recursivas son una traducción directa del diagrama.

Además podemos suponer que la estructura del servidor y del cliente son de la forma siguiente:

client init (resp:resps) = init : client (next resp) resps
server      (req:reqs)   = process req : server reqs

donde suponemos que next es una función que, a partir de una respuesta desde el servidor, determina la siguiente petición, y process es una función que procesa una petición del cliente, devolviendo la respuesta apropiada.

Desafortunadamente, este programa tiene un problema serio: ¡no produce ninguna salida! El problema es que client, como es usada recursivamente en la inicialización de reqs y resps, espera una comparación con la lista de respuestas antes de enviar la primera petición. En otra palabras, la comparación de patrones se realiza "demasiado pronto." Una forma de resolver el problema es redefinir client como sigue:

client init resps         = init : client (next (head resps)) (tail resps)

A pesar de que funciona, esta solución no es tan expresiva como la anterior. Una forma mejor de resolver el problema es a través de un patrón perezoso:

client init ~(resp:resps) = init : client (next resp) resps

Puesto que los patrones perezosos son irrefutables, la comparación tiene éxito de inmediato, permitiéndose que la petición inicial sea "enviada," y a su vez permitiendo que se genere la primera respuesta; de esta forma "arrancamos" el motor, y la recursión hace el resto.

Un ejemplo de cómo actua este programa es el siguiente:

init                    = 0
next resp               = resp
process req             = req+1

en el cual vemos que:

take 10 reqs => [0,1,2,3,4,5,6,7,8,9]

Como otro ejemplo de uso de patrones perezosos, consideremos la definición de Fibonacci dada anteriormente:

fib             = 1 : 1 : [ a+b | (a,b) <- zip fib (tail fib) ]

Podemos intentar reescribirla usando un patrón nombrado:

fib@(1:tfib)    = 1 : 1 : [ a+b | (a,b) <- zip fib tfib ]

Esta versión de fib tiene la (pequeña) ventaja de que no usa tail a la derecha, puesto que aparece disponible en una forma no "destructiva" en el miembro de la izquierda a través del identificador tfib.

[Este tipo de ecuaciones se llama un enlace a través de patrones (pattern binding); éstas son aquellas ecuaciones donde la parte izquierda completa es un patrón; obsérvese que tanto fib como tfib aparecen ligados en el ámbito de la declaración.]

Ahora, utilizando el mismo razonamiento que antes, deberíamos concluir que este programa no genera ninguna salida. Sin embargo, cursiosamente funciona, y la razón es simple: en Haskell se supone que el enlace a través de patrones tiene un carácter ~ implícito delante, reflejando el comportamiento esperado, y evitando algunas situaciones anómalas que están fuera del alcance de este tutorial. De esta forma, vemos que los patrones perezosos juegan un papel importante en Haskell, y de forma implícita.

4.5  Ambito léxico y declaraciones locales

A menudo es deseable generar un ámbito dentro de una expresión, con el objetivo de crear ligaduras o definiciones locales no "vistas fuera"---es decir, alguna forma de "estructuración por bloques". Para ello tenemos dos formas en Haskell:

Expresiones Let.

Las expresiones let de Haskell son útiles cuando se requiere un conjunto de declaraciones locales. Un ejemplo simple es el siguiente:

let y   = a*b
    f x = (x+y)/y
in f c + f d

El conjunto de definiciones creada por una expresión let es mutuamente recursiva, y la comparación de patrones es tratada en forma perezosa (es decir, tienen un ~ implícito). La únicas declaraciones permitidas son declaraciones de tipos de funciones, enlace de funciones, y enlace de patrones.

Cláusulas Where.

A veces es conveniente establecer declaraciones locales en una ecuación con varias con guardas, lo que podemos obtener a través de una cláusula where:

f x y  |  y>z           =  ...
       |  y==z          =  ...
       |  y<z           =  ...
     where z = x*x

Nótese que ésto no puede obtenerse con una expresión let, la cual únicamente abarca a la expresión que contiene. Solamente se permite una cláusula where en el nivel externo de un grupo de ecuaciones o expresión case. Por otro lado, las cláusulas where tienen las mismas propiedades y restricciones sobre las ligaduras que las expresiones let.

Estas dos formas de declaraciones locales son muy similares, pero recordemos que una expresión let es una expresión, mientras que una cláusula where no ---forma parte de la sintaxis de la declaración de funciones y de expresiones case.

4.6  Sangrado (layout)

El lector puede preguntarse cómo es que los programas Haskell evitan el uso de punto-coma o algún otro tipo de separador con objeto de determinar el final de las ecuaciones, declaraciones, etc. Por ejemplo, consideremos la expresión let de la última sección:

let y   = a*b
    f x = (x+y)/y
in f c + f d

¿Cómo distingue el analizador entre el código anterior y el siguiente:?

let y   = a*b f
    x   = (x+y)/y
in f c + f d

La respuesta es que Haskell usa una sintaxis bi-dimensional llamada layout que esencialmente presupone que las declaraciones están "alineadas por columnas." En el ejemplo primero, nótese que tanto y como f comienzan en la misma columna. Las reglas aparecen con detalle en el Informe (§2.5,§B.3), pero en la práctica, el uso es intuitivo. Únicamente hay que recordar dos detalles:

En primer lugar, el siguiente carácter que siga a una de las palabras reservadas where, let, o of, determina la columna de comienzo donde deben escribirse las declaraciones locales correspondientes a where, let o case (esta regla también es aplicable a la cláusula where cuando se use en declaraciones de clases e instancias, que veremos en la Sección 5). Por tanto, podemos comenzar las declaraciones en la misma línea, o en la línea siguiente a la palabra reservada, etc. (La palabra reservada do, que discutiremos más tarde, también usa esta regla).

En segundo lugar, debemos asegurarnos que la columna de comienzo aparece más a la derecha que la correspondiente a la declaración actual (en otro caso tendríamos ambigüedad). El "final" de una declaración se detecta cuando aparece algo a la izquierda de la columna de comienzo asociada. (Haskell presupone que los tabuladores cuentan como 8 espacios; este detalle debe tenerse en cuenta cuando usemos un editor con otro convenio.)

El "Layout" es realmente una forma abreviada de un mecanismo de agrupamiento explícito que debemos mencionar ya que puede ser útil en ciertas circunstancias. El ejemplo de expresión let anterior es equivalente a:

let { y   = a*b
    ; f x = (x+y)/y
    }
in f c + f d

Nótese las llaves y punto-coma. Tal notación explícita es útil si queremos colocar varias declaraciones en una sola línea; por ejemplo, la siguiente expresión es válida:

let y   = a*b;  z = a/b
    f x = (x+y)/z
in f c + f d

Otros ejemplos de uso del layout con delimitadores pueden verse en §2.7.

El uso del layout reduce en gran medida el desorden sintáctico asociado con los grupos de declaraciones, mejorando la legibilidad. Es fácil de aprender y recomendamos su uso.


Una Introducción agradable a Haskell
anterior siguiente inicio