Pull to refresh

Типы-расширения в Dart

Level of difficultyMedium
Reading time9 min
Views1.7K
Original author: Dart Team

Тип-расширение (extension type) – это абстракция, которая происходит на этапе компиляции и "оборачивает" существующий тип, предоставляя для него новый, сугубо статический интерфейс. Типы-расширения являются важным компонентом статической интеграции с JavaScript (static JS interop), поскольку они позволяют легко изменять интерфейс существующего типа (что критически важно для любого вида взаимодействия) без затрат на создание реального объекта-обёртки.

Типы-расширения позволяют строго определить набор операций (или интерфейс), доступных для объектов базового типа, который называется типом представления. При определении интерфейса для типа-расширения вы можете оставить некоторые методы типа представления, отбросить другие, заменить некоторые из них и добавить новую функциональность.

Пример. Рассмотрим пример, где базовый тип int оборачивается типом-расширением, допускающим только те операции, которые имеют смысл для идентификационных номеров:

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); // Допустимо: Переопределённый оператор '<'.
}

Примечание:
Типы-расширения выполняют ту же роль, что и классы-обёртки, но они не требуют создания дополнительного объекта во время выполнения, что может оказаться затратным, когда требуется обернуть множество объектов. Поскольку типы-расширения являются исключительно статическими и удаляются при компиляции, накладные расходы при их использовании стремятся к нулю.

От типов-расширений следует отличать методы-расширения (extensions), которые также представляют собой статическую абстракцию. Методы-расширения добавляют функциональность напрямую к каждому экземпляру базового типа. Типы-расширения работают по-другому: интерфейс типа-расширения применяется только к тем выражениям, чей статический тип совпадает с этим типом-расширением. Изначально этот интерфейс отличен от интерфейса базового типа.

Объявление

Новый тип-расширение объявляется с помощью конструкции extension type и имени, за которым следует тип представления в скобках:

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

Объявление типа представления (int i) указывает, что базовым типом для типа-расширения E является int, а ссылка на объект представления имеет имя i. Это объявление неявно определяет:

  • Геттер для объекта представления с типом представления в качестве возвращаемого типа: int get i.

  • Конструктор: E(int i) : i = i.

Объект представления позволяет типу-расширению взаимодействовать с объектом базового типа. На этот объект можно ссылаться по имени в теле типа-расширения:

  • Внутри типа-расширения как i (или this.i в конструкторе).

  • Извне через e.i (где e имеет статический тип, совпадающий с типом-расширением).

Объявление типа-расширения может включать типовые параметры, как и в случае с классами или методами-расширениями:

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

Конструкторы в типах расширений

Расширения в Dart позволяют добавлять конструкторы. Изначально, объявление типа представления само по себе неявно создает безымянный конструктор. А вот любой дополнительный конструктор, который что-то делает с объектом (не просто перенаправляет вызов), должен инициализировать переменную экземпляра объекта представления через this.i в списке инициализаторов или в списке параметров.

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!"); // Именной конструктор, берет еще параметры.
}

Но можно пойти другим путем – дать имя конструктору при объявлении типа представления, освободив тем самым место для безымянного конструктора внутри тела расширения:

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

Также можно полностью спрятать конструктор от внешнего мира, используя приватную конструкторскую синтаксис классов (). Полезно, например, в случае, когда при создании объекта E нужно принимать параметром именно строку (String), хотя внутренним типом будет простой int:

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

Кроме того, типы расширения поддерживают перенаправляющие конструкторы, а также конструкторы-фабрики (которые в том числе могут перенаправлять вызов на конструкторы подтипов расширения).

Члены

Члены типа расширения определяются точно так же, как члены обычных классов. Ими могут быть методы, геттеры, сеттеры или операторы (переменные-члены экземпляра, не помеченные как external, а также абстрактные члены – не допускаются).

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

Важно помнить: члены интерфейса типа представления по умолчанию не входят в интерфейс типа расширения . Например, в NumberE нам нужно заново объявить operator + специально, чтобы он «пробрался» в интерфейс типа расширения. Однако, мы можем добавить что угодно свое, как, например, геттер i или метод isValid.

Ключевое слово implements в типах расширений

С помощью ключевого слова implements в типах расширения можно:

  • Создать отношение подтипов

  • Добавить члены объекта представления в интерфейс типа расширения

Клауза implements схожа с тем, как она связывает метод расширения с типом, для которого он написан (on). Члены, доступные в супертипе, автоматически будут и у подтипа (если только у самого подтипа нет члена с тем же именем).

Тип расширения может реализовывать (implements) только:

  • Свой тип представления. Это делает все интерфейсные члены типа представления доступными внутри типа расширения.

extension type NumberI(int i) 
  implements int{
  // 'NumberI' теперь может использовать все члены 'int'
  // и добавлять свои.
}
  • Супертип своего типа представления. Так вы подключаете лишь члены супертипа, но необязательно все, что есть у самого типа представления.

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?;
}
  • Другой тип расширения, действующий на тот же тип представления. Это позволяет переиспользовать код между несколькими типами расширения (наподобие множественного наследования).

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

Узнать подробнее, как implements влияет на поведение типов, можно в разделе «Применение типов расширений»

Аннотация @redeclare

Если вы определили в типе расширения член с именем, которое уже есть у супертипа, то это не переопределение, как в обычных классах, а скорее переобъявление. Член типа расширения полностью «перекрывает» одноименный член супертипа, не позволяя предоставить альтернативную реализацию той же функции.

Чтобы пояснить компилятору, что вы это делаете сознательно, используйте аннотацию @redeclare. Анализатор предупредит, если обнаружит, что имя написано с ошибкой.

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

Также можно включить правило анализатора annotate_redeclares, которое будет давать предупреждение, если член типа расширения перекрывает метод супертипа, и при этом не помечен @redeclare.

Применение типов расширений

Чтобы создать объект типа расширения, обратитесь к его конструктору – все как с обычными классами:

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

Далее вызывайте методы объекта как обычно.

Существует два основных, хоть и очень разных, сценария работы с типами расширения:

  • Предоставить расширенный интерфейс для существующего типа.

  • Предоставить другой интерфейс для существующего типа.

Важно! Тип представления никогда не является подтипом типа расширения, поэтому вы не можете использовать его вместо типа расширения, там, где он ожидается.

1. Предоставление расширенного интерфейса

Когда тип расширения реализует (implements) свой тип представления, его можно считать «прозрачным». Он как будто дает возможность «заглянуть» внутрь объекта базового типа.

Такой тип расширения способен вызывать все члены типа представления (кроме переобъявленных), а также свои собственные дополнительные. По сути, это новый расширенный интерфейс к уже существующему типу. Он применим там, где статический тип выражения совпадает с типом расширения.

Это значит, что в отличие от непрозрачных типов расширения, здесь можно обращаться напрямую к членам типа представления:

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

Также возможно создать «частично прозрачный» тип расширения, который переобъявляет некоторые члены супертипа, добавляет новые или изменяет существующие. Это дает возможность использовать более строгую типизацию у части методов или задавать другие значения по умолчанию.

Еще один способ частичного расширения интерфейса - реализовывать не сам тип представления, а его супертип. Например, если тип представления приватный, но его супертип публичный и содержит необходимую вам часть интерфейса.

2. Предоставление другого интерфейса

«Непрозрачные» типы расширения (не реализующие свой тип представления) во время компиляции рассматриваются как абсолютно новые сущности, отличные от типа представления. Нельзя присвоить такой тип напрямую типу представления, он также не раскрывает члены этого представления.

Посмотрим как это работает на примере уже знакомого типа 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'.
];
}

Такой подход позволяет переопределить интерфейс существующего типа. Это дает возможность смоделировать интерфейс, заточенный под ваши нужды (как было с типом IdNumber во введении), пользуясь при этом производительностью и удобством простого встроенного типа, например, int.

Это максимально приближенное к полной инкапсуляции, как у классов-оберток, поведение (хотя на практике это лишь частично защищенная абстракция).

Особенности типов

Типы расширения – это исключительно инструмент компиляции. Во время выполнения от них не остается и следа. Любые проверки типов происходят именно с типом представления.

Это делает типы расширения небезопасной абстракцией, так как тип представления всегда можно выявить во время выполнения и обратиться к реальному объекту.

Динамические проверки типов (e is T), приведения (e as T) и другие запросы к типам во время выполнения (например, switch (e) ... или if (e case ...) ) все опираются именно на тип представления объекта и проверяют соответствие этому типу. Это же верно и когда статическим типом е является тип расширения, и когда проверка делается против типа расширения (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 (нечетное).
}
}

Аналогично, статическим типом совпавшего значения в примере ниже оказывается тип расширения:

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'.
}
}

При работе с типами расширения важно об этом помнить. Тип расширения существует и влияет на поведение только на этапе компиляции, во время выполнения он «стирается».

Выражение с типом расширения Е и типом представления R в качестве статического типа во время выполнения будет просто объектом типа R. Даже сам тип расширения исчезает. Внутри программы List<E> в точности тоже самое, что и List<R>.

Иными словами, настоящий класс-обертка способен инкапсулировать объект, тогда как тип расширения – лишь взгляд на этот объект в перспективе компиляции. Из-за этой особенности типы расширения небезопасны, зато дают возможность обойтись без объектов-оберток и, в некоторых ситуациях, добиться существенного прироста производительности.

Tags:
Hubs:
Total votes 4: ↑3 and ↓1+4
Comments0

Articles