built_value de Dart para modelos de objetos inmutables

La semana pasada escribí sobre built_collection. Terminé comentando que para realmente aprovechar las colecciones inmutables, necesitas colecciones inmutables…

La semana pasada escribí sobre built_collection. Terminé comentando que para realmente aprovechar las colecciones inmutables, necesitas valores inmutables. Así que aquí estamos: built_value. Esta es la segunda pieza importante detrás de mi charla en el Dart Developer Summit (video).

Tipos de valor

#

El paquete built_value es para definir tus propios tipos de valor. El término tiene un significado preciso, pero lo usamos informalmente para referirnos a tipos donde la igualdad se basa únicamente en el valor. Por ejemplo, los números: mi 3 es igual a tu 3.

No solo eso: mi 3 siempre será igual a tu 3; no puede cambiar a ser 4, o null, o un tipo completamente diferente. Los tipos de valor son naturalmente inmutables. Esto los hace simples de usar y de razonar sobre ellos.

Todo esto suena terriblemente abstracto. ¿Para qué sirven los tipos de valor? Bueno, resulta que para mucho. Muchísimo. Podría argumentarse — y de hecho lo argumento, a menudo — que cualquier clase que se use para modelar el mundo real debería ser un tipo de valor. Observa:

var user1 = new User(name: "John Smith");
var user2 = new User(name: "John Smith");

print(user1 == user2);

¿Qué debería imprimir? Crucialmente, se supone que ambas instancias se refieren a alguien en el mundo real. Como sus valores son idénticos deben referirse a la misma persona. Así que deben considerarse iguales.

¿Qué pasa con la inmutabilidad? Considera:

user1.nickname = 'Joe';

¿Qué significa actualizar el apodo de un “User”? Podría implicar cualquier número de cambios; tal vez el texto de bienvenida en mi página web usa el apodo, y eso debería actualizarse. Probablemente tengo algún almacenamiento en algún lugar, así que eso también necesitará actualizarse. Ahora tengo dos problemas principales:

  • No sé quién tiene una referencia a “user1”. El valor acaba de cambiar debajo de ellos; dependiendo de cómo lo estén usando, esto podría tener cualquier número de efectos impredecibles.

  • Cualquiera que tenga “user2” o similar ahora tiene un valor desactualizado.

La inmutabilidad no puede ayudar con el segundo problema, pero sí elimina el primero. Significa que no hay actualizaciones impredecibles, solo explícitas:

var updatedUser = new User(name: "John Smith", nickname: "Joe");
saveToDatabase(updatedUser); // Database will notify frontend.

Crucialmente, significa que los cambios son locales hasta que se publican explícitamente. Esto conduce a código simple que es fácil de razonar — y de hacer tanto correcto como rápido.

El problema con los tipos de valor

#

Entonces, la pregunta obvia: si los tipos de valor son tan útiles, ¿por qué no los vemos por todas partes?

Desafortunadamente son extremadamente laboriosos de implementar. En Dart y en la mayoría de los otros lenguajes orientados a objetos, se necesita una gran cantidad de código repetitivo. En mi charla en el Dart Developer Summit mostré cómo una simple clase de dos campos necesita tanto código repetitivo que llena una diapositiva entera (video).

Introduciendo built_value

#

Necesitamos o bien una característica del lenguaje — lo cual es emocionante de discutir, pero poco probable que llegue pronto — o alguna forma de metaprogramación. Y lo que encontramos es que Dart ya tiene una forma muy agradable de hacer metaprogramación: source_gen.

El objetivo es claro: hacer que definir y usar tipos de valor sea tan fácil que podamos usarlos dondequiera que un tipo de valor tenga sentido.

Primero necesitaremos un breve desvío para ver cómo se puede abordar este problema con source_gen. La herramienta source_gen crea código fuente generado en nuevos archivos junto a tu código fuente mantenido manualmente, así que necesitamos dejar espacio para una implementación generada. Eso significa una clase abstracta:

abstract class User {
  String get name;

  @nullable
  String get nickname;
}

Eso tiene suficiente información para generar una implementación. Por convención, el código generado comienza con “$”, para marcarlo como privado y generado. Así que la implementación generada se llamará “$User”. Para permitir que extienda “User” habrá un constructor privado para este propósito llamado “_”:

=== user.dart ===

abstract class User {
  String get name;

  @nullable
  String get nickname;

  User._();
  factory User() = UserImpl;
}

=== user.g.dart is generated by source_gen ===

class _$User extends User {
  String name;
  String nickname;

  _$User() : super._();
}

Necesitamos usar la declaración “part” de Dart para incorporar el código generado:

=== user.dart ===

library user;

part 'user.g.dart';

abstract class User {
  String get name;

  @nullable
  String get nickname;

  User._();
  factory User() = _$User;
}

=== user.g.dart is generated by source_gen ===

part of user;

class _$User extends User {
  String name;
  String nickname;

  _$User() : super._();

  // Generated implementation goes here.
}

¡Estamos llegando a algún lado! Tenemos una forma de generar código y conectarlo al código que escribimos a mano. Ahora volvamos a la parte interesante: lo que realmente tienes que escribir a mano y lo que built_value debería generar.

Nos falta una forma de especificar realmente los valores para los campos. Podríamos pensar en usar parámetros opcionales nombrados:

factory User({String name, String nickname}) = _$User;

Pero esto tiene un par de inconvenientes: te obliga a repetir todos los nombres de los campos en el constructor, y solo proporciona una forma de establecer todos los campos de una sola vez; ¿qué pasa si quieres construir un valor pieza por pieza?

Afortunadamente, el patrón builder viene al rescate. Ya hemos visto qué tan bien funciona para colecciones en Dart — gracias al operador cascade. Asumiendo que tenemos un tipo builder, podemos usarlo para el constructor — pidiendo una función que tome un builder como parámetro:

abstract class User {
  String get name;

  @nullable
  String get nickname;

  User._();
  factory User([updates(UserBuilder b)]) = _$User;
}

Eso es un poco sorprendente, pero conduce a una sintaxis muy simple para la instanciación:

var user1 = new User((b) => b
    ..name = 'John Smith'
    ..nickname = 'Joe');

¿Qué hay de crear nuevos valores basados en los anteriores? El patrón tradicional de builder proporciona un método “toBuilder” para convertir a un builder; luego aplicas tus actualizaciones y llamas a “build”. Pero un patrón más agradable para la mayoría de los casos de uso es tener un método “rebuild”. Al igual que el constructor, toma una función que toma un builder, y permite actualizaciones inline fáciles:

var user2 = user.rebuild((b) => b
    ..nickname = 'Jojo');

Sin embargo, todavía queremos “toBuilder”, para casos en los que quieras mantener un builder por un rato. Así que queremos dos métodos para todos nuestros tipos de valor:

abstract class Built<V, B> {
  // Creates a new instance: this one with [updates] applied.
  V rebuild(updates(B builder));

  // Converts to a builder.
  B toBuilder();
}

No necesitas escribir la implementación de estos, built_value la generará por ti. Así que puedes simplemente declarar que “implement Built”:

library user;

import 'package:built_value/built_value.dart';

part 'user.g.dart';

abstract class User implements Built<User, UserBuilder> {
  String get name;

  @nullable
  String get nickname;

  User._();
  factory User([updates(UserBuilder b)]) = _$User;
}

¡Y eso es todo! Un tipo de valor definido, una implementación generada y fácil de usar. Por supuesto, la implementación generada no son solo campos: también proporciona “operator==”, “hashCode”, “toString” y verificaciones de null para los campos obligatorios.

Sin embargo, he omitido un detalle importante: dije “asumiendo que tenemos un tipo builder”. Por supuesto, estamos generando código, así que la respuesta es simple: lo generaremos por ti. El “UserBuilder” referenciado desde “User” se crea en “user.g.dart”.

a menos que quisieras escribir algo de código en el builder, lo cual es algo perfectamente razonable de querer hacer. Si eso es lo que quieres, sigues el mismo patrón para el builder. Se declara como abstracto, con un constructor privado y una factory que delega a la implementación generada:

abstract class UserBuilder extends Builder<V, B> {
  @virtual
  String name;

  @virtual
  String nickname;

  // Parses e.g. John "Joe" Smith into username+nickname.
  void parseUser(String user) {
    ...
  }

  UserBuilder._();
  factory UserBuilder() => _$UserBuilder;
}

Las anotaciones “@virtual” provienen de “package:meta”, y son necesarias para permitir que la implementación generada sobrescriba los campos. Ahora que has añadido métodos de utilidad a tu builder puedes usarlos inline tal como podrías asignar a campos:

var user = new User((b) => b..parseUser('John "Joe" Smith'));

Los casos de uso para personalizar un builder son relativamente raros, pero pueden ser muy poderosos. Por ejemplo, podrías querer que tus builders implementen una interfaz común para establecer campos compartidos, de modo que puedan usarse indistintamente.

Builders anidados

#

Hay una característica importante de built_value que aún no has visto: builders anidados. Cuando un campo de built_value contiene un built_collection u otro built_value, por defecto está disponible en el builder como un builder anidado. Esto significa que puedes actualizar campos profundamente anidados más fácilmente que si toda la estructura fuera mutable:

var structuredData = new Account((b) => b
    ..user.name = 'John Smith'
    ..user.nickname = 'Joe'
    ..credentials.email = 'john.smith@example.com'
    ..credentials.phone.country = Country.us
    ..credentials.phone.number = '555 01234 567');

var updatedStructuredData = structuredData.rebuild((b) => b
    ..credentials.phone.country = Country.switzerland
    ..credentials.phone.number = '555 01234 555');

¿Por qué “más fácilmente” que si la estructura fuera mutable?

En primer lugar, el método “update” proporcionado por todos los builders significa que puedes entrar en un nuevo scope cuando quieras, “reiniciando” el operador cascade y haciendo las actualizaciones que quieras tanto de forma concisa como inline:

var updatedStructuredData = structuredData.rebuild((b) => b
    ..user.update((b) => b
        ..name = 'Johnathan Smith')
    ..credentials.phone.update((b) => b
        ..country = Country.switzerland
        ..number = '555 01234 555'));

En segundo lugar, los builders anidados se crean automáticamente según sea necesario. Por ejemplo, en el código de benchmark de built_value definimos un tipo llamado Node:

abstract class Node implements Built<Node, NodeBuilder> {
  @nullable
  String get label;
  @nullable
  Node get left;
  @nullable
  Node get right;

  Node._();
  factory Node([updates(NodeBuilder b)]) = _$Node;
}

Y la creación automática de builders nos permite crear cualquier estructura de árbol que queramos inline:

var node = new Node((b) => b
  ..left.left.left.right.left.right.label = 'I’m a leaf!'
  ..left.left.right.right.label = 'I’m also a leaf!');

var updatedNode = node.rebuild((b) => b
  ..left.left.right.right.label = 'I’m not a leaf any more!'
  ..left.left.right.right.right.label = 'I’m the leaf now!');

¿Mencioné un benchmark? Al actualizar, built_value solo copia las partes de la estructura que necesitan actualización, reutilizando el resto. Así que es rápido — y eficiente en memoria*.*

Pero no solo puedes construir árboles. Con built_value tienes a tu disposición modelos de objetos inmutables completamente tipados … que son tan rápidos y poderosos como árboles inmutables eficientes. Puedes mezclar y combinar datos tipados, estructuras personalizadas como el ejemplo “Node”, y colecciones de built_collection:

var structuredData = new Account((b) => b
    ..user.update((b) => b
        ..name = 'John Smith')
    ..credentials.phone.update((b) => b
        ..country = Country.us
        ..number = '555 01234 567')
    ..node.left.left.left.account.update((b) => b
        ..user.name = 'John Smith II'
        ..user.nickname = 'Is lost in a tree')
    ..node.left.right.right.account.update((b) => b
        ..user.name = 'John Smith III'));

Estos son los tipos de valor de los que hablo cuando argumento que la mayoría de los datos deberían ser tipos de valor!

Más sobre built_value

#

He cubierto por qué built_value es necesario y cómo se ve usarlo. Hay más por venir: built_value también proporciona EnumClass, para clases que actúan como enums, y serialización JSON, para comunicación servidor/cliente y almacenamiento de datos. Hablaré sobre eso en futuros artículos.

Después de eso profundizaré en el ejemplo de chat que usa built_value en un sistema de extremo a extremo con servidor y cliente.

Edición: siguiente artículo.

Más de Dart