Desarrollo dirigido por tipos: la firma primero y el hueco como pregunta
Escribir la firma antes que el cuerpo parece una cuestión de estilo y es en realidad un método de trabajo con una teoría detrás. Esta lección desarrolla el desarrollo dirigido por tipos tal como se practica en Elm: declarar el tipo como especificación ejecutable, abrir un hueco con Debug.todo, interrogar al compilador sobre qué debe ir en ese hueco mediante la técnica del valor deliberadamente incorrecto, y refinar hasta que el programa compile. Después introduce el resultado que da fundamento al método, que es la parametricidad: un tipo suficientemente general restringe tanto el espacio de implementaciones posibles que a menudo deja una sola, y de ahí se sigue que leer una firma polimórfica permite deducir lo que la función puede y no puede hacer sin mirar su cuerpo. La consecuencia práctica se enuncia como una heurística de diseño: cuando una implementación admite demasiadas variantes plausibles, el problema no está en la implementación sino en que el tipo es demasiado ancho, y estrecharlo con tipos opacos y uniones convierte una decisión libre en una decisión forzada.
Hay dos maneras de empezar una función y la diferencia entre ellas no es de gusto. La primera consiste en escribir el cuerpo pensando en los pasos que hay que dar y añadir la firma al final, cuando ya se sabe qué entra y qué sale; la segunda consiste en escribir la firma primero, dejar el cuerpo vacío y usar el tipo como una pregunta a la que el resto del trabajo responde. La primera es la que casi todo el mundo aprende, porque es la única disponible en los lenguajes donde el tipo es un comentario verificado. La segunda es un método completo, con antecedentes teóricos sólidos y con una práctica muy desarrollada en la familia de lenguajes con tipos dependientes, donde se resume en tres verbos: escribir el tipo, definir el esqueleto y refinar los huecos. Elm no tiene tipos dependientes ni un mecanismo formal de huecos, pero tiene inferencia total, un compilador que dice con exactitud qué esperaba y una biblioteca estándar construida sobre esa costumbre. Con eso basta para practicar el método casi en su totalidad, y quien lo practica descubre pronto un efecto que no esperaba: no es que los tipos verifiquen el código que ya has pensado, es que te ahorran pensarlo.
- Aplicar el ciclo completo de escribir la firma, abrir el hueco y refinar hasta que el programa compile.
- Interrogar al compilador sobre el tipo esperado en un punto concreto mediante la técnica del valor deliberadamente incorrecto.
- Deducir de una firma polimórfica qué implementaciones son posibles, apoyándose en la parametricidad.
- Estrechar un tipo demasiado ancho con uniones y tipos opacos hasta que la implementación quede prácticamente forzada.
La firma como especificación
Una firma en Elm no es documentación: es un contrato que el compilador comprueba y, más importante, es una decisión de diseño tomada antes de escribir código. Cuando declaras que una función va de List Chiste a Maybe Chiste estás afirmando tres cosas a la vez, que puede no haber resultado, que el resultado es un elemento y no una lista, y que nada más entra en juego. Esas tres afirmaciones acotan el problema antes de resolverlo, y en la mayoría de los casos el trabajo difícil de programar consiste precisamente en acotar el problema.
-- Paso 1: escribir el tipo, sin cuerpo todavia
masVotado : List Chiste -> Maybe Chiste
masVotado chistes =
Debug.todo "pendiente"
-- Paso 2: el esqueleto que la forma del tipo sugiere
masVotado : List Chiste -> Maybe Chiste
masVotado chistes =
case chistes of
[] ->
Nothing
primero :: resto ->
Debug.todo "comparar primero con el mejor del resto"
-- Paso 3: refinar hasta que compile
masVotado : List Chiste -> Maybe Chiste
masVotado chistes =
List.foldl mejorDeDos Nothing chistes
El orden importa por una razón que se aprecia mejor invirtiéndolo. Si escribes primero el cuerpo, la firma que sale al final describe lo que hiciste, y por tanto hereda cualquier decisión tomada por inercia: una lista que podría haber sido un conjunto, un entero que representaba dos cosas distintas, un valor por defecto que se coló para no tratar un caso. Si escribes primero la firma, la implementación tiene que justificarse ante ella. El tipo deja de ser un resumen y pasa a ser una restricción, y las restricciones son lo que hace que un diseño tenga forma.
El hueco como pregunta
En los lenguajes con huecos tipados basta con escribir un símbolo especial y el compilador responde diciendo qué tipo se espera ahí y qué hay disponible en el contexto. Elm no ofrece ese mecanismo, pero permite una aproximación muy eficaz que conviene conocer porque cambia el ritmo de trabajo. La clave es que Debug.todo encaja con cualquier tipo y por tanto no provoca ningún mensaje informativo; para que el compilador hable hay que ponerle delante algo que sepa que está mal.
-- Debug.todo encaja con todo: el compilador no dice nada util
paso : Modelo -> Modelo
paso modelo =
Debug.todo "aqui"
-- Truco: escribe un valor imposible y el compilador revela lo que esperaba
paso : Modelo -> Modelo
paso modelo =
()
-- TYPE MISMATCH: something is off with the body of the paso definition
-- The body is: ()
-- But the type annotation on paso says it should be: Modelo
Merece la pena adoptar esta técnica como hábito y no como recurso desesperado. Cuando no sepas qué debe ir en un punto del cuerpo, no te quedes mirando la pantalla: escribe cualquier valor obviamente incorrecto en ese punto, compila y lee el tipo que el mensaje declara esperar. Estás usando el compilador como un servicio de consulta que responde en un segundo a la pregunta que de otro modo resolverías releyendo tres módulos. La variante avanzada consiste en dejar el hueco dentro de una expresión compuesta para averiguar el tipo intermedio de una tubería larga, que es exactamente el dato que falta cuando una composición no encaja y no se sabe en qué eslabón se rompió.
Parametricidad: cuántas implementaciones caben en un tipo
Aquí está el resultado teórico que convierte el método en algo más que una costumbre ordenada. En un lenguaje sin escotillas, sin reflexión y sin excepciones, una función polimórfica no puede inspeccionar los valores cuyo tipo es una variable: no sabe qué son, no puede compararlos, no puede fabricarlos. De esa incapacidad se sigue que el tipo restringe brutalmente el espacio de implementaciones posibles, hasta el punto de que en muchos casos lo reduce a una sola. Es lo que se conoce como parametricidad, y la consecuencia práctica es que leer una firma general permite deducir lo que la función hace sin abrir su cuerpo.
-- Solo hay una implementacion total posible: devolver el argumento
identidad : a -> a
-- Solo puede reordenar, repetir o descartar: jamas inventar un elemento
barajar : List a -> List a
-- No puede mirar dentro de a: solo aplicar la funcion recibida
mapear : (a -> b) -> Maybe a -> Maybe b
-- En cambio esta firma admite miles de implementaciones distintas
procesar : String -> String
El contraste entre las dos últimas es la lección entera condensada. mapear tiene un tipo tan estrecho que la implementación correcta es casi la única que compila, y por eso nadie necesita leer su documentación para saber qué hace. procesar tiene un tipo tan ancho que no dice absolutamente nada: podría recortar espacios, podría cifrar, podría devolver la cadena vacía siempre. Cuando una función necesita un nombre largo y un comentario para entenderse, casi siempre es porque su tipo ha renunciado a explicarla.
Menos libertad, más certeza
Cuanto más general es una variable de tipo, menos puede hacer la función con ella, y por tanto más se sabe de lo que hace sin leerla.
No puede inventar
Una función que devuelve a sin recibir ningún a no puede existir de forma total. La firma delata la imposibilidad antes de escribir nada.
Firmas que se buscan
En un ecosistema con tipos precisos se busca por firma y no por nombre, porque el tipo identifica la operación mejor que cualquier etiqueta.
Ancho igual a ambigüedad
String -> String admite infinitas implementaciones. Si el tipo no distingue, el compilador no puede ayudarte a elegir la correcta.
Estrechar el tipo hasta que la implementación sea obvia
De todo lo anterior sale la heurística que más rendimiento da en el trabajo diario, y conviene enunciarla como regla: cuando dudes entre varias implementaciones plausibles, el problema no está en la implementación sino en el tipo. Un tipo ancho traslada la decisión al cuerpo de la función y a la memoria del equipo; un tipo estrecho la traslada al sistema de tipos, donde no se olvida. Estrechar consiste en sustituir representaciones genéricas por tipos que solo admiten valores válidos, y el coste son unas líneas de declaración.
-- Ancho: nada impide pasar un correo donde se espera un identificador
registrar : String -> String -> Bool -> Resultado
-- Estrecho: cada argumento solo admite lo que significa
type Correo
= Correo String
type Alias
= Alias String
type Consentimiento
= Aceptado
| Rechazado
registrar : Correo -> Alias -> Consentimiento -> Resultado
flowchart LR A[Tipo ancho] --> B[Muchas implementaciones posibles] B --> C[La decision vive en la cabeza del equipo] D[Tipo estrecho] --> E[Pocas implementaciones posibles] E --> F[La decision vive en el compilador] F --> G[El hueco casi se rellena solo] style D fill:#a6e3a1,color:#11111b style C fill:#f38ba8,color:#11111b style G fill:#cba6f7,color:#11111b
El tercer argumento del ejemplo ilustra un caso especialmente frecuente. Un booleano es el tipo más ancho que existe para una decisión, porque sus dos valores no dicen a qué pregunta responden: en el punto de llamada aparece una constante suelta que no significa nada y que se puede invertir sin que nada proteste. Sustituirlo por una unión de dos constructores con nombre no añade capacidad ninguna al programa y sin embargo elimina de golpe una familia entera de errores, hace legible la llamada y prepara el terreno para el día en que aparezca un tercer caso, que en los dominios reales aparece casi siempre.
Detrás de todo este método hay una idea que cuesta ver mientras se practica y que reordena la relación entera con el sistema de tipos, y es que un tipo no describe datos: recorta un espacio de programas. Cada firma que escribes traza una frontera y declara que las implementaciones que quedan fuera no son legítimas, y por tanto la pregunta central del diseño no es qué tipo describe mejor mis datos sino cuánta libertad quiero dejarle al que escriba el cuerpo, incluido yo dentro de seis meses. Esa reformulación tiene consecuencias que van bastante más allá de Elm. Explica por qué el reflejo de generalizar prematuramente es más dañino de lo que parece: cada vez que ensanchas un tipo para que sirva también para otro caso, estás devolviendo libertad al cuerpo y con ella devolviendo la posibilidad de equivocarse, y lo estás haciendo para atender a un caso hipotético a costa del caso real. Explica por qué la biblioteca estándar de un lenguaje bien diseñado está llena de firmas polimórficas cortas: no es elegancia gratuita, es que un tipo lo bastante general se documenta solo y se implementa casi solo. Explica por qué las funciones más difíciles de mantener de cualquier proyecto son siempre las que reciben cadenas y booleanos sueltos y devuelven cadenas: no son difíciles porque su lógica sea compleja, son difíciles porque su tipo abdicó y toda la información se refugió en nombres, comentarios y costumbres orales que se erosionan. Y explica, por último, la sensación desconcertante que describe todo el que practica el método una temporada, la de que a veces la función se escribe sola. No hay magia en eso ni hay un compilador que adivine intenciones: lo que ocurre es que el trabajo intelectual se hizo antes, al elegir el tipo, y que un espacio de programas suficientemente recortado contiene tan pocos habitantes que encontrar el correcto deja de ser un acto de creación y pasa a ser un acto de lectura. Pensar en tipos, en definitiva, es hacer el diseño en el único momento en que todavía es barato cambiarlo.
- Escribe cinco firmas de funciones que necesites y no implementes ninguna hasta tener las cinco; observa cuántas decisiones de diseño tomaste sin escribir código.
- Usa el truco del valor imposible en tres puntos distintos de una tubería larga y anota el tipo intermedio que el compilador revela en cada uno.
- Enumera todas las implementaciones totales posibles de
a -> a, deList a -> List ay deMaybe a -> a, y explica por qué la última no puede existir. - Toma una función tuya cuyo tipo sea de cadena a cadena y estrecha su firma con tipos opacos hasta que su nombre resulte innecesario.
- Sustituye un parámetro booleano por una unión de dos constructores y compara la legibilidad de tres puntos de llamada antes y después.
- Busca en la biblioteca estándar dos funciones cuya firma te permita predecir su comportamiento sin leer la documentación y explica qué propiedad de sus tipos lo hace posible.