Кодогенерация на dart
Dart - довольно молодой, легкий в освоении язык программирования. Подавляющее большинство из нас приходят к нему исключительно ради разработки на Flutter. И оно понятно. Ведь это реактивный кроссплатформенный фреймворк с отличной документацией от корпорации Google и long time support.
Однако его "молодость" все же накладывает отпечаток на разработку. Вероятно, местами dart может оказаться не настолько гибким, насколько бы хотелось. Не настолько, насколько это возможно в других языках, что местами может приводить к написанию избыточного шаблонного кода, и, как следствие, нарушению основополагающего принципа DRY (don't repeat yourself).
Тем не менее, язык развивается, и, возможно, в ближайшем будущем догонит своих старших братьев.
Возможно, если вы читаете эту статью из будущего, то недоумеваете о чем речь. Но сейчас в 2021 году в версии 2.14.1 в dart нет partial классов, возможности перегрузки оператора преобразования типов, наследования статических методов, инициализации полей по умолчанию и много другого. Про многие из эти "фич" можно сказать, что они не нужны, но неплохо было бы, если бы многие из них и были. К счастью разработчики dart предусмотрели несколько утилит, которые позволяют творить удивительные вещи - такие вещи, которые могут нивелировать недоработки языка. И одна из таких удивительных вещей - это кодогенерация.
Кодогенерация позволяет дать вашему коду сверхспособности и приблизить будущие возможности языка. И если вы не боитесь будущего, добро пожаловать под кат...
Кодогенерация
На тему кодогенерации в dart написано много статей, но в свое время при изучением этой темы я столкнулся с рядом трудностей и нюансов, которым в них уделялось недостаточно внимания. Чтобы не повторять этой ошибки, я постараюсь в этом руководстве расписать подробно каждый шаг, чтобы в последствии осталось как можно меньше вопросов. Но обо всем по порядку...
Лирическое отступление
Для начала пару слов о том, что мы будем генерировать
Мы все знаем, что у dart есть замечательный сахар для конструктора. Вместо того, чтобы каждый раз описывать инициализацию полей объекта в конструкторе:
class C { C(int? a){ this.a = a; } int? a; }мы можем использовать короткий синтаксис:
class C { C(this.a); int? a; }Удобно, не правда ли? Однако представьте, что у такого класса много полей?
class C { C({this.a, this.b, this.c, this.d, this.e, this.f, this.g, this.h}); int? a; int? b; int? c; int? d; int? e; int? f; int? g; int? h; }
Согласно dart design каждое из этих полей мы должны прописать том или ином виде в аргументы конструктора, если хотим иметь возможность его инициализировать при создании экземпляра. В противном случае нам придется для инициализации каждого поля писать отдельную строку кода:
var c = C(); c.a = 1; c.b = 2; c.c = 3; // etc...
Если вспомнить, что у dart есть замечательный сахар в виде двух точек, то можно было бы предположить, что мы можем сделать так:
var c = C()..a = 1..b = 2;
Пусть выглядит странно, но эффективно (мало кода). Однако, как выясняется, для обычных полей это не работает (работает только методов и геттеров), а значит даже такой "лайфхак" отпадает.
А что же у старших "братьев", golang и C#?
Чтобы раскрыть некоторые утерянные возможности в dart, глянем немного "налево". Там мы увидим, что в C# есть такая фича, как инициализатор класса:
class Program { class A { public int a; public int b; public int c { get; set; } } static void Main(string[] args) { var a = new A { a = 1, b = 2, c = 3 }; } }
Как видите, не нужно в конструкторе (в примере он даже не объявлен) описывать инициализацию каждого поля. В этом коде нет ничего лишнего для того, чтобы инициализировать поля объекта. Аналогично это работает и в golang, в котором решили даже не вводить понятие "конструктор".
Но в dart нет инициализатора полей, нет в нем и сахарного объявления свойств, как в листинге выше. И да, в конце концов, в dart поля - это поля, а не свойства, как в kotlin.
Что же делать? Неужели опустить руки и писать так, как предлагают нам разработчики dart?
Не знаю, как вы, а я не люблю серые будни и нудную, однообразную работу. Поэтому после перебора всех перечисленных вариантов и не был готов сдаваться.
Когда я понял, что кодогенерация открывает безграничные (почти) возможности, я подумал, почему бы нам для каждого класса просто не объявить extension, который будет ожидать поля класса. При этом не нужно будет писать конструктор для этих полей руками. Не знаю, как вам, а мне этот вариант показался очень удобным. Ведь теперь можно писать, например, так:
class C{ int? a; int? b; int? c; } var c = C()._(a: 1, b: 2, c: 3);
Вместо
class C{ int? a; int? b; int? c; } var c = C(); c.a = 1; c.b = 2; c.c = 3;
Вам тоже по душе? Тогда поехали дальше...
Вернемся к нашим "баранам"
Откроем существующий или создадим новый проект, для которого будем генерировать код (далее - "рабочий проект"). Создадим файл в корне директории lib с именем some.dart со следующим содержимым:
part "some.g.dart"; @constructor class State{ int count = 0; }
Не обращаем пока внимания на ошибки. Их должно быть две: не найден файл some.g.dart и не найдено определение аннотации @constructor.
Далее создаем рядом новый проект: теоретически можно использовать уже существующий рабочий проект, для которого эта самая кодогенерация необходима, однако я не рекомендую так делать до тех пор, пока вами не будет достигнут дзен в кодогенерации. Важно:
- Новый проект должен лежать в одной папке (директории) с рабочим проектом
Устанавливаем зависимости. Для начала нам надо установить в только что созданный проект пакеты. Их три:
build_runner: ^2.0.0 source_gen: build_config:
-
source_gen - главная зависимость. Своего рода фреймворк для кодогенерации. Можно вместо него использовать build, над которым он является удобной оберткой, но я предпочту собственно обертку :). Ложим ее в dev_dependencies.
-
build_config - пакет, читающий конфиг, который отвечает за то, из чего именно и что именно нужно сгенерировать. Так же ложим его в dev_dependencies.
-
build_runner - собственно нужен для запуска процесса кодогенерации. Можно положить в dependencies (в отличие от пакетов в dev_dependencies будет доступен всем использующим наш пакет пакетам).
Для установки вышеуказанных пакетов прописываем их в pubspec.yaml, как на картинке ниже:
И запускаем команду
flutter run pub get
которая их установит в наш проект.
Далее создадим в корне нового проекта файл build.yaml, в котором будем писать наш конфиг. Конфиг у нас будет простейший:
builders: constructor_generator: import: "package:project_name/builder.dart" builder_factories: ["constructorGenerator"] build_extensions: {} auto_apply: dependents build_to: cache applies_builders: ["source_gen|combining_builder"]
Вместо constructor_generator может быть любое уникальное имя
Вместо [project_name] пишем имя нашего настоящего проекта (совпадает с именем корневой директории проекта).
После создаем файл builder.dart, который будет содержать всю логику кодогенерации расширения. Обратите внимания, что именно на этот файл мы сослались в директиве import в нашем конфиге. Этот файл будет содержать точку входа - в нашем случае функцию constructorGenerator, имя которой мы указали для опции builder_factories (обратите внимание, что у одного builder может быть несколько точек входа)
Итак, объявим функцию constructorGenerator в файле builder.dart:
Builder constructorGenerator(BuilderOptions options) => SharedPartBuilder([ConstructorGenerator()], 'constructor_generator');
Здесь мы будем использовать для генерации Builder-а функцию SharedPartBuilder из пакета source_gen, которая генерирует код для разделяемых модулей, а не LibraryBuilder, которая бы сгенерировала самостоятельный модуль
Рядом объявим класс ConstructorGenerator, унаследованный от Generator (т.к. мы будем делать кодогенерацию из аннотированных классов, можно было бы использовать GeneratorForAnnotation, но мы в образовательных целях возьмем именно Generator) и переопределим в нем метод generate, как тут.
Как вы, вероятно, догадались, этот метод возвращает нам тот самый сгенерированный код. Комментировать сам код внутри generate, я полагаю, не имеет особого смысла, т.к. предполагается, что читатель знаком с основами языка dart (иначе вряд ли бы дошел до понимания необходимости кодогенерации).
И последнее, что нам необходимо сделать - создать файл с аннотациями, annotations.dart рядом с builder.dart со следующим содержимым:
class Constructor { const Constructor(); } const constructor = Constructor();
После того, как мы это сделали, вернемся к рабочему проекту. Откроем файл pubspec.yaml и пропишем в dependencies ссылку на наш пакет с кодогенерацией:
project_name: path: ../project_name/
где вместо project_name - имя нашего проекта с кодом генерации, который лежит рядом.
Теперь можно вернуться к файлу some.dart и указать import для аннотации constructor (alt + enter если вы используете android studio или ctrl + . - если vscode сделают это автоматически). Если зависимость не будет найдена, попробуйте выполнить pub get. После проделанных операций у вас должна остаться только одна ошибка: об отсутствующем файл some.g.dart.
И теперь все, что осталось сделать, это запустить кодогенерацию в нашем рабочем(!) проекте:
flutter clean && flutter packages pub get && flutter packages pub run build_runner build
Если вы все сделали правильно, то увидите в терминале примерно следующее:
По окончанию выполнения скрипта рядом с нашим файлом some.dart должен сгенерироваться файл some.g.dart с содержимым like this:
part of 'state.dart'; // // ConstructorGenerator // extension StateConstructor on State { void _({int? count}) { if (count != null) this.count = count; } }
Отлично. Теперь вы можете спокойно использовать конструкцию:
var state = State().._(count: 5);
Неплохо, правда? Запустив команду flutter packages pub run build_runner watch, вы можете спокойно писать код, и extensions будут генерироваться сами при любом изменении класса State. Ну разве не супер?
Хорошо. Теперь вы владеете основами кодогенерации на dart и можете внести в свою жизнь новые краски безграничных (почти) возможностей. А у нас на этом все. Исходники доступны в репозитории на гитхаб