Итак, здравствуйте! Меня зовут Никита Синявин. Я ведущий 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. Их использование требует некоторого уровня фантазии, практики и насмотренности, но как только вы найдёте первый реальный кейс — откроете для себя новый уровень выразительности и контроля в вашем коде.
ссылка на оригинал статьи https://habr.com/ru/articles/923010/
Добавить комментарий