Итак, здравствуйте! Меня зовут Никита Синявин. Я ведущий Flutter-разработчик в компании BetBoom, автор тг-блога Botlotogy Tech и BDUI-фреймворка для Flutter — Duit. Также являюсь лидером сообщества мобильных разработчиков Mobile Assembly | Калининград.
Бьюсь об заклад, что вы используете статические расширения типов каждый день! Но, как показывает практика изучения исходного кода open source библиотек и общения с другими разработчиками, большое количество тех людей, что используют Dart, так и не оценили по достоинству такого мощного инструмента как extension types. Это не "еще один способ добавить метод", а концептуально другой метод работы с типами в Dart!
Такое положение дел меня не устраивает. Я здесь, чтобы показать вам всю мощь этой классной фичи, копнув глубже поверхностных обзоров. И начну с самого начала — с появления extension methods, чтобы мы вместе проследили, как Dart эволюционировал к extension types.
История статических расширений типов - extension methods
Релиз Dart 2.7 от 11 декабря 2019 года, помимо прочих изменений, принес в жизнь разработчиков новую фичу - extension methods
(далее - EM).
В некотором роде это была революция, предоставляющая нам - разработчикам, решение одной из фундаментальных проблем языка - расширение функциональности закрытых или сторонних типов.
Сейчас уже попросту невозможно представить себе код без расширений. На их базе построена работа с множеством библиотек (go_router
, bloc
, etc), где частый кейс - расширение функциональности BuildContext
. И все это без модификации исходного кода, создания оберток и наследования!
Но время не стоит на месте и разработчики Dart вводят в язык совершенно новые концепции.
Extension types
В то время как EM принесли возможность добавлять новые методы к существующим типам, с появлением extension types
(далее - ET) в Dart 3.3 дело пошло дальше - появился способ создавать новые статические типы поверх существующих реализаций.
Это принципиально иной уровень абстракции. Но начав пристальнее изучать эту фичу я встретился с интересным парадоксом: при всей своей глубине и возможностях ET "остаются в тени", не востребованными широким кругом разработчиков.
Тем интереснее становится ситуация, когда выясняется, что единственный пример массового применения ET - package:web, один из ключевых пакетов для экосистемы Dart/Flutter. Но за пределами веба они почти не встречаются в популярных пакетах.
Значит ли это, что фича слабая или мы пока просто не осознали ее потенциал? Давайте разбираться!
Ключевые сходства и принципиальные различия фич
Я отлично помню свое первое знакомство с ET. На первых этапах было совершенно не ясно, зачем нам "аналог" уже существующих расширений, ведь при первом приближении они имели массу сходств:
И EM, и ET работают с существующими типами, добавляя функциональность к типам, которые вы не можете или не хотите модифицировать напрямую. Например, классы из
dart:core
или сторонних библиотек.Ни EM, ни ET не создают дополнительных объектов в памяти при вызове. Компилятор "раскрывает" их во время компиляции.
В runtime значение остаётся экземпляром исходного типа (
int
,String
, etc). Никаких сюрпризов с внезапной заменой типа.Оба механизма позволяют добавлять методы, геттеры, сеттеры и переопределять операторы, которые работают с экземплярами типа.
Но все вышеперечисленное - лишь верх айсберга. Несмотря на кажущуюся схожесть, различия между двумя механизмами весьма значительны, как уровне предоставляемых возможностей, так и концептуально. Предлагаю рассмотреть их детальнее.
В то время как EM "просто" добавляет новые методы экземпляру существующего типа, ET создает новый тип на основе базового, по сути вводя абстракцию, которую компилятор воспринимает как самостоятельный тип.
EM автоматически совместим со всеми экземплярами расширяемого класса. Любое значение int имеет методы `extension on int`. ET же требует как явного "оборачивания" экземпляра базового типа, так и явного приведения типов.
ET поддерживает использование статических членов. Доступно объявление статических методов, полей, именованных и фабричных конструкторов.
Одна из основных фич ET - скрытие (hiding) членов базового класса, что дает возможности для контроля API любых типов в Dart.
ET несет не только новую или переопределенную функциональность для базового типа, но и смысловую нагрузку. А вот EM больше воспринимаются, как набор утилитарных методов.
Extension types в боевых условиях - package:web
Покончив с теорией, мы можем перейти к реальному примеру использования ET. Я задался вопросом: "Почему для разработки package:web использовались именно ET вместо обычных расширений?" и нашел для этого несколько основных причин:
Обеспечение типобезопасности. Даже с
extension on JSObject
все JS-объекты остаются одним типом — компилятор не видит разницы междуWindow
,HTMLElement
и тд. ET создает объекты уникального типа, хотя в runtime они по прежнему являютсяJSObject
. Компилятор Dart предотвращает путаницу между объектами.
// Без ET: ошибка только в Runtime
Window window = ...//получаем объект Window;
window.querySelector('div'); // У Window нет такого метода!
Абстракция без накладных расходов. Извечная проблема с любыми классами-обертками - лишние аллокации памяти. Для критичных к производительности и потребляемым ресурсами системам это крайне важный фактор для того, чтобы этого избегать всеми силами. ET в runtime не создает дополнительных объектов, при этом добавляет гарантии безопасности типов на уровне компилятора. В контексте package:web для частых вызовов JS-API (например, в анимациях или обработке событий) создание множества полноценных объектов-обёрток в секунду приводило бы к нагрузке на
GC
и просадкам FPS. Благодаряextension types
становится возможным достигнуть и максимальной производительности, и экономии ресурсов. Обёртка существует только на уровне типов. В runtime — прямой доступ к JS-объекту.Семантика взаимодействия с JS. Библиотека
package:js
работала с типомdynamic
, что само по себе - не лучшее решение. Это делало код небезопасным, т.к ошибки могли обнаруживать только во время выполнения. ET переворачивает игру: все JS-объекты становятся статически типизированными, а разработка становится удобнее, потому-что начинает работать простая и приятная вещь - автодополнение.
// Сильно упрощенное объявление ET для JS-объектов:
extension type Window(JSObject _) implements JSObject {
external Document get document;
external void alert(String message);
}
extension type Document(JSObject _) implements JSObject {
external HTMLElement? querySelector(String selector);
}
extension type HTMLElement(JSObject _) implements JSObject {
external DOMTokenList get classList;
}
Почему extension types "не взлетел"?
Парадокс ET в том, его success-case стал его проклятием, который одновременно демонстрирует мощь концепции ET, и является серьезным барьером. Реализация слишком сложна для изучения, а js-interop - нишевая областью для основной массы разработчиков.
Так почему мы не видим массового использования ET?
Когнитивная нагрузка. Концепция статической обёртки, которая существует только в compile-time, требует переключения парадигмы. Разработчики привыкли к двум типовым моделям: создание классов-оберток с вытекающим из этого оверхедом либо
extension methods
для утилитарных методов
ET, в свою очередь, является неким гибридом благодаря которому вы создаёте новый тип (напримерUserId
), но в runtime он исчезает. Это вызывает диссонанс и приходится постоянно держать в голове разницу между статической и runtime-семантикой.Отсутствие практик проектирования. ET решают узкий класс проблем: оптимизация классов-обертов, типизированные и защищенные интерфейсы. Но эти сценарии редко встречаются в повседневных задачах при разработке среднестатистического приложения. Когда такая задача все же возникает, разработчики не связывают ее решение и использованием ET. Нет понимания, как правильно проектировать ET, когда выбирать их, а не класс.
Синтетические примеры. Документация Dart предлагает примеры вроде обертки
UserId
надint
. Хоть это и является технически верным примером, но никак не раскрывает всех возможностей ET. Да и не вдохновляет, если честно. Зачем мне это, если можно использоватьtypedef
?
Отсутствие "мостика" между UserID(int id)
и package:web
, необходимость учитывать новые возможности языка при проектировании - все это приводит к тому, что действительно классная фича языка остается не востребованной.
Конструктивная критика - это, безусловно, хорошо. Но есть такой тезис - "критикуешь - предлагай". Приглашаю вас рассмотреть более "живой" вариант применения этой мощной фичи Dart.
Инкапсуляция внутренней логики и объектный стиль взаимодействия с процедурами
Представим ситуацию: вам предстоит работа с низкоуровневыми API или с неким набором процедур. Частая сложность, с которой сталкиваются разработчики при встрече с подобными задачами - отсутствие выразительного API, что может приводить к трудно диагностируемым ошибкам.
В качестве примера возьмем работу с изолятами, где доминирует процедурный стиль с ручным менеджментом портов. Это мощный, но низкоуровневый механизм, где очень легко допустить ошибку. ET открывает другой путь, позволяя элегантным образом создавать объектно-ориентированный контракты поверх низкоуровневых механизмов. Рассмотрим, как этот механизм Dart позволяет адаптировать базовую реализацию изолята-воркера под конкретные задачи.
Для начала реализуем класс-воркер.
//Базовый воркер
final class Worker {
final SendPort sP;
final ReceivePort rP;
final Isolate isolate;
Worker._(
this.isolate,
this.sP,
this.rP,
);
static Future create(void Function(SendPort) entryPoint) async {
final rp = ReceivePort();
final isolate = await Isolate.spawn(
entryPoint,
rp.sendPort,
);
final sp = await rp.first as SendPort;
return Worker._(isolate, sp, rp);
}
}
//Обработчик событий типа RemoteMessage
void _isolateEntryPoint(SendPort sendPort) {
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
receivePort.listen(
(message) async {
switch (message) {
case RemoteMessage():
try {
final res = await message.computation();
message.sendPort.send(res);
break;
} catch (e) {
message.sendPort.send(e);
}
default:
throw UnimplementedError();
}
},
);
}
//Обработчик событий типа String
void _isolateEntryPoint2(SendPort sendPort) {
final receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
receivePort.listen(
(message) async {
switch (message) {
case String():
print(message);
break;
default:
throw UnimplementedError();
}
},
);
}
Обратите внимание на то, что все воркеры, создаваемые через статический метод create имеют разные entry-point, функции isolateEntryPoint
и isolateEntryPoint2
. При этом сам класс воркера не обладает дополнительными методами, которые помогут с ним взаимодействовать. Исправим это с помощью extension types
.
//Только для вывода сообщений
extension type PrintWorker(Worker worker) {
void print(String message) {
worker.sP.send("Isolate ${Isolate.current.hashCode}: $message");
}
}
//Только для выполнения тяжёлых вычислений
extension type ComputeWorker(Worker worker) {
Future compute(Future Function() computation) async {
final responsePort = ReceivePort();
worker.sP.send(RemoteMessage(
computation,
responsePort.sendPort,
));
return await responsePort.first;
}
}
Ключевая идея состоит в том, что базовый класс Worker
скрывает общую логику создания изолята, а ET добавляют специализированные методы, которые ограничивают возможности взаимодействия с воркером.
Что это дает на практике? Если вы создаете разное количество воркеров под разные задачи (например 1 воркер для логирования событий, 3 воркера для тяжелых вычислений) или даже управляете их жизненным циклом динамически, такой подход позволит защититься от ошибок при работе с публичными API воркера, которые и реализованы на базе ET. Весь API взаимодействия с воркерами сосредоточен в одном месте, не размазан по коду и не требует создания иерархии наследования для реализации подобного поведения. Помимо этого, код становится выразительнее: worker.sendPort.send(() => 2 + 2)
- computeWorker.compute(() => 2 + 2)
void main() async {
final w = PrintWorker(await Worker.create(_isolateEntryPoint2));
final w2 = ComputeWorker(await Worker.create(_isolateEntryPoint));
//Используем только объявленные в расширении метода
w.print("Hello from isolate");
w.compute(...); //Ошибка!
final result = await w2.compute(
() async => 2 + 2,
);
print("Computation res: $result");
}
Результаты таковы:
Нам удалось инкапсулировать весь низкоуровневый управляющий код, логику формирования сообщений и их отправку по портам. То есть пользователь будет видеть только семантически значимые операции.
Новые типы воркеров могут добавляться без изменения базового кода класса
Worker
.В отличие от реализации подобного подхода через классы-обертки или иерархию наследования от класса
Worker
, мы бы получили оверхед (местами значительный, за счет динамической деспетчеризации вызовов). ET не создают дополнительных объектов в памяти. В runtimePrintWorker
исчезает, остаётся только исходныйWorker
, но с обеспечением гарантий безопасности в compile-time.
В качестве вывода
Extension types - не просто очередная фича Dart, а принципиально новый способ проектирования абстракций. Они предлагают то, чего не достичь ни с помощью extension methods, ни с помощь классов - статическую типобезопасность без оверхеда в runtime.
Умелое применение extension types - еще один шаг к профессиональному владению Dart. Их использование требует некоторого уровня фантазии, практики и насмотренности, но как только вы найдёте первый реальный кейс - откроете для себя новый уровень выразительности и контроля в вашем коде.