Pull to refresh
240.8
FirstVDS
Виртуальные серверы в ДЦ в Москве

Большой код. Учимся генерировать F#-исходники с помощью Fantomas. Часть 3. Модули и типы

Level of difficultyHard
Reading time20 min
Views1.1K

В прошлых двух частях мы ознакомились с синтаксической моделью 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, а чередой элементов-кейсов. То есть каждая привязка letdo) идёт отдельным элементом. А если у вас есть вызов 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

Tags:
Hubs:
Total votes 10: ↑10 and ↓0+10
Comments2

Articles

Information

Website
firstvds.ru
Registered
Founded
Employees
51–100 employees
Location
Россия
Representative
FirstJohn