Dart 3.1 y una retrospectiva sobre programación de estilo funcional en Dart 3

La coincidencia de patrones y los switches exhaustivos se unen para habilitar modelos de datos de estilo funcional que se integran perfectamente con…

Pattern matching y switches exhaustivos se unen para habilitar modelos de datos de estilo funcional que se integran de forma fluida con el núcleo orientado a objetos de Dart.

#
Un diff de un refactor de Dart 3 usando características de estilo funcional dentro del código base interno de Dart.
Diff de un refactor de Dart 3 usando características de estilo funcional dentro del código base interno de Dart

Hoy lanzamos Dart 3.1, nuestra primera versión estable desde el gran lanzamiento de Dart 3.0 en mayo. Dart 3.1 contiene un puñado de actualizaciones menores y algunos ajustes de API para un mayor uso de los modificadores de clase introducidos en 3.0 (que puedes leer más en el changelog). Sin embargo, la mayor parte de nuestro tiempo lo hemos dedicado a nuevos elementos del roadmap que esperamos que lleguen a beta y estable en las próximas versiones. ¡Estén atentos a más sobre eso en el futuro!

Así que, en lugar de un post de versión tradicional, estamos revisitando un subconjunto de las principales características de Dart 3 para hablar sobre cómo pueden cambiar completamente, y en algunos casos, mejorar significativamente la forma en que escribes y estructuras tu código Dart.

¿Cómo modelas los datos?

#

Los lenguajes orientados a objetos (OO) y funcionales difieren en muchas formas, pero se puede argumentar que cómo cada paradigma modela los datos es la característica definitoria que los separa. Específicamente, la cuestión de modelar diferentes variaciones de datos relacionados y las operaciones sobre esas variantes.

Pero "¿cómo debería modelar estos datos?" normalmente no es algo a lo que le demos mucha atención consciente al iniciar un nuevo proyecto. Tendemos a usar por defecto el paradigma de modelado de datos que es común al tipo de lenguaje que estamos usando, en contraposición a lo inverso de elegir el lenguaje basándonos en el modelo que tiene más sentido para nuestros datos.

Si estás usando un lenguaje OO, modelarás los datos con jerarquías de clases y operaciones en subtipos. Si estás usando ciertos lenguajes funcionales, el equivalente al modelo de jerarquía de clases es el modelo de tipo de dato algebraico, cuyo equivalente a operar en subtipos es hacer switching sobre ellos con pattern matching.

Una comparación simplificada lado a lado del modelo de jerarquía de clases orientado a objetos y el modelo de tipo de dato algebraico funcional.
Comparación simplificada lado a lado del modelo de jerarquía de clases orientado a objetos y el modelo de tipo de dato algebraico funcional

Dart es un lenguaje orientado a objetos, pero ha ido incorporando características funcionales de manera constante con el tiempo, permitiendo un enfoque más multiparadigma para el modelado de datos. Más recientemente, Dart 3 añadió pattern matching, nueva funcionalidad en switch, y sealed types. Estas características hacen posible implementar tipos de datos algebraicos en Dart, permitiéndote escribir código de estilo funcional mientras continúas maximizando las capacidades del núcleo orientado a objetos de Dart.

Los lenguajes multiparadigma como Dart te dan las herramientas y la oportunidad de elegir cómo diseñas cualquier cosa, desde una expresión de una sola línea hasta jerarquías de clases enteras. Puedes considerar qué modelo tiene más sentido para tu proyecto, o incluso solo para tu preferencia personal. Para ayudarte a tomar la mejor decisión, este artículo resume la estructura y fortalezas de cada paradigma por separado, y luego te enseña cómo usar las nuevas características de Dart 3 para refactorizar algunos diseños clásicos orientados a objetos que más se benefician de ser escritos en un estilo funcional.

Enfoque orientado a objetos

#

Cuando tienes operaciones específicas para diferentes tipos de datos, el enfoque estándar de organización en lenguajes OO es crear un método en una clase base, y un conjunto de subclases que sobrescriben la clase base para definir su comportamiento único. Cada subclase tiene sus datos y operaciones juntos en un lugar dentro de su declaración.

Toma este ejemplo (pseudocódigo de alto nivel) de modelado de recetas. Tiene sentido tener objetos de receta, acoplando ingredientes y pasos junto con la receta. La clase base de receta probablemente tendría algunas funciones para los métodos de cocción que cada receta sobrescribe con sus requisitos únicos:

Las jerarquías de clases con métodos de instancia facilitan añadir nuevas subclases sin tener que tocar ningún código existente. Esto funciona perfectamente para algunos dominios, como Flutter, donde tienes incontables widgets que todos extienden la clase Widget. Cada widget puede extender y sobrescribir de forma única cualquier comportamiento necesario directamente dentro de su definición. Definitivamente no necesitas saber cómo cada subtipo de widget define sus métodos para añadir comportamiento especializado a tus propios widgets.

Enfoque funcional (tipos de datos algebraicos)

#

Puedes pensar en la arquitectura de estilo funcional como la inversa de la arquitectura OO. En lugar de tener todo el código para un tipo en un lugar (métodos de instancia OO en declaraciones de subclases), tienes todo el código para una operación en un lugar (switching funcional sobre tipos para definir comportamiento).

Lo que plantea la pregunta, ¿cuándo tiene sentido saber cómo cada subtipo en una jerarquía define una operación? Podría ser por varias razones:

  • Cuando añadir, mantener y entender variantes del comportamiento de una misma operación entre tipos es más fácil de hacer cuando están todos lado a lado en el código.

  • Cuando no puedes modificar las subclases tú mismo, pero quieres definir un nuevo comportamiento específico para cada una de ellas.

  • Cuando las variaciones del comportamiento de una operación para diferentes tipos están más relacionadas entre sí que con los tipos sobre los que operan*.*

A veces es obvio, pero mayormente es solo un cambio de perspectiva. Piensa en el ejemplo de la receta otra vez. Desde el punto de vista de, digamos, un manual de horno, tendría mucho más sentido agrupar las instrucciones de horneado en un solo lugar para cada receta:

En este ejemplo, la estructura del programa se centra en la operación bake. Cualquiera que sean los tipos sobre los que opera bake, son simplemente diferentes posibles salidas de la misma función; bake es independiente de los tipos sobre los que opera.

Este es el modelo de tipo de dato algebraico (llamado "algebraico" por la teoría de conjuntos matemática). Es el modelo organizacional central de los lenguajes funcionales, de la misma manera que las jerarquías de clases son centrales para los lenguajes OO. Los tipos de datos algebraicos separan el comportamiento de los datos agrupando el comportamiento para todos los tipos por operación.

¡Y ahora, es posible implementar de forma cohesiva tipos de datos algebraicos con Dart 3!

Modelado de tipos de datos algebraicos orientados a objetos

#

Los lenguajes funcionales generalmente implementan tipos de datos algebraicos mediante pattern matching sobre los casos en un sum type para asignar comportamiento a cada variante. Dart 3 logra lo mismo con pattern matching en casos de switch, y aprovecha el hecho de que el subtipado OO ya modela naturalmente los sum types. Esto nos permite implementar verdaderos tipos de datos algebraicos multiparadigma usando objetos que se integran en Dart de forma fluida.

Las siguientes secciones te muestran cómo diseñar modelos de tipos de datos algebraicos en Dart, junto con ejemplos pre-Dart 3 de la misma funcionalidad.

Agrupando comportamientos entre tipos

#

Partes individuales del lenguaje Dart (como sentencias, clases, y literales) cada una tiene sus propias definiciones en jerarquías de clases, pero todas están sujetas a operaciones de múltiples sistemas (como operaciones de parser, formatter, y compiler). ¡Imagina lo confusa que sería la implementación del lenguaje si cada función que aplica a cada elemento del lenguaje tuviera que definirse dentro de las declaraciones de esos elementos! Se vería algo así:

El código interno de Dart ya se inclina naturalmente hacia el enfoque funcional de separar funciones de las definiciones de tipos por esta razón. Toma la biblioteca annotation_verifier en el analyzer de Dart. Contiene funciones que definen comportamiento para anotaciones (como @override o @deprecated) dependiendo de la parte del código a la que está adjunta la anotación (como cómo @override afecta a una clase en contraposición a un campo).

Pero asignar comportamiento por tipo no es tan sencillo como tomar la decisión de separar el comportamiento en primer lugar. La forma estándar de definir comportamientos por tipo usa sentencias if-else encadenadas, que se ven mucho en el verificador de anotaciones. Toma la siguiente función de verificación escrita sin usar ninguna característica de Dart 3. Verifica el comportamiento de la anotación recientemente contribuida @visibleOutsideTemplate que opta por no participar de los efectos en cascada de otra anotación, @visibleForTemplate:

La función usa elaboradas sentencias if-else encadenadas, verificando si el abuelo de la anotación es un cierto tipo de declaración (ya sea ClassDeclaration, EnumDeclaration, o MixinDeclaration), y luego definiendo su comportamiento basándose en el tipo.

Con Dart 3, puedes usar object patterns en casos de switch para refactorizar significativamente esta estructura a un estilo más declarativo, haciénndola más corta y fácil de leer. Y el autor original hizo exactamente eso! 16 líneas de sentencias if-else encadenadas se reducen a una sentencia switch de 7 líneas:

Cada caso aquí es un object pattern que coincide con el tipo estático de grandparent. En lugar de decir if (object is Type && object.property != null), cada caso verifica si el patrón del objeto coincide con el patrón Type(propertyOfType). Además, cuando un objeto coincide con un object pattern, implícitamente requiere que no sea null, así que no hay necesidad de una verificación explícita de null!

Los object patterns también pueden contener variable patterns anidados que te permiten extraer (o desestructurar) valores de propiedades de un objeto en la misma línea de código en la que estás haciendo matching. La sintaxis (:var metadata) simplemente significa "coincide y declara una nueva variable con el mismo nombre que este getter". Así es como la variable metadata entra en el scope para el bucle for final. ¡Bastante conciso!

Notar que el bucle for ahora es común entre cada caso. La propiedad declaredElement de cada tipo es en realidad un subtipo diferente de otro tipo, InterfaceElement (ya sea un classElement, enumElement, o mixinElement). Así que, la sentencia if-else encadenada pre-Dart 3 iteraba sobre metadata en cada cláusula if por separado, para asegurar que la anotación final fuera type safe para cada uno de los posibles tipos que metadata podría tomar.

Ahora, la estructura refactorizada usa object patterns profundamente anidados para cada caso, para hacer un upcast de metadata a su supertipo, InterfaceElement. Esto hace que un único bucle for compartido iterando sobre metadata sea type safe entre los casos.

Sintaxis de patterns de Dart anotada mostrando un object pattern, object pattern anidado, upcast, y variable pattern.
Representación anotada de la sintaxis para un object pattern y variable patterns profundamente anidados

Hacer switching sobre object patterns importa para la implementación de tipos de datos algebraicos de Dart por su capacidad de probar subtipos y desestructurar valores de forma concisa. Un buen efecto secundario son las garantías simultáneas que puede proporcionar una sola línea de código. Para reiterar, cada case pattern en este refactor está verificando simultáneamente:

  • El objeto es uno de los tipos ClassDeclaration, EnumDeclaration, o MixinDeclaration.

  • El objeto tiene la propiedad declaredElement.

  • declaredElement tiene la propiedad metadata.

  • metadata es de tipo InterfaceElement.

  • Ninguno de los objetos o propiedades en cuestión es null.

Este es el ejemplo perfecto de qué tan exhaustivamente Dart 3 implementa patterns para contemplar tantos matices de los lenguajes OO, y realmente hacer de los tipos de datos algebraicos orientados a objetos una opción de diseño realista en Dart.

Las pruebas de tipo sobre object patterns son geniales para separar el comportamiento de los tipos. Pero le falta una característica del subtipado OO, donde el compilador te avisa si declaras un nuevo subtipo pero no defines comportamiento para uno de los métodos abstractos de su supertipo. ¿Cómo puede el modelo de tipo de dato algebraico de Dart implementar las mismas garantías de seguridad cuando ya no tratamos con métodos de instancia en declaraciones de tipos? La respuesta es exhaustiveness checking.

Exhaustiveness checking

#

Las implementaciones de tipos de datos algebraicos de los lenguajes funcionales usan sum types enumerables, lo que significa que el compilador siempre está al tanto de todas las posibles variaciones del tipo sobre el que se hace switching. El compilador puede entonces avisarte si a tu switch le falta un caso, y por lo tanto que es posible que algunos valores pasen por ese switch sin ser tratados.

Esto se llama exhaustiveness checking. Técnicamente siempre ha existido en Dart para tipos enumerables como enums y booleanos. Esos tipos tienen un conjunto de valores posibles que no puede cambiar, y si estás haciendo switching sobre ellos, el compilador sabe cuándo te falta uno y te avisa. Usar una cláusula default es otro tipo de pseudo exhaustividad. Como default coincide con todos los casos no contemplados explícitamente, hace que el compilador considere un switch exhaustivo sin saber si todos los tipos potenciales están realmente contemplados.

Como se mencionó, queríamos usar subtipos en lugar de sum types para la versión de Dart del modelado de tipos de datos algebraicos. Pero como las clases en Dart pueden ser extendidas desde cualquier biblioteca, sería imposible para el compilador enumerar exhaustivamente los subtipos de una clase ya que no puede saber si hay subclases declaradas en bibliotecas externas.

Para solucionar esto y completar la implementación de tipos de datos algebraicos de Dart, añadimos el modificador de clase sealed a Dart 3. Una clase sealed no puede ser extendida ni implementada desde ninguna biblioteca excepto la suya (el archivo que contiene su definición). Esto asegura que el compilador siempre esté al tanto de todos los subtipos posibles, haciénndolos completamente enumerables.

Aquí hay un ejemplo de un refactor real que entró en el SDK de Dart como parte de la versión 3.1: sellar FileSystemEvent para que sus subtipos puedan ser exhaustivamente switchados. Prepárate, los refactors son difíciles…

¡Solo bromeaba, no fue difícil en absoluto! Cabe señalar, sin embargo, que sellar una jerarquía de clases existente es un breaking change. El código que apunta a versiones anteriores de Dart no podrá implementar ni extender la clase, así que siempre verifica las dependencias y alerta a cualquier usuario que pueda estar subtipando tus clases en otro lugar.

Sellar FileSystemEvent permite que los eventos producidos por FileSystemEntity.watch, que corresponden a los subtipos de FileSystemEvent, sean exhaustivamente switchados. Es típico escuchar este stream de eventos y usar sentencias if-else encadenadas para determinar una acción basándose en el tipo de los eventos que ocurren.

Pero sellar la clase base no solo te permite hacer switching sobre object patterns, como el ejemplo de _checkVisibleOutsideTemplate de la sección anterior. También asegura que al hacerlo, estás contemplando cada posible valor que podría surgir para ese tipo, sin necesidad de un caso default:

Si alguna vez se añadiera un nuevo subtipo que extienda FileSystemEvent, quizás FileSystemSyncEvent por ejemplo, el compilador sería consciente de ello porque solo podría añadirse a la misma biblioteca que FileSystemEvent. Como la jerarquía de clases está sealed, el compilador requiere que cualquier switch sobre sus instancias sea exhaustivo, y generará un error para alertar al usuario (quien escribió el switch, no el dueño de la biblioteca) de casos no manejados:

bash
The type 'FileSystemEvent' is not exhaustively matched by the switch cases since it doesn't match 'FileSystemSyncEvent'

Combinar clases sealed y switches sobre object patterns habilita un estilo completo de arquitectura de programas orientada a objetos con tipos de datos algebraicos en Dart.

Características funcionales adicionales

#

El ejemplo de switch exhaustivo anterior incluye aún más características funcionales de Dart 3 además de las que facilitan los tipos de datos algebraicos.

Notar que el switch está a la derecha de la sentencia return de la función _fileListener — eso es la nueva switch expression de Dart 3. El énfasis general en expresiones y funciones es un elemento clave de los lenguajes funcionales. Dart 3 creó switch expressions que pueden producir un valor y ir en cualquier lugar donde se permita una expresión.

¿Y qué está devolviendo _fileListener en el ejemplo anterior, de todos modos? Eso es un record, otra nueva característica de Dart 3 también relacionada con la programación funcional. Los records te permiten devolver múltiples valores heterogéneos desde una función, ampliando la utilidad de las funciones en Dart, y alejándose aún más de la dependencia en clases personalizadas (que sería la única otra forma de devolver múltiples valores de diferentes tipos sin perder sus tipos en el proceso).

Resumen

#

Puedes modelar tipos de datos algebraicos en Dart:

  • Escribir una función que hace switch on una instancia de una clase sealed, y over sus subtipos,

  • Y definiendo varianzas en el comportamiento de cada subtipo en los casos del switch.

Hacer switching sobre object patterns te permite mantener todas las operaciones juntas de forma sucinta, y el exhaustiveness checking asegura que el compilador te alerte si te faltan definiciones de comportamiento para algún tipo. Y todo está construido sobre las clases orientadas a objetos que Dart ya usa.

Lo mejor es que no tienes que elegir entre un estilo orientado a objetos o funcional; los dos paradigmas se integran y puedes usar el estilo que mejor se ajuste a la operación que estás definiendo.

Puedes hacer que las jerarquías de clases existentes sean más funcionales con modificaciones menores, e incluso mezclar el uso de métodos de instancia con tipos de datos algebraicos dentro de la misma jerarquía de clases. Ya sea que tenga sentido acoplar comportamiento estrechamente a un tipo, o agrupar comportamiento para diferentes tipos en una función, puedes usar el estilo que tenga más sentido.

Esperamos que esta introducción despierte tu interés en la programación funcional y probar las nuevas características de Dart 3. ¡Quién sabe, quizás veamos el primer programa Dart completamente de estilo funcional de uno de ustedes pronto!

Recursos

#

Para más información sobre programación funcional en y alrededor de Dart, consulta estos recursos:

Más de Dart