В прошлой главе мы избавились от необходимости непрерывно соприкасаться с C#-генераторами Godot, после чего пришли к выводу, что нам нужен адекватный задаче DSL. Я дам небольшую вводную по написанию самого дешёвого, но при этом крайне эффективного варианта, а все возможные навороты и прочую крутотень оставлю для DLC статей за пределами текущего цикла (иначе он никогда не закончится). Начнём мы с инициализации нод, настройки статических характеристик и выстраивания иерархии, а в следующий раз разберёмся с описанием их поведения.

Оглавление

new IDisposable

В F# мы можем не писать ключевое слово new при вызове конструктора:

// Следующие конструкции идентичны друг другу по смыслу.
let items = ResizeArray()
let items = new ResizeArray()

Так что на письме конструктор отличается от функции только регистром первой буквы, что обусловлено конвенцией, а не ограничениями языка:

type small () =
    do ()

ignore ()
small ()

При этом слово new не дружит с пайпами:

let items =
    [1..10]
    // Работает:
    |> ResizeArray

let items =
    [1..10]
    // Не работает:
    |> new ResizeArray

Необязательность и краткость вкупе с дополнительными проблемами привели к тому, что «вызывающий» new был изгнан почти отовсюду. В настоящее время он держится только за счёт типов, которые реализуют интерфейс System.IDisposable, так как компилятор видит в них угрозу:

FS0760	Рекомендуется создавать объекты, поддерживающие интерфейс IDisposable с помощью "new Type(args)", а не "Type(args)" или "Type" в качестве значения функции, представляющего конструктор; это делается для того, чтобы указать, что ресурсы могут принадлежать созданному значению.

Обычно я следую этой рекомендации, но её можно проигнорировать или даже скрыть:

#nowarn "FS0760"

Проблема Godot и F# в том, что класс GodotObject (а значит, и все ноды Node :> GodotObject) поддерживает IDisposable, о чём компилятор непрерывно воет. Тем, кто сидит на чистом беспримесном Godot, стоит задуматься о #nowarn, а тем, кто готов ваять DSL, стоит спрятать все проблемные конструкторы в функциях или методах, ведь с точки зрения компилятора IDisposable, полученный откуда-то ещё, не несёт опасности:

let node () =
    // Очевидно кошерно:
    new Node()

// Неочевидно кошерно:
let myNode = node()

Не знаю, почему, но в F#-сообществе считается хорошим тоном писать DSL на базе свободно болтающихся функций. Обычно их названия очень быстро выпадают из моей памяти (приблизительно как хоткеи в IDE), посему данный вид DSL на моих проектах дублируется трушными самописными гейтвеями. 2-3 типа или модуля, которые помогут добраться до всего за несколько точек (как Ctrl + . в IDE) и не тратить время на ненужные или ложные воспоминания. Это вкусовщина, но я знаю, что процент подобных мне достаточно велик, чтобы я мог апеллировать к этому доводу публично. К моему удовольствию, особенности API Godot практически исключают альтернативные подходы.

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

// Ошибка компиляции:
let myNode = node(Name = "MyNode")

Поэтому вместо них «придётся» использовать статические методы:

type MyType =
    static member node () = new Node ()

// Валидный код:
let myNode = MyType.node(Name = "MyNode")

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

В этом месте мне пару раз предлагали прикрепить метод Create к соответствующему типу ноды. Сей подход презентуется как F#-way, но он работает только для алгебраических типов и модулей, так как от них нельзя наследоваться. С обычными «классами» он сбоит:

type Node with
    static member Create () = new Node()

// Валидный код:
let myNode = Node.Create(Name = "MyNode")

// Тоже валидный код, но результат неожиданный:
let myCamera = // : Node
    Camera2D.Create(Name = "Camera")

Метод Node.Create виден в Camera2D, так как Camera2D :> Node. Однако пользователь и компилятор трактуют его совершенно по-разному. Чтобы не попадать в такие ситуации, лучше всего заводить для фабрик отдельный, ни с чем не связанный корень:

type GDNodes =
    static member Node () = new Node()
    static member Node2D () = new Node2D()
    static member Camera2D () = new Camera2D()
    ...

Противная история об именах

Я собирался создать приватный пакет для Godot DSL и даже придумал ему удобное название — Фагот (музыкальный духовой инструмент) = F# + Godot. Однако гуглёж показал, что фагот в странах к западу от Рейна называют bassoon, что вообще не созвучно движку. Хуже того, механическая транслитерация фагота на английский теряет ударение и в ходе обратного перевода получает совершенно иное значение, за открытое упоминание которого я рискую получить по шапке от модераторов (кто не в курсе, сбегайте в переводчик самостоятельно). Такая фигня с потенциальными названиями либ на моей памяти случается уже не в первый раз.

Непотребные картинки

В этом месте авторский замысел был подвергнут жесточайшей цензуре. Оригинальное изображение найдёте в чате.

Что делать с этим, я так и не придумал. Пока остановился на сокращении FG. В плане перевода оно не лучше первого, но ввиду схожести с уже существующим GD все моментально улавливают суть происходящего. И так как мне трудно поддерживать в статьях код, слишком отличающийся от того, что я использую в реальности (были прецеденты, всем глазастым спасибо), то я попрошу понять и простить присутствие FG в дальнейших примерах кода:

type FG =
    static member Node () = new Node()
    static member Node2D () = new Node2D()
    static member Camera2D () = new Camera2D()
    ...

Мы каким-то чудом пережили члены с многочленами в пятом классе и это тоже как-то переживём.

Поступило интересное предложение заменить FG на Doom-ный BFG (якобы Bassoon Fsharp Godot). Наверное, это хорошая альтернатива для людей, находящихся в англоязычном культурном поле. Я себя к этому кругу не причисляю, поэтому спокойно сосуществую с FG.

Построение деревьев

Ноды не существуют сами по себе. Для того, чтобы они приносили пользу, они должны находиться в общем древе нод приложения, так что вопрос их размещения — это ключевой момент DSL. Из коробки нам дано только это:

main.AddChild camera

Условного AddRange не завезли, поэтому массовые спавны приходится обегать в цикле:

[
    camera
    map
    hud
]
|> Seq.iter main.AddChild

Его можно спрятать в методе:

type Node with
    member this.AddChildren children =
        for child in children do
            this.AddChild child

main.AddChildren [
    camera
    map
    hud
]

Однако на практике собирать такие коллекции не очень удобно. Риск потерять какую-то из нод чрезвычайно велик, что в лучшем случае обернётся для нас 2-3 лишними запусками. Если речь не идёт о критически важном взаимно обусловленном порядке элементов, лучше разрешать проблему усыновления в момент инициализации дочерней ноды.

Node знакома с понятием Parent, но работать с ним можно только при помощи методов GetParent и GetParentOrNull. Метод SetParent отсутствует. Его можно добавить, но задачу декларативного описания он не решит, в отличие от свойства:

type Node with
    member this.Parent
        with set value = (value : Node).AddChild this

let camera = FG.Camera2D(
    Position = panel.CustomMinimumSize * -0.5f
    , Parent = main
)

Ветка get () = мне не пригодилась, но её можно описать несколькими способами, поэтому я оставлю её на ваше усмотрение. Ветку set value = можно усложнить и встроить в неё:

  • Механизм Reparent на случай, если вы хотите неявно менять родителя у уже усыновлённых нод;

  • Сброс через присвоение null.

Как правильно? Не знаю. Ну или точнее знаю, но это «знаю» распространяется на конкретные установки конкретной команды на конкретном проекте. Претендовать на универсальность я не хочу и не буду, так как уже порядочно обжёгся на взаимодействии с существующими фреймворками с неправильными дефолтами.

У свойства Parent есть одно неочевидное следствие — им можно описывать целые деревья, а не только одного родителя:

let vbox = FG.VBoxContainer(
    Parent = FG.MarginContainer(
        Parent = FG.PanelContainer(
            Parent = hud
            , ...
        )
        , ...
    )
    , ...
)

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

Механизм сеттеров работает и в обратном направлении. Мы можем встроить сеттер для коллекции Children, что позволит спавнить ноды непосредственно в определении их родителя:

type Node with
    member this.Children'AddRange
        with set value =
            this.AddChildren value

hud.AddChild ^ FG.VBoxContainer(
    Alignment = BoxContainer.AlignmentMode.Center
    // ...
    , Children'AddRange = [
        currentTime
        FG.HBoxContainer(
            SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter
            // ...
            , Children'AddRange = [
                pauseButton
                restartButton
            ]
        )
        timer
    ]
)

Сеттеры предполагают замещение одного значения другим. Если A = 42, то оно не может одновременно быть равным -1. Аналогично запись Children = [...] должна означать, что старая коллекция элементов будет удалена. Такое поведение достижимо, но оно мне не нужно и даже более того, оно мне вредно. При этом я не хочу ломать ожидания рядового пользователя, поэтому упаковываю действия с сайд-эффектами в виде сеттера со столь необычным именем. Так как канона нет, каждый коллектив должен сделать этот выбор самостоятельно. От себя добавлю лишь то, что одинарная кавычка вынуждает IDE иначе резать имя на токены (как минимум в VS), что сказывается на перемещении курсора через клавиатуру, чем я активно пользуюсь.

Как и в случае с Parent, свойство Children'AddRange избавляет нас от необходимости создавать привязки для служебных нод. Всё, что нам не понадобится, может быть определено и благополучно забыто прямо в момент укладки дерева.

Иногда глубина дерева может быть чересчур большой. Слишком часто один контейнер завёрнут в другой контейнер, а тот в ещё один и так далее. Это чревато 2-5 уровнями вложенности с муторной вознёй со скобками на километровых расстояниях. В F# такие вещи принято разруливать через пайпы, наивная адаптация к которым может быть реализована так:

type Node with
    member this.Add'Child child =
        this.AddChild child
        this

Для нод, что не понадобятся нам далее, метод сработает:

FG.VBoxContainer(
    ...
)
|> FG.MarginContainer(
    ...
).Add'Child
|> FG.PanelContainer(
    ...
).Add'Child
|> hud.AddChild

Но если надо сохранить последнюю ноду в привязке, можно обнаружить, что её тип был потерян в недрах Node.Add'Child. Например, здесь panel презентуется как Node, а не как PanelContainer:

let panel = // : Node
    FG.VBoxContainer(
        ...
    )
    |> FG.MarginContainer(
        ...
    ).Add'Child
    |> FG.PanelContainer(
        ...
    ).Add'Child

Чтобы вернуть изначальный тип, нам придётся отложить F#-овые type extensions и определить расширение в C#-стиле:

[<Extension>]
type NodeExt =
    [<Extension>]
    static member Add'Child<'a when 'a :> Node>(this : 'a, child) : 'a = 
        this.AddChild child
        this

Параметр this будет конкретизироваться в точке вызова, поэтому он получит вполне конкретный тип на входе и, как следствие, конкретный тип на выходе:

let panel = // : Panel
    FG.VBoxContainer(
        ...
    )
    |> FG.MarginContainer(
        ...
    ).Add'Child
    |> FG.PanelContainer(
        ...
    ).Add'Child

Такой же метод можно добавить для Children'AddRange:

    [<Extension>]
    static member Add'Children<'a, 'item when 'a :> Node and 'item :> Node>(this : 'a, items : 'item seq) : 'a =
        for item in items do
            this.AddChild item
        this

Тогда коллекцию можно будет описывать на одном уровне с её родителем:

[
    FG.Button(
        ...
    )
    FG.Button(
        ...
    )
]
|> FG.HBoxContainer(
    ...
).Add'Children
|> ...

При работе с коллекциями может возникнуть недопонимание со стороны компилятора, так как обычно он выводит конкретный дженерик-тип коллекции по её первому элементу. В Godot эта эвристика вообще не работает, так как, скорее всего, голова списка будет принадлежать какому-то далёкому наследнику Node, например, Label, из-за чего компилятор будет ругаться на последующие Button, GD.Implements._process и т. д. Проще всего типизировать коллекцию через апкаст головы до уровня приемлемого предка:

// Спёрто из Hopac.
// Обратите внимание на `inline`.
let inline asNode (node : Node) = node
let inline asControl (control : Control) = control

[
    // С этого момента вся коллекция будет интерпретироваться как список `Node`
    asNode ^ FG.Label(
        ...
    )
    // Здесь нет нужды что-то апксатить, но и вреда от лишнего `asNode` не будет.
    FG.Button(
        ...
    )
    GD.Implements._input ^ fun _ _ ->
        ...
]
|> FG.VBoxContainer(
    ...
).Add'Children
|> ...

Setters! Setters everywhere!

Мы можем превратить любой метод с сайд-эффектом в сеттер. Например, можно взять методы расширения из Часть 9. Первичный конструктор, _Ready:

type ViewportId with
    member this.SetActive active = RenderingServer.ViewportSetActive(this.AsRid, active)
    member this.SetTransparentBackground enabled = RenderingServer.ViewportSetTransparentBackground(this.AsRid, enabled)
    member this.SetSize size = RenderingServer.ViewportSetSize(this.AsRid, (size : Vector2I).X, size.Y)

И навернуть поверх них пачку сеттеров:

type ViewportId with
    member this.Active with set value = this.SetActive value
    member this.TransparentBackground with set value = this.SetTransparentBackground value
    member this.Size with set value = this.SetSize value

И тогда инициализация ViewportId превратится из перечисления процедур:

let viewport = ViewportId.Create()

viewport.SetTransparentBackground true
viewport.SetSize size
viewport.SetActive true

В вот такую декларацию:

let viewport = ViewportId.Create(
    TransparentBackground = true
    , Size = viewportSize
    , Active = true
)

Напомню исходное определение типа:

[<RequireQualifiedAccess>]
type ViewportId = { AsRid : Rid }

Такое несоответствие между основой и практикой выглядит примечательно даже для бывалых F#-истов. Но оно возможно только благодаря наличию статического класса RenderingServer с крайне интересным поведением. То есть ситуация сродни сочетанию бочки бензина рядом с группой противников в классических шутерах. Мы, конечно, можем говорить о том, что такие кейсы ушли в прошлое, но будет глупо отрицать, что они реинкарнируют в новых играх (и обычном софте) в других ипостасях. Де-факто статические менеджеры постоянно встречаются в нашем коде, и мы можем эксплуатировать их (с некоторым риском) для наращивания наших SCDU.

Восполнение недостающих абстракций

TabBar — менее экзотичный пример, но куда более болезненный. У этого контрола есть список вкладок, который мы не можем подержать в руках. Вместо этого мы должны работать с ними через методы:

  • TabCount : int

  • AddTab (?title : string, ?icon : Texture2D) : unit

  • SetTabTitle (tabIdx : int, title : string) : unit

  • GetTabTitle (tabIdx : int) : string

  • SetTabIcon (tabIdx : int, icon : Texture2D) : unit

  • SetTabHidden (tabIdx : int, hidden : bool) : unit

  • SetTabDisabled (tabIdx : int, disabled : bool) : unit

  • И т.д.

Это ужасно неудобно. Мало того, что чувствуешь себя полуслепым, так ещё и код превращается в объёмное и одновременно хрупкое перечисление процедур. В F# этот бедлам можно спрятать в сеттеры, которые будут механически мапить входные данные в параметры методов:

type TabBar with
    member this.Tabs'AddRange
        with set tabs =
            for tab in tabs do // : string
                this.AddTab tab

    member this.TabsDisabled'AddRange
        with set value =
            for index, disabled in value do // : int * bool
                this.SetTabDisabled(index, disabled)

let tabs = FG.TabBar(
    Tabs'AddRange = [
        "Raw"
        "Parsed"
    ]
    , CurrentTab = 0
    , TabsDisabled'AddRange = [
        1, true
    ]
    ...
)

Идею можно развить и прийти к полноценной DTO-вкладки:

type TabItem = {
    Title : string option
    Icon : Texture2D option
    IsDisabled : bool
    IsHidden : bool
    ...
}

type TabBar with
    member this.TabItems'AddRange
        with set value =
            value
            |> Seq.iter ^ TabItem.install this

let tabs = FG.TabBar(
    TabItems'AddRange = [
        TabItem.ofTitle "Raw"
        { TabItem.ofTitle "Parsed" with IsDisabled = true }
    ]
    , CurrentTab = 0
    ...
)

Или даже к системе вкладок, которая избавит нас от возни с индексами и прочей чепухой:

let parsedTab = TabItem(
    Title = "Parsed"
    , IsDisabled = true
)

...

parsedTab.Selected.Add ^ fun () -> ...

let tabs = FG.TabBar(
    TabItems = TabItemCollections.create [
        TabItem(
            Title = "Raw"
            , OnSelected = fun () -> ...
        )
        parsedTab
    ]
)

Содержимое этой системы нельзя назвать заоблачно сложным, но и мимоходом его не разобрать, так что я оставлю его на потом. Сейчас же нам важно понять, что через сеттер можно выражать явления гораздо более мудрёные, чем те, что закладывались разработчиками типа изначально. Это критически важно в условиях dotnet, где большая часть кода пишется C#-ерами с нулевой оглядкой на F#.

Хозяйке на заметку: Почти все декларируемые нами type extensions будут недоступны в C#. Не беспокойтесь об этом.

Латание дырявых абстракций

Думается, что авторы Godot не особо оглядывались на весь dotnet, так как и C#, и F# оказались в ситуации, когда API отображает реальное положение дел не в полной мере. Например, у Control есть неявно, но часто используемое свойство AnсhorsPreset : int with get, set, которое по-хорошему должно иметь тип Control.LayoutPreset option (ну или nullable), так как задавать его надо через метод:

Control.SetAnchorsPreset(preset : Control.LayoutPreset, ?keepOffset : bool) : unit

Или даже через:

Control.SetAnchorsAndOffsetsPreset(preset : Control.LayoutPreset, ?resizeMode : Control.LayoutPresetMode, ?margin : int) : unit

Control.LayoutPreset — это enum<int32>, описывающий варианты расположения дочернего контрола в прямоугольнике родителя, но в нём отсутствует кейс «уйди, я сделаю это лучше тебя» (я только что родил краткое описание менеджера). Мне понятно, что хотел сказать автор, но непонятно, почему он не оставил кнопку сброса, ибо отсутствующий сценарий является частью канона. Вместо этого в AnсhorsPreset необходимо запихивать «неизвестное» числовое значение. Несмотря на свою неадекватность, в API движка эта ситуация повторяется ещё неизвестное количество раз, и каждый раз я для подстраховки подсматриваю в .tscn. Мы можем исправить её элементарным хаком:

type Control with
    member this.AnchorsPreset'Set
        with set value =
            this.SetAnchorsAndOffsetsPreset(value)

let vbox = FG.VBoxContainer(
    Parent = FG.MarginContainer(
        Parent = FG.PanelContainer(
            Parent = hud
            , AnchorsPreset'Set = Control.LayoutPreset.TopRight
        )
        , ...
    )
    , ...
)

Или полноценной абстракцией:

module LayoutPreset =
    type Main = {
        AnchorsPreset : Control.LayoutPreset
        ResizeMode : Control.LayoutPresetMode option
        Margin = int option
    }

    let ofLayoutPreset anchorsPreset = {
        AnchorsPreset = anchorsPreset
        ResizeMode = None
        Margin = None
    }

    let topLeft = ofLayoutPreset Control.LayoutPreset.TopLeft
    ...

type Control with
    member this.LayoutPreset
        with set value = LayoutPreset.install value

let panel = FG.PanelContainer(
    Parent = hud
    , LayoutPreset = LayoutPreset.topLeft
    ...
)

На практике установка «корректного» LayoutPreset.Main в половине случаев дополняется настройками GrowDirection, так что в идеале рекорд стоит снабдить ещё двумя полями. А что касается «некорректного», то его можно нормально представить только через связку DU, которая будет в ручном режиме контролировать AnchorLeft/Top/Right/Bottom. Если сложить всё вместе, получится мини-комбайн для очень шустрой симуляции RelativePanel. В XAML-based фреймворках ею очень неудобно пользоваться, но в F# в сочетании с Godot получается огненная штука. Каскадные подсказки в стиле Old World или презентации в стиле FsReveal делаются на ура именно благодаря этому механизму.

Ложечка дёгтя

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

let panel = FG.PanelContainer(
    Parent = hud
    , Lay // Тут VS изредка тупит.
)

panel.Lay // А тут не тупит никогда.

Промежуточное заключение

Как я говорил в начале статьи, это самый простой и самый эффективный по отношению к затраченным усилиям DSL из всех мне известных. Но обычно я использую его для проектов, где не хочу показывать домашние заготовки. Для серьёзного махача мы применяем кое-что менее быстрорастворимое и куда более заточенное под обработку бизнес-логики. Тем не менее представленного набора достаточно (ну или будет достаточно после следующей главы) для полноценной разработки и конкуренции с остальными «UI-языками».

В последнем я уверен, так как несколько лет назад я по ложечке выел мозг одного талантливого JS/TS-ера с хорошо развитым речевым аппаратом (редкий зверь, как мне кажется). И хоть сейчас я не смогу вспомнить ни один из фреймворков, потому что они смешались в кашу как марвеловские мстители (Романофф и Холли Берри в компании взрослых мужиков с детскими психотравмами), считаю, что тот опыт заимствования был крайне успешен. Я вообще не вижу предпосылок к отрыву конкурентов от нас, так как любое технологическое решение может быть разобрано, воспроизведено и адаптировано под наши нужды и зачастую при помощи одних только сеттеров.

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


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


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

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