В прошлых двух частях мы ознакомились с синтаксической моделью F#-кода и с инструментами для неё. Объёмный пример туда уже не влез, но необходимость в нём осталась. Так родились ещё две заключительные части цикла. Их объединяет общий проект, но в остальном они представляют собой сборную солянку фактов, практик и наблюдений, которые было бы трудно разместить в каталогизированной документации.
Мы возьмём сугубо игровую задачу с понятным результатом и на её примере узнаем:
на какие ноды AST стоит обратить внимание в первую очередь;
где
Fantomas
-у нельзя доверять;где можно хакать;
где лучше придерживаться пуризма;
и как на F# можно строить
Fluent
API.
В этой части мы сосредоточимся на общей организации генератора, входных данных и основных элементах AST. В следующей сделаем то же самое, но на более сложном уровне, сместив повествование в сторону устройства Fluent
API.
До этого я не пробовал заочно описывать генераторы, так что в попытке облегчить задачу мы даже поковыряли видеоформат, но пришли к выводу, что человечество к такому пока ещё не готово. Тем не менее какие-то плюшки из этого мы извлекли, что будет видно дальше по тексту. Основную трудность представляет экспозиция основных нод фантомаса, и если этот элемент убрать, то несмотря на кажущуюся отвлечённость, генераторы кода представляют собой отличный пример для передачи опыта, так как обладают практически завершённым жизненным циклом. У нас есть входные данные, адаптированный к ним алгоритм и готовый результат, который разработчик сам может пощупать руками без участия пользователей.
Фототур
Когда-то на одном из моих (ныне здравствующих) петов потребовалось выразить графовые отношения в виде конкретного API/DSL. Я имею в виду, что если между вершинами А
и Б
есть ребро, то находясь в вершине-объекте А
, можно переместиться в вершину-объект Б
при помощи метода member this.Б
. В зависимости от задач это перемещение могло требовать дополнительную информацию в виде параметров на этапе компиляции.
Причём API требовалось как для «трекинга» экземпляра, когда через методы протаскивается и модифицируется стейт, так и для глобальных правил, типа «для всех переходов из А
в Б
выполняемых в тёмное время суток делай то-то». Если вы знакомы с XAML, то за аналогию можно взять прямое задание свойств в Control
-ах и непрямое через Style
и Setter
-ы. Граф у этих DSL общий, но это всё-таки разные DSL, которые работают на различных категориях объектов. Когда DSL несколько вместо одного, слоёв абстракций становится сильно больше, чем вмещается в статью. Я не стал перекраивать под неё существующий проект (а также под хелперы из второй части) и просто взял новую тему.
У меня есть настольная игра «Фототур», в которой есть готовый ненаправленный граф между регионами РФ. Перемещение по этому графу напоминает мою изначальную задачу, но при этом его гораздо проще объяснять. Можно сразу выдохнуть, так как в рамках статьи мы будем писать DSL только для поля «Фототура». Я дам 3 абзаца правил в очень вольном изложении, но чисто для создания антуража. Значимо опираться на эти правила мы не будем, нам нужен только граф(ы).
Это реклама игры, замаскированная под техническую статью.
В игре «Фототур» игроки отыгрывают роль фотографов, которые стремятся заполнить свой конкурсный фотоальбом быстрее и «дороже» остальных. Для этого они выполняют заказы, привязанные к оборудованию и конкретным локациям. Привычной валюты здесь нет, вместо неё есть время, выраженное в расстоянии, которое нужно преодолеть, чтобы переместить себя в точку с нужной достопримечательностью. Карта там большая:
Я ограничусь лишь её маленьким кусочком. Чуть левее от центра есть скопление зелёных точек:
Это один из нескольких регионов, который можно условно назвать Поволжьем (в самой игре имён у регионов нет, авторы выразили всё через иконки). Регионы влияют на бонусные баллы конкретной партии, но никак не влияют на перемещение между точками. Главное, чтобы эти точки были соединены.
Соединения могут быть автомобильной дорогой, иногда ещё и железной (т. е. железка вместе, а не вместо асфальта). За раз можно перемещаться либо только на машине (2 ребра), либо только на поезде (6 рёбер). Поездом значительно быстрее, но он реже выпадает на кубах, и железных дорог сильно меньше автомобильных. Существует третий вид транспорта — некоторые города имеют аэропорты, и из любого аэропорта можно за один ход попасть в любой другой при помощи самолёта.
Таким образом, Фототур даёт нам целых три графа для DSL. Граф самолёта является полным (т. е. в нём все вершины соединены со всеми), но в нём отсутствует большинство городов. В автомобильном графе есть все города, но перемещаться по нему из конца в конец можно вечность. ЖД-граф оказался где-то посередине, но он распадается на две независимые подсистемы (чисто по причинам игрового баланса, на нас это не повлияет).
Наша задача — построить несколько API, которые гарантируют наличие используемых путей на этапе компиляции. Выглядеть это будет приблизительно так:
let attraction, path =
Attractions.ДворецЗемледельцев
.StartRouteViaHayway()
.ЖигулёвскиеГоры()
.ХребетЯлангас()
.ДворецЗемледельцев()
.ЖигулёвскиеГоры()
.StopRoute()
attraction
|> Expect.equal "" Attractions.ЖигулёвскиеГоры
let expectedPath = [
AttractionCards.ДворецЗемледельцев
AttractionCards.ЖигулёвскиеГоры
AttractionCards.ХребетЯлангас
AttractionCards.ДворецЗемледельцев
AttractionCards.ЖигулёвскиеГоры
]
path
|> Expect.equal "" expectedPath
Каким образом подобное API могло оказаться полезным в реальном проекте?
У меня есть несколько пет-проектов для обслуживания моих велопоездок. Езжу я преимущественно по просёлочным дорогам и на достаточно большие расстояния. Некоторые поездки могут длиться по 10 часов, что в случае сольных заездов и плохой связи может нехило напрягать моих близких.
В последнюю поездку прошедшего сезона я выломал правый шатун (держит педаль) на въезде в зону, где связь практически отсутствует. Меня забрали через 2 часа, из которых большую часть времени я толкал велик сначала до асфальта, а потом к точке знакомой команде спасения. Но если бы велик сломался на час позже, то один только выход до телефонной связи занял бы часа 3-4.
Чтобы снизить риски, я отписываюсь своим о направлении движения и о достижении значимых точек на карте. Адресов за пределами деревень нет, так что привязка может осуществляться даже к здоровенному пню или крайне неприятной дорожной яме. Большинство таких точек имеет устоявшееся в коллективе название. Перед поездкой я могу проложить предстоящий маршрут в fsx
-скрипте и запустить его на сервере. После этого мне достаточно будет ставить галочки в приложении, а сервер добежит с этой информацией до чата, тайм-трекера и других сервисов.
DSL, который пишется в данной статье, является сильно упрощённой версией DSL для велика. В оригинале мне требуется API для работы с пока ещё безымянными точками, готовыми маршрутами, ветвлениями, показаниями одометра, заметками и просто сложными штуками, которые ни в один заранее подготовленный UI не запихнёшь. Это тот случай, когда лучше кода ничего придумать не получается.
upd:
Ближе к дате публикации я попробовал игровой движок Godot
и внезапно обнаружил, что аналогичные конструкции могут конкурировать с UI-механизмами, которые Godot
предоставляет из коробки. Это особенно актуально ввиду того, что Godot
построил интеграцию с dotnet
через partial
-механизм C# классов, который меня конкретно выбешивает.
Устройство проекта
Весь пример состоит из пяти последовательных стадий, две из которых мы успеем рассмотреть в этой части. Каждая стадия состоит из трёх файлов:
StageN.Handmade.fs
— файл с «рукописным» кодом. Обычно в нём находятся корневые типы и некоторые уникальные значения.StageN.Generator.fsx
— скрипт с генератором. Он должен самостоятельно добыть зависимости проекта, исходники выше по списку и пакеты, необходимые для генератора (Fantomas
и т. д.). Данный скрипт предполагает ручной запуск, в результате которого сгенерированный код окажется вStageN.Generated.fs
.StageN.Generated.fs
— результат генератора.
В реальных проектах столь ярко выраженная стадиальность отсутствует, поэтому там имена файлов больше говорят о содержимом. Я раскидал стадии по трём папкам, но это косметическое разделение никак не скажется на пространствах имён.
В каждой стадии данные файлы лежат в том же порядке, так что StageN.Generator.fsx
и StageN.Generated.fs
зависят от StageN.Handmade.fs
. А StageN.Handmade.fs
в свою очередь зависит от Stage_.Handmade.fs
и Stage_.Generated.fs
предыдущих стадий. Stage_.Generator.fsx
из разных стадий преднамеренно друг на друга не ссылаются.
Может звучать сложно, но если бы мы «чисто гипотетически» написали UI для управления генераторами, то там было бы всего две важных ручки. Запуск конкретного файла генератора (TopRight
) и последовательный запуск всех генераторов до первой ошибки (BottomLeft
):
Любое изменение в файлах является показанием к повторному запуску расположенных ниже по списку стадий. Однако прямой связи между генераторами нет, поэтому если изменения ограничиваются только рефакторингом какого-либо Generator.fsx
, то нет никакого смысла перезапускать генераторы ниже, так как их входная информация не изменилась. Правда, понять, что рефакторинг действительно не сказался на результате, можно только испытав его на практике:
Вычисление подобных отношений индивидуально, но в целом очень механистично. Тем не менее его стоит держать в голове сразу на этапе проектирования, чтобы сэкономить время разработки. Ни одна из стадий не занимает более 10 секунд (при условии, что нужные пакеты уже стоят на компе), и это немного лишь пока файлов мало. К тому же IDE напрягается каждый раз при смене исходников. Например, пару лет назад в VS была быстро прогрессирующая утечка памяти на больших файлах с обёртками для UI. И примитивный фильтр позволял продлить время работы между перезагрузками с 3-4 генераций до 20, что уже было достаточным для перебежки между коммитами.
Я не стал править AstHelpers.fsx
из предыдущей части, а просто нарастил имеющиеся хелперы в MyAstHelpers.fsx
. Причём сделал это уже после того, как сгенерировал всё что требовалось. В среднем у меня уходило чуть больше часа на каждую стадию. Получалось объёмно, но это была дорога в один конец с моментальным ответом на ошибку, поэтому объём меня не беспокоил. Несколько недель спустя я отрефакторил все генераторы и думаю, что вынос хелперов занял больше времени, чем вся первая версия. Читать код стало сильно удобнее, и он даже начал влезать в экран, но на реальном проекте я бы пару раз подумал, прежде чем переписать завершённый генератор. FCS
забирает на себя такую долю ответственности, что после первого результата необходимость за ним присматривать практически отпадает.
MyAstHelpers.fsx
— общая точка старта для всех генераторов, но при этом он не содержит хелперов, строго зависимых от данного проекта. Поэтому его можно утащить и использовать где-то ещё.
Generator.Helpers.fs
— в нём хранится наша мета. В этой мете лежат почти все литералы, используемые в кодогенераторах: имена модулей, типов, функций и свойств. Также туда попали несколько функций, определяющих правила именования. В зависимости от конкретного проекта можно решить, поставлять мету вместе с итоговой сборкой или нет. Конкретно здесь файл с метой был исключён из компиляции.
Загадочная мета в рамках проекта почти полностью исчерпывается модулями вида:
module Namespaces =
let root = "PhotoTour"
module Types =
let card = "AttractionCard"
let attraction = "Attraction"
let _Attraction transport = $"%s{transport}{attraction}"
...
module Attraction =
let instance = "instance"
let via_ transport = $"Via%s{transport}"
...
module Modules =
let cards = "AttractionCards"
...
...
Этот код слишком тупой (как и поддержка рефакторинга скриптов в IDE), так что он никак не поможет автоматически амортизировать будущие изменения. Но его задача — ломать код генераторов до того, как кто-то успеет сгенерировать невалидные исходники.
Типовое содержимое StageN.Generator.fsx
Загрузка зависимостей:
// Файлы проекта выше по списку:
#load "Stage1.Handmade.fs"
#load "Stage1.Generated.fs"
#load "Stage2.Handmade.fs"
// Кодоген:
#load "../MyAstHelpers.fsx"
#load "../Generator.Literals.fs"
ЭЭЭКСПЕРИМЕНТЫ!!! REPL позволяет нам подбирать необходимые AST-конструкции в том же файле. Так что организационно я просто бомбил Parse
нужными мне конструкциями, после чего механически воспроизводил их чуть ниже:
"""
let listSample = [12;23;34]
"""
|> SourceText.ofString
|> Parse.parseFile false <| []
|> fst
Сборка AST и вывод кода в файл:
Code.fromModules [
SynModuleOrNamespace.createNamespace Namespaces.root [
// Полезное.
]
]
|> fun content ->
System.IO.File.WriteAllText(
System.IO.Path.Combine(
__SOURCE_DIRECTORY__
, "StageN.Generated.fs"
)
, content
)
С SynModuleOrNamespace
мы работали в прошлый раз. Тогда это был модуль верхнего уровня, а не пространство имён, но различия исчерпываются двумя скучными аргументами в фабрике.
Этап 1. Карты достопримечательности
Карта конкретной достопримечательности выглядит так:
Почти все характеристики карты могут быть механически преобразованы в конкретные типы и поля. Но бонусы достопримечательностей обладают слишком высокой индивидуальностью, чтобы писать их через алгебраический тип в отсутствие ECS. Однако единственное, что влияет на DSL, это номер достопримечательности и её название. Номер нужен для ориентации в графе, а название — для создания соответствующих членов и типов DSL. На этих двух свойствах мы и сосредоточимся, остальные «механические» свойства я просто не стал удалять (потому что это красиво):
type Kind =
| Urban
| Natural
// В самой игре данные регионы не названы и обозначены лишь цветом.
type Region =
| North
| Central
| Volga
| South
| Ural
| Siberia
| FarEast
type PhotoEquipment =
| Film
| Lens
| Smartphone
| Quadcopter
| Tripod
// Skipped
//type Bonus = class end
type AttractionCard = {
Id : int
Name : string
// Too long.
//Description : string
Place : string
Kind : Kind
Region : Region
VictoryPoints : int
PhotoEquipments : PhotoEquipment list
// Skipped
//Bonus : Bonus option
}
Модель карты достопримечательности мы определим руками в Handmade.fs
, а всё остальное напишет генератор.
Результат генерации
На первом этапе мы сгенерируем модуль со всеми экземплярами карт. Нечто подобное можно было встретить в типах Colors
или Brushes
, если вы контактировали с WPF
/AvaloniaUI
. Имея идентификаторы, их можно было бы предварительно загрузить в словарь и надёргать необходимые экземпляры в рантайме в момент инициализации модуля:
module Cards =
let ``name of concrete attraction`` = Internal.findByCardId 42
Сущностно полученный код будет мало чем отличаться от кода минимального генератора из прошлой части, так что если у вас нет веских причин действовать иначе, то лучше этим и ограничиться. Однако в учебных целях мы пойдём более радикальны путём, соберём эти рекорды непосредственно в коде:
/// Generated.
module AttractionCards =
let ХребетЯлангас =
{ AttractionCard.Id = 21
Name = "Хребет Ялангас"
Place = "Республика Башкортостан"
Kind = Kind.Natural
Region = Region.Volga
VictoryPoints = 3
PhotoEquipments = [ Lens; Tripod; Smartphone ] }
А потом ещё сложим их в одном месте:
let all =
[ ЗаповедникБасеги
СкульптураОлень
УтёсСтепанаРазина
НабережнаяБрюгге
ЖигулёвскиеГоры
ДворецЗемледельцев
ХребетЯлангас ]
Преобразование имён
В первую очередь необходимо определиться с правилами преобразования строковых имён в имена членов. Эти правила должны быть едины для всех генераторов, так что соответствующий модуль будет расположен в Generator.Literals.fs
. F# позволяет квотировать почти любое имя, и генерировать новые мемберы с такими именами не сложно. Использование квотированных имён мало заботит вас, когда вы работаете из IDE, но оно обрастает сложностями, когда сгенерированный код должен к ним обращаться. Если речь идёт не о терминальных нодах, то лучше иметь API без сложных имён (как минимум в виде дублёра).
Мы «заглавим» первые буквы слов и избавимся от пробелов и всех знаков препинания. Это может быть рискованно в некоторых доменах, так как возникают различные наборы имён, которые могут сводится к одному и тому же варианту. Однако наш домен не такой, и мы ничем не рискуем:
module Syntaxify =
let main str =
str
|> Seq.mapFold (fun needUp char ->
if System.Char.IsLetterOrDigit char then
if needUp
then System.Char.ToUpper char, false
else char, false
else
' ', true
) true
|> fst
|> Seq.filter ^ function
| ' ' -> false
| _ -> true
|> Array.ofSeq
|> System.String
let inline private (|HasName|) (hasName : 'a when 'a:(member Name : string)) =
hasName.Name
let inline (|Name|) (HasName name) = main name
let inline name (HasName name) = main name
Наш пример начинается с mapFold
и SRTP. Это не самое лучшее начало, но могу заверить, что больше таких редкостей в проекте не будет. Необходимость Seq.mapFold
вытекает из алгоритма, который, судя по тестовым прогонам, затруднений не вызывает, так что с ним при желании разберётесь.
Для тех, кто не сталкивался с SRTP, активный шаблон HasName
представляет бОльшую проблему. F# может накладывать сложные требования на дженерики в функциях, если эти функции помечены inline
. С полным перечнем можно ознакомиться по ссылке выше, но почти все они сводятся к дактайпингу на этапе компиляции.
Конкретно здесь HasName
ожидает объект типа 'a
, у которого есть свойство Name
типа string
. Данное ограничение за счёт inline
и автоматического вывода типов будет «унаследовано» в функции name
, а потом и в шаблоне Name
. Поэтому на этапе компиляции вы не сможете передать в них тип без соответствующего свойства.
Обычно мне хватает Syntaxify.main
с несколькими редко используемыми альтернативами. Но конкретно в этом проекте данный метод вызывался только на поле Name
типа AttractionCard
, который относится к предметной области. Мне не хотелось к ней привязываться, поэтому HasName
пришёлся очень кстати. Так что я попросил бы неофитов, добравшихся до этого момента, использовать данный приём только при складывании аналогичного комбо.
Генератор
Я определил входные данные прямо в коде, чтобы оставить десериализацию за скобками:
let attractions = [
let regions = [
Region.Volga
, [
15, "Заповедник Басеги", "Пермский край", Kind.Natural, 2, []
16, "Скульптура \"Олень\"", "Нижний Новгород", Kind.Urban, 1, [Smartphone]
...
]
]
for region, preCards in regions do
for id, name, place, kind, victoryPoints, photoEquipment in preCards do
{
AttractionCard.Id = id
Name = name
Region = region
...
}
]
Опуская неймспейс, мы сгенерируем один модуль:
[Messages.generated]
|> SynModuleDecl.NestedModule.create Modules.cards [
for attraction in attractions do
SynExpr.Record.create [
Field.int32 $"{Types.card}.Id" attraction.Id
Field.string "Name" attraction.Name
Field.string "Place" attraction.Place
Field.longIdent "Kind" $"Kind.{attraction.Kind}"
Field.longIdent "Region" $"Region.{attraction.Region}"
Field.int32 "VictoryPoints" attraction.VictoryPoints
Field.create "PhotoEquipments" ^ SynExpr.ArrayOrList.list [
for item in attraction.PhotoEquipments do
Ident.parseSynExprLong ^ string item
]
]
|> SynModuleDecl.Let.value ^ Syntaxify.name attraction
SynExpr.ArrayOrList.list [
for Syntaxify.Name attractionName in attractions do
Ident.parseSynExprLong attractionName
]
|> SynModuleDecl.Let.value "all"
]
С точки зрения AST, модуль в пространстве имён является вложенным, хотя в жизни этот термин применяют только к модулям в модулях. Для консистентности в рамках статьи я буду придерживаться AST-определения.
До этого мы не сталкивались с вложенными модулями, и это первая встреченная нами «самостоятельная» сущность:
type SynModuleDecl.NestedModule with
// Непривычный порядок аргументов задан по образу и подобию реального модуля:
// comments
// |> create name [
// decl
// ]
static member create name decls comments =
SynModuleDecl.NestedModule.CreateByDefault(
SynComponentInfo.CreateByDefault(
Ident.parseLong name
, xmlDoc = PreXmlDoc.create ^ Array.ofList comments
)
, SynModuleDeclNestedModuleTrivia.CreateByDefault(
Some Range.Zero // `module`
, Some Range.Zero // `=`
)
, decls = decls
)
Первым параметром в NestedModule.CreateByDefault
идёт SynComponentInfo
. Это крайне важный тип, который определяет имя модуля, его атрибуты, комментарии и уровень приватности. SynComponentInfo
также используется для определения обычных типов (type <name> = ...
). Из-за этого в нём есть слоты для дженерик-аргументов и их ограничений, которые применительно к модулям не имеют силы. По своей важности SynComponentInfo
приближается к SynExpr
(выражение), SynType
(тип) и SynPat
(шаблон), но при этом он не имеет аналогии в привычном категориальном аппарате, и иногда мне остро не хватает данного термина при общении.
Содержимое модуля состоит из SynModuleDecl.Let
, раньше они прятались в letBiding
, но теперь надо разобрать их подробнее. Сам Let
является лишь тонкой обёрткой над SynBinding
:
type SynModuleDecl.Let with
static member create synPat body =
SynModuleDecl.Let.CreateByDefault(
bindings = [
SynBinding.create
(SynLeadingKeyword.Let.CreateByDefault())
synPat
body
]
)
static member value name =
SynPat.Named.create name
|> SynModuleDecl.Let.create
SynBinding
отвечает за часть let
, use
и do
, и все member
, val
, abstract
, а также за все допустимые комбинации между ними, включая сверх этого rec
, static
и т. д. Весь этот зоопарк скрывается в типе SynLeadingKeyword
, который прячется в SynBindingTrivia
. Данный факт не перестаёт меня удивлять, так как данные ключевые слова явно контекстно обусловлены, но их приходится прокидывать на глубину в три яруса:
type SynBinding with
static member create leadingKeyword synPat body =
SynBinding.CreateByDefault(
SynValData.empty
, synPat
, body
, SynBindingTrivia.CreateByDefault(
leadingKeyword
, equalsRange = Some Range.Zero
)
)
SynBinding
содержит информацию о приватности, мутабельности, inline
, комментариях, возвращаемом типе и атрибутах, но самое главное, он содержит тело привязки в виде SynExpr
и её левую часть до знака равно. Эта левая часть выражена типом SynPat
. По идее это шаблон, но в более широком смысле, чем мы себе обычно представляем. В отношении let a
мы легко можем вспомнить, что a
— это результат распознавания аналогичный:
match something with
| a -> ...
Мы можем вспомнить про let (min, max) = minMax items
, что также объясняет наличие шаблона вместо «стандартного» имени. Но в отношении this.Member
сделать подобный кульбит уже сложнее. Ещё сложнее представить, что в let f x y =
связка f x y
является шаблоном. Мы просто называем эту штуку SynPat
и не пытаемся совместить её с шаблонами в привычных категориях.
Остальная часть кода касается построения SynExpr
. Как видно, весь код генератора почти целиком состоит из:
Условно стандартных фабрик AST-узлов, типа
SynExpr.ArrayOfList.list
изMyAstHelpers.fsx
;Строковых констант, типа
Namespace.root
илиTypes.card
, которые определены вGenerator.Literals.fs
;Небольшой доли данных из рантайма, которую преобразуют в примитивы и упаковывают в соответствующие AST-конструкции.
Модуль Field
выделяется из всего этого, так как одноимённого типа в AST нет, но это всего лишь узкоспециализированный хелпер, который задан парой строк выше:
module Field =
let create = SynExprRecordField.create
let make valueToSynExpr name = valueToSynExpr >> create name
let int32 = make SynExpr.Const.int32
let string = make SynExpr.Const.string
let longIdent = make Ident.parseSynExprLong
В модуле содержится самая «опасная» функция данного генератора — Field.string
. В недрах этого метода собирается константа на основе строки. Сам метод не приведёт к ошибке, проблема в особенностях экранирования на этапе форматирования. Fantomas
будет экранировать все двойные кавычки в строке, вне зависимости от варианта написания (SynStringKind
):
Expected: => Actual: | Equals:
" \" " => " \" " | true
" \"\" " => " \"\" " | true
""" " """ => """ \" """ | false
@" "" " => @" \"\" " | false
Забавно, что остальные спецсимволы его не интересуют. Я считаю это образцовым примером того, за что мы «любим» энтерпрайз-код. Столкнувшись с проблемой, её захакали, после чего никак не выразили это в API и не дали никаких инструментов, чтобы заменить хак, когда он уже конкретно стреляет в ногу. Обходить эту проблему приходится через работу с конечным кодом, и спасает лишь то, что предметных областей, где надо генерировать нестандартные строки, очень немного.
В нашем случае сложная строка попадается только в:
16, "Скульптура \"Олень\"", "Нижний Новгород", Kind.Urban, 1, [Smartphone]
И Fantomas
с ней справляется:
let СкульптураОлень =
{ AttractionCard.Id = 16
Name = "Скульптура \"Олень\""
Place = "Нижний Новгород"
Kind = Kind.Urban
Region = Region.Volga
VictoryPoints = 1
PhotoEquipments = [ Smartphone ] }
В данном генераторе рекорд собирается вручную, но обычно «кодификация» алгебраических типов автоматизируется рекурсивным образом при помощи TypeShape
(когда-нибудь мы коснёмся и этой либы) или обычной рефлексии. Именно поэтому имена полей рекорда и кейсов DU не были вынесены в общую мету, как это было сделано с Modules.cards
и т. п.
Тут же стоит обратить внимание, что attractions
был полностью воспроизведён в AttractionCards.all
. Тем самым вся входная информация оказалась внутри проекта, и только поэтому её не надо дублировать где-либо ещё.
Этап 2. Узлы
На этом этапе мы введём корневой тип достопримечательности:
type Attraction (card : AttractionCard) =
member this.Card = card
Он не предполагает какого-либо наследуемого поведения ни сейчас, ни в перспективе, это просто привязка к конкретной карте.
Результат генерации
Чтобы у каждой достопримечательности был свой уникальный набор свойств и методов, необходимо, чтобы каждая из них имела отдельный тип (то есть по новому типу на каждую карточку). В данном случае речь идёт о точке в пространстве, которая не может существовать в двух экземплярах. Хранить какие-либо данные в экземплярах этого типа я также не предполагаю, поэтому речь идёт о классическом singleton
-е:
type ЗаповедникБасеги private () =
inherit Attraction(AttractionCards.ЗаповедникБасеги)
static member val instance = ЗаповедникБасеги()
Данному типу нет необходимости лежать в общем пространстве имён, поэтому их семейство лучше изолировать:
/// Generated.
module Attraction =
type ЗаповедникБасеги private () =
inherit Attraction(AttractionCards.ЗаповедникБасеги)
static member val instance = ЗаповедникБасеги()
Чтобы пользователю было удобно добывать инстансы экземпляров, их лучше вынести в отдельный модуль, как мы сделали ранее с AttractionCards
:
/// Generated.
module Attractions =
let ЗаповедникБасеги = Attraction.ЗаповедникБасеги.instance
Генератор
Код генератора:
// Типы.
[Messages.generated]
|> SynModuleDecl.NestedModule.create Modules.attraction [
for Syntaxify.Name attractionName in AttractionCards.all do
SynModuleDecl.Types.CreateByDefault [
SynTypeDefn.``type <name> private () =`` attractionName [
SynMemberDefn.ImplicitInherit.CreateByDefault(
Ident.parseSynType Types.attraction
, Ident.parseSynExprLong $"{Modules.cards}.{attractionName}"
|> SynExpr.paren
)
Ident.parseSynExprLong attractionName
|> SynExpr.app SynExpr.Const.unit
|> SynMemberDefn.Member.staticMemberVal Types.Attraction.instance
]
]
]
// Хелперы.
[Messages.generated]
|> SynModuleDecl.NestedModule.create Modules.attractions [
for Syntaxify.Name attractionName in AttractionCards.all do
Ident.parseSynExprLong $"{Modules.attraction}.{attractionName}.{Types.Attraction.instance}"
|> SynModuleDecl.Let.value attractionName
]
Теперь мы можем опереться на типы и модули, определённые на предыдущих этапах. В частности, теперь можно использовать AttractionCards.all
, чтобы пройтись по всем существующим достопримечательностям.
Здесь мы впервые сталкиваемся с определением типа:
type SynTypeDefn with
static member create info implicitConstructor members =
SynTypeDefn.CreateByDefault(
info // : SynComponentInfo
, SynTypeDefnRepr.ObjectModel.CreateByDefault()
, SynTypeDefnTrivia.CreateByDefault(
SynTypeDefnLeadingKeyword.Type.CreateByDefault()
, Some Range.Zero
)
, implicitConstructor = Some implicitConstructor
, members = members
)
// Пример результата исчерпывающе описывает содержимое функции.
static member ``type <name> private () =`` name members =
SynTypeDefn.create
(SynComponentInfo.CreateByDefault(Ident.parseLong name))
SynMemberDefn.ImplicitCtor.privateUnit
members
Все типы в F# начинаются с одного и того же ключевого слова type
(или and
), а дальнейшие различия заключаются в «ядре», которое идёт сразу после знака равно. За это ядро отвечает SynTypeDefnRepr
, в нашем случае мы используем самую простую объектную модель с SynTypeDefnKind.Unspecified
(скрыто в ObjectModel.CreateByDefault()
). У ObjectModel
есть коллекция members : SynMemberDefn list
, так же как и у SynTypeDefn
. Парсер сначала заполнит первую, но ввиду универсальности мы предпочитаем использовать вторую. Fantomas
справляется с обоими вариантами, но если у вас нет необходимости в старом синтаксисе class/struct end
с val
-ами и прочими динозаврами, о ObjectModel.members
можно забыть. SynTypeDefnKind.Unspecified
, сам по себе не оставляет следов в «теле» типа. Из-за этого собранный тип может оказаться некорректным, если обе коллекции members
окажутся пусты.
Тип SynMemberDefn
вопреки названию необходимо трактовать шире, чем принято в быту. В SynMemberDefn
входят не только member
, override
или interface
, но и тело primary
-конструктора. Причём это тело выражается не одним SynExpr
, а чередой элементов-кейсов. То есть каждая привязка let
(и do
) идёт отдельным элементом. А если у вас есть вызов inherit
-конструктора, то SynMemberDefn.ImplicitInherit
должен возглавлять список members
.
На фоне этого SynMemberDefn.ImplicitCtor
, который вроде как отвечает за primary
-конструктор, оказывается не тем, чем кажется. Во-первых, он хранит информацию только о параметрах конструктора, не о теле. Во-вторых, его надо класть не в общую коллекцию members
, а в отдельное поле implicitConstructor
в типе SynTypeDefn
, и другие кейсы SynMemberDefn
туда помещать нельзя. В-третьих, парсер дублирует его и помещает в коллекцию members
в типе ObjectModel
, однако при форматировании конкретно этот кейс будет проигнорирован.. То есть без SynTypeDefn.implicitConstructor
вы не обнаружите объявление первичного конструктора в выходном коде. Это всё представляет из себя довольно мутную схему, оправдать которую можно лишь историческими событиями, о которых я пока не знаю. В идеале я бы предпочёл иметь отдельный тип для выражения первичного конструктора, чтобы он никак не пересекался с SynMemberDefn
.
Выше говорилось, что параметры функций упаковываются в SynPat
, но определения конструкторов имеют другую природу. По правилам F# их параметры должны передаваться в скобках через запятую. За это отвечает специальный тип SynSimplePats
. Если взять его условно пустую вариацию, то Fantomas
оставит от него одни лишь ()
:
type SynMemberDefn.ImplicitCtor with
static member privateUnit =
// private ()
SynMemberDefn.ImplicitCtor.CreateByDefault(
SynSimplePats.SimplePats.CreateByDefault[] // ()
, SynMemberDefnImplicitCtorTrivia.CreateByDefault()
, Some ^ SynAccess.Private.CreateByDefault() // private
)
Проблема одноимённых типа и модуля
Наш генератор сработал, но если попытаться собрать проект в таком виде, компилятор выдаст ошибку. F# запрещает создавать одноимённые модуль и тип в разных файлах. type Attraction
находится в Stage2.Handmade.fs
, а module Attraction
в Stage2.Generated.fs
. Так как нам в действительности не особо важно, где находятся типы конкретных достопримечательностей, то модуль Attraction
можно было бы переименовать и/или переместить (например, в модуль Attractions
). Это хороший ход, и я рекомендую сделать его в реальных условиях. Однако он не всегда возможен, поэтому мы разберём вариант переноса type Attraction
в Stage2.Generated.fs
.
Мы можем добавить AST-декларацию Attraction
в генераторе, и с арсеналом из последующих этапов это сделать очень легко. Но рукописные типы могут занимать сотни строк, а в виде AST — ещё больше. Сборку нужного AST можно автоматизировать, его также можно выдирать из исходников. Но на самом деле, пока это дерево не предполагает какой-либо параметризации, нет никакого смысла отказываться от строкового представления. Мы можем определить строку с нужным кодом прямо в генераторе:
let attractionDefinition = """
type Attraction (card : AttractionCard) =
member this.Card = card
"""
После чего вставить её в AST через в качестве SynModuleDecl.Expr
:
SynModuleOrNamespace.createNamespace Namespaces.root [
Ident.parseSynExprLong attractionDefinition
|> SynModuleDecl.Expr.CreateByDefault
Это крайне агрессивный хак, который работает только за счёт того, что Fantomas
пишет «идентификатор» не глядя. Он никак не учитывает уровень табуляции, так что его контроль полностью на стороне хакера. Табуляцию сложно вычислить «внезапно» из точки установки, но если вы знаете, что она вам понадобится, вы всегда сможете её вычислить в ходе сборки AST.
Когда-нибудь в Fantomas
могут прикрутить валидацию SynExpr
или LongIdent
. Если такое произойдёт, то можно вместо готового куска кода подсаживать ключ (н-р, в виде того же SynExpr
), после чего заменить его через String.Replace
:
|> fun preContent ->
preContent.Replace(attractionDefinitionKey, attractionDefinition)
|> fun content ->
System.IO.File.WriteAllText(
System.IO.Path.Combine(
__SOURCE_DIRECTORY__
, "Stage2.Generated.fs"
)
, content
)
Обычно Fantomas
падает по всякой мелочи, типа непереданной запятой и т. п., так что мы далеко не сразу обнаружили хак с SynExpr
. А после того, как обнаружили, я несколько дней пребывал с ощущением, что здесь что-то не так. В принципе я до сих пор считаю, что после неоправданных проблем с экранированием строк дыра в SynExpr
выглядит как очень плохая шутка. Или крестик снимите, или трусы наденьте ™.
В силу исторических причин в наших генераторах всегда используется замена по Guid
. Коллекция таких слотов на финальном этапе заменяет строки вида "a9dbbe07-458b-4352-96c6-6600f39d5832"
на что-то более полезное:
type RawCodeSlot (code : string) =
member _.Id : System.Guid
member _.Code : string
member _.ToSynExpr : unit -> SynExpr
...
static member insert : string -> RawCodeSlot seq -> string
Промежуточный итог
Исходный код первых двух стадий расположен здесь.
К этому моменту мы познакомились с AST-проекциями модулей, типов, и пропертей, парочкой багофич Fantomas
и способами экономно передавать зависимости между генераторами. При всём при этом мы пока так и не дошли до Fluent
API по графу. О нём поговорим в следующей части , если до неё кто-нибудь кроме меня дойдёт.
Продолжение здесь.
Автор статьи @kleidemos
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS