« Elm: Sintaxis Básica… « || Inicio || » Elm: Ejercicios Proye… »

Elm: Definiendo Tipos para Modelar

Última modificación: 25 de Noviembre de 2016, y ha tenido 59 vistas

Muchos lenguajes tienen problemas para expresar con comodidad datos que tengan estructuras extrañas, lo que a veces lleva a hacer complicados malabares por medio de flags, booleanos o cadenas para poder conseguir los resultados deseados. Suele ser muy común que en muchos lenguajes acabemos adaptando el problema al lenguaje por incapacidad para hacerlo al revés. Cuando eso ocurre, acabamos teniendo un código que es difícil de entender y de mantener.

En esta entrada vamos a ver las formas que proporciona Elm para adaptar sus estructuras de datos a las necesidades del problema que estemos modelando.

Declaraciones

Los Tipos (Types) son, quizás, la herramienta más potente que tenemos para modelar. Piensa en ellos como una declaración que le permite al compilador comprobar qué datos espera cada función y actuar en consecuencia. Tener claros los tipos que se manejan y cómo las funciones transforman unos en otros es esencial para que el número de errores disminuya (el porcentaje más alto de errores se produce siempre por alguna descoordinación de tipos... una función espera recibir/devolver un tipo, y está recibiendo/devolviendo otro).

La forma en que se escriben estas declaraciones en Elm es por medio de las signaturas, donde indicamos exactamente el tipo de dato con el que estamos trabajando.

valor : Int
valor =  21

nombres : List String
nombres = [ "Alberto", "Benito", "Carlos" ]

libro : { titulo: String, autor: String, paginas: Int }
libro =
  { titulo = "El guardián entre el centeno", autor = "J.D. Salinger", pages = 143 }

Por ahora no parece muy importente, pero todo cambia cuando empezamos a tratar con funciones:

import List exposing (sum, map, length)

tamanoMedioNombres : List String -> Float
tamanoMedioNombres nombres =
  sum (map String.length nombres) / length nombres

esLargo : { registro | paginas : Int } -> Bool
esLargo libro =
  libro.paginas > 400

En el ejemplo de tamanoMedioNombres indicamos al compilador que estamos esperando una lista de cadenas como entrada, de forma que pueda avisarnos si se le intenta pasar algo que no sea eso. Esta declaración hace que no se puedan producir errores en tiempo de ejecución debido a ello, ya que cada vez que se llame a esta función, el compilador puede verificar si el dato que se le pasa es del tipo esperado. También estamos indicando que el valor esperado de salida es de tipo Float, y gracias a las funciones que intervienen en su definición, podemos el compilador también podrá decirnos a priori (no en tiempo de ejecución) si hay algún tipo de incompatibilidad en la definición de la función.

La función esLargo funciona de forma similar, tal y como se ha definido, espera un registro que contenga el campo paginas de tipo Int (no importa si tiene otros muchos campos), de forma que, si es así, el compilador puede estar seguro de que el resultado revuelto se producirá sin errores y será del tipo adecuado.

Por tanto, en ambos casos estamos escribiendo declaraciones en las que se dice "Requiero este tipo de entrada, y te devolveré este tipo de salida", lo que evita errores en tiempo de ejecución porque el compilador puede comprobar si todas las llamadas de todas las funciones en nuestro código son respetan todas las restricciones declaradas.

Nota: En realidad, gracias a un procedimiento llamado "inferencia de tipos", podríamos evitarnos todas esta declaraciones, ya que a partir de las funciones básicas que ya están definidas con los tipos adecuados, el compilador puede inferir todas las declaraciones de las funciones que vamos creando. Aunque, por tanto, podríamos evitar declarar las signaturas, es conveniente hacerlo para estar seguros de que las definiciones que hagamos en efecto son coherente son el comportamiento esperado de las funciones.

Vamos a ver a continuación de qué forma podemos extender los tipos de datos que vienen predefinidos en Elm, definiendo nuestros propios tipos con el fin de adaptarlos a las necesidades de los problemas que tenemos que resolver.

Enumeraciones

Es bastante común crear un tipo de dato que enumera unos cuantos posibles estados/opciones. Imagina que estamos creando una aplicación de lista de tareas y queremos que se filtren qué tareas son visibles, dependiendo de si queremos mostrar todas, solo las activas, o solo las completadas. Podemos representar estas tres opciones como:

type Visibility = Todas | Activas | Completadas

Hemos definido un nuevo tipo de dato, llamado Visibilidad, que tiene exactamente tres posibles valores: Todas, Activas, Completadas, lo que significa que si usas algo que tenga ese tipo, debe ser a la fuerza una de esas tres cosas.

A partir de este nuevo tipo, podríamos usar una expresión de tipo case para actuar de distinta forma dependiendo del dato recibido.

toString : Visibilidad -> String
toString visibilidad =
case visibilidad of
Todas -> "Todas"
Activas -> "Activas"
Completadas -> "Completadas"

Enumeraciones Extendidas

Vamos a extender un poco el tipo anterior, supongamos ahora que queremos mantener información acerca de quién está accediendo a nuestra página. Podríamos decir, por ejemplo si es un usuario anónimo, o está accediendo logeado, pero en este caso, no basta con enumerar las opciones, sino que en una de ellas sería interesante disponer también de quién es el usuario. Para ello, podemos definir:

type Usuario = Anonimo | Logeado String

En caso de que el usuario que accede esté logeado, podemos usar la información extra que sabemos de él, y que va almacenada en el propio tipo, para mostrar su imagen, por ejemplo:

fotoUsuario : Usuario -> String
fotoUsuario usuario =
    case usuario of
      Anonimo        -> "anonimo.png"
      Logeado nombre ->  "usuarios/" ++ nombre ++ "/foto.png"

Si no está logeado, usamos una foto general, pero si está logeado podemos usar la foto que él haya subido a su perfil. Si tenemos un conjunto de usuarios, por ejemplo:

usuariosActivos : List Usuario
usuariosActivos =
    [ Anonimo
    , Logeado "Alberto"
    , Logeado "Benito"
    , Anonimo
    ]

El tipo de dato nuevo nos permite realmente mezclar distintos tipos de información bajo el mismo tipo, y podemos combinarlos para, por ejemplo, hacer la siguiente función que usa List.map:

fotos =
    List.map fotoUsuario usuariosActivos

-- fotos =
--     [ "anonimo.png"
--     , "usuarios/Alberto/foto.png"
--     , "usuarios/Benito/foto.png"
--     , "anonimo.png"
--     ]

Este tipo de dato tan simple pero flexible es la base de casi todos los tipos de datos que se usan en el modelado de problemas.

Tipos de Unión (ADT)

El tipo anterior es un caso particular de una familia de tipos más general que se conoce como Tipos de Unión, y que en otros contextos se denominan Tipos Abstractos de Datos (por ejemplo, en Haskell).

Supongamos que estás creando un escritorio con distintos tipos de controles. Uno para mostrar gráficas, otro para mostrar información de acceso, y otro para mostrar líneas temporales. Los tipos de unión nos pueden facilitar trabajar con ellos uniformemente:

type Control
    = Grafica (List (Int, Int))
    | DatosLog (List String)
	| LineaTiempo (List (Time, Int))

Podemos ver este nuevo tipo (como su nombre indica) como una forma de poner juntos tres tipos diferentes, cada uno de ellos con una etiqueta que indica el subtipo que es, lo que nos permitirá después tratarlos de forma individualizada escribiendo algo como:

vista : Control -> Element
vista control =
    case control of
      Grafica puntos      -> muestraGrafica puntos
      DatosLog logs       -> flow down (map vistaLog logs)
      LineaTiempo eventos -> muestraLineaTiempo eventos

Dependiendo de qué tipo de control estamos mirando, se renderizará de forma distinta. Y podemos personalizarlo incluso más añadiendo otros tipos:

type Escala = Normal | Logaritmica

type Control
    = Grafica (List (Int, Int))
    | DatosLog (List String)
	| LineaTiempo Escala (List (Time, Int))

Eliminando el dato NULL

Muchos lenguajes tienen el concepto de dato nulo, null. En estos lenguajes, cada vez que esperas recibir un dato de un tipo concreto, puedes recibir un null, y es tarea tuya ir controlando si es así o no para actuar en consecuencia.

El inventor de este concepto, Tony Hoare, dijo al respecto:

Le llamo mi error del millón de dólares. Fue la invención de la referencia nula en 1965. En aquell época, estaba diseñando el primer sistema de tipos completo para referencias en un lenguaje orientado a objetos (ALGOL W). Mi objetivo era asegurar que todos los usos de referencias fueran completamente seguras, con comprobación automática por parte del compilador. Pero no pude resistir la tentación de poner una referencia nula, simplemente porque era muy fácil implementarla. Esto ha llevado a innumerables errores, vulnerabilidades y caídas de sistemas, que probablemente ha causado millones de dólares en pérdidas y daños en los últimos 40 años.

Elm, como la mayoría de los lenguajes funcionales, evitan este problema completamente por medio de un tipo llamado Maybe, que puede verse como una forma de explicitar null, de forma que sabemos qué hacer para manejarlos.

type Maybe a = Just a | Nothing

Observa que este tipo usa un tipo parametrizado, de forma que puede usarse sobre cualquier otro tipo de dato. Así, si definimos un tipo Maybe Int, podemos tener o bien Just un entero, o bien Nothing. Por ejemplo, supongamos que queremos pasar de cadenas a meses.

String.toInt : String -> Maybe Int

aMes : String -> Maybe Int
aMes cadena =
    case String.toInt cadena of
      Nothing -> Nothing
      Just n ->  if n > 0 && n <= 12 then Just n else Nothing

La signatura de aMes indica explícitamente que devolverá un entero..o quizás no, pero no tienes que estar adivinando si hay un valor null rondando en la ejecución. Aunque pueda parecer un detalle menor, la cantidad de código que se ahorra para no tener que manejar los null es considerable.

Estructuras de Datos Recursivas

Implementar una lista en Elm es realmente sencillo.

type List a = Vacia | Cabeza a (List a)

Una lista de tipo a, o bien es una lista Vacia, o bien es una Cabeza que contiene un elemento de ese mismo tipo, seguido de una lista. De esta forma, podemos crear (List Int) o (List String), o con el tipo que sea. Un ejemplo de lista del tipo (List Int) podría ser:

Vacia
Cabeza 1 Vacia
Cabeza 3 (Cabeza 2 (Cabeza 1 Vacia))

Todos los datos anteriores tienen el mismo tipo, así que pueden usarse en los mismos sitios. Además, gracias al uso de patrones, podemos definir funciones que sean capaces de actuar en función de la estructura interna de las listas. Por ejemplo:

sum : List Int -> Int
sum xs =
    case xs of
      Vacia               -> 0
      Cabeza primero cola -> cabeza + sum cola

Hacer listas es solo el comienzo. Siguiendo la misma idea podemos construir otro tipo de estructuras, como los árboles binarios. Por ejemplo:

type Arbol a = Empty | Nodo a (Arbol a) (Arbol a)

O un pequeño lenguaje de programación, por ejemplo el Álgreba de Boole:

type ExprBooleana
    = T
    | F
    | Not Boolean
    | Or  Boolean Boolean
    | And Boolean Boolean

true = Or T F
false = And T (Not T)

Un vez definido el tipo y los posibles valores, podemos definir funciones para evaluar (a un valor Booleano) cualquier ExprBooleana.

« Elm: Sintaxis Básica… « || Inicio || » Elm: Ejercicios Proye… »