В прошлой части мы познакомились с Abstract Syntax Tree
(AST
). В этой займёмся его сборкой в полезных объёмах и генерации конечного кода.
Проклятье ствола Бориса
Хрестоматийная цитата про тяжесть от Бориса-Хрен-Попадёшь попала в прошлую часть ещё из документации по нашей внутренней версии Мириада (теперь мы зовём его Тайрон). Когда текст первой версии цикла (тогда ещё статьи) был сдан, мы начали готовить иллюстрации и естественным образом зацепились за тему "Большого куша". По Борису я испытывал некоторые сомнения, так как по сюжету фильма тот самый тяжёлый револьвер так и не выстрелил. Несмотря на удачную эксплуатацию его вау-эффекта, мне хотелось избежать подобного сравнения. Однако всё оказалось гораздо хуже, наш выстрел реально дал осечку.
В первой версии цикла я предлагал использовать связку:
// Представлено лишь для истории, в прод не совать.
#r "nuget: FSharp.Compiler.Service, 41.0.2"
#r "nuget: FsAst, 0.12.0"
#r "nuget: Fantomas, 4.6.6"
Которая запускалась в контексте global.json
:
{
"sdk": {
// = 6.0.4.
"version": "6.0.202"
}
}
За несколько лет мы её использовали четыре с половиной раза для генерации 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
Хоть FsAst
и умер, я считаю полезным объяснить, что это был за зверь, так как использованная в нём методика оказалась достаточно удобной, чтобы её в каком-то виде тиражировали в других решениях. Данный пакет был создан, чтобы не собирать все DU вручную, и он состоит буквально из двух .fs
-файлов.
Важно: Код в данном параграфе соответствует старым версиям AST.
В AstCreate.fs
определены хелперы для наиболее часто используемых типов. Большинство из них являются фабриками, которые добавляют необязательную часть к действительно важной входной информации. Например, следующий метод принимает список пар имя свойства + значение (если есть)
и возвращает SynExpr
, который отвечает за создание экземпляра (не определение!) рекорда:
type SynExpr with
static member CreateRecord (fields: list<RecordFieldName * option<SynExpr>>) =
let fields = fields |> List.map (fun (rfn, synExpr) -> SynExprRecordField (rfn, None, synExpr, None))
SynExpr.Record(None, None, fields, range0)
Без подсказок IDE я не могу сказать, что означает любой из упомянутых None
, но мне это и не важно.
В файле AstRcd.fs
находятся рекорды-дублёры для содержимого кортежей из DU оригинального AST. Вот пример ParsedImplFileInputRcd
— дублёра ParsedImplFileInput
(тело файла, включающее всё AST):
type ParsedImplFileInputRcd = {
File: string
IsScript: bool
QualName: QualifiedNameOfFile
Pragmas: ScopedPragma list
HashDirectives: ParsedHashDirective list
Modules: SynModuleOrNamespace list
IsLastCompiland: bool
IsExe: bool
}
with
member x.FromRcd =
ParsedImplFileInput(x.File, x.IsScript, x.QualName, x.Pragmas, x.HashDirectives, x.Modules, (x.IsLastCompiland, x.IsExe))
type ParsedImplFileInput with
member x.ToRcd =
let (ParsedImplFileInput(file, isScript, qualName, pragmas, hashDirectives, modules, (isLastCompiland, isExe))) = x
{ File = file; IsScript = isScript; QualName = qualName; Pragmas = pragmas; HashDirectives = hashDirectives; Modules = modules; IsLastCompiland = isLastCompiland; IsExe = isExe }
К нему прилагается 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
FsAst
пытался облегчить не только создание нового AST, но и чтение или изменение существующего. Однако работать с входными данными в виде AST чрезвычайно трудоёмко.
Если предполагается лишь создание нового кода, а не его анализ, то предпочтительнее вообще никогда не читать данные элементов древа. Речь идёт обо всех элементах, даже о тех, что мы сами создали на предыдущих этапах генерации. Считайте, что при создании любого элемента AST вы преобразовываете данные в фарш (который мясо, а не F#). Дальше вы можете запихивать фарш в тесто, смешивать с другим фаршем и т. д., но вы не должны разбирать его на составляющие в попытке восстановить курицу. Если такое происходит, значит вы нарушили процесс.
При таких вводных в теории можно ограничиться одним AstCreate
, при условии, что он будет обновлён и расширен до необходимой степени покрытия. Но это не равнозначная замена, так как схема с _Rcd
позволяла готовить иммутабельные болванки-прототипы. Опыт показывает, что на больших дистанциях поддержка этой фичи стоит затраченных усилий.
Меня всегда нервировали индивидуальные особенности методов в AstCreate
. Они по-разному интерпретировали опциональные параметры, создавали и потребляли то обычные, то _Rcd
версии экземпляров и так далее. Возникало ощущение, что подход авторов эволюционировал с ростом опыта, но это отражалось только на новых методах. Старые не трогали, видимо, из соображений обратной совместимости. С учётом накопленного отставания создание AstCreate
с нуля оказалось предпочтительнее обновления.
Подозреваю, что авторы Мириада рассуждали сходным образом, поэтому там также отказались от _Rcd
-компоненты. Однако они написали аналог AstCreate
вручную, я же (после травмы нанесённой Fabulous
-ом) сгенерировал его через рефлексию на TypeShape
. В результате получилось чуть больше двух тысяч строк подобного кода для DU:
module SynExpr =
// ...
type Record =
static member CreateByDefault(?baseInfo, ?copyInfo, ?recordFields, ?range) =
SynExpr.Record(
baseInfo |> Option.defaultValue Option.None,
copyInfo |> Option.defaultValue Option.None,
recordFields |> Option.defaultValue List.empty,
range |> Option.defaultValue Range.Zero
)
// Пример SCDU
type ParsedImplFileInput with
static member CreateByDefault
(
fileName,
qualifiedNameOfFile,
flags,
trivia,
?isScript,
?scopedPragmas,
?hashDirectives,
?contents,
?identifiers
) =
ParsedImplFileInput.ParsedImplFileInput(
fileName,
isScript |> Option.defaultValue false,
qualifiedNameOfFile,
scopedPragmas |> Option.defaultValue List.empty,
hashDirectives |> Option.defaultValue List.empty,
contents |> Option.defaultValue List.empty,
flags,
trivia,
identifiers |> Option.defaultValue Set.empty
)
И ещё пару сотен строк для рекордов:
type SynMemberFlags with
static member CreateByDefault
(
memberKind,
?isInstance,
?isDispatchSlot,
?isOverrideOrExplicitImpl,
?isFinal,
?getterOrSetterIsCompilerGenerated
) : SynMemberFlags =
{ IsInstance = isInstance |> Option.defaultValue false
IsDispatchSlot = isDispatchSlot |> Option.defaultValue false
IsOverrideOrExplicitImpl = isOverrideOrExplicitImpl |> Option.defaultValue false
IsFinal = isFinal |> Option.defaultValue false
GetterOrSetterIsCompilerGenerated = getterOrSetterIsCompilerGenerated |> Option.defaultValue false
MemberKind = memberKind }
Не самый изящный код, но он не для глаз, ибо генерируется машиной по очень простым правилам:
| option -> Option.None
| list -> List.empty
| array -> Array.empty
| set -> Set.empty
| range -> Range.Zero
| bool -> false
| PreXmlDoc -> PreXmlDoc.Empty
// и ещё несколько кейсов для очень редких DU/Enum.
Достаточно знать правила образования методов, а не их конкретное наполнение.
Потенциально данные фабрики можно было бы улучшить. Скажем, я бы хотел подвинуть некоторые аргументы в начало списка, чтобы при вызове SynExpr.Record.CreateByDefault
передавать сразу список, а не (recordFields = [..])
. Можно определить типы только с опциональными параметрами и генерировать по ним дефолтные значения для других CreateByDefault
, не забыв про рекурсию. Но у меня пока нет желания поддерживать какой-либо пакет в публичном пространстве (особенно при наличии более развитого в привате).
Поэтому я оставил всё на минимальном уровне в расчёте на то, что те, кому приспичит, сделают аналог своими силами. К тому же данный код всё равно нельзя считать конечным, так как он не учитывает взаимосвязь параметров.
В уже знакомом по прошлым примерам SynType.App
:
Координаты угловых скобок должны иметь значение
Some range0
(илиSome Range.Zero
), если выбрана суффиксная форма записи.Если типов-аргументов больше одного, то они должны снабжаться
(args.Length - 1)
запятыми, которые передаются отдельным параметром.
В SynModuleOrNamespace
аргумент trivia : SynModuleOrNamespaceTrivia
содержит координаты слов module
или namespace
, если они есть, и зависит от аргумента kind : SynModuleOrNamespaceKind
.
SynModuleOrNamespaceTrivia.CreateByDefault(
match kind with
| NamedModule ->
SynModuleOrNamespaceLeadingKeyword.Module Range.Zero
| AnonModule ->
SynModuleOrNamespaceLeadingKeyword.None
| DeclaredNamespace
| GlobalNamespace ->
SynModuleOrNamespaceLeadingKeyword.Namespace Range.Zero
)
В SynLongIdent
, создание которого ранее скрывалось за магическим оператором (!)
, надо принять:
Список
Ident
(строк между точками).Список координат точек, который короче предыдущего на единицу (в дописанном виде).
Список
IdentTrivia
, в котором для каждогоIdent
может быть определены особенности его отображения (напримерSome(IdentTrivia.OriginalNotation("|>"))
для"op_PipeRight"
).
Вообще область Trivia
появилась недавно и является постоянным источником проблем.
Мне понятно, зачем её добавили, но мне всё ещё неясно, как её удобно заполнять. Так что пространство возможного здесь сильно больше, чем может показаться на первый взгляд. Меня такая свобода не особо радует, поэтому рекомендую наслаждаться преимуществами тирании в отдельно взятой квартире вырабатывать стандарт специально под проект или команду.
Конкретно для данного цикла все хелперы будут лежать в трёх файлах:
UnionExts.fsx
-CreateByDefault
для DU.RecordExts.fsx
-CreateByDefault
для рекордов.AstHelpers.fsx
- набор улучшений написанных вручную, который тянет два файла выше.
В последнем файле также будут лежать методы преобразования строк с именами в конкретные элементы дерева:
module Ident =
let parse: str : string -> Ident
let parseLong: str : string -> LongIdent
let parseSynLong: str : string -> SynLongIdent
let parseSynType: str : string -> SynType
let parseSynExprLong: str : string -> SynExpr
let parseSynIdent: str : string -> SynIdent
Fantomas
В отличие от большинства других языков, в F# форматирование не поставляется из коробки.
Вызвано это тем, что без скобок и тонны других "ненужностей", у компилятора (или форматера?) в невалидных кейсах не хватает информации для корректной перестройки кода.
В MS когда-то решили не взгромождать подобную задачу на себя, поэтому сообщество создало Fantomas
самостоятельно. По прямому назначению в моей команде мы им не пользуемся, т. к. нас не устраивает результат форматирования. Он синтаксически корректен, но не способствует чтению нашего кода. Проблема довольно распространённая, и не факт, что она когда-нибудь разрешится. Поэтому, Fantomas
-ом в моём окружении пользуются только люди, пишущие на F# набегами для поддержки интеграции. Я воспринимаю это как инерцию C#-сознания, отношусь к этому терпимо, но не способствую распространению данной практики.
AST -> string
Самый главный метод в Fantomas
выглядит так:
namespace Fantomas.Core
type CodeFormatter =
///<summary>
/// Format an abstract syntax tree
///</summary>
static member FormatASTAsync: ast: Fantomas.FCS.Syntax.ParsedInput -> Async<string>
В ParsedInput
содержит AST либо кода (ParsedImplFileInput
, с ним пересекались выше), либо сигнатурного файла (ParsedSigFileInput
). Оба типа содержат огромное количество параметров, которые вы предпочтёте проигнорировать. Однако даже в CreateByDefault
придётся передать имя (как правило, фейковое) и несколько конструкций "неизвестного назначения". Минимальный код, что генерирует пустую строку, выглядит так:
ParsedImplFileInput.CreateByDefault(
"Сюзанна.fs"
, QualifiedNameOfFile.QualifiedNameOfFile ^ Ident.parse "Сюзанна.fs"
, (false, false)
, ParsedImplFileInputTrivia.CreateByDefault()
)
|> ParsedInput.ImplFile
|> Fantomas.Core.CodeFormatter.FormatASTAsync
|> Async.RunSynchronously
Чтобы заполнить файл чем-то полезным, необходимо передать всё в параметр contents
.
В нём будет лежать список SynModuleOrNamespace
с модулями и пространствами имён.
ParsedImplFileInput.CreateByDefault(
"Сюзанна.fs"
, QualifiedNameOfFile.QualifiedNameOfFile ^ Ident.parse "Сюзанна.fs"
, (false, false)
, ParsedImplFileInputTrivia.CreateByDefault()
, contents = // <- Полезное совать сюда.
)
SynModuleOrNamespace
хоть и является DU, имеет лишь один кейс (SCDU).
Выбор между модулем и пространством задаётся через параметр kind : SynModuleOrNamespaceKind
. Предполагается, что contents
будет задан либо единственным модулем верхнего уровня (в начале файла и без =
), либо списком пространств имён. Ещё есть SynModuleOrNamespaceKind.AnonModule
для особых случаев, типа скриптов и последних файлов, в которых, с точки зрения пользователя, нет ни пространств, ни модулей верхнего уровня.
У FormatASTAsync
есть перегрузки, которые в дополнение к ParsedInput
принимают либо FantomasConfig
, либо строковый исходник кода (нужен для точного форматирования Trivia
). В последних версиях необходимость в ином конфиге почти отпала, но мне всё ещё интересно, почему нет перегрузки метода, совмещающего оба дополнительных параметра.
У FormatASTAsync
также есть собратья, которые форматирую файл или фрагмент кода (диапазон в рамках строки). Потенциально последний вариант можно использовать, чтобы создавать собственные расширения к IDE.
string -> AST
Для обратного преобразования есть два варианта. В том же CodeFormatter
есть метод ParseAsync
.
namespace Fantomas.Core
type CodeFormatter =
///<summary>
/// Parse a source string using given config
///</summary>
static member ParseAsync:
isSignature: bool * source: string
-> Async<(Fantomas.FCS.Syntax.ParsedInput * string list) array>
Но обычно в интерактиве используется:
namespace Fantomas.FCS
module Parse =
val parseFile: isSignature: bool -> sourceText: Text.ISourceText -> defines: string list -> Syntax.ParsedInput * FSharpParserDiagnostic list
Где экземпляр Text.ISourceText
получается одним единственным способом:
namespace Fantomas.FCS.Text
///<summary>
/// Functions related to ISourceText objects
///</summary>
module SourceText =
///<summary>
/// Creates an ISourceText object from the given string
///</summary>
val ofString: string -> ISourceText
Parse
лежит в пакете Fantomas.FCS
, а CodeFormatter
в Fantomas.Core
. Core
зависит от FCS
, поэтому Parse
имеет приоритетное распространение. Иных существенных различий между методами нет. Главное, что при помощи нехитрых манипуляций можно извлечь ParsedInput
из строки:
let parse sourceCode =
Parse.parseFile false (SourceText.ofString sourceCode) []
|> fst
Функция parse
— основной инструмент изучения синтаксического древа. При работе в REPL ParsedInput
даёт в меру читаемое представление, именно его предстоит изучать, чтобы представлять, какую структуру надлежит собрать.
В этом месте может возникнуть проблема из-за объёмов информации. Fantomas
ожидает содержимое готового файла, поэтому для получения фрагмента сложной структуры необходимо разместить её в "естественном окружении". Нельзя просто запросить синтаксическое древо у override this.Dispose() =
. Конструкция ошибочна с точки зрения цельного файла, но даже с ухищрениями компилятор воспримет её как функцию override
, в которую поочерёдно передали this.Dispose
и ()
, после чего результат выражения сравнили с чем-то ещё. Вам потребуется абстрактно корректная конструкция, предполагающая декларацию типа с имплементацией метода:
type Sample =
override this.Dispose () =
Не существует иных способов указать компилятору на желаемую область применения. Поэтому потребуется не только положить искомый override
внутрь нужного окружения, но найти его после этого в полученном AST. Некоторые конструкции могут производить циклопических размеров деревья, и здесь имеет смысл напомнить о возможности переопределения принтеров типов в fsi
. Метод fsi.AddPrinter
заменит дефолтный или прошлый принтер для указанного типа в выводе REPL. Это переопределение не коснётся стандартного вывода через sprintf
и его аналоги.
Как правило, я накидываю что-то вроде этого:
// Аналогичный код уже присутствует в примере в файле `AstHelpers.fsx`.
fsi.AddPrinter ^ fun (p : Range) ->
if p.StartLine = p.EndLine
then
if p.StartColumn = p.EndColumn
then $"{p.StartLine},{p.StartColumn}"
else $"{p.StartLine},{p.StartColumn}-{p.EndColumn}"
else $"{p.StartLine},{p.StartColumn}-{p.EndLine},{p.EndColumn}"
fsi.AddPrinter ^ fun (p : PreXmlDoc) ->
if p.IsEmpty then
"PreXmlDoc.Empty"
else
p.ToXmlDoc(false, None).UnprocessedLines
|> String.concat "\\n"
|> sprintf "PreXmlDoc %A"
fsi.AddPrinter ^ fun (SynLongIdent (p, _, _)) ->
p
|> Seq.map ^ fun p -> p.idText
|> String.concat "."
|> sprintf "SynLongIdent %A"
fsi.AddPrinter ^ fun (p : Ident) ->
sprintf "Ident %A" p.idText
fsi.AddPrinter ^ fun (p : LongIdent) ->
p
|> Seq.map ^ fun p -> p.idText
|> String.concat "."
|> sprintf "LongIdent %A"
Перечисленные типы встречаются очень часто, однако несут сравнительно немного информации и при этом занимают очень много экранного места. В зависимости от особенностей ТВД я этот список дополняю. Но очевидно, что главный способ поддержания чистоты — грамотный подбор минимальных примеров.
Пример
Пример находится здесь.
Чтобы зафиксировать материал, я накидал минимальный пример генератора в MinimalGenerator.fsx
. Это скрипт, который генерирует один единственный файл AssemblyCompilation.fs
. В данном файле содержится модуль следующего вида:
module AssemblyCompilation
let machineName = "WIN-ABRAKADABRA"
let timestamp = System.DateTime(2023, 10, 7, 6, 59, 32, System.DateTimeKind.Utc)
В machineName
содержится имя компа, на котором собиралась библиотека. Я не шарю, представляет ли данное имя какую-то ценность, так что перестраховался и руками заменил на его абракадабру. В timestamp
лежит время начала сборки. Безусловной ценности эти значения не имеют, они нужны лишь как пример использования времени и места сборки в генерации кода.
Конкретно этот пример имеет смысл автоматизировать. Поэтому в .fsproj
файле проекта добавлена задача, что вызывает fsi
на MinimalGenerator.fsx
перед каждой сборкой проекта.
Особенностей характерных только для кодогенераторов здесь нет, так что механизм можно использовать для любых скриптов, которые требуется запускать при сборке проекта.
<Target Name="Generate build context" BeforeTargets="BeforeBuild">
<Exec Command="dotnet fsi MinimalGenerator.fsx" ConsoleToMSBuild="true">
<Output TaskParameter="ConsoleOutput" PropertyName="OutputOfExec" />
</Exec>
</Target>
В Program.fs
всего лишь выводится сообщение на основе сгенерированного AssemblyCompilation
:
System.DateTime.UtcNow - AssemblyCompilation.timestamp
|> printfn "С начала сборки данной либы на %s прошло %O" AssemblyCompilation.machinName
Генератор
Файл MinimalGenerator.fsx
ссылается на скрипты в папке AstHelpers
. Опираясь на "автоматические" и "крафтовые" хелперы, скрипт генерирует необходимое синтаксическое древо, преобразует в код и записывает в файл.
Code.fromModules [
SynModuleOrNamespace.CreateByDefault(
Ident.parseLong "AssemblyCompilation"
, SynModuleOrNamespaceKind.NamedModule
, SynModuleOrNamespaceTrivia.CreateByDefault(
SynModuleOrNamespaceLeadingKeyword.Module.CreateByDefault()
)
, decls = [
// let machinName
// let timestamp
]
)
]
|> fun content ->
System.IO.File.WriteAllText(
System.IO.Path.Combine(
__SOURCE_DIRECTORY__
, "AssemblyCompilation.fs"
)
, content
)
Преобразование переменных в код выглядит интереснее. letBinding
принимает строковое имя переменной привязки и её тело.
let letBinding name body =
SynModuleDecl.Let.CreateByDefault(
bindings = [
SynBinding.CreateByDefault(
SynValData.empty
, SynPat.Named.CreateByDefault(
Ident.parseSynIdent name
)
, body
, SynBindingTrivia.CreateByDefault(
SynLeadingKeyword.Let.CreateByDefault()
, equalsRange = Some Range.Zero
)
)
]
)
Это очень частный случай сборки SynBinding
, так как данная привязка не позволяет указывать параметры, возвращаемые значения, атрибуты и т. д.
Поэтому letBinding
определена локально, а не в наборе хелперов.
decls = [
let letBinding = ...
// let machineName
// let timestamp
]
На практике быстрее всего пользу приносит генерация кода для констант. У ()
, строк, чисел, чисел с единицами измерений и байтовых массивов ("0_0!"B = [|48uy; 95uy; 48uy; 33uy|]
) есть соответствующие кейсы SynConst
. Большинство из них ожидают экземпляр одноимённого типа. В SynConst.String
нужно можно дополнительно указать формат записи ("
, @"
или """
). В Fantomas
экранирование символов в string
сделано с ошибками, но в нашем случае можно просто взять System.Environment.MachineName
и собрать из него SynExpr
.
SynConst.String.CreateByDefault System.Environment.MachineName
|> SynExpr.Const.CreateByDefault
|> letBinding "machineName"
Для создания System.DateTime
необходимо вызвать конструктор и передать в него 7 аргументов. С точки зрения синтаксического древа, вызов конструктора не отличается от любой другой функции (SynExpr.App
). То же самое справедливо про передачу нескольких аргументов. У нас будет лишь один аргумент, который выражен скобками (SynExpr.Paren
).
Внутри скобок кортеж (SynExpr.Tuple
).
Ident.parseSynExprLong "System.DateTime"
|> SynExpr.app (
let args = [
let now = System.DateTime.UtcNow
now.Year
now.Month
now.Day
now.Hour
now.Minute
now.Second
]
SynExpr.tuple [
for arg in args do
SynConst.Int32 arg
|> SynExpr.Const.CreateByDefault
Ident.parseSynExprLong "System.DateTimeKind.Utc"
]
|> SynExpr.paren
)
|> letBinding "timestamp"
Первые 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
(и обёртки) неограниченно долго.
В отличие от прошлых моих циклов, этот не был завершён к моменту публикации первой части. Во-первых, из-за того, что я не рассчитал итоговый объём. Изначально вообще планировался быстрый лёгкий пост про кодоген по следам коллективной прогулки. Во-вторых, я несколько раз ошибся с размерами примеров и было решено выпустить их отдельными частями, отранжировав по сложности. Поэтому продолжение будет, но не сразу...
upd: Продолжение здесь.
Автор статьи @kleidemos
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS