Новая жизнь или начало экзистенциального кризиса?

Ни для кого не секрет, что популярные языки программирования развиваются по схожему сценарию. Сначала появляется новый язык программирования с амбициозной и благой целью решить те или иные проблемы, уже существующего языка программирования, которые, иначе, как созданием нового языка программирования, не решить. Далее язык набирает популярность, часто, путем обрастания различными модными фичами. Теряет связь с миром свою простоту и лаконичность в бесконечной погоне за новыми возможностями. В какой-то момент наступает экзистенциальный кризис в виде Франкенштейна. А как отмыть кровь решать подобные проблемы мы уже знаем. Конечно, я несколько утрирую (или нет?) и возможно не все так плохо? Сегодня поговорим об этом в общем и о новом функционале языка Dart в частности.

Введение

За что мы все любим Dart? Любим же, правда ведь? Конечно за Flutter простоту, низкий порог входа, предсказуемость, легкость, декларативность, высокую скорость разработки и другие крутые качества. Dart развивается, появляются новые крутые возможности. Из последних можно вспомнить алгебраические структуры данных, сопоставление с образцом, типы расширения (об области применения которых мы говорили здесь) и другие. Обратите внимание, что так как Dart все еще довольно молод, добавление новых фич не является чем-то кардинально сложным. Новые фичи хотя и не льются, как из рога изобилия (что, в принципе, хорошо), но появляются довольно быстро. Но это, если сравнивать с языками другой категории (да-да, C#, мы смотрим на тебе, где ADT?). В то же время, чем более сложные проекты реализуются на языке Dart и чем их больше, тем сильнее виден недостаток в более лаконичных и гибких языковых конструкциях. Грубо говоря шаблонного кода становится все больше и больше, а средств, для борьбы с ним, язык не предоставляет. И это одна из причин появления новых возможностей в любом языке программирования.

С другой стороны бездумное добавление модных функциональных возможностей, которые зачастую, плохо взаимодействую друг с другом, очень быстро приводит к излишнему усложнению языка программирования. Что в свою очередь снижает приток новых пользователей. Да и старым пользователям изучать каждый релиз "новый язык" несподручно (привет C++). Как итог, рано или поздно приведет к забвению. Таким образом задача разработчиков состоит в том, чтобы находить баланс между хотелками пользователей, идеологией языка и отсутствием развития.

Не малую роль во всем этом играет заложенный изначально фундамент. Если фундамент не очень, то воздвигнутый на нем небоскреб рассыпется очень быстро. Можно конечно жечь мосты, но как показывает практика (👋 Python) - это довольно болезненный и длительный процесс. А если же поддерживать обратную совместимость, то, кроме вышеописанных проблем добавляется еще одна - снижение скорости разработки, и как следствие, доставка новых фич начинает хромать.

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

  • изначально мощная, гибкая и выразительная система типов;
  • точечная реализация конкретных фич (зачастую с добавлением синтаксического сахара на каждый чих) с надеждой залатать дыры в несовершенстве языка;
  • метапрограммирование:
    • кодогенерация;
    • рефлексия;
    • макросы:
      • препроцессор;
      • декларативные;
      • императивные;
      • и другие;
  • и другие.

К первой группе языков можно отнести, например, Haskell, Idris и другие. Основная проблема такого подхода - это очень высокий порог входа. Программировать на подобных языках значительно сложнее. И, на данном этапе, подавляющее большинство разработчиков явно не готова к такому. Хотя, невооруженным глазом видно, что мы потихоньку, но верно, идет в этом направлении. Вот дойдем ли? Ко второй группе языков можно отнести, видимо, подавляющее большинство популярных языков программирования сегодня. Например, C++, C#, Java и другие. Почему это происходит и к чему ведет, мы уже выше рассмотрели. К последней группе можно отнести языки программирования, которые подошли к проблеме с практической стороны. Поняв тот факт, что всем угодить не получится, но очень хотелось бы, языки этой группы позволяют использовать разработчику метапрограммирование для достижения его целей. Здесь уже, кто во что горазд. Это и кодогеренация, и рефлексия и разного рода макросы.

We need to go deeper

Метапрограммирование позволяет написать программу, которая напишет (или модифицирует уже существующую программу, в том числе и саму себя) другую программу. Которая, в свою очередь, и будет решать конкретную задачу. Реализуется это разными средствами. В частности с помощью кодогенерации, рефлексии и макросами. Возможности метапрограммирования широко варьируются от языка к языку. Начиная от текстовой подстановки в C, системы типов в TypeScript, заканчивая полноценными макросами в Haxe, Rust, и Lisp, которые позволяют полноценно работать на уровне AST, и даже, вводить новый синтаксис.

Интересный момент можно заметить в ходе развития разных языков, который заключается в том, что при добавлении одного вида метапрограммирования в язык, в скором времени приводит к появлению и других видов или даже альтернативных реализаций. Например, в языке C# изначально присутствовала рефлексия, а так же возможность генерации и динамического выполнения кода во время работы приложения. Кроме этого, различные средства предоставляли возможность модификации кода уже после компиляции. Так называемое аспектно-ориентированное программирование (AOP). В какой-то момент язык уперся в возможности предоставляемые этим функционалом (или их было недостаточно удобно использовать). И опять встал вопрос добавления макросов или кодогенерации. Как итог, функционал последнего был добавлен.

Другой пример язык Rust, который имеет одновременно два вида макросов: декларативные и процедурные. Или C++, в котором одновременно живут шаблоны, препроцессор и функции времени компиляции. Похожую ситуацию можно найти и в других языках программирования.

Ну, а что же виновник статьи? Как ни странно (учитывая молодость) Dart тоже можно отнести к языкам этой категории. Dart "поддерживает" рефлексию (посредством dart:mirrors), но не на всех платформах и с кучей разных "но". Какого-то развития и поддержки за последние годы не видно. Основной леймотив такой: поддержка AOT платформ не позволяет (или усложняет) реализовать рефлексию. Но мы то знаем, что это вполне реально (см. C#), хоть и с нюансами, и скорее отговорка, нежели реальная причина. Интересно, что рефлексия находится в некотором подвешенном состоянии, ни развития, но и полного удаления тоже не происходит. В общем ни рыба не мясо.

Build_runner, build_runner - скорость без границ

На практике скупые языковые средства Dart замещаются кодогенерацией. В основном, с использованием пакета build_runner. Вроде бы полноценная кодогенерация? Что еще нужно то? Но практика показывает, что реализация build_runner - это, возможно, худший вариант кодогенерации с которой мне приходилось работать. Во-первых, она очень медленная (ну ооооочень медленная), даже на небольших проектах. Иногда задаешься вопросом, ну как можно было сделать тааак медленно? И ведь как-то смогли). Для некоторых разработчиков нет ничего невозможного! А так как кодогенерацию, в текущих реалиях языка Dart, приходится использовать на каждый чих, то со временем кодогенерация начинает занимать еще больше времени. Во-вторых, написание, хоть сколько-нибудь сложного кодогенератора то еще веселье. Почти полное отсутствие документации, обучающих статей, каких-то вменяемых примеров, а так же библиотек, позволяющих упростить и ускорить разработку довольно сильно влияет желание подобным заниматься. По этому, зачастую для конкретной задачи используется наиболее подходящий пакет с pub.dev, который, обычно, не в полной мере покрывает необходимые функциональные потребности. В третьих, кодогенерация в Dart - это буквально костыль с боку. Костыль на столько, что эту самую кодогенерацию (в том числе и потому, что она очень медленная), необходимо запускать вручную! Карл! Единственный плюс состоит в том, что разработчики Dart и сами понимают и не отрицают всех проблем существующего решения кодогенерации, что уже очень хорошо.

We are limited by the technology of our time

И таким образом мы плавно перетекаем к вопросу, каких именно модных фич нехватает в языке Dart прямо сейчас? И как именно, команда Dart пытается решить этот вопрос?

Чего ж тебе еще надо, собака?

What else do you need?

Начнем с шаблонного (не путать с обобщенным) кода (в просторечие бойлерплейта). С одной стороны молодость языка, а так же, кумиры, которые повлияили на основы языка, и тесное взаимодействие с Flutter командой, позволили Dart иметь довольно удобный, понятный и декларативный синтаксис. При этом с одной стороны, он очень понятен большинству разработчиков (в том числе и тем, кто на нем никогда не писал), так как в основе лежит C подобный синтаксис (от которого, к слову, большинство современников старается уйти, но это уже отдельная история). Разного рода синтаксический сахар и последние нововведения в системе типов, делают написание кода довольно приятным времяпрепровождением. Но с другой стороны, отсутствие выразительных средств и адекватных инструментов для работы с метапрограммированием приводит к необходимости либо написание большого количества шаблонного кода, либо к использованию кодогенерации. Основные минусы использования кодогенерации в Dart уже были обозначены. Минусы шаблонного кода общеизвестны:

  • раздувание кодовой базы;
  • увеличение количества потенциальных ошибок в коде (в одном месте код изменили, а в другом, подобном коде, нет);
  • нарушение принципов DRY;
  • уменьшение скорости разработки в частности, и интереса, в целом;
  • и многие другие.

Банальным примером может служить задача парсинга JSON данных в типизированный объект и обратно. Эту же задачу можно расширить если вспомнить о необходимости работать с базами данных и внешними сервисами (Open API и другие). Этот вопрос стоит довольно остро. Обычно используется кодогенерация, и в случаях, когда она не справляется (да, зачастую пакеты недостаточно кастомизируемы, а внешние сервисы на своей волне) приходится обращаться к ручному написанию кода.

В качестве другого примера можно привести написание кода для Flutter приложения. От многословного кода, здесь страдают не только пользователи, но и разработчики. Для написания стейтлесс виджета, необходимо описать и поля класса и конструктор. Все это сильно усугубляется при необходимости написания виджетов на основе других виджетов. В таком случае количество пробрасываемых полей увеличивается кратно числу используемых виджетов. И в данном случае, это код ради кода, так как все самое важное в большинстве стейтлесс виджетов - это build() метод. Еще хуже дело обстоит со стейтфул виджетами. Стейтфул виджеты состоят из двух частей: неизменяемого класса, собственно виджета, и класса состояния. На практике, получается, что код стейтфул виджета - это шаблонный код, в котором пробрасываются используемые данные и единственный метод, требующий реализации - это метод создания стейта. Если почитать исходники стандартных Flutter виджетов, то просто огромное количество кода - это проброс и инициализация полей. И это конечно боль. Конечно, если вы часто пишите стейтфул виджеты.

Функциональное программирование все больше и больше проникает в нашу повседневную жизнь, а вместе с ним и боль от отсутствия соответствующих средств для работы с ним в языке программирования. Все мы прекрасно знаем плюсы неизменяемых типов данных - дата классов. Что уж говорить, если в основе Flutter лежат эти самые неизменяемые дата классы - виджеты (есть и другие примеры, например, темы). Благодаря особенностям фреймворка виджетам достаточно быть только неизменяемыми. Остальные особенности дата классов не требуется. Чего не скажешь о других частях фреймворка, а именно темах, фигурах, декорациях и т.п. И если где-то Flutter позволяет срезать углы (с теми же виджетами), то при написании логики, в большинстве случаев, такой финт не пройдет. И здравствуйте шаблонный код для инициализации полей дата класса, а так же реализация метода копирования для бедных - copyWith(). И куда же без сладкой парочки: реализации == и hasCode. Не мудрено, что такие пакеты как freezed, equatable и другие подобные, невероятно популярны. Писать (и поддерживать в будущем) подобный код ручками мало у кого есть желание. Kotlin, C# и другие вводят специальный синтаксис и/или отдельные типы предоставляющие функционал дата классов. Haskell, Rust и другие позволяют генерировать реализацию подобных методов автоматически.

Последний пункт, на котором мы остановимся - это стейт менеджмент. Мало какое Flutter приложение не использует пакет для менеджмента состояния. Еще веселее ситуация состоит, если приложение использует более одного пакета. К сожалению это не такая редкая ситуация, как может показаться на первый взгляд. Особенно на этапе перехода с одного пакета на другой. Хорошо если подобный переход вообще завершится. Если ограничить список наиболее популярными решениями, то мы получим что-то вроде следующего (без сортировки):

  • BloC;
  • RiverPod;
  • MobX;
  • и другие.

С одной стороны все выше перечисленные пакеты позволяют использовать их без кодогенерации, что, несомненно, здорово. С другой стороны тот же BloC известен своей большой любовью к бойлерплейту. Возможно это один из главнейших минусов этого подхода. Да, существует несколько облегченная версия Cubit позволяющая сократить код посредством отказа от использования событий. Но это не всегда возможно, так как функционально эти подходы не равнозначны. Во вторых - это не кардинально влияет на количество кода, так как приходится использовать большое количество разных виджетов для непосредственного взаимодействия с BloC.

Похожая ситуация и с пакетом RiverPod. Большое количество разных типов провайдеров приводит не только к более высокому порогу входа, но, зачастую, и к использованию кодогенерации. Благо, она есть. Не могу сказать насколько пакет RiverPod на практике может быть использован без кодогенерации. Не так много опыта работы с ним. Было бы интересно узнать ваше мнение по этому вопросу в комментариях.

Пакет MobX можно использовать без кодогенерации, но не уверен, что найдется много желающих. Кодогенерация в этом проекте реализована, конечно несколько коряво, и довольно сильно усложняет использование последних языковых возможностей Dart, но все же довольно сильно упрощает саму разработку. В итоге мы получаем относительно стройный API, с которым и происходит взаимодействие. Скорее всего, если не использовать кодогенерацию, то большую часть автогенерируемого кода, придется писать ручками.

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

One ring to rule them all

Как мы теперь понимаем перед разработчиками Dart стоит довольно нетривиальная задача. А именно решить кучу различных проблем, в том числе и тех, что не описаны выше. При этом не превратить Dart в C++. Разработчики уже не только определились с решением, но и реализовали его, в той или иной степени. Совсем скоро (по крайней мере, я на это надеюсь) в Dart появится функционал статического метапрограммирования, или, проще говоря, макросы. Этот функционал уже доступен в дев. ветке под флагом. Его то мы, сегодня, и будем пробовать. Но прежде чем перейти непосредственно к трапезе, давайте рассмотрим основные требования и ограничения, которые были выставлены к разрабатываемому функционалу.

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

With great power comes great responsibility

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

  • проблемы с производительностью (довольно острая проблема, особенно с учетом уже существующей кодогенерации);
  • проблемы с безопасностью, которые, в последнее время у всех на слуху;
  • сложности с взаимодействием в IDE:
    • навигация по макросам и сгенерируемому коду;
    • визуализация генерируемого кода;
    • поиск;
    • подсказки;
    • отладка;
    • качество ошибок;
    • и другие;
  • и другие.

По этому нет ничего удивительного в том, что макросы в Dart уже разрабатываются более трех лет. У разработчиков, невооруженным глазом видно, жгучее желание сделать сразу нормально, что, несомненно, очень похвально. Но получится ли? Большой вопрос, частично на который постараюсь ответить.

Были выделены следующие требования к разрабатываемому функционалу:

  • возможность анализа кода программы: получение списка полей классов, в том числе и полей родительских классов; списка методов, конструкторов и их параметров. Проще говоря базовые возможности рефлексии на статическом уровне;
  • генерация нового API в существующих классах. Иначе говоря, необходимо не только проанализировать существующие классы, но и иметь возможность сгенерировать код, на основе этого анализа. Предполагается возможность добавления новых методов, полей, причем, как в публичных, так и в приватных классах;
  • генерация новых классов с нуля, что позволит создавать новые классы на основе уже существующих. И, в принципе, не только на основе классов, но и глобальных функций (вспомните стейтлесс виджеты);
  • макросы должны быть комбинируемыми. Макросы должно быть можно переиспользовать. Например, макрос для генерации дата классов должен, в том числе, использовать макросы для генерации copyWith(), ==, hashCode и так далее;
  • генерируемый код должен поддерживать отладку и быть видимым для пользователя, что сильно повысит удобство использования макросов;
  • и другие.

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

  • поддержка модульной компиляции. Для любой библиотеки должна быть возможность скомпилировать ее полностью с учетом всех макросов. При этом, чтобы не нарушить модульную компиляцию использоваться должны только транзитивные зависимости библиотеки;
  • должна быть поддержка инкрементальной компиляции. Благодаря инкрементальной поддержке функционал hot reload сможет работать как раньше, в том числе, и при использовании макросов;
  • должна быть поддержка вычисления выражений. Вычисление выражений довольно важный инструмент. Это необходимо учитывать. Таким образом макросы не должны негативно повлиять на функционал вычисления выражений;
  • и другие.

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

Исходя из выше описанных требований и ограничений команда Dart пришла к следующим пунктам:

  • макросы в Dart должны писаться на самом Dart, таким образом код макросов будет запускаться на этапе компиляции;
  • использование Dart в качестве языка для написания макросов убивает сразу кучу зайцев:
    • нет необходимости учить новый синтаксис. Кроме того, можно будет использовать (переиспользовать) уже существующий код на Dart (хотя как мы дальше узнаем, с этим пунктом есть сложности). Отсутствие отдельного языка программирования позволит сэкономить время и переиспользовать уже существующие инструменты (подсветку синтаксиса, авто-форматирования и так далее);
    • Тьюринг полнота языка позволяет покрыть огромное количество хотелок пользователей (чего не скажешь об ограничениях, которые многие хотелки убьют на корню);
    • часть кода на Dart уже выполняется во время компиляции. Таким образом можно будет переиспользовать этот код, что ускорит процесс разработки;
  • декларативные макросы, зачастую слишком ограничены. Кроме того, в будущем подобные ограничения вполне могут привести к необходимости введения нового вида макросов, для борьбы с ограниченностью существующих. История показывает, что это нередкое явление (да что уж тянуть, что-то подобное уже происходит в Dart, см. Aspect Macro ниже). Таким образом - это еще одна причина использовать императивный вид макросов.

Отдельно отмечено, что макросы сильно и явно ограничены (будут ограничены) во взаимодествии с внешним миром, естественно, в целях безопасности. А именно, предполагается, что у макросов будет доступ только к следующим пакетам:

  • dart:async;
  • dart:collection;
  • dart:convert;
  • dart:core;
  • dart:math;
  • dart:typed_data.

Таким образом никакого тебе dart:io, dart:isolate и подобного. Настолько все ограничено, что даже встал вопрос о том, как решить проблему шаринга утилитарного кода между макросами (см. Aspect Macro далее). Таким образом, сильно разгуляться не получится. На мой скромный взгляд, ограничения слишком жесткие. Но посмотрим, что будет дальше, так сказать, время покажет.

Первый взгляд

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

Макросы уже доступны в дев. ветке некоторое время. Мы будем ориентироваться на ветку 3.5-dev, ссылку для скачивания можно найти здесь.
Для тестирования будем использовать Dart SDK версии 3.5.0-69.0.dev. Далее создаем Dart проект, название может быть любым, пусть будет test_upcoming_macros. Функционал макросов находится в стадии эксперимента, и для его включения необходимо:

  1. Включить соответствующий флаг анализатора в analysis_options.yaml:
analyzer:
  enable-experiment:
    - macros
  1. Объявить зависимости от пакета macros и dev пакета _fe_analyzer_shared в pubspec.ymal. А так же явно указать путь до них:
dependencies:
  macros: any

dev_dependencies:
  _fe_analyzer_shared: any

dependency_overrides:
  macros:
    git:
      url: https://github.com/dart-lang/sdk.git
      path: pkg/macros
      ref: main
  _fe_analyzer_shared:
    git:
      url: https://github.com/dart-lang/sdk.git
      path: pkg/_fe_analyzer_shared
      ref: main
  1. Далее подтягиваем зависимости с помощью команды pub.get.
  2. При компиляции необходимо указывать дополнительный флаг --enable-experiment=macros. Например, так dart run --enable-experiment=macros. Либо в настройках вашей IDE. Для IDEA - это можно сделать в конфигурации запуска, поле VM options.

Теперь можно приступить, собственно, к написанию первого макроса на Dart. Это будет наиболее простой макрос, который будет добавлять метод greet() в целевой класс. Для этого создаем новый файл hello.dart со следующим содержимым:

import 'package:macros/macros.dart';

macro class Hello implements ClassDeclarationsMacro {
  const Hello();

  @override
  void buildDeclarationsForClass(
    ClassDeclaration clazz,
    MemberDeclarationBuilder builder,
  ) {
    builder.declareInType(
      DeclarationCode.fromString('''
      void greet() {
        print('Hello, World!');
      }
      '''),
    );
  }
}

Чтобы использовать наш макрос, нужно пометить аттрибутом Hello (имя нашего макроса) класс, в котором мы хотим добавить новый метод. Отредактируем файл hello_main.dart таким образом:

import 'package:test_upcoming_macros/hello.dart';

@Hello()
class Foo {}

void main() {
  Foo().greet();
}

При запуске этой программы мы увидим заветное:

Hello, World!

Стоит так же отметить, что я был приятно удивлен тем фактом, что IDE еще до запуска программы смогла подсказать о наличии метода greet() в классе Foo. При этом все это сработало моментально!

Обратите внимание, как просто выглядит код макроса. Фактически используется новое ключевое слово macro, переопределяем метод buildDeclarationsForClass() интерфейса ClassDeclarationsMacro. Этот метод предоставляет доступ к билдеру членов класса. Метод declareInType(), как не трудно догадаться из названия, позволяет объявить новый член. А дальше генерируем простейший код в строковом виде и вуаля! Конечно же вся простота из-за простоты реализуемого функционала. Далее мы увидим, что "настоящие" макросы выглядит несколько менее просто и привлекательно.

Наверное вам интересно как же выглядит сгенерированный код? С другой стороны и так примерно понятно, что мы должны увидеть. Но все же давайте взглянем (код отформатирован вручную):

augment library 'file:///C:/Projects/test_upcoming_macros/bin/hello_main.dart';

augment class Foo {
  void greet() {
    print('Hello, World!');
  }
}

Немного неожиданно, но интересненько. Мы видим новое ключевое слово augment. Как оказалось, это отдельный, независимый от макросов, функционал. Фактически аугментации - это аналог partial классов из C#, но на максималках. Аугментации позволяют размазать реализацию некоторого класса (в общем случае и других типов данных) по нескольким файлам. Но, но, но...! Может воскликнуть, читатель. Ведь Dart уже имеет похожий функционал в виде part файлов. Зачем необходимо реализовывать еще один аналог? Об этом, собственно, и идет речь в статье. Функционал плодится, и к чему подобное может привести, должно быть плюс-минус понятно. Очевидно, что функционал аугементаций несколько шире функционала part файлов по своим возможностям. И основной причиной его появления, конечно, являются макросы. С другой стороны, ведь можно было расширять уже имеющийся функционал part файлов, а не вводить новый. Так как аугментации еще полностью не реализованы, возможно, они будут переименованы (как это часто бывает), а может быть и нет. Если любопытно, то текущую версию спецификации можно почитать здесь.

Интересный момент, аугментации позволяют полностью или частично заменить уже имеющуюся часть кода (например, метод) или добавить код как до другого, так и после (фактически обернуть некоторый участок кода, а для этого предполагается ввести еще одно ключевое слово). И это идет несколько в разрез с функциональными требованиями, предъявляемыми к макросам. А именно момент о том, что уже имеющийся код не может быть изменен. Можно конечно сослаться на то, что это именно аугментации меняют код, а не макросы, но все же это довольно спорный момент. Ладно, это мелочи. Давайте попробуем, так сказать, на своей шкуре, аугментации.

Предположим у нас есть файл models.dart со следующим содержимым:

Person fromJson(dynamic json) {
  print('models fromJson');
  return Person(json['name']);
}

class Person {
  final String name;

  Person(this.name);
}

И мы хотим отдельного от этого файла реализовать метод для преобразования из/в JSON. Для этого создадим файл models_json.dart со следующим содержимым:

augment library 'models.dart';

augment Person fromJson(dynamic json) {
  print('models_json fromJson');
  return Person(json['name']);
} 

augment class Person {
  dynamic toJson() {
    return {
      'name': name,
    };
  }
}

А для того, чтобы им воспользоваться добавить в файл models.dart следующий импорт:

import augment 'models_json.dart';

После чего следующий код:

import 'package:test_upcoming_macros/models.dart';

void main() {
  Person p = Person('Ivan');
  print(p.toJson());

  print(fromJson({'name': 'Peter'}).name);
}

Выведет на экран:

{name: Ivan}
models_json fromJson
Peter

Обратите внимание на то, что объявленную в models.dart функцию fromJson, фактически, полностью заменила ее аугментированная версия из models_json.dart.

Сильно не будем останавливаться на функционале аугментации, но все же минимально необходимо было рассмотреть его, для того, чтобы был более понятен процесс работы и написания макросов. Интересно, у вас есть идеи на тему того, когда функционал аугментаций в Dart может быть полезным за исключением кодогенерации и макросов? Другими словами, как вы считаете, стоило ли добавлять функционал аугментаций в Dart? Будите ли вы его использовать для разделения кода на несколько файлов? Ведь с появлением этого функционала будет нельзя однозначно сказать, что именно делает код? Необходимо будет проверить нет ли аугментаций, и если есть, что именно они делают и каким образом это влияет на код.

Официальные примеры

Рассмотрим несколько официальных примеров. "Исправление" версии и код остальных примеров можно посмотреть здесь.

Observable macro позволяет генерировать код для реакции на изменение полей целевого класса. Упрощенная версия функционала используемого в том же MobX-е. Сам макрос выглядит так:

import 'dart:async';

import 'package:macros/macros.dart';

macro class Observable implements FieldDeclarationsMacro {
  const Observable();

  @override
  Future<void> buildDeclarationsForField(
      FieldDeclaration field, MemberDeclarationBuilder builder) async {
    final name = field.identifier.name;
    if (!name.startsWith('_')) {
      throw ArgumentError(
          '@observable can only annotate private fields, and it will create '
          'public getters and setters for them, but the public field '
          '$name was annotated.');
    }
    var publicName = name.substring(1);
    var getter = DeclarationCode.fromParts(
        [field.type.code, ' get $publicName => ', field.identifier, ';']);
    builder.declareInType(getter);

    var print =
        // ignore: deprecated_member_use
        await builder.resolveIdentifier(Uri.parse('dart:core'), 'print');
    var setter = DeclarationCode.fromParts([
      'set $publicName(',
      field.type.code,
      ' val) {\n',
      print,
      "('Setting $publicName to \${val}');\n",
      field.identifier,
      ' = val;\n}',
    ]);
    builder.declareInType(setter);
  }
}

Макрос находит все поля помещенные мета-тегом Observable, и если это публичное поле, то генерирует исключение. Если же это приватное поле, то для него генерируются сеттер и геттер. Причем, в сеттере каждое присвоение выводится в консоль. Пример использования выглядит так:

import 'package:macro_proposal/observable.dart';

void main() {
  var jack = ObservableUser(age: 10, name: 'jack');
  jack.age = 12;
  jack.name = 'john';
}

class ObservableUser {
  @Observable()
  int _age;

  @Observable()
  String _name;

  ObservableUser({
    required int age,
    required String name,
  })  : _age = age,
        _name = name;
}

При запуске этого кода в консоли мы увидим:

Setting age to 12
Setting name to john

Что собственно и требовалось. А уже на основе этого можно вызывать коллбек или реализовать Listenable, что по своей сути, уже дело техники.

Следующий пример показывает как можно использовать макрос DataClass для генерации дата классов:

import 'package:macro_proposal/data_class.dart';

void main() {
  var joe = User(age: 25, name: 'Joe', username: 'joe1234');
  print(joe);

  var phoenix = joe.copyWith(name: 'Phoenix', age: 23);
  print(phoenix);
  
  var joe2 = joe.copyWith();
  print('Is equal: ${joe == joe2}');
  print('Is identical: ${identical(joe, joe2)}');
}

@DataClass()
class User {
  final int age;
  final String name;
  final String username;
}

@DataClass()
class Manager extends User {
  final List<User> reports;
}

При запуске на экране мы увидим следующее:

User {
  age: 25
  name: Joe
  username: joe1234
}
User {
  age: 23
  name: Phoenix
  username: joe1234
}
Is equal: true
Is identical: false

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

// ...

macro class DataClass implements ClassDeclarationsMacro, ClassDefinitionMacro {
  const DataClass();

  @override
  Future<void> buildDeclarationsForClass(
      ClassDeclaration clazz, MemberDeclarationBuilder context) async {
    await Future.wait([
      const AutoConstructor().buildDeclarationsForClass(clazz, context),
      const CopyWith().buildDeclarationsForClass(clazz, context),
      const HashCode().buildDeclarationsForClass(clazz, context),
      const Equality().buildDeclarationsForClass(clazz, context),
      const ToString().buildDeclarationsForClass(clazz, context),
    ]);
  }

  @override
  Future<void> buildDefinitionForClass(
      ClassDeclaration clazz, TypeDefinitionBuilder builder) async {
    await Future.wait([
      const HashCode().buildDefinitionForClass(clazz, builder),
      const Equality().buildDefinitionForClass(clazz, builder),
      const ToString().buildDefinitionForClass(clazz, builder),
    ]);
  }
}

// ...

Полный код данного макроса довольно объемный можно посмотреть здесь. Отчетливо видно, что макрос генерации дата класс, фактически состоит из нескольких отдельных и независимых друг от друга макросов. А именно макрос для генерации конструктора, макрос для генерации метода copyWith(), макрос для генерации хэшкода и оператора ==, а так же макрос для генерации метода toString(). Плюсы такого подхода очевидны - переиспользование кода в других макросах, а так же возможность использовать конкретный функционал определенного макроса. Например, если для каких-то конкретных целей нам необходимо реализовать только метод copyWith(), мы можем пометить соответствующих класс аннотацией @CopyWith() и все. Остальной функционал дата классов, в таком случае, сгенерирован не будет.

Следующий пример: макрос JsonSerializable, который, как понятно из названия, позволяет генерировать код для преобразования из/в JSON. Пример использования:

import 'dart:math';

import 'package:macro_proposal/json_serializable.dart';

void main() {
  var rand = Random();
  var rogerJson = {
    'age': rand.nextInt(100),
    'name': 'Roger',
    'username': 'roger1337'
  };

  final user = User.fromJson(rogerJson);

  print(user.age);
  print(user.toJson());
}

@JsonSerializable()
class User {
  final int age;
  final String name;
  final String username;

  User({required this.age, required this.name, required this.username});
}

Здесь все аналогично пакету json_serializable. Помечаем целевой класс мета-тегом JsonSerializable, вследствие чего будут сгенерированы конструктор fromJson() и метод toJson(). На экране мы увидим вывод аналогичный следующему:

95
{age: 95, name: Roger, username: roger1337}

Далее идет пример генерации стейтлесс класса из build метода:

import 'package:test_upcoming_macros/functional_widget.dart';

@FunctionalWidget()
Widget buildGap(BuildContext context, double width) {
  return SizedBox(width: width);
}

void main() {
  final gap = BuildGap(15);
  print(gap);
  print(gap.build(BuildContext()));
  print((gap.build(BuildContext()) as SizedBox).width);
}

Этот пример показывает использование макросов для генерации стейтлесс виджетов из соответствующего build метода. Конечно для полноценного использования необходим Flutter. По этому в этом примере я добавил недостающие классы для симуляции поведения, ибо на суть это не повлияет, а вот увидеть результат работы позволит. На основе метода buildGap() генерируется соответствующий класс с именем BuildGap, который далее мы можем использовать аналогично тому, как если бы мы написали его сами.

В официальных примерах так же есть пример генерации бойлерплейта для InheritedWidet-ов. А также пример макроса для реализации типо-безопасных инъекций зависимостей времени компиляции (Dependency Injection, DI) и другие. Но, как и большинство остальных официальных примеров запустить, как говорится, из коробки не получилось. Где-то нехватает какого-то функционала, где-то просто ошибки. Сами же примеры довольно показательны, и красноречиво говорят о том, какой именно функционал, предполагается, будет реализовываться с помощью макросов.

И напоследок посмотрим пример макроса ADT, найденного в сети. Этот макрос позволяет упростить генерацию алгебраических типов данных, по типу того, как это делается в Haskell и других языках:

import 'package:test_upcoming_macros/adt.dart';

@ADT()
sealed class Expr {
  const Expr();

  Expr._lit(int value);
  Expr._add(Expr left, Expr right);

  int eval() => switch (this) {
        Lit(:final value) => value,
        Add(:final left, :final right) => left.eval() + right.eval(),
      };
}

void main() {
  print(Add(Lit(3), Lit(4)).eval());
}

Как выше уже упоминалось макросы в Dart не позволяют генерировать новый синтаксис, таким образом, чтобы выйти из положения, автор этого макроса использует приватные именованные конструкторы. На основе которых и генерируются соответствующие классы с полями из конкретного конструктора.

Проба пера

Разумеется одно дело смотреть как макросы пишут другие, и совсем другое попробовать написать свой макрос. И понять насколько это сложно? Насколько API удобное и интуитивно понятное или нет, особенно с учетом того, что доокументацию, еще не завезли. А так же сравнить эти ощущения с аналогичными при использовании кодогенерации из пакета build_runner. Если вспоминать предыдущий опыт работы с build_runner-ом, то основная попытка была написать кодогенерацию для расширений тем (Theme Extensions), когда они только появились. Да, какой-то результат был реализован, но заняло это непозволительно много времени. Отсутствие внятной документации, не интуитивное API и разного рода другие проблемы и сложности не позволили получить результат, который хотелось бы. Попробуем реализовать нечто такое с помощью макросов. Но сначала рассмотрим другие, более простые, но не менее интересные и полезные, вещи.

You know, I'm something of a metaengineer myself

  1. Начнем с простого. Генерация типизированного класса с путями к ресурсам. Допустим у нас в проекте есть папка assets с несколькими файлами локализации в json формате (в общем случае это могут быть любые ресурсы, в том числе и изображения). Макрос должен генерировать пути к этим файлам для последующего использования в коде. Сильно упрощенный аналог пакета flutter_gen. Пример использования выглядит так:
import 'package:test_upcoming_macros/assets.dart';

@Assets('assets')
class A {}

void main() {
  print(A.locale_en); // ./assets/locale_en.json
  print(A.locale_ru); // ./assets/locale_ru.json
}

Сам код макроса получился таким:

import 'dart:convert';
import 'dart:io';

import 'package:macros/macros.dart';

macro class Assets implements ClassDeclarationsMacro {
  const Assets(this.dir);

  final String dir;

  @override
  Future<void> buildDeclarationsForClass(
      ClassDeclaration clazz,
      MemberDeclarationBuilder builder,
  ) async {
    await for (final file in Directory('./$dir/').list()) {
      if (file is! File) continue;

      final path = file.path;
      final (name, ext) = parse(path.split('/').last);

      builder.declareInType(DeclarationCode.fromString(
        'static const $name = \'$path\';',
      ));

      if (ext == 'json') {
        _validateJson(file);
      } 
      // TODO: support other types
    }
  }

  Future<void> _validateJson(File file) async {
    try {
      final text = await file.readAsString();
      jsonDecode(text);
    } catch (e) {
      throw StateError('Only valid json resources are supported');
    }
  }

  static (String name, String ext) parse(String fullName) {
    final i = fullName.lastIndexOf('.');
    final name = i == -1 ? fullName : fullName.substring(0, i);
    final ext = i == -1 ? '' : fullName.substring(i + 1);
    return (name, ext);
  }
}

Подобного рода макросы обычно самые простые для написания. Так как в них нет необходимости анализировать код, и фактически вся генерация осуществляется на основе некоторых данных. В данном случае на файлах в папке и их расширении. Аналогично в код макроса можно добавить и другие типы ресурсов, в том числе изображения. Так же интересный момент - это валидация. На примере JSON файлов, данный макрос на этапе компиляции позволяет проверить корректность данных. Таким образом не получится скомпилировать приложение с некорректным, по структуре, JSON файлом. И мы не получим ошибку времени выполнения, в таком, случае.

  1. Следующий макрос в чем-то похож на предыдущий. Этот макрос позволяет генерировать типизированные данные на основе некоторого файла. Например, для хранения настроек приложения. Допустим файл настроек выглядит так:
{
  "version": "1.5.0",
  "build": 13,
  "debugOptions": false,
  "price": 14.0
}

С помощью макроса Config мы можем использовать эти данные прямо в приложении:

import 'package:test_upcoming_macros/config.dart';

@Config('assets/config.json')
class AppConfig {}

void main() async {
  await AppConfig.initialize();

  print(AppConfig.instance.version);
  print(AppConfig.instance.build);
  print(AppConfig.instance.debugOptions);
  print(AppConfig.instance.price);
}

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

  1. На практике довольно часто может встретиться необходимость реализации так называемого multi dispatcher-а или некоторого аналога типизированной рассылки сообщений. Например, у нас есть следующий интерфейс:
abstract interface class Delegate {
  void onPress(int a);

  void onSave(String path, double content);
  
  // ... other methods
}

Классов, реализующих этот интерфейс много. И хотелось бы работать со списком таких интерфейсом как с классом, реализующим этот самый интерфейс. В таком случае для вызова соответствующих методов интерфейса у всех реализаций в списке достаточно вызвать соответствующий метод у класса обертки. Если бы мы реализовывали этот функционал вручную, то код выглядел бы следующим образом:

class MultiDelegate implements Delegate {
  final List<Delegate> _delegates;
  
  MultiDelegate(this._delegates);
  
  void onPress(int a) {
    for (final delegate in _delegates) {
      delegate.onPress(a);
    }
  }

  void onSave(String path, double content){
    for (final delegate in _delegates) {
      delegate.onSave(path, context);
    }
  }
  
  // ... other methods
}

Как видно из кода это обычная композиция. И ничего сложного здесь нет. Но стоит отметить, что по своей сути, это код чистой воды бойлерплейт. Более того, не получится реализовать подобный код (по крайней мере в Dart) в обобщенном виде. Один раз написал и много раз используешь с различными делегатами. Любое изменение в коде интерфейса придется вручную доделывать и в каждой подобной обертке. Или можно забыть это сделать, как например, в случае с добавлением нового необязательного параметра. И как следствие привет новым, дремлющим багам, ждущих своего часа для наиболее неподходящего момента пробуждения. Все как мы любим). В общем даже из описания - это боль и негодование. Но с помощью макроса MultiDelegate (хотелось бы конечно придумать более лаконичное имя, но что-то не получилось...) эту задачу можно переложить на руки машины, которая не устает.

Использовать этот макрос можно так:

import 'package:test_upcoming_macros/multicast.dart';

@Multicast()
abstract interface class Delegate {
  void onPress(int a);

  void onSave(String path, double content);

  // ... other methods
}

class FirstDelegate implements Delegate {
  @override
  void onPress(int a) => print('First onPress: $a');

  @override
  void onSave(String path, double content) =>
      print('First onSave: $path, $content');
}

class SecondDelegate implements Delegate {
  @override
  void onPress(int a) => print('Second onPress: $a');

  @override
  void onSave(String path, double content) =>
      print('Second onSave: $path, $content');
}

void main() {
  Delegate d = DelegateMulticast([
    FirstDelegate(),
    SecondDelegate(),
  ]);

  d.onPress(5);
  d.onSave('settings.txt', 5.0);
}

А вывод будет следующим:

First onPress: 5
Second onPress: 5
First onSave: settings.txt, 5.0
Second onSave: settings.txt, 5.0

Код макроса хоть и не очень сложный, но все же здесь нам уже приходится анализировать другой код. И уже на основе него генерировать класс обертку:

import 'dart:async';

import 'package:macros/macros.dart';

macro class Multicast implements ClassTypesMacro {
  const Multicast();

  @override
  Future<void> buildTypesForClass(ClassDeclaration clazz,
      ClassTypeBuilder builder,) async {
    final name = '${clazz.identifier.name}Multicast';

    // ignore: deprecated_member_use
    var multicast = await builder.resolveIdentifier(
        Uri.parse('package:test_upcoming_macros/multicast.dart'), 'MulticastMethod');

    final parts = [
      '@',
      multicast,
      '()',
      '\nclass $name implements ${clazz.identifier.name} {\n',
      'final List<',
      clazz.identifier,
      '> _delegates;\n',
      '$name(this._delegates);',
      '\n}',
    ];

    builder.declareType(name, DeclarationCode.fromParts(parts));
  }
}

macro class MulticastMethod implements ClassDeclarationsMacro {
  const MulticastMethod();

  @override
  Future<void> buildDeclarationsForClass(ClassDeclaration clazz,
      MemberDeclarationBuilder builder,) async {
    // TODO: should be a better way
    final delegateIdentifier = clazz.interfaces.first.identifier;
    final delegateType = await builder.typeDeclarationOf(delegateIdentifier);
    final methods = await builder.methodsOf(delegateType);

    for (final method in methods) {
      final args = <Object>[];
      final params = <Object>[];
      for (final p in method.positionalParameters) {
          params.add(p.code);
          params.add(', ');
          args.add(p.identifier.name);
          args.add(', ');
      }

      final namedParams = <Object>[];
      for (final p in method.namedParameters) {
        namedParams.add(p.code);
        namedParams.add(', ');
        args.add('${p.identifier.name}:${p.identifier.name}');
        args.add(', ');
      }

      final parts = [
        method.returnType.code,
        ' ',
        method.identifier.name,
        '(',
        ...params,
        if (namedParams.isNotEmpty)
          '{',
        if (namedParams.isNotEmpty)
          ...namedParams,
        if (namedParams.isNotEmpty)
          '}',
        ') {\n',
        ' for (final delegate in _delegates) {\n',
        '   delegate.${method.identifier.name}(\n',
        ...args,
        '\n);',
        ' }\n',
        '}',
      ];

      builder.declareInType(DeclarationCode.fromParts(parts));
    }
  }
}
  1. Теперь самое время вернутся к оригинальной идее, а именно, к макросу для генерации тем расширений. Темы расширений фактически позволяют использовать аналог тем из Flutter только для своих виджетов, аля кастомные темы. На практике это очень удобно. Если вы не используете, то вы многое теряете. Для реализации кастомной темы необходимо объявить класс наследующийся от ThemeExtension<T>, а так же реализовать пару обязательных методов: copyWith(), lerp() и один of() для удобства использования. И если первая часть задачи обычно не вызывает проблем, то вторая - это, опять-таки, чистой воды механическая работа. Реализацию copyWith() мы уже видели в одном из официальных примеров, по этому просто переиспользуем. Реализация метода of() по своей сути похожа на аналогичные методы в InheritedWidget классах, но попробуем реализовать без подглядок. И самая сложная часть - это метод lerp().

Пример использования:

import 'package:test_upcoming_macros/build_context.dart';
import 'package:test_upcoming_macros/theme_ext.dart';

@ThemeExt()
class ButtonTheme extends ThemeExtension<ButtonTheme> {
  final double? size;
}

void main() {
  final context = BuildContext(
    theme: Theme(extensions: [
      ButtonTheme(
        size: 10,
      ),
    ]),
  );

  final buttonTheme = ButtonTheme.of(context);
  print(buttonTheme?.size); // 10.0

  final buttonTheme2 = buttonTheme?.copyWith(size: 20);
  print(buttonTheme2?.size); // 20.0

  final lerpedTheme = buttonTheme?.lerp(buttonTheme2, .5);
  print(lerpedTheme?.size); // 15.0
}

Здесь, аналогично примеру с FunctionalWidget флаттер окружение симулируется соответствующими классами. Таким образом здесь мы объявили тему ButtonTheme с возможностью задания некоторого размера. И благодаря макросу CustomTheme будет сгенерированы все вышеописанные методы плюс конструктор. Аналогичным образом можно будет добавить и другие типы данных: Color, TextStyle, EdgeInsets и так далее. Соответствующий код макроса можно посмотреть в репозитории.

  1. Напоследок рассмотрим наиболее сложный пример, из мною, на данный момент, реализованных. А именно типизированные роуты. Сразу отмечу, что добиться идеального результата, которого хотелось, не получилось, по различным причинам, в основном из-за багов и недореализованности API макросов. В будущем большая часть из проблем, с которыми пришлось столкнуться должна уйти. Как мы знаем, несмотря на появление Navigator 2.0, который всех проблем не решил, хотя и облегчил во многом существующие реализации, проблема типизации в работе с навигатором / роутером все еще с нами. Да и популярные Navigator 2.0 пакеты предоставляют кодогенерацию как раз для решения этих проблем. Другими словами, слежение за корректностью данных, получаемых экранами и возвращаемых ими же, а так извлечение параметров из пути все так же лежит на наших плечах. Для простоты примера был использован API аналогичный API Navigator 1.0, но весь код можно адаптировать и для навигатора второй версии.

Макрос Route позволяет сгенерировать код для навигации на некоторый экран, в том числе с учетом необходимых параметров. Пример использования макроса выглядит так:

import 'package:test_upcoming_macros/route.dart';

@Route(path: '/profile/:profileId?tab=:tab', returnType: 'bool')
class ProfileScreen extends StatelessWidget {
  final int profileId;
  final String? tab;

  @override
  Widget build(BuildContext context) {
    return Button(onPressed: () {
      print('onSaveButton clicked (profileId: $profileId, tab: $tab)');
      // close current screen
      pop(context, true);
    });
  }
}

@Route(path: '/login')
class LoginScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Button(onPressed: () {
      print('On logged in button pressed');
      pop(context);
    });
  }
}

void main() async {
  final routeBuilders = [
    LoginScreen.buildLoginRoute,
    ProfileScreen.buildProfileRoute,
  ];
  final app = MaterialApp(onGenerateRoute: (route, [arguments]) {
    print('onGenerateRoute: $route');
    for (final builder in routeBuilders) {
      final screen = builder(route, arguments);
      if (screen != null) return screen;
    }
    throw 'Failed to generate route for $route.';
  });

  final context = app.context;
  final hasChanges =
      await context.navigator.pushProfile(profileId: 15, tab: 'settings');
  print('Has changes: $hasChanges');

  await context.navigator.pushLogin();
  print('Login screen closed');
}

Вывод программы:

Navigator.push /profile/15?tab=settings
onGenerateRoute: /profile/15?tab=settings
onSaveButton clicked (profileId: 15, tab: settings)
Navigator.pop true
Has changes: true
Navigator.push /login
onGenerateRoute: /login
On logged in button pressed
Navigator.pop null
Login screen closed

Здесь у нас есть экран профиля с обязательным параметром - идентификатором профиля и необязательным параметром - именем вкладки. Результатом работы экрана будет значение логического типа. Как видно из примера выше макрос сгенерировал метод pushProfile() для перехода к экрану с указанием всех обязательных и не обязательных параметров. А так же типизированный метод pop() для закрытия экрана. Кроме этого, был сгенерирован метод ProfileRoute.buildRoute() для создания экрана из пути с вычленением необходимых данных (идентификатора профиля и названия вкладки, в данном случае). С одной стороны все это выглядит очень и очень неплохо, в плане уменьшения количества шаблонного кода. С другой же стороны все еще несколько далеко от идеала. Часть проблем, с которыми пришлось столкнуться решить либо не удалось, либо привело к ухудшению пользовательского опыта. Идеально было бы получить нечто такое:

import 'package:test_upcoming_macros/route.dart';

@Route(path: '/profile/:profileId?tab=:tab', returnType: bool)
class ProfileScreen extends StatelessWidget {
  final int profileId;
  final String? tab;

  @override
  Widget build(BuildContext context) {
    return Button(onPressed: () {
      print('onSaveButton clicked (profileId: $profileId, tab: $tab)');
      // close current screen
      pop(context, true);
    });
  }
}

@Route(path: '/login')
class LoginScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Button(onPressed: () {
      print('On logged in button pressed');
      pop(context);
    });
  }
}

void main() async {
  final app = MaterialApp(onGenerateRoute: AppRoutes.onGenerateRoute);

  final context = app.context;
  final hasChanges =
      await context.navigator.pushProfile(profileId: 15, tab: 'settings');
  print('Has changes: $hasChanges');

  await context.navigator.pushLogin();
  print('Login screen closed');
}

Это пример того, к чему я стремился. Отличия следующие:

  • результирующий тип задается явно - ссылкой на соответствующий тип данных, а не через типо-небезопасный строковый параметр;
  • генерируется общий для всех роутов метод генерации роутов (AppRoutes.onGenerateRoute). Сейчас только методы для каждого экрана. А собирать их в кучу приходится вручную, что конечно ни в какие ворота не годится...;

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

Из-за недоработок самих макросов и, возможно, неполного понимая некоторых нюансов, не удалось реализовать то, что больше всего хотелось (объединение всех маршрутов в одном месте). Но в любом случае получилось более чем неплохо. И довольно близко к тому, что требуется. Для указания возвращаемого типа данных пришлось использовать строки, хотя, по-хорошему, должна быть ссылка на конкретный тип данных. Согласно текущей спецификации и официальным примерам (см. ChecksExtensions.dart) - это должно быть возможным. Таким образом момент с возвращаемым типом данных должен решится в будущем. И последний момент - это сбор всех сгенерированных роутов в одном месте. И здесь мне не совсем понятно можно ли это будет реализовать или нет. Но это очень хотелось бы сделать, так как подобная необходимость возникает довольно часто. По идее это должно быть реализуемо, но на данном этапе, с учетом уже описанного API, что-то я не смог разобраться как это сделать. Возможно сказывается недостаток в API (например, в некоторых примерах это явно отмечается: API для реализации интерфейсов пока нет), либо я недостаточно погрузился и разобрался. Ну и самый неприятный вариант, что этого нельзя будет сделать. В таком случае, это довольно печально, так как подобное много где требуется. И это, на мой взгляд, довольно сильно ударит по возможностям макросов.

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

Итоги

Хотелось бы подвести некоторые итоги знакомства с макросами. Здесь можно отметить как положительные, так и отрицательные моменты. К положительным можно отнести:

  • понятный и простой API, и это с учетом довольно раннего этапа, со временем должно стать еще лучше;
  • скорость работы (на простейших и коротких примерах);
  • скорость разработки макросов. Удалось реализовать большинство макросов в довольно короткие сроки;
  • приятный пользовательский опыт: взаимодействие (написание и использование) с макросами не выглядит как нечто инородное, как в случае с build_runner;
  • довольно широкие возможности;
  • и другие.

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

  • Dart analysis service скорее не работает, чем работает. И возможно это самое неприятное, на данный момент, в контексте использования и написания макросов. Подсказки к коду и переходы постоянно отрубаются. IDE лихорадит. Происходят какие-то падения и пользовательский опыт в этом плане просто на нуле, если не ниже;
  • форматирование кода макросов и аугментаций ломается, неприятно, но несмертельно, на данном этапе;
  • часть API просто не работает. Из испробованного макросы для перечислений, расширений и расширяемых типов просто не запускаются. Это момент, тоже, довольно неприятный, так как пришлось долго с этим разбираться, в том числе и менять стратегию реализации некоторых макросов;
  • не работающие официальные примеры. Понятно, что на текущем этапе маловероятно чтобы все примеры работали, с другой стороны это все таки минус. Некоторые, довольно интересные, примеры запустить так и не удалось. Часть других примеров запустить получилось, но с внесением, порой, существенных правок, на что ушло дополнительное время;
  • слаботипизированный API. Объективно List<Object> parts ... не только выглядит убого, но и типо-небезопасно. Подобные баги удалось найти даже в официальных примерах. Но даже если исключить момент с типо-безопасностью генерировать код как список очень и очень не удобно. Трудно следить и проставлять пробелы, переносы; трудно воспринимать генерируемый код как единое целое, и как следствие постоянные ошибки и необходимость вносить изменения. Этот момент также довольно негативно сказался на пользовательском опыте и затруднил реализацию макросов. И позитивного можно отметить, что согласно текущей спецификации авторы это тоже понимают. И даже предлагается решение: введение квази цитирования в язык Dart, аналогичное по функционалу Tagged templates из JS. Это должно кардинальным образом изменить ситуацию в лучшую сторону;
  • ограниченный API. Выше мы уже обсуждали, что по различным причинам API макросов довольно сильно ограничен. Нельзя то, нельзя это. С одной стороны, это вроде как хорошо, но с другой стороны, по ощущениям, ухудшает пользовательский опыт, а порой и вовсе может сделать реализацию макроса невозможной. Для реализации некоторых макросов, с учетом отсутствия полноценного доступа к dart:io, может поставить крест или сильно усложнить, например, реализацию DB first подхода или любых других, где нужен доступ к запуску процессов, параметрам окружения или скачиванию данных из интернета. Другими словами реализовать макрос Open API по удаленному URL, судя по всему, не получится;
  • неизвестно сколько времени ждать до полноценного релиза;
  • выше упоминалось, что добавление макросов может привести к добавлению другого функционала, зачастую необходимого из-за ограниченности макросов (точнее из-за того, как эти макросы ограничили). Даже на простом примере с макросом генерации роутов видно, сколько дополнительного кода приходится писать. И это все еще усложняется тем фактом, что макросы не могут нормально переиспользовать утилитарный код друг друга. И это, при активном использовании макросов, конечно проблема (бойлерплейт), которую, макросы и были призваны решить. И одним из предлагаемых решений, приготовьтесь, ввести в язык Aspect макросы. Это макросы, используемые другими макросами, для генерации общего (подобного) кода. Ну, то есть, макросы еще не реализовали, но уже в процессе обсуждения то, как и какими средствами довести эти макросы до ума;
  • местами слабые или отсутствующие методы рефлексии. Хотелось бы иметь информацию не только о базовых классах, но и о классах наследниках. В API объявлены методы для получения всех типов данных, объявленных в библиотеке (что хорошо), но закрадываются сомнения о том, что именно будет этот метод возвращать? Другими словами, в идеале, хотелось бы иметь информацию о типах не одного конкретного файла, а и и нескольких или вообще всего пакета;
  • отладка макросов не работает;
  • переход к сгенерированному коду (в IDEA) не работает;
  • ну и другие.

Начало экзистенциального кризиса

Возможно подзаголовок звучит несколько громко. Но все же хотелось сказать о том, что далеко не всем разработчикам по душе подобные изменения. И на то есть куча различных причин. Опыт других языков программирования в плане макросов тоже, довольно показателен, в этом плане.

Макросы сильно усложняют код. Способствуют сильному ухудшению понимания того, что именно происходит в кодовой базе. Появляется много магического кода. Новым разработчикам сложнее работать в подобных языках. Так как макросы, кроме прочего, вводят новый, мета, уровень программирования. А для начала, не плохо бы освоить первоначальный уровень. Макросы довольно сильно усложняют язык. И за собой тащат другой более продвинутый функционал, что, в свою очередь, еще сильнее усложняет язык. Даже на примере Dart мы уже видим, что макросы привнесли аугментирование (при чем похожий функционал в Dart уже есть, таким образом теперь их два) и потенциально квази цитирование и аспектные макросы. Макросы довольно сложно сделать хорошо. Макросы сильно влияют на производительность IDE в негативную сторону. И если на простом примере влияние макросов на скорость и корректность отображаемых в IDE подсказок не влияет, то со сложными макросами и большой кодовой базой закрадываются сомнения, в том, получится ли сохранить комфорт использования IDE на том же уровне? Усложняется отладка кода. Любой новый функционал, добавляемый в язык в будущем необходимо разрабатывать уже с учетом макросов, что усложняет и удлиняет цикл разработки. Да, что уж говорить, если на сам функционал макросов уже потрачено более трех лет и он все еще не готов. А ведь сколько полезного можно было реализовать за это время? А сколько еще времени потребуется?

Усложняется чтение кода. В случае с макросами недостаточно прочитать непосредственно текст кода, необходимо понять, что именно и как делают используемые в коде макросы. Можно конечно читать сгенерированный код, но далеко не факт, что это поможет, ведь он может быть (по тем или иным причинам), плохо читаемым (обфусцированным и/или минимизированным).

Все вышеперечисленное и разного рода другие, явно не упомянутые моменты, прямо или косвенно влияют на все. На то как язык будет использоваться. На то кем он будет использоваться. На его популярность и развитие. Ведь очевидно, макросы не будут последней фичей реализованной в Dart. Развитие языка продолжится, естественно, путем добавления нового функционала. И ведь так легко не уследить, и вуаля, новый Франкенштей (ну, это, конечно же, если Google не убьют Dart/Flutter раньше). С другой стороны не он первый ни он последний.

Очень интересно, как на ваш взгляд, макросы в Dart - это благо? Или нет? Если да, то какой первый макрос вы хотели бы написать/использовать? Огорчают ли вас ограничения, которые макросы будут иметь? Или на ваш взгляд эти ограничения целесообразны и скорее благо? Если же нет, то почему нет? Не нравятся возможности? Потенциальные сложности в написании или понимании? Или же на ваш взгляд Dart стоит развиваться в другом направлении, например, в направлении нового функционала или синтаксического сахара? Или же вы приверженец аскетизма и заветы языка Go наше все? Считаете ли вы, что внесение такого неоднозначного и мощного функционала в язык Dart привнесет новую жизнь и новую кровь? Или же, возможно, это отправная точка и конец, для некогда, уютного, удобного, функционального и простого языка? Так много вопросов...

Пишите в комментариях, очень любопытно узнать, что вы думаете. И до встречи, буквально в следующей статье и спасибо за внимание.