В прошлой части мы познакомились с Abstract Syntax Tree (AST). В этой займёмся его сборкой в полезных объёмах и генерации конечного кода. Хрестоматийная цитата про тяжесть от Бориса-Хрен-Попадёшь попала в прошлую часть ещё из документации по нашей внутренней версии Мириада (теперь мы зовём его Тайрон). Когда текст первой версии цикла (тогда ещё статьи) был сдан, мы начали готовить иллюстрации и естественным образом зацепились за тему "Большого куша". По Борису я испытывал некоторые сомнения, так как по сюжету фильма тот самый тяжёлый револьвер так и не выстрелил. Несмотря на удачную эксплуатацию его вау-эффекта, мне хотелось избежать подобного сравнения. Однако всё оказалось гораздо хуже, наш выстрел реально дал осечку. В первой версии цикла я предлагал использовать связку: Которая запускалась в контексте global.json: За несколько лет мы её использовали четыре с половиной раза для генерации DTO по чужим протоколам, когда было недостаточно выставить генератор в виде наносервера наружу, и приходилось его переписывать и передавать непосредственно в руки. Во всех случаях мы генерировали только модули и DTO, состоящие из алгебраических типов, с сериализацией которых обычно нет никаких проблем. За всё время никто не удосужился опробовать эту схему на member или let. Но недавно нашёлся джун, который после прочтения альфа-версии статьи решил испробовать методу. Он скачал пример, начал его расширять и внезапно словил MissingMemberException. FsAst (будет разобран чуть ниже) был важным элементом системы, без него кодогенерация приобретала нечеловеческие объёмы. 90% кода работы с AST происходило через него, так что именно он определял версии остальных пакетов. Когда в Fantomas создали Fantomas.FCS, поддержка FsAst прекратилась, но замены ему никто не создал. Нас это не особо напрягало, так как для тяжёлых задач у нас была своя альтернатива, а для публичных, как мы думали, хватало старых версий. Последние версии FsAst зависели от FCS 41.0.1-3. Fantomas на указанном диапазоне дружил только 41.0.1 и 41.0.3. В свою очередь интерактив из-за ограничений dotnet SDK и FSharp.Core на указанном диапазоне работал только с 41.0.2 (подробно проблема была описана в прошлой части). Наш основной кодопишущий комбайн, работая на Fantomas, 4.6.6 и FSharp.Compiler.Service, 41.0.1 успел намахать сотни тысяч строк кода только на одних F#-адаптациях UI-фреймворков. Это была очень стабильная связка, хоть она и требовала некоторых хаков из-за неистребимых багов в Fantomas, типа потери пробелов и т. д. Апгрейд до 41.0.2 был вынужденной мерой, которая не должна была сильно сказаться на процессе. Однако с данным переходом FCS изменил сигнатуру нескольких важных методов, и это убило Fantomas. Двигаться вверх некуда, так как FsAst впадает в кому до того, как появляется рабочая комбинация Fantomas, SDK и FCS. Двигаться вниз нет желания, так как в районе 39 версии в FCS был глобальный ренейминг, который очень сложно преодолевать. Отправить пользователя туда — всё равно что читать данный текст в дореволюционном алфавите. Я, можетъ, и былъ готовъ преодолѣвать ​подобныя​ страданія ради ​Берви​-Флеровского, но сейчасъ мнѣ проще дождаться соотвѣтствующей ​нейронки​. Бросать статью не хотелось, поэтому я был вынужден написать аналог FsAst для свежего Fantomas.FCS. Хоть FsAst и умер, я считаю полезным объяснить, что это был за зверь, так как использованная в нём методика оказалась достаточно удобной, чтобы её в каком-то виде тиражировали в других решениях. Данный пакет был создан, чтобы не собирать все DU вручную, и он состоит буквально из двух .fs-файлов. Важно: Код в данном параграфе соответствует старым версиям AST. В AstCreate.fs определены хелперы для наиболее часто используемых типов. Большинство из них являются фабриками, которые добавляют необязательную часть к действительно важной входной информации. Например, следующий метод принимает список пар имя свойства + значение (если есть) и возвращает SynExpr, который отвечает за создание экземпляра (не определение!) рекорда: Без подсказок IDE я не могу сказать, что означает любой из упомянутых None, но мне это и не важно. В файле AstRcd.fs находятся рекорды-дублёры для содержимого кортежей из DU оригинального AST. Вот пример ParsedImplFileInputRcd — дублёра ParsedImplFileInput (тело файла, включающее всё AST): К нему прилагается 2 свойства/метода, FromRcd : Origin и ToRcd : OriginRcd, которые конвертируют эти типы друг в друга. В отличие от DU, здесь можно получать и заменять конкретные поля через record.IsExe или { record with IsExe = true }. В некотором смысле это DTO наоборот, промежуточный тип, но который мы используем не для коммуникации с инфраструктурой, а для описания бизнес-логики. У ParsedImpFileInput есть лишь один кейс, этот тип — так называемый SCDU (Single Case Discriminated Union). Но такие же конструкции определены и для обычных DU, типа SynPat (шаблоны). Они определяют по _Rcd-типу для каждого кейса, которые потом объединяются в единый SynPatRcd, такой же DU, как и SynPat, но в котором каждый кейс содержит _Rcd тип вместо кортежа. Разумеется, всё это богатство снабжено конвертерами. Это очень утилитарный код, который самозародился и рос почти бессистемно. Поэтому он очень близок к земле, но ему не хватает некоторой доли стандартизации. Также он не затрагивает часть редких DU, и их обработку придётся писать самостоятельно. В остальном для использования данного пакета (или файлов) не требуется серьёзных интеллектуальных усилий. FsAst пытался облегчить не только создание нового AST, но и чтение или изменение существующего. Однако работать с входными данными в виде AST чрезвычайно трудоёмко. Если предполагается лишь создание нового кода, а не его анализ, то предпочтительнее вообще никогда не читать данные элементов древа. Речь идёт обо всех элементах, даже о тех, что мы сами создали на предыдущих этапах генерации. Считайте, что при создании любого элемента AST вы преобразовываете данные в фарш (который мясо, а не F#). Дальше вы можете запихивать фарш в тесто, смешивать с другим фаршем и т. д., но вы не должны разбирать его на составляющие в попытке восстановить курицу. Если такое происходит, значит вы нарушили процесс. При таких вводных в теории можно ограничиться одним AstCreate, при условии, что он будет обновлён и расширен до необходимой степени покрытия. Но это не равнозначная замена, так как схема с _Rcd позволяла готовить иммутабельные болванки-прототипы. Опыт показывает, что на больших дистанциях поддержка этой фичи стоит затраченных усилий. Меня всегда нервировали индивидуальные особенности методов в AstCreate. Они по-разному интерпретировали опциональные параметры, создавали и потребляли то обычные, то _Rcd версии экземпляров и так далее. Возникало ощущение, что подход авторов эволюционировал с ростом опыта, но это отражалось только на новых методах. Старые не трогали, видимо, из соображений обратной совместимости. С учётом накопленного отставания создание AstCreate с нуля оказалось предпочтительнее обновления. Подозреваю, что авторы Мириада рассуждали сходным образом, поэтому там также отказались от _Rcd-компоненты. Однако они написали аналог AstCreate вручную, я же (после травмы нанесённой Fabulous-ом) сгенерировал его через рефлексию на TypeShape. В результате получилось чуть больше двух тысяч строк подобного кода для DU: И ещё пару сотен строк для рекордов: Не самый изящный код, но он не для глаз, ибо генерируется машиной по очень простым правилам: Достаточно знать правила образования методов, а не их конкретное наполнение. Потенциально данные фабрики можно было бы улучшить. Скажем, я бы хотел подвинуть некоторые аргументы в начало списка, чтобы при вызове SynExpr.Record.CreateByDefault передавать сразу список, а не (recordFields = [..]). Можно определить типы только с опциональными параметрами и генерировать по ним дефолтные значения для других CreateByDefault, не забыв про рекурсию. Но у меня пока нет желания поддерживать какой-либо пакет в публичном пространстве (особенно при наличии более развитого в привате). Поэтому я оставил всё на минимальном уровне в расчёте на то, что те, кому приспичит, сделают аналог своими силами. К тому же данный код всё равно нельзя считать конечным, так как он не учитывает взаимосвязь параметров. В уже знакомом по прошлым примерам SynType.App: Координаты угловых скобок должны иметь значение Some range0 (или Some Range.Zero), если выбрана суффиксная форма записи. Если типов-аргументов больше одного, то они должны снабжаться (args.Length - 1) запятыми, которые передаются отдельным параметром. В SynModuleOrNamespace аргумент trivia : SynModuleOrNamespaceTrivia содержит координаты слов module или namespace, если они есть, и зависит от аргумента kind : SynModuleOrNamespaceKind. В SynLongIdent, создание которого ранее скрывалось за магическим оператором (!), надо принять: Список Ident (строк между точками). Список координат точек, который короче предыдущего на единицу (в дописанном виде). Список IdentTrivia, в котором для каждого Ident может быть определены особенности его отображения (например Some(IdentTrivia.OriginalNotation("|>")) для "op_PipeRight"). Вообще область Trivia появилась недавно и является постоянным источником проблем. Мне понятно, зачем её добавили, но мне всё ещё неясно, как её удобно заполнять. Так что пространство возможного здесь сильно больше, чем может показаться на первый взгляд. Меня такая свобода не особо радует, поэтому рекомендую наслаждаться преимуществами тирании в отдельно взятой квартире вырабатывать стандарт специально под проект или команду. Конкретно для данного цикла все хелперы будут лежать в трёх файлах: UnionExts.fsx - CreateByDefault для DU. RecordExts.fsx - CreateByDefault для рекордов. AstHelpers.fsx - набор улучшений написанных вручную, который тянет два файла выше. В последнем файле также будут лежать методы преобразования строк с именами в конкретные элементы дерева: В отличие от большинства других языков, в F# форматирование не поставляется из коробки. Вызвано это тем, что без скобок и тонны других "ненужностей", у компилятора (или форматера?) в невалидных кейсах не хватает информации для корректной перестройки кода. В MS когда-то решили не взгромождать подобную задачу на себя, поэтому сообщество создало Fantomas самостоятельно. По прямому назначению в моей команде мы им не пользуемся, т. к. нас не устраивает результат форматирования. Он синтаксически корректен, но не способствует чтению нашего кода. Проблема довольно распространённая, и не факт, что она когда-нибудь разрешится. Поэтому, Fantomas-ом в моём окружении пользуются только люди, пишущие на F# набегами для поддержки интеграции. Я воспринимаю это как инерцию C#-сознания, отношусь к этому терпимо, но не способствую распространению данной практики. Самый главный метод в Fantomas выглядит так: В ParsedInput содержит AST либо кода (ParsedImplFileInput, с ним пересекались выше), либо сигнатурного файла (ParsedSigFileInput). Оба типа содержат огромное количество параметров, которые вы предпочтёте проигнорировать. Однако даже в CreateByDefault придётся передать имя (как правило, фейковое) и несколько конструкций "неизвестного назначения". Минимальный код, что генерирует пустую строку, выглядит так: Чтобы заполнить файл чем-то полезным, необходимо передать всё в параметр contents. В нём будет лежать список SynModuleOrNamespace с модулями и пространствами имён. SynModuleOrNamespace хоть и является DU, имеет лишь один кейс (SCDU). Выбор между модулем и пространством задаётся через параметр kind : SynModuleOrNamespaceKind. Предполагается, что contents будет задан либо единственным модулем верхнего уровня (в начале файла и без =), либо списком пространств имён. Ещё есть SynModuleOrNamespaceKind.AnonModule для особых случаев, типа скриптов и последних файлов, в которых, с точки зрения пользователя, нет ни пространств, ни модулей верхнего уровня. У FormatASTAsync есть перегрузки, которые в дополнение к ParsedInput принимают либо FantomasConfig, либо строковый исходник кода (нужен для точного форматирования Trivia). В последних версиях необходимость в ином конфиге почти отпала, но мне всё ещё интересно, почему нет перегрузки метода, совмещающего оба дополнительных параметра. У FormatASTAsync также есть собратья, которые форматирую файл или фрагмент кода (диапазон в рамках строки). Потенциально последний вариант можно использовать, чтобы создавать собственные расширения к IDE. Для обратного преобразования есть два варианта. В том же CodeFormatter есть метод ParseAsync. Но обычно в интерактиве используется: Где экземпляр Text.ISourceText получается одним единственным способом: Parse лежит в пакете Fantomas.FCS, а CodeFormatter в Fantomas.Core. Core зависит от FCS, поэтому Parse имеет приоритетное распространение. Иных существенных различий между методами нет. Главное, что при помощи нехитрых манипуляций можно извлечь ParsedInput из строки: Функция parse — основной инструмент изучения синтаксического древа. При работе в REPL ParsedInput даёт в меру читаемое представление, именно его предстоит изучать, чтобы представлять, какую структуру надлежит собрать. В этом месте может возникнуть проблема из-за объёмов информации. Fantomas ожидает содержимое готового файла, поэтому для получения фрагмента сложной структуры необходимо разместить её в "естественном окружении". Нельзя просто запросить синтаксическое древо у override this.Dispose() =. Конструкция ошибочна с точки зрения цельного файла, но даже с ухищрениями компилятор воспримет её как функцию override, в которую поочерёдно передали this.Dispose и (), после чего результат выражения сравнили с чем-то ещё. Вам потребуется абстрактно корректная конструкция, предполагающая декларацию типа с имплементацией метода: Не существует иных способов указать компилятору на желаемую область применения. Поэтому потребуется не только положить искомый override внутрь нужного окружения, но найти его после этого в полученном AST. Некоторые конструкции могут производить циклопических размеров деревья, и здесь имеет смысл напомнить о возможности переопределения принтеров типов в fsi. Метод fsi.AddPrinter заменит дефолтный или прошлый принтер для указанного типа в выводе REPL. Это переопределение не коснётся стандартного вывода через sprintf и его аналоги. Как правило, я накидываю что-то вроде этого: Перечисленные типы встречаются очень часто, однако несут сравнительно немного информации и при этом занимают очень много экранного места. В зависимости от особенностей ТВД я этот список дополняю. Но очевидно, что главный способ поддержания чистоты — грамотный подбор минимальных примеров. Пример находится здесь. Чтобы зафиксировать материал, я накидал минимальный пример генератора в MinimalGenerator.fsx. Это скрипт, который генерирует один единственный файл AssemblyCompilation.fs. В данном файле содержится модуль следующего вида: В machineName содержится имя компа, на котором собиралась библиотека. Я не шарю, представляет ли данное имя какую-то ценность, так что перестраховался и руками заменил на его абракадабру. В timestamp лежит время начала сборки. Безусловной ценности эти значения не имеют, они нужны лишь как пример использования времени и места сборки в генерации кода. Конкретно этот пример имеет смысл автоматизировать. Поэтому в .fsproj файле проекта добавлена задача, что вызывает fsi на MinimalGenerator.fsx перед каждой сборкой проекта. Особенностей характерных только для кодогенераторов здесь нет, так что механизм можно использовать для любых скриптов, которые требуется запускать при сборке проекта. В Program.fs всего лишь выводится сообщение на основе сгенерированного AssemblyCompilation: Файл MinimalGenerator.fsx ссылается на скрипты в папке AstHelpers. Опираясь на "автоматические" и "крафтовые" хелперы, скрипт генерирует необходимое синтаксическое древо, преобразует в код и записывает в файл. Преобразование переменных в код выглядит интереснее. letBinding принимает строковое имя переменной привязки и её тело. Это очень частный случай сборки SynBinding, так как данная привязка не позволяет указывать параметры, возвращаемые значения, атрибуты и т. д. Поэтому letBinding определена локально, а не в наборе хелперов. На практике быстрее всего пользу приносит генерация кода для констант. У (), строк, чисел, чисел с единицами измерений и байтовых массивов ("0_0!"B = [|48uy; 95uy; 48uy; 33uy|]) есть соответствующие кейсы SynConst. Большинство из них ожидают экземпляр одноимённого типа. В SynConst.String нужно можно дополнительно указать формат записи (", @" или """). В Fantomas экранирование символов в string сделано с ошибками, но в нашем случае можно просто взять System.Environment.MachineName и собрать из него SynExpr. Для создания System.DateTime необходимо вызвать конструктор и передать в него 7 аргументов. С точки зрения синтаксического древа, вызов конструктора не отличается от любой другой функции (SynExpr.App). То же самое справедливо про передачу нескольких аргументов. У нас будет лишь один аргумент, который выражен скобками (SynExpr.Paren). Внутри скобок кортеж (SynExpr.Tuple). Первые 6 элементов кортежа — это int (то есть SynConst.Int32 внутри SynExpr.Const). Последний элемент для нас является Enum-ом, но для парсера, это всего лишь "идентификатор с точками" (SynExpr.LongIdent). Тот же тип, что отвечает за функцию System.DateTime. В этой точке индивидуальные особенности генератора заканчиваются. Устройство SynExpr.app, SynExpr.tuple и прочих руками написанных хеплеров можно изучить в исходниках. Встроенные в них приоритеты и особенности обусловлены моими личными опытом и манерой письма. Поэтому непосредственное содержимое AstHelpers.fsx я рекомендую заимствовать осознанно и строго поштучно по мере необходимости. Мы познакомились с AST, FCS и Fantomas, которые являются условной константой кодогенерации. FsAst и его вынужденная замена — это опция, которую необходимо поддерживать самостоятельно, пока этим не займётся кто-то с очень солидным багажом свободного времени. Это может казаться серьёзной проблемой. Но по личному опыту могу сказать, что кодогенераторы обновляют модель FCS очень редко. Скажем, если дело касается таких консервативных направлений, как WPF, то какой-нибудь экстрактор ресурсов и стилей может существовать на 39 версии FCS вечно. В более динамичных случаях в дело вступает разделение генераторов на изолированные контексты, каждый из которых существует со своей версией FCS (поговорим об этом в следующих частях). Обновление FCS должно быть продиктовано требованиями задачи, в противном случае оно не производится. Исходя из этого, большинство может без каких-либо последствий базироваться на одной и той же версии FCS (и обёртки) неограниченно долго. В отличие от прошлых моих циклов, этот не был завершён к моменту публикации первой части. Во-первых, из-за того, что я не рассчитал итоговый объём. Изначально вообще планировался быстрый лёгкий пост про кодоген по следам коллективной прогулки. Во-вторых, я несколько раз ошибся с размерами примеров и было решено выпустить их отдельными частями, отранжировав по сложности. Поэтому продолжение будет, но не сразу... Автор статьи @kleidemos НЛО прилетело и оставило здесь промокод для читателей нашего блога: — 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS Маркетолог