Extension types in Dart

An extension type is an abstraction that occurs at compile time and “wraps” an existing type by providing a new, purely static interface for it. Extension types are an important component of static JS interop because they allow you to easily modify the interface of an existing type (critical for any kind of interaction) without the expense of creating an actual wrapper object.

Extension types allow you to strictly define the set of operations (or interface) available to objects of the base type, called presentation type. When defining an interface for an extension type, you can keep some of the view type's methods, discard others, replace some of them, and add new functionality.

Example. Let's look at an example where the base type int turns into an extension type that allows only those operations that make sense for identification numbers:

extension type IdNumber(int id) {
  // Переопределяем оператор '<' для типа 'int':
  operator <(IdNumber other) => id < other.id;
  // Не определяем оператор '+', 
  // поскольку сложение не имеет смысла для идентификационных номеров.
}
void main() {
// Без строгости типа-расширения,
// 'int' позволяет совершать небезопасные операции с идентификаторами:
int myUnsafeId = 42424242;
myUnsafeId = myUnsafeId + 10; // Это работает, но не должно быть разрешено для ID.
var safeId = IdNumber(42424242);
safeId + 10; // Ошибка компиляции: Нет оператора '+'.
myUnsafeId = safeId; // Ошибка компиляции: Неверный тип.
myUnsafeId = safeId as int; // Допустимо: Приведение к типу представления.
safeId < IdNumber(42424241); // Допустимо: Переопределённый оператор '<'.
}

Note:
Extension types serve the same role as wrapper classes, but they do not require the creation of an additional object at run time, which can be expensive when there are many objects to wrap. Because extension types are purely static and are removed at compilation, the overhead of using them tends to be zero.

Extension methods should be distinguished from extension types, which also represent a static abstraction. Extension methods add functionality directly to each instance of the base type. Extension-types work differently: the extension-type interface is applied only to those expressions whose static type matches this extension type. Initially, this interface is different from the base type interface.

Announcement

A new extension type is declared using the construct extension type and a name followed by presentation type in brackets:

extension type E(int i) {
// Определение набора операций.
}

View type declaration (int i) indicates that the base type for the extension type E is intand the link to presentation object has a name i. This declaration implicitly specifies:

  • Getter for a view object with the view type as the return type: int get i.

  • Constructor: E(int i) : i = i.

A view object allows an extension type to interact with an object of a base type. This object can be referenced by name in the body of the extension type:

  • Inside the extension type as i (or this.i in the constructor).

  • From outside through e.i (Where e has a static type that matches the extension type).

An extension type declaration can include type parameters, as is the case with classes or extension methods:

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

Constructors in extension types

Dart extensions allow you to add constructors. Initially, the view type declaration itself implicitly creates an unnamed constructor. But any additional constructor that does something with the object (not just redirects the call) must initialize the view object instance variable via this.i in the initializer list or in the parameter list.

extension type E(int i) {
  E.n(this.i); 
  E.m(int j, String foo) : i = j + foo.length;
}
void main() {
E(4); // Неявный безымянный конструктор.
E.n(3); // Именной конструктор.
E.m(5, "Hello!"); // Именной конструктор, берет еще параметры.
}

But you can go the other way – give a name to the constructor when declaring the view type, thereby freeing up space for an unnamed constructor inside the extension body:

extension type const E.(int it) {
E(): this.(42);
E.otherName(this.it);
}
void main2() {
E();
const E.(2);
E.otherName(3);
}

It is also possible to completely hide the constructor from the outside world by using private constructor class syntax (). Useful, for example, when when creating an object E you need to take the string as a parameter (String), although the internal type will be simple int:

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

In addition, extension types support redirecting constructors, as well as factory constructors (which can also redirect calls to extension subtype constructors).

Members

Members of an extension type are defined in exactly the same way as members of regular classes. They can be methods, getters, setters, or operators (instance member variables not marked as externalas well as abstract members are not allowed).

extension type NumberE(int value) {
// Переопределенный оператор:
NumberE operator +(NumberE other) =>
NumberE(value + other.value);
// Геттер:
NumberE get myNum => this;
// Метод:
bool isValid() => !value.isNegative;
}

Important to remember: Default view type interface members not included into an extension type interface. For example, in NumberE we need to re-announce operator + specifically so that it “sneaks” into the extension type interface. However, we can add anything of our own, such as a getter i or method isValid.

Keyword implements in extension types

Using a keyword implements in extension types you can:

  • Create a subtype relationship

  • Add view object members to extension type interface

Clause implements similar to the way it associates an extension method with the type for which it is written (on). Members available in the supertype will automatically be available in the subtype (unless the subtype itself has a member with the same name).

An extension type can implement (implements) only:

extension type NumberI(int i) 
  implements int{
  // 'NumberI' теперь может использовать все члены 'int'
  // и добавлять свои.
}
  • A supertype of its representation type. This way you only include members of the supertype, but not necessarily everything that the view type itself has.

extension type Sequence<T>(List<T> _) implements Iterable<T> {
  // Улучшенные операции по сравнению с обычным списком List
}
extension type Id(int id) implements Object {
// Тип расширения перестает допускать значения null
static Id? tryParse(String source) => int.tryParse(source) as Id?;
}
  • Another extension type that operates on the same view type. This allows code to be reused between multiple extension types (similar to multiple inheritance).

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);
}

To learn more about how implements affects the behavior of types, see the section “Using Extension Types”

annotation @redeclare

If you define a member in an extension type with a name that the supertype already has, then this Not overriding, as in regular classes, but rather re-announcement. An extension type member completely overrides the supertype member of the same name, preventing it from providing an alternative implementation of the same function.

To make it clear to the compiler that you are doing this on purpose, use the annotation @redeclare. The analyzer will warn if it detects that the name is misspelled.

extension type MyString(String _) implements String {
// Заменяет 'String.operator[]'
@redeclare
int operator [](int index) => codeUnitAt(index);
}

You can also enable the analyzer rule annotate_redeclareswhich will give a warning if a member of an extension type overrides a supertype method and is not marked @redeclare.

Applying Extension Types

To create an object of an extension type, refer to its constructor – just like with regular classes:

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);
}

Next, call the object's methods as usual.

There are two main, albeit very different, scenarios for working with extension types:

  • Provide an extended interface for an existing type.

  • Provide a different interface for an existing type.

Important! A view type is never a subtype of an extension type, so you cannot use it in place of an extension type where it is expected.

1. Providing an advanced interface

When an extension type implements (implements) its own type of representation, it can be considered “transparent”. It seems to make it possible to “look” inside an object of the base type.

This type of extension is capable of calling all members of the view type (except those overdeclared), as well as its own additional ones. Essentially it's new extended interface to an already existing type. It is applicable where the static type of the expression is the same as the extension type.

It means that Unlike opaque extension types, you can access members of the view type directly:

extension type NumberT(int value)
implements int {
// Не переобъявляет члены 'int' явным образом.
NumberT get i => this;
}
void main () {
// Все в порядке - 'прозрачность' позволяет
// обращаться к членам 'int' прямо из типа расширения:
var v1 = NumberT(1); // тип v1: NumberT
int v2 = NumberT(2); // тип v2: int
var v3 = v1.i - v1;  // тип v3: int
var v4 = v2 + v1; // тип v4: int
var v5 = 2 + v1; // тип v5: int
// Ошибка: интерфейс типа расширения недоступен напрямую из типа представления
v2.i;
}

It is also possible to create a “partially transparent” extension type that redeclares some members of the supertype, adds new ones, or modifies existing ones. This makes it possible to use stricter typing for some methods or set other default values.

Another way to partially extend an interface is to implement not the view type itself, but its supertype. For example, if the view type is private, but its supertype is public and contains the part of the interface you need.

2. Providing a different interface

“Opaque” extension types (those that do not implement their view type) are treated at compile time as completely new entities distinct from the view type. You cannot assign such a type directly to a view type, nor does it expose the members of that view.

Let's see how it works using an example of an already familiar type NumberE :

void testE() {
var num1 = NumberE(1);
int num2 = NumberE(2); // Ошибка: нельзя присвоить 'NumberE' типу 'int'.
num.isValid(); // Порядок: вызов метода расширения.
num.isNegative();  // Ошибка: 'int' не является членом 'NumberE' 
var sum1 = num1 + num1;   // Порядок: оператор '+' определен в 'NumberE'.
var diff1 = num1 - num1;  // Ошибка: оператор '-' не определен.
var diff2 = num1.value - 2; // Доступ к объекту представления. 
var sum2 = num1 + 2;    // Ошибка: нельзя присвоить 'int'  параметру типа 'NumberE'. 
List<NumberE> numbers = [
NumberE(1),
num1.next, // Геттер 'next' возвращает тип 'NumberE'.
1,         // Ошибка: нельзя добавить 'int' в список 'NumberE'.
];
}

This approach allows redefine interface of an existing type. This makes it possible to model an interface tailored to your needs (as was the case with the type IdNumber in the introduction), while taking advantage of the performance and convenience of a simple built-in type, e.g. int.

This is as close to the full encapsulation behavior of wrapper classes as possible (though in practice it is only a partially protected abstraction).

Features of types

Extension types are purely a compilation tool. During execution, not a trace remains of them. Any type checking occurs specifically with the representation type.

This makes extension types unsafe abstraction, since the representation type can always be discovered at runtime and accessed by the real object.

Dynamic type checks (e is T), casts (e as T) and other type queries at runtime (e.g. switch (e) ... or if (e case ...) ) all rely specifically on the representation type of the object and check compliance with this type. The same is true when the static type е is the extension type, and when the check is done against the extension type (case MyExtensionType(): ...).

void main() {
var n = NumberE(1);
// Во время выполнения тип 'n' - это все равно 'int'.
if (n is int) print(n.value); // Выведет 1.
// Методы 'int' доступны из 'n' во время выполнения.
if (n case int x) print(x.toRadixString(10)); // Выведет 1.
switch (n) {
case int(:var isEven): print("" class="formula inline">{isEven ? "четное" : "нечетное"})"); // 1 (нечетное).
}
}

Likewise, the static type of the matched value in the example below turns out to be the extension type:

void main() {
int i = 2;
if (i is NumberE) print("Это так");         // Выведет 'Это так'.
if (i case NumberE v) print("Значение: ); // Значение: 2'.
  switch (i) {
    case NumberE(:var value): print("Значение: " class="formula inline">value");   // Значение: 2'.
}
}

It's important to keep this in mind when working with extension types. The extension type exists and affects behavior only at compile time; it is “erased” at runtime.

Expression with extension type Е and presentation type R as a static type at runtime it will simply be an object of type R. Even the extension type itself disappears. Inside the program List<E> exactly the same as List<R>.

In other words, a true wrapper class is capable of encapsulating an object, whereas an extension type is just a compiler's view of that object. Because of this feature, extension types are unsafe, but they make it possible to do without wrapper objects and, in some situations, achieve significant performance gains.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *