Saltar al contenido principal

Tipos de extensión

Aprende cómo escribir una interfaz exclusivamente estática para un tipo existente.

Un tipo de extensión es una abstracción en tiempo de compilación que "envuelve" un tipo existente con una interfaz diferente y exclusivamente estática. Son un componente principal de la interoperabilidad estática de JS (static JS interop) porque pueden modificar fácilmente la interfaz de un tipo existente (crucial para cualquier tipo de interoperabilidad) sin incurrir en el costo de un contenedor real.

Los tipos de extensión imponen disciplina en el conjunto de operaciones (o interfaz) disponibles para los objetos de un tipo subyacente, llamado tipo de representación (representation type). Al definir la interfaz de un tipo de extensión, puedes optar por reutilizar algunos miembros del tipo de representación, omitir otros, reemplazar otros y añadir nueva funcionalidad.

El siguiente ejemplo envuelve el tipo int para crear un tipo de extensión que solo permite operaciones que tengan sentido para números de identificación (ID):

dart
extension type IdNumber(int id) {
  // Wraps the 'int' type's '<' operator:
  operator <(IdNumber other) => id < other.id;
  // Doesn't declare the '+' operator, for example,
  // because addition does not make sense for ID numbers.
}

void main() {
  // Without the discipline of an extension type,
  // 'int' exposes ID numbers to unsafe operations:
  int myUnsafeId = 42424242;
  myUnsafeId = myUnsafeId + 10; // This works, but shouldn't be allowed for IDs.

  var safeId = IdNumber(42424242);
  safeId + 10; // Compile-time error: No '+' operator.
  myUnsafeId = safeId; // Compile-time error: Wrong type.
  myUnsafeId = safeId as int; // OK: Run-time cast to representation type.
  safeId < IdNumber(42424241); // OK: Uses wrapped '<' operator.
}

Sintaxis

#

Declaración

#

Define un nuevo tipo de extensión con la declaración extension type y un nombre, seguido de la declaración del tipo de representación entre paréntesis:

dart
extension type E(int i) {
  // Define set of operations.
}

La declaración del tipo de representación (int i) especifica que el tipo subyacente del tipo de extensión E es int, y que la referencia al objeto de representación se llama i. La declaración también introduce:

  • Un getter implícito para el objeto de representación con el tipo de representación como tipo de retorno: int get i.
  • Un constructor implícito: E(int i) : i = i.

El getter de representación da acceso al objeto de representación tipado como el tipo subyacente. El getter está dentro del alcance (scope) en el cuerpo del tipo de extensión, y puedes acceder a él usando su nombre como cualquier otro getter:

  • Dentro del cuerpo del tipo de extensión usando i (o this.i).
  • Fuera con una extracción de propiedad usando e.i (donde e tiene el tipo de extensión como su tipo estático).

Las declaraciones de tipos de extensión también pueden incluir parámetros de tipo al igual que las clases o extensiones:

dart
extension type E<T>(List<T> elements) {
  // ...
}

Constructores

#

Opcionalmente, puedes declarar constructores en el cuerpo de un tipo de extensión. La declaración de representación en sí misma es un constructor implícito, por lo que de forma predeterminada toma el lugar de un constructor sin nombre para el tipo de extensión. Cualquier constructor generativo no redireccionado adicional debe inicializar la variable de instancia del objeto de representación usando this.i en su lista de inicializadores o parámetros formales.

dart
extension type E(int i) {
  E.n(this.i);
  E.m(int j, String foo) : i = j + foo.length;
}

void main() {
  E(4); // Implicit unnamed constructor.
  E.n(3); // Named constructor.
  E.m(5, "Hello!"); // Named constructor with additional parameters.
}

O bien, puedes nombrar el constructor de la declaración de representación, en cuyo caso hay espacio para un constructor sin nombre en el cuerpo:

dart
extension type const E._(int it) {
  E(): this._(42);
  E.otherName(this.it);
}

void main2() {
  E();
  const E._(2);
  E.otherName(3);
}

También puedes ocultar por completo el constructor, en lugar de simplemente definir uno nuevo, utilizando la misma sintaxis de constructor privado que para las clases, _. Por ejemplo, si solo quieres que los clientes construyan E con un String, a pesar de que el tipo subyacente es int:

dart
extension type E._(int i) {
  E.fromString(String foo) : i = int.parse(foo);
}

También puedes declarar constructores generativos de reenvío, o constructores de tipo factory (que también pueden reenviar a constructores de subtipos de extensión).

Miembros

#

Declara miembros en el cuerpo de un tipo de extensión para definir su interfaz de la misma manera que lo harías con los miembros de una clase. Los miembros de un tipo de extensión pueden ser métodos, getters, setters u operadores (no se permiten variables de instancia que no sean external ni miembros abstractos):

dart
extension type NumberE(int value) {
  // Operator:
  NumberE operator +(NumberE other) =>
      NumberE(value + other.value);
  // Getter:
  NumberE get myNum => this;
  // Method:
  bool isValid() => !value.isNegative;
}

Los miembros de la interfaz del tipo de representación no son miembros de la interfaz del tipo de extensión por defecto. Para hacer que un miembro de la interfaz del tipo de representación esté disponible en el tipo de extensión, debes escribir una declaración para él en la definición del tipo de extensión, como el operador + en NumberE. También puedes definir nuevos miembros no relacionados con el tipo de representación, como el getter i y el método isValid.

Implementa

#

Opcionalmente, puedes usar la cláusula implements para:

  • Introduce una relación de subtipo en un tipo de extensión, Y
  • Añade los miembros del objeto de representación a la interfaz del tipo de extensión.

La cláusula implements introduce una relación de aplicabilidad similar a la que existe entre un método de extensión y su tipo on. Los miembros que son aplicables al supertipo también son aplicables al subtipo, a menos que el subtipo tenga una declaración con el mismo nombre de miembro.

Un tipo de extensión solo puede implementar:

  • Su tipo de representación. Esto hace que todos los miembros del tipo de representación estén disponibles de forma implícita para el tipo de extensión.

    dart
    extension type NumberI(int i)
      implements int{
      // 'NumberI' can invoke all members of 'int',
      // plus anything else it declares here.
    }
    
  • Un supertipo de su tipo de representación. Esto hace que los miembros del supertipo estén disponibles, aunque no necesariamente todos los miembros del tipo de representación.

    dart
    extension type Sequence<T>(List<T> _) implements Iterable<T> {
      // Better operations than List.
    }
    
    extension type Id(int _id) implements Object {
      // Makes the extension type non-nullable.
      static Id? tryParse(String source) => int.tryParse(source) as Id?;
    }
    
  • Otro tipo de extensión que sea válido en el mismo tipo de representación. Esto te permite reutilizar operaciones en múltiples tipos de extensión (similar a la herencia múltiple).

    dart
    extension type const Opt<T>._(({T value})? _) {
      const factory Opt(T value) = Val<T>;
      const factory Opt.none() = Non<T>;
    }
    extension type const Val<T>._(({T value}) _) implements Opt<T> {
      const Val(T value) : this._((value: value));
      T get value => _.value;
    }
    extension type const Non<T>._(Null _) implements Opt<Never> {
      const Non() : this._(null);
    }
    

Lee la sección Uso para obtener más información sobre el efecto de implements en diferentes escenarios.

@redeclare

#

Declarar un miembro de tipo de extensión que comparte nombre con un miembro de un supertipo no es una relación de anulación (override) como ocurre entre clases, sino más bien una re-declaración (redeclaration). La declaración de un miembro de tipo de extensión reemplaza por completo cualquier miembro del supertipo con el mismo nombre. No es posible proporcionar una implementación alternativa para la misma función.

Puedes usar la anotación @redeclare de package:meta para indicarle al compilador que estás eligiendo a sabiendas usar el mismo nombre que el miembro de un supertipo. El analyzer te advertirá si eso no es realmente cierto, por ejemplo, si uno de los nombres está mal escrito.

dart
import 'package:meta/meta.dart';

extension type MyString(String _) implements String {
  // Replaces 'String.operator[]'.
  @redeclare
  int operator [](int index) => codeUnitAt(index);
}

También puedes habilitar la regla del linter annotate_redeclares para obtener una advertencia si declaras un método de tipo de extensión que oculta un miembro de una superinterfaz y no está anotado con @redeclare.

Uso

#

Para usar un tipo de extensión, crea una instancia de la misma manera que lo harías con una clase: llamando a un constructor:

dart
extension type NumberE(int value) {
  NumberE operator +(NumberE other) =>
      NumberE(value + other.value);

  NumberE get next => NumberE(value + 1);
  bool isValid() => !value.isNegative;
}

void testE() {
  var num = NumberE(1);
}

Luego, puedes invocar miembros en el objeto tal como lo harías con un objeto de clase.

Existen dos casos de uso principales igualmente válidos, pero sustancialmente diferentes, para los tipos de extensión:

  1. Proporcionar una interfaz extendida para un tipo existente.
  2. Proporcionar una interfaz diferente para un tipo existente.

1. Proporcionar una interfaz extendida para un tipo existente

#

Cuando un tipo de extensión implementa (implements) su tipo de representación, puedes considerarlo "transparente", porque permite que el tipo de extensión "vea" el tipo subyacente.

Un tipo de extensión transparente puede invocar todos los miembros del tipo de representación (que no estén re-declarados), además de cualquier miembro auxiliar que defina. Esto crea una nueva interfaz extendida para un tipo existente. La nueva interfaz está disponible para expresiones cuyo tipo estático es el tipo de extensión.

Esto significa que sí puedes invocar miembros del tipo de representación (a diferencia de un tipo de extensión no transparente ), de esta manera:

dart
extension type NumberT(int value)
  implements int {
  // Doesn't explicitly declare any members of 'int'.
  NumberT get i => this;
}

void main () {
  // All OK: Transparency allows invoking `int` members on the extension type:
  var v1 = NumberT(1); // v1 type: NumberT
  int v2 = NumberT(2); // v2 type: int
  var v3 = v1.i - v1;  // v3 type: int
  var v4 = v2 + v1; // v4 type: int
  var v5 = 2 + v1; // v5 type: int
  // Error: Extension type interface is not available to representation type
  v2.i;
}

También puedes tener un tipo de extensión "mayormente transparente" que añada nuevos miembros y adapte otros mediante la re-declaración de un nombre de miembro dado del supertipo. Esto te permitiría usar tipos más estrictos en algunos parámetros de un método, o diferentes valores predeterminados, por ejemplo.

Otro enfoque de tipo de extensión mayormente transparente consiste en implementar un tipo que sea un supertipo del tipo de representación. Por ejemplo, si el tipo de representación es privado pero su supertipo define la parte de la interfaz que importa para los clientes.

2. Proporcionar una interfaz diferente para un tipo existente

#

Un tipo de extensión que no es transparente (que no implementa (implements) su tipo de representación) se trata estáticamente como un tipo completamente nuevo, distinto de su tipo de representación. No puedes asignarlo a su tipo de representación y no expone los miembros de su tipo de representación.

Por ejemplo, toma el tipo de extensión NumberE que declaramos en Uso:

dart
void testE() {
  var num1 = NumberE(1);
  int num2 = NumberE(2); // Error: Can't assign 'NumberE' to 'int'.

  num1.isValid(); // OK: Extension member invocation.
  num1.isNegative(); // Error: 'NumberE' does not define 'int' member 'isNegative'.

  var sum1 = num1 + num1; // OK: 'NumberE' defines '+'.
  var diff1 = num1 - num1; // Error: 'NumberE' does not define 'int' member '-'.
  var diff2 = num1.value - 2; // OK: Can access representation object with reference.
  var sum2 = num1 + 2; // Error: Can't assign 'int' to parameter type 'NumberE'.

  List<NumberE> numbers = [
    NumberE(1),
    num1.next, // OK: 'next' getter returns type 'NumberE'.
    1, // Error: Can't assign 'int' element to list type 'NumberE'.
  ];
}

Puedes usar un tipo de extensión de esta manera para reemplazar la interfaz de un tipo existente. Esto te permite modelar una interfaz que tenga sentido para las restricciones de tu nuevo tipo (como el ejemplo de IdNumber en la introducción), al mismo tiempo que te beneficias del rendimiento y la conveniencia de un tipo predefinido simple, como int.

Este caso de uso es lo más cercano que puedes estar de la encapsulación completa de una clase contenedora (pero en realidad es solo una abstracción parcialmente protegida).

Consideraciones de tipo

#

Los tipos de extensión son un constructo de envoltura en tiempo de compilación. En tiempo de ejecución, no hay absolutamente ningún rastro del tipo de extensión. Cualquier consulta de tipo u operación similar en tiempo de ejecución funciona sobre el tipo de representación.

Esto hace que los tipos de extensión sean una abstracción insegura, porque siempre puedes descubrir el tipo de representación en tiempo de ejecución y acceder al objeto subyacente.

Las pruebas de tipo dinámicas (e is T), las conversiones de tipo o casts (e as T), y otras consultas de tipo en tiempo de ejecución (como switch (e) ... o if (e case ...)) se evalúan todas en función del objeto de representación subyacente, y la comprobación de tipo se realiza contra el tipo en tiempo de ejecución (runtime type) de ese objeto. Eso es cierto tanto cuando el tipo estático de e es un tipo de extensión, como al realizar la prueba contra un tipo de extensión (case MyExtensionType(): ... ).

dart
void main() {
  var n = NumberE(1);

  // The run-time type of 'n' is the representation type 'int'.
  if (n is int) print(n); // Prints 1.

  // Can use 'int' methods on 'n' at run time.
  if (n case int x) print(x.toRadixString(10)); // Prints 1.
  switch (n) {
    case int(:var isEven): print("$n (${isEven ? "even" : "odd"})"); // Prints 1 (odd).
  }
}

De manera similar, el tipo estático del valor coincidente es el del tipo de extensión en este ejemplo:

dart
void main() {
  int i = 2;
  if (i is NumberE) print("It is"); // Prints 'It is'.
  if (i case NumberE v) print("value: ${v.value}"); // Prints 'value: 2'.
  switch (i) {
    case NumberE(:var value): print("value: $value"); // Prints 'value: 2'.
  }
}

Cuando i obtiene el tipo estático NumberE basándose en un cast o una coincidencia de patrones, i sigue refiriéndose al mismo objeto, y v se refiere al mismo objeto que i, y el objeto en sí permanece sin cambios. Solo cambió el tipo estático (y, por lo tanto, los métodos que podemos llamar en este objeto). En particular, el cambio de tipo no implica la invocación de un constructor. Si deseas ejecutar a un constructor (por ejemplo, para realizar algún tipo de validación), entonces es necesario escribir una invocación explícita al constructor (como NumberE(i)).

Es importante tener en cuenta este comportamiento al utilizar tipos de extensión. Recuerda siempre que un tipo de extensión existe y tiene importancia en tiempo de compilación, pero se elimina (erased) durante la compilación.

Por ejemplo, considera una expresión e cuyo tipo estático es el tipo de extensión E. Supongamos que el tipo de representación de E es R. El tipo en tiempo de ejecución (run-time type) del valor de e es entonces un subtipo de R. Incluso el tipo mismo se elimina; List<E> es exactamente lo mismo que List<R> en tiempo de ejecución.

En otras palabras, una clase contenedora (wrapper class) real puede encapsular un objeto envuelto, mientras que un tipo de extensión es simplemente una vista en tiempo de compilación sobre el objeto envuelto. Si bien un contenedor real es más seguro, la compensación es que los tipos de extensión te dan la opción de evitar objetos contenedores, lo que puede mejorar enormemente el rendimiento en algunos escenarios.