В этом многословном, но сравнительно простом цикле я дам введение в генерацию F#-кода.
Как правило, для этих целей в сообществе рекомендуют использовать Myriad, что, по-моему, не совсем правильно, но на его примере можно увидеть, что тема кодогенерации очень объёмна. Однако я хочу сосредоточиться лишь на очень маленькой области кустарной кодогенерации по месту требования. Её гораздо легче освоить, в отличие от энтерпрайз-монстров, и уж тем более починить или захакать при поддержке. Так что данный цикл будет полезен в первую очередь для работы малой организованной группой. Обеспечение кодогенерации в крупных долгоживущих проектах я оставлю за скобками.

Пару предупреждений о коде

Я адепт крышечки. Этот оператор экономит некоторое количество скобочек за счёт игр с приоритетом выполнения. Для того чтобы код работал, вам потребуется определить:

let inline (^) f x = f x

А любителям REPL пригодится:

#r "nuget: Fantomas.Core, 6.2.1"
#r "nuget: FSharpx.Collections, 3.1.0"

Личный опыт

Исторически мой опыт F#-кодогенерации начинался с одной из ранних версий Myriad (если, конечно, не считать период конкатенации строк). В те времена мы локальной группой даже пытались перевести и опубликовать несколько объёмных статей от автора библиотеки. Однако понимание происходящего не приходило. Дело встало и не двигалось, пока на одну очень дорогую для меня ошибку в публичной либе не наложился доклад/обсуждение по C#-кодогену на локальном митапе. После чего процесс рванул.

В те времена для работы Myriad требовались:

  • FSharp.Compiler.Service — компилятор в виде nuget-пакета. Нужен он не для компиляции, а как источник модели синтаксиса. Их там несколько, но нам важны только абстрактные синтаксические деревья (детально ниже). В оригинале Untyped Abstract Syntax Tree или просто AST.

  • FsAst — библиотека для ускоренного создания абстрактных синтаксических деревьев из FCS.

  • Fantomas — попытка реализовать автоматическое форматирование F#-кода. Именно Fantomas генерирует конечные строки из экземпляров синтаксического древа.

FsAst в тот момент потерял мейнтейнера и был в заброшенном состоянии. Сам F# недавно обновился. А Fantomas и FSharp.Compiler.Service в последних стабильных версиях имели несколько критичных для нашей задачи багов. Поэтому мы взяли самую свежую версию Fantomas с соответствующей версией FSharp.Compiler.Service и потратили несколько суток на правки форкнутых исходников FsAst и Myriad. Fantomas крайне чувствительно относился к версии FCS, поэтому при малейшем продвижении последнего вперёд нас заваливало MissingMemberException отовсюду, несмотря на формально почти пустой Changelog. Из-за чрезмерной свежести альфы приходилось повторять такой же объём работ на каждые два апдейта в течение нескольких месяцев.

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

Весь этот экскурс в историю призван не только удовлетворить возможное любопытство, но и предупредить о вероятных проблемах. За прошедшие годы ситуация значительно улучшилась, о чём я расскажу ниже. Несмотря на это, затрагивая FCS и Fantomas, вы влезаете в область, где модель (и её корректность) настолько доминирует над удобством использования, что в ходе развития она, не задумываясь, ломает пользователя через колено.
Поэтому здесь надо не укладывать пол избы досками, а забивать сваями/столбиками до той же плоскости. Это противоречит стремлению повторно использовать один и тот же код, но в противном случае вы в какой-то момент можете столкнуться с ситуацией, что строевого леса необходимой длины не существует в природе и надо придумывать что-то радикально сложнее кувалды из кейса со сваями. В этой парадигме мы будем действовать далее. Наша цель — разовые акции без претензий на фреймворк.

Как там у взрослых?

  1. Myriad предполагает регистрацию себя и своих плагинов в .fsproj в качестве специализированных нод.

  2. Далее в проект необходимо добавить .fs-файлы для вывода генерируемого кода.

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

  4. После чего Myriad при каждой компиляции будет шерстить указанные входные файлы на предмет соответствия их расширений к подключённым плагинам. Несмотря на то, что в примерах на вход подаются только .fs-файлы, Myriad пытается быть всеядным.

  5. При удачном поиске найденному плагину скармливается контекст из пути к файлу, к его проекту и т. п., из которого он должен сгенерировать либо набор деклараций типов / модулей, либо итоговый код в виде строки.

  6. Все полученные выходы плагинов конвертируются в результирующий код, объединяются и выводятся в .fs-файл, согласно графу.

Схема довольно тупая, хоть и оставляет кучу вопросов на потом. Наверное, она работает для типовых задач, характерных для промышленной разработки. Но для своих проектов я такую схему использовал только на этапе освоения. Мне не нравится:

  • Протекание Myriad в проект в виде атрибутов и особенно зависимостей.

  • Невозможность опереться на данные проекта из своего плагина-генератора.

  • Обязательная упаковка своих плагинов в .dll.

  • Затянутость сборки проекта из-за пайплайна Myriad, даже когда он не создаёт новых данных.

  • И ещё много некритичных, но раздражающих особенностей.

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

А как у нас?

Дабы не ограничиваться негативной программой, сразу определим свой пайплайн (и забудем о нём до выхода примера в следующей части).

  1. Мы включим в проект выходные .fs-файлы. Это касается даже тех случаев, когда конкретный список файлов формируется динамически. Ручного добавления существующих файлов хватает за глаза. Допускаю, что можно поработать с файлом проекта из скрипта, но как-то мне не довелось столкнуться с задачей, где это было бы востребовано.

  2. Далее мы добавим .fsx-файлы, в которых и опишем всю логику кодогенерации. В базовой версии можно указать пути к выходным файлам прямо в коде скрипта, без необходимости работы с xml.

  3. Входные данные также можно добывать в коде скрипта без привязки к внешним назначениям. В вырожденном случае они могут вообще не касаться файлов, а исчерпываться рефлексией на типах или конкретных объектах, определённых в вашем же проекте. Для этого достаточно подгрузить файлы и зависимости через директивы #load и #r, соответственно. В более общем случае данные могут прийти из лежащих рядом файлов, причём не обязательно .fs, гораздо чаще это .json, .yaml или .xml.

  4. Наконец, для любителей msbuild вполне допустимо получать граф зависимостей через fsi.CommandLineArgs. Тогда в .fsproj достаточно будет определить задачу консольного вызова dotnet fsi MyUberGenerator.fsx — input.json output.fs.

Как видно, по большинству пунктов мы получили гораздо большую свободу действий, чем предполагает Myriad. Риски тоже увеличились, но короткое плечо подвоза позволит при необходимости купировать последствия ошибок. Так как в данном пайплайне напрочь отсутствует инфраструктура коммуникации между кодогенераторами, то по умолчанию нам потребуется гораздо большее число выходных файлов, чем у Myriad. Но понятно, что всегда можно заморочиться и написать скрипт для склейки результата. Главное — в процессе случайно не связать все кодогенераторы между собой, тем самым привязав их к одной версии FCS. Тогда при каждом обновлении придётся править все генераторы, а не только тот, что нужно было обновить именно сейчас.

Я сознательно обозначил вызов скрипта из .fsproj лишь как опцию, а не как обязательную к выполнению задачу. В большинстве реальных проектов кодогенерация является акцией со строго ручным вызовом, где повторный запуск излишен, а то и вовсе противопоказан.

Что берём с собой

Абстрактное синтаксическое дерево F# или сокращённо AST — главный инструмент кодогенерации. Оно используется в компиляторе, и мы можем получить его модель из пакета FSharp.Compiler.Service (namespace FSharp.Compiler.Syntax). Однако сам FSharp.Compiler.Service является гораздо более всеобъемлющей структурой, для обеспечения которой FCS накладывает несколько серьёзных ограничений.

Например FSharp.Compiler.Service, в отличие от большинства других библиотек, жёстко зависит (= вместо >=) от версии FSharp.Core. Это некритично при работе в проекте, где встроен механизм замены. Но в fsi (интерактивном режиме) нельзя переопределить версию FSharp.Core. Ни через прямую ссылку на нужную .dll, ни через директиву вида #r "nuget: Hopac, 0.5.0". Из-за этого может возникнуть ситуация, когда на конкретную версию dotnet SDK может существовать лишь одна версия FCS. Не у каждой версии FCS была версия Fantomas, которая была бы с ней дружна. Просуммировав данные ограничения, можно столкнуться с тем, что для каких-то SDK не существует корректных наборов FCS + Fantomas. То есть парсить код при помощи FCS ещё можно, но при генерации его через Fantomas надо пропетлять между MissingMemberException, что не всегда возможно.

Это тот случай, когда ноша очень конкретно тянет карман. Собрав необходимую долю страданий, разработчики Fantomas изъяли часть FSharp.Compiler.Service, ответственную за представление AST, и вынесли её в отдельный пакет Fantomas.FCS (namespace Fantomas.FCS.Syntax). Это не форк, это зеркало, которое позволяет быстрее стягивать обновления дерева из компилятора без самого компилятора. Модели AST в них идентичны, так что они синтаксически подобны, но несовместимы. Fantomas умеет работать только с Fantomas.FCS, поэтому если кому-то нужен один и тот же набор функций и в собственном плагине для VS, и в кодогене, то ему придётся поддерживать две одинаковые библиотеки с различным набором зависимостей. Мы в данной статье будем работать только с Fantomas.FCS.

Fantomas.FCS заметно сократил сложность поддержки кодогена, но есть вещи, которые он устранить не в состоянии. Модель древа преимущественно выражена через алгебраические типы и линейно зависит от стандарта F#. Поэтому, когда обновляется язык, обновляется модель дерева, причём, как правило, достаточно радикально, чтобы ваши кодогенераторы не смогли пережить обновление FCS. Сверх этого добавляются постоянные микроправки со стороны команды компилятора. Их вы переживёте с меньшими усилиями, но пострадать всё равно придётся.

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

AST — это не то, чего вы ждали

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

Начинающие разработчики наивно полагают, что получив AST от типа из примера ниже, смогут тривиальным образом определить все поля, которые имеют тип int.

open System

type SomeId = int32

type SomeType = {
    A : string
    B : int
    C : System.Int32
    D : Int32
    E : SomeId
}

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

  • "string"

  • "int"

  • "System.Int32"

  • "Int32"

  • "SomeId"

Проблема не в том, что парсер не знает об алиасах или открытых пространствах. Дело в том, что с точки зрения парсера, каждое поле выражается объектом с данными вида:

- Name: A
  Type: [string]
- Name: B
  Type: [int]
- Name: C
  Type: [System, Int32]
- Name: D
  Type: [Int32]
- Name: E
  Type: [SomeId]

В действительности AST будет помнить не только о строках, но и о точных координатах каждого токена, об отсутствии комментариев у полей и дженериков у типов, о позабытых пробелах в конце строк и т. д. Но здесь нет ничего об упомянутых типах. У вас нет готовых System.Type, к которым можно было бы написать запрос. Вам придётся самостоятельно проверять их существование и происхождение, а затем добывать экземпляр и устанавливать тождественность.

Это выглядит как катастрофа, если стоит задача написать универсальное всеядное решение на все случаи жизни, которое использует AST в качестве исходной информации. Поэтому при работе с AST в качестве входных данных вы всегда будете исходить из допущений, которые необходимо отражать в документации. Например, Myriad.Plugins.FieldsGenerator ищет атрибуты у рекордов в исходниках, но делает это не через рефлексию, а через AST. Из-за этого он среагирует на первый вариант и не сможет справиться двумя последующими:

// Распознаёт, т. к. ожидает увидеть строку с ключом группы конфигурации.
[<Generator.Fields "groupKey">]

...
[<Literal>]
let groupKey = "groupKey"
// Не распознаёт, т. к. для вычисления необходимо иметь типизированное древо или конкретный экземпляр, у которого можно было бы посмотреть содержимое константы/параметра.
[<Generator.Fields groupKey>]

...
type GenerateFields = Generator.FieldsAttribute
// Не распознаёт, т. к. для этого надо либо знать об алиасе, либо получить экземпляр атрибута и удостовериться в тождественности.
[<GenerateFields "groupKey>]

По факту, плагин проверяет, что имя атрибута из AST является концом полного имени искомого атрибута Myriad.Plugins.Generator.Fields[Attribute]. Поэтому чисто гипотетически он может среагировать на одноимённый FieldsAttribute из недр вашего легаси. Или на [<s "groupKey">], мой маленький абузер.

Проблем добавляет то, что сам AST почти целиком написан на алгебраических типах, то есть на рекордах и размеченных объединениях (DU). Очень здоровенных DU. Например, если обратиться к .fsi-определению SynExpr, рядовой конструкции выражения, то можно обнаружить, что с 15 по 408 строку занимает длинный список вот таких кейсов:

  ///<summary>
  /// F# syntax: (expr)
  ///
  /// Parenthesized expressions. Kept in AST to distinguish A.M((x, y))
  /// from A.M(x, y), among other things.
  ///</summary>
  | Paren of expr: SynExpr * leftParenRange: Fantomas.FCS.Text.range * rightParenRange: Fantomas.FCS.Text.range option * range: Fantomas.FCS.Text.range

И это не самый большой кейс, в каком-нибудь For содержится кортеж на 9 элементов. Повсеместное именование элементов кортежа слегка облегчает чтение данных. Но оно никак не помогает при создании или модификации существующих значений. Чтобы поменять for ... to ... на for ... downto ..., необходимо извлечь все параметры, а потом заменить шестой элемент (direction : bool).

match expr with
| SynExpr.For (forDebugPoint, toDebugPoint, ident, equalsRange, identBody, _, toBody, doBody, range) ->
    SynExpr.For (forDebugPoint, toDebugPoint, ident, equalsRange, identBody, false, toBody, doBody, range)

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

AST — это то, что вы захотите создать

AST — очень грубый, очень вербозный и очень тяжёлый инструмент.

Однако все эти качества проистекают из его охренительной тотальности. AST вбирает в себя всё, что можно встретить в F#-коде и даже больше. Он может сожрать недописанный или невалидный код и не умереть. Именно благодаря этому раскраска в наших IDE жива даже в фазу изменения, а не только в момент синтаксической корректности. AST содержит информацию с горочкой, но благодаря Fantomas мы можем пренебречь наиболее трудоёмкой её частью, если она нас не интересует.

Например, Fantomas.FCS.Text.range встречается почти во всех нодах и отвечает за координаты токенов или их частей. Подсчитать range заранее трудоёмко до невозможности.
Однако нам это и не потребуется, так как все его упоминания будут заменены на range0, экземпляр range нулевой длины и позиции. Весь сформированный нами AST можно будет передать Fantomas на форматирование, после чего он самостоятельно заменит дефолтные range0 на корректные значения.

Важно: range0 тождественен Range.Zero, но при этом Range.Zero гораздо привычнее для F#-истов, поэтому во всех примерах я буду использовать именно Range.Zero. Однако в проде я рекомендую использовать range0 для поддержки местного канона.

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

Предположим, что генератор должен в какой-то момент создавать конструкции следующего вида:

let sample : MyGenericType<string option>.Enumerator = ..

Причём тип sample заранее неизвестен. Для его создания нам не нужно думать обо всех тонкостях синтаксиса. Проблема решается рекурсивной функцией над структурой типа, характерной конкретно для вашего генератора. Достаточно, чтобы на каждом этапе генерировался валидный SynType (этот тип отвечает за написание типа в сигнатурах).
Его ручная сборка будет выглядеть так:

// Я люблю заваливать схемы дополнительной информацией (а тексты отступлениями), так как считаю, что объект, не снабжённый достаточным контекстом, обладает чрезмерной степенью свободы, чтобы он мог зафиксироваться в памяти. Я не отношусь к тем людям, что могут запомнить (дата * событие) list. Мне необходимы причинно-следственные связи, чтобы даты были зажаты в тиски обстоятельств. Однако в данном цикле я пока не использую схемы как точку опоры, так что если вас утомляет чтение "побочных квестов" вы можете спокойно их пропускать без ущерба для понимания материала.

А если выразить её абстрактно, то так:

FCS слишком громоздкий, чтобы использовать его в бизнес-логике, поэтому на практике стоит сразу озаботиться переходом на собственные проекции/подмножества типа MyTypeSignature. Они в действительности очень индивидуальны, например:

  • Я всегда пишу int array вместо int[], поэтому мне не надо поддерживать дополнительный кейс для SynType.Array (его нет на рисунке).

  • В F# невозможно определять вложенные типы, поэтому при работе с F#-only доменом я могу обходиться без AfterDot / SynType.LongIdentApp.

  • Почти всегда дженерики с одним аргументом я пишу в постфикс форме, так что обычно я ограничиваюсь одним Generic кейсом, а форму записи выбираю внутри MyTypeSignature.ToSynType по числу аргументов. Правда, если AfterDot существует, то он не должен передавать постфиксную форму в LongIdentApp, то есть надо прокинуть флаг в f.

  • В представленной схеме сохраняется риск отстрела ног из-за двойного App, т. е. Generic<'a><'b>. Данная конструкция невозможна в F#, и Fantomas может интерпретировать её произвольным образом — от исключения до генерации некорректного кода. При необходимости двойной App можно сделать невыразимым через распиливание MyTypeSignature на связку типов.

  • Ещё целая прорва случаев, смысл которых сводится к тому, что а) универсального решения нет и б) именно ваше решение может очень удобно эксплуатировать особенности вашего домена.

Как узнать, что собирать

Общий механизм сборки AST может быть понят даже новичком. Однако конкретные факты, то есть именования и порядок укладки, надо зубрить получать при помощи специальных инструментов.

Sharplab — сервис-анализатор, где в качестве результата можно выбрать Syntax Tree. Вывод организован через UI-ное древо, его удобно исследовать, но неудобно преобразовывать в код. Также оно в значительной мере подчищено, в нём не хватает некоторых листов. В зависимости от задачи это может быть важно.

Fantomas Tools — выводит дерево в виде кода, который можно допилить и использовать для создания того же самого дерева. Эта фича крайне полезна на ранних стадиях, но теряет актуальность по мере роста собственной кодовой базы. На поздних стадиях от неё остаётся лишь одна вербозность.

REPL — ручками парсим, ручками выводим в интерактив. В начале "%A"-вывод проигрывает Fantomas Tools, но кастомизация отображения и разбор объектов прямо в коде делают своё дело. К тому же это единственный способ пытать конкретно вашу версию FCS. С моей точки зрения, это лучший способ анализа синтаксических деревьев, особенно если вы дружите с подходящим UI-фреймворком.

Parse.parseFile false (SourceText.ofString "printfn \"Hello world\"") []
|> printfn "%A"
(ImplFile
   (ParsedImplFileInput
      ("tmp.fsx", true, QualifiedNameOfFile Tmp$fsx, [], [],
       [SynModuleOrNamespace
          ([Tmp], false, AnonModule,
           [Expr
              (App
                 (NonAtomic, false, Ident printfn,
                  Const
                    (String ("Hello world", Regular, (1,8--1,21)), (1,8--1,21)),
                  (1,0--1,21)), (1,0--1,21))], PreXmlDocEmpty, [], None,
           (1,0--1,21), { LeadingKeyword = None })], (false, false),
       { ConditionalDirectives = []
         CodeComments = [] }, set [])), [])

Промежуточный итог

Мы знаем, что и как делать, чтобы построить необходимое AST-дерево. Теперь нужно сделать это в обозримое время и преобразовать результат в конечный код, о чём и пойдёт речь в следующей части.

Автор статьи @kleidemos


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS

Комментарии (0)