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

Оглавление

Implements

Ноды GD.Implements (из 10 главы) выделяются на фоне базового набора, так как их задача — окольными путями прикрутить override какого-нибудь метода к своему хозяину. Было бы логично реализовать их в качестве отдельных сеттеров. Например, так могла бы выглядеть обработка _Process:

type Node with
    member this.Add'_Process action =
        this.AddChild ^ GD.Implements._process action

let mutable progress = 0f

let idleOrcVeteran = FG.Sprite2D(
    Texture = GD.load "uid://drvyds43kum8k"
    , Hframes = 6
    , Vframes = 6
    , Parent = main
    , Scale = Vector2.One * 10f
    , Add'_Process = fun this delta ->
        progress <- progress + float32 delta
        (this.GetParent() :?> Sprite2D).Frame <- int (progress * 10f) % 4
)

Несмотря на видимую логичность шага, мне данный сеттер не зашёл. Во-первых, мне не нравится то, что вся обработка будет сосредоточена в рамках одной функции и, что ещё хуже, одной ноды. Вместо этого я предпочитаю очень мелкую «нарезку кубиками», которая если и предполагает наличие лишь одного GD.Implements._process, то только за счёт его радикального усложнения. То есть мне необходимо плодить тысячи маленьких процессов и совершенно неважно, что в целях оптимизации они могут схлопнуться в одну меганоду (которая ещё и лежит не пойми где). Можно начать с чего-то такого:

type Node with
    member this.Add'_Process actions =
        for action in actions do
            this.AddChild ^ GD.Implements._process action

let orcVeteran = FG.Sprite2D(
    Texture = GD.load "uid://drvyds43kum8k"
    , Hframes = 6
    , Vframes = 6
    , Parent = main
    , Scale = Vector2.One * 10f
    , Add'_Process = [
        let mutable progress = 0f
        fun this delta ->
            progress <- progress + float32 delta
            (this.GetParent() :?> Sprite2D).Frame <- 6 + int (progress * 10f) % 6

        // Рекомендую обзавестись набором хелперов для типовых кейсов по аналогии с Hopac-овскими серверами.
        // Тогда `_Process` выше можно будет описать чуть покороче.
        _Process.iterProgress ^ fun this progress ->
            (this.GetParent() :?> Sprite2D).Position <-
                Vector2.Left * 300f
                + Vector2.Right * (200f * progress % 600f)
    ]
)

Для простых сценариев этого достаточно. А для сложных необходимо понимать, что мы делаем и зачем, тогда оптимизированный вариант будет ощущаться не сложнее массива префиксов. В этом деле не будет лишним опыт работы с Hopac или Akka (причём именно на F#).

Во-вторых, мне очень не нравится мелкий надомный downcast в рантайме. Он в целом очень хрупок, а в сочетании с массовым промышленным спавном превращается в почти непрерывную боль. В целях сохранения нервов можно описывать Implements ноды строго после формирования скоупа. То есть только тогда, когда компилятор уже знаком со всеми действующими лицами. Для этого мы в любом случае сохраняем подопытную ноду в привязке, а потом добавляем _process либо в эту же ноду, либо в их общего родителя:

main.AddChildren [
    let patrolOrcVeteran = FG.Sprite2D(
        Texture = GD.load "uid://drvyds43kum8k"
        , Hframes = 6
        , Vframes = 6
        , Parent = main
        , Scale = Vector2.One * 10f
    )

    // Не забываем положить ноду в предка.
    patrolOrcVeteran

    GD.Implements._process (
        let mutable progress = 0f
        fun _ delta ->
            progress <- progress + float32 delta
            sprite.Frame <- 6 + int (progress * 10f) % 6
    )

    GD.Implements._process ^ _Process.iterProgress ^ fun this progress ->
        let distance = 600f - 200f * progress % 1200f
        sprite.FlipH <- distance > 0f
        sprite.Position <-
            Vector2.Left * 300f
            + Vector2.Right * abs distance
]

Процедура выглядит вербознее, чем специальный сеттер и сопряжена с небольшим риском закоммитить что-то не то:

  • fix: Забыл положить ноду в предка.

  • fix: Положил ноду в предка два и более раз.

  • fix: Положил ноду в несколько разных предков.

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

Если же остаться на нашей ветке, то из научного интереса можно пойти дальше. В теории последовательное описание реально объединить с сеттерами:

// Общее решение.
FG.Sprite2D(
    Texture = GD.load "uid://drvyds43kum8k"
    , Hframes = 6
    , Vframes = 6
    , Parent = main
    , Scale = Vector2.One * 10f
    , Children'AddRange'WithOwner = fun sprite -> [
        GD.Implements._process ^ _Process.iterProgress ^ fun _ progress ->
            sprite.Frame <- 6 + int (progress * 10f) % 6
    ]
)

// Специализированное.
FG.Sprite2D(
    Texture = GD.load "uid://drvyds43kum8k"
    , Hframes = 6
    , Vframes = 6
    , Parent = main
    , Scale = Vector2.One * 10f
    , Add'_Process = [
        let mutable progress = 0f
        fun coloRect this delta ->
            GD.Implements._process ^ _Process.iterProgress ^ fun _ progress ->
                sprite.Frame <- 6 + int (progress * 10f) % 6
    ]
)

Но сеттеры не могут в дженерики, поэтому colorRect будет типизирован не как ColorRect, а как Node. Генерализацию поддерживают только методы-расширения, определённые в C#-стиле:

[<Extension>]
type NodeExt =
    [<Extension>]
    static member Add_Process (this : #Node, action) =
        this.AddChild(
            GD.Implements._process ^ action this
        )
        this

let patrolOrcVeteran =
    let frames = MiniFolks.Orcs.OrcVeteran.frames
    FG.Sprite2D(
        Frames = frames
        , Parent = main
        , Scale = Vector2.One * 10f
    ).Add_Process(
        let mutable progress = 0f
        fun sprite this delta ->
            progress <- progress + 10f * float32 delta
            sprite.Frame <- frames.Walk.Circledf progress
    ).Add_Process(
        let mutable progress = 0f
        let inline (!) x = x * 300f
        fun sprite this delta ->
            let distance = !2f - 200f * progress % !4f
            sprite.FlipH <- distance > 0f
            sprite.Position <-
                Vector2.Left * !1F
                + Vector2.Right * abs distance
    )

Код рабочий, но следует понимать, что обработчики редко описывают логику только и исключительно одной ноды. Куда чаще нам выгодно дёргать от одной до нескольких сотен пяти нод в рамках одной реакции, что предполагает некоторое дистанцирование от владельца _Process. Последовательное описание эту дистанцию обеспечивает, а вот fluent-синтаксис — нет.

Обработка сигналов

Напомню, что сигналами в Godot называется «механизм обмена сообщениями между нодами». Звучит очень громко, но в сравнении с собратьями сигналы выглядят чрезвычайно блёкло. Они уступают даже RoutedEvent-ам WPF, про конкуренцию с акторными системами можно вообще не заикаться. Из практических соображений будет проще считать сигналы альтернативной реализацией dotnet-ных событий с до омерзения рыхлой системой типов. То есть мы знаем, что у нод есть некие сигналы (к сожалению, не в виде объектов), в которые можно положить некоторое количество лямбд, и они будут вызваны, если сработает триггер сигнала-события. При такой постановке пользоваться существующими сигналами не страшно, но я не вижу смысла в создании новых сигналов, если речь не идёт об интеграции с редактором или GDScript.

Две главы назад я говорил, что самые частые события Godot не были представлены в виде сигналов, из-за чего их приходилось ловить override-ами методов в специальных нодах (Implements). С позиции унификации к этому факту надо относиться с сожалением, но если подумать, то подобного ощущения у меня не возникает. Обе системы заворачиваются в DSL, так что их оригинальное происхождение перестаёт играть роль. В теории Implements и сигналы реально спроецировать так, что они станут неотличимыми друг от друга, но это потребует некоторого усложнения сигналов, чего я бы в воспитательных целях предпочёл не делать.

Сигналов в Godot на несколько порядков больше, чем методов, требующих override. Таким образом, где-то здесь располагается точка оптимума по соотношению «удобство использования / размер DSL». Дальше всё будет не так легко, как было. Мы не сможем десятком методов и свойств выразить всё возможное многообразие ситуаций.

Такие штуки должны выжигаться генераторами кода, о которых на Хабре есть несколько объёмных статей за моим авторством. И хоть на мой взгляд, генераторы по сложности усвоения разработчиком стоят на несколько ступенек ниже, чем Godot, я не могу расчехлить Fantomas.FCS так, чтобы меня не бросили читать даже самые преданные фанаты Godot. Вместо этого мы попробуем решать проблемы по мере их поступления, то есть будем дописывать DSL руками только для тех сигналов, что собираемся использовать. Подобный план по силам даже начинающим разработчикам и чуть позднее станет ясно, почему он может быть оправдан для всех остальных.

Event в F#

В C# подписка и отписка на события реализованы через операторы += и -=, потому что они буквально производят операцию суммирования или вычитания делегатов с последующей перезаписью указанного свойства. При этом сам делегат доступен для вызова только внутри класса. На одних аксессорах такую схему выразить нельзя, поэтому для них пришлось завести отдельный синтаксис (event). F# обошёлся без специальных конструкций. Нам хватило одного типа — 'a Event:

type 'a Event =
    member Trigger : arg 'a -> unit
    member Publish : 'a IEvent

    interface 'a IEvent with ...

Если экземпляр этой штуки у нас в руках, то мы можем инициировать событие (Trigger). Если же у нас в руках только 'a IEvent, то мы можем только под/отписываться (AddHandler, RemoveHandler, Add и Subscribe). Таким образом уровень доступа зависит не от синтаксиса языка, а от того, какой именно объект мы передали дальше. Степень контроля и свободы у такой схемы гораздо выше, чем у C#-ного event.

Во многом она появилась под воздействием первичного конструктора, где нужно было работать с событиями, не покидая зону let, но на практике мы эксплуатируем её в значительно большем числе ситуаций. Нам совсем не обязательно надо находится на уровне класса, чтобы определить событие. Мы можем сделать это в недрах любой функции:

let f () =
    let changed = Event()
    let mutable value = 42
    let setValue newValue =
        if value <> newValue then
            let old = value
            value <- newValue
            changed.Trigger {|
                Previous = old
                Current = newValue
            |}
    {|
        GetValue = fun () -> value
        SetValue = setValue
        Changed = changed.Publish
    |}

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

let map mapping (event : string IEvent) =
    let result = Event<_>()
    event.Add (fun p -> result.Trigger ^ mapping p)
    result

let event = Event<string>()

(map int event).Add ^ printfn "%i"

На этой идее был построен модуль Event, где определены типовые операции над событиями:

event // .Publish // : string IEvent
|> Event.choose ^ Int32.tryParse // : int IEvent
|> Event.add ^ printfn "%i"

Благодаря этой штуке в .NET и далее везде появился Rx, ныне называемый System.Reactive. Нравится он не всем, и я тоже не в восторге, но только из-за того, что мне хорошо знаком Hopac, где ряд моментов был разыгран значительно лучше, чем в Rx. В остальном это, безусловно, скачок вперёд. Его ближайшим аналогом можно считать те же гопаковские Job и Alt в сравнении с пресловутыми Task.

Их ситуации очень похожи друг на друга по последствиям для кода и архитектуры в целом. Это что-то вроде изобретения пластика. Благодаря ему/им мы используем стабильные готовые к переносу объекты, которые в любой момент можно создать, отсыпать в карман и уйти выполнять задачу в другой скоуп.

Развивая объектное представление, можно выразить комбинацию из подписки и отписки в виде отдельного объекта System.IDisposable:

let event = Event<string>()

let sub =
    event.Publish // : string IEvent по совместительству поддерживает `string IObservable`
    |> Observable.choose ^ Int32.tryParse // : int IObservable
    |> Observable.subscribe ^ printfn "%i" // System.IDisposable

Пока этот IDisposable жив, сообщения будут обрабатываться, и как только мы его грохнем, процесс остановится:

open FSharp.Control.Reactive // Он включает `Rx` / `System.Reactive`.

// Через 3 секунды.
System.DateTimeOffset.Now.AddSeconds 3
// Заспавнится ровно одно событие.
|> Observable.timer
// Которое убьёт подписку выше.
|> Observable.subscribe ^ fun time ->
    sub.Dispose()

В результате нам не надо бегать за владельцем события и просить его изъять из делегата «вот этот метод». Оба объекта уже зашиты в IDisposable. Будущее наступило.

Перегрузки, опять

Хочу пожаловаться на перегрузки IEvent<'a>.Subscribe. Их 2 штуки, «основная» потребляет 'a IObserver, а расширение — функцию 'a -> unit. В отсутствие явных маркеров приоритет отдаётся перегрузке с интерфейсом, о которой уже никто не помнит:

// `handle : _ IObserver`, хотя люди ждут `'a -> unit`.
let f handle =
    event.Subscribe handle

Поэтому в нашем Utils лежит вот такое расширение:

type System.IObservable<'a> with
    member this.subscribe handler =
        this.Subscribe(callback = handler)

Благодаря отдельному имени оно исключает разночтение:

// handle : 'a -> unit
let f handle =
    event.subscribe handle

Изъяны интеропа

Фактически C# ничего не знает о системе событий F#, но подавляющее большинство событий из C# в F# отображаются именно через IEvent. За это преобразование отвечает компилятор, но он обрабатывает только те события, что описаны через System.EventHandler и 'a System.EventHandler. Столь жёсткие рамки обусловлены рядом соображений, которые нам необходимо заимствовать в дальнейшем, так что воспроизведём логическую цепочку прямо сейчас.

Обратим внимание на то, что состав параметров в System.EventHandler известен заранее:

public delegate void EventHandler(object? sender, EventArgs e);
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

obj : sender — отправитель сообщения, под которым подразумевается объект-владелец события. При переезде в F# был признан абсолютно бесполезным, так как идеально заменяется своим владельцем:

owner.Changed.Add ^ fun ev ->
    // Если есть типизированный `owner`, то зачем нетипизрованный `sender`?
    let size = owner.GetSize()
    ...

Даже если нам потребуется автономная версия, будет проще замапить исходное событие, чем даункастить sender:

owner.Changed
|> Event.map ^ fun ev -> {|
    Args = ev
    Sender = owner // Типизирован, в отличие от `sender : obj`
|}

e : EventArgs и e : TEvenArgs — сообщение. Это основные данные события, которые упакованы ровно в один объект, что очень важно, так как именно такая форма позволяет представлять событие в виде 'a IEvent или 'a IObservable.

Судя по всему, в компиляторе не пришли к однозначному выводу, как должны конвертироваться делегаты с иным составом параметров, поэтому их адаптацию вытолкнули в сферу ответственности разработчика. F# умеет в туплы, причём лучше многих, так что в принципе мы могли бы обернуть большее число параметров в какой-нибудь IObservable<int * string * bool>, но такой конструкцией можно пользоваться только на сравнительно небольших расстояниях и при достаточной инаковости компонентов кортежа.

Когда-то мне пришлось потратить пару дней на выяснение того, что означает <bool * bool * bool>. Вопреки канону о «Задаче трёх буль» нельзя написать интересную или поучительную историю. Все её участники принимали муки без особых на то причин.

На расстоянии параметрам нужны имена, и в отличие от туплов, кастомные делегаты их имеют. Подозреваю, что именно по этим соображениям авторы Godot обратили на них внимание. Почему они отказались от полноценных DTO, я не знаю, скорее всего негативно сказалось python-овское прошлое GDScript. В результате в Godot сделали всё по устаревшей схеме, и нам придётся самостоятельно преобразовывать сигналы в события.

ISignal -> IEvent

Сигналы не имеют объектного представления. Вместо внятного Button.Pressed : unit IEvent мы имеем два метода:

member add_Pressed : System.Action -> unit
member remove_Pressed : System.Action -> unit

Код IEvent можно найти в недрах компилятора, и при желании мы можем спроецировать его на сигналы Godot. Однако мне проще создать ещё одно событие:

let button = FG.Button()

let pressed =
    let event = Event()
    let handler = System.Action(fun () -> event.Trigger ())
    button.add_Pressed handler
    let sub = IDisposable.create ^ fun () -> button.remove_Pressed handler
    {|
        Sub = sub
        Event = event.Publish
    |}

pressed.Event
|> Observable.subscribe ^ fun () ->
    GD.printf "Button pressed!!!"

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

Важно понимать, что pressed.Event — это прокси, который существует отдельно от кнопки. У него нет эксклюзивного контроля над оборачиваемым событием, поэтому мы можем подписаться на Pressed независимо от pressed.Event, или даже через ещё одно прокси. Благодаря тому, что я подписываюсь исключительно через Observable.subscribe, мне на этот факт абсолютно наплевать, но механическому переносу C#-кода он помешает (специально для джунов говорю!).

pressed можно вынести в расширение:

type ButtonBase with
    member this.Pressed'ToEvent () =
        let event = Event()
        let handler = System.Action(fun () -> event.Trigger ())
        button.add_Pressed handler
        let sub = IDisposable.create ^ fun () -> button.remove_Pressed handler
        {|
            Sub = sub
            Event = event.Publish
        |}

И потом размножить эту схему на остальные сигналы. В них будут отличаться три позиции:

  1. add_<SignalName> — добавление подписки.

  2. remove_<SignalName> — удаление подписки.

  3. let handler = <DelegateName>(fun <all parameters> -> event.Trigger <all parameters as object>) — «конструктор» делегата.

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

Сложность handler заключается в чрезвычайном многообразии фабрик. Если для unit IEvent хватило «стандартного» System.Action, то для всех остальных сигналов были определены свои делегаты с именованными параметрами. Например, для ButtonBase.Toggled нужно обернуть bool -> unit в вот такую конструкцию:

let handler = ButtonBase.ToggledEventHandler(fun toggledOn -> event.Trigger toggledOn)

Если параметров несколько, как в HttpRequest.RequestCompleted, их придётся упаковать в один объект:

let handler = HttpRequest.RequestCompletedEventHandler(fun result code headers body ->
    event.Trigger {|
        Result = result
        StatusCode = code
        Headers = headers
        Body = body
    |}
)

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

Memento mori

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

В System.Reactive есть тип — System.Reactive.Disposables.CompositeDisposable. Представьте себе IDisposable ResizeArray, который сам по себе поддерживает интерфейс IDisposable. Если эту коллекцию убить, то она пройдётся по своему содержимому и убьёт каждого пассажира, после чего самоочистится и будет заворачивать и убивать каждого входящего. Выйти из этой коллекции (и живой, и мёртвой) можно только вперёд ногами, поэтому Remove элемента также вызывает его смерть.

Сразу оговорюсь, что в своих проектах я использую самолепный тип, несколько лучше отвечающий нуждам Hopac и MVU. CompositeDisposable был взят только для Хабра (и быть может для itch), но задачу хранения IDisposable оба типа решают почти одинаково. К сожалению, этой разницы оказалось достаточно, чтобы CompositeDisposable периодически генерировал исключения. Из этого следует, что я не умею его готовить, но так как исключения происходят после смерти подопытных объектов, я их просто игнорирую, приблизительно так же, как жалобы убитых мною Akka-акторов.

Идея в том, что мы должны объединять подписки в группы по моменту их смерти в момент их рождения. То есть, когда мы создаём юнит, мы складываем все его подписки в выделенный для него CompositeDisposable. И когда юнит умрёт (по правилам нашей игры), мы убьём его CompositeDisposable, а вместе с ним — и все его подписки.

module IDisposable =
    ...

    // В `FSharp.Control.Reactive` есть **свойство** `Disposable.Composite`, но меня оно сбивает с толку.
    let composite () = new System.Reactive.Disposables.CompositeDisposable()

module DramaticOrcVeteran =
    let create () =
        let disposables = IDisposable.composite ()
        let host = FG.Node2D()
        do disposables.Add ^ IDisposable.create host.QueueFree

        let sprite = FG.Sprite2D(Parent = host, ...)
        let aliveProcess = ...
        let button = FG.Button(
            Text = "Die like a drama queen"
            , Parent = host
            , ...
        )

        // Анимированная смерть.
        let dieDramatically () =
            aliveProcesses.QueueFree()
            let mutable progress = 0f
            host.AddChild ^ GD.Implements._process ^ fun _ delta ->
                progress <- progress + 10f * float32 delta
                if progress > float32 frames.Death.Count + 3f then
                    // Реальная смерть всей ноды.
                    disposables.Dispose ()
                else
                    sprite.Frame <- frames.Death.OneWayf progress

        do  let buttonPressed = button.Pressed'ToEvent()
            disposables.Add buttonPressed.Sub
            disposables.Add ^ buttonPressed.Event.subscribe dieDramatically

        {|
            View = host
            Disposables = disposables
        |}

Такие группы могут образовывать иерархии, точно так же как акторы или ноды. В примитивном варианте:

fraction.Disposables.Add dramaticOrcVeteran.Disposables

В развитом:

type CompositeDisposable with
    member this.CreateChild () =
        let child = IDisposable.composite ()
        this.Add child
        child.Add ^ IDisposable.create ^ fun () -> this.Remove child
        child

let mob =
    fraction.Disposables.CreateChild()
    |> DramaticOrcVeteran.create

В последнем случае мы вводим строгое требование — «Каждое зачатие должно предваряться покупкой места на кладбище». Когда-то здесь использовалась аналогия с утиль-сбором, но мне объяснили, что в текущих условиях это работает как антиреклама.

Оно упростит конвертацию сигналов:

type ButtonBase with
    member this.Pressed'AsEvent (disposables : CompositeDisposable) =
        let event = Event()
        let handler = System.Action(fun () -> event.Trigger ())
        button.add_Pressed handler
        disposables.Add ^ IDisposable.create ^ fun () -> button.remove_Pressed handler
        event.Publish

Но может прибить нас гвоздями к Rx. Если надо, чтобы DSL существовал без привязки к пакету, можно остановиться на промежуточном варианте:

type ButtonBase with
    member this.Pressed'AsEvent consumeSub =
        let event = Event()
        let handler = System.Action(fun () -> event.Trigger ())
        button.add_Pressed handler
        consumeSub ^ IDisposable.create ^ fun () -> button.remove_Pressed handler
        event.Publish

let pressed = button.Pressed'AsEvent disposables.Add

Подписка через сеттер

Pressed'AsEvent если и конвертируется в свойство, то только в ветку геттера, что не очень удобно при однопроходном описании ноды. В идеале нам нужно передать некоторый объект в кнопку и после этого к ней не возвращаться. Можно начать с выноса event:

type ButtonBase with
    member this.Pressed'PushTo consumeSub event =
        let handler = System.Action(fun () -> (event : _ Event).Trigger ())
        button.add_Pressed handler
        consumeSub ^ IDisposable.create ^ fun () -> button.remove_Pressed handler

let pressed = Event()

button.Pressed'PushTo disposables.Add pressed

pressed.subscribe ^ fun () ->
    GD.printfn "Button pressed!"

А затем продолжить заменой event на event.Trigger:

member this.Pressed'PushTo consumeSub trigger =
    let handler = System.Action(fun () -> trigger ())
    ...

button.Pressed'PushTo disposables.Add pressed.Trigger

Осталось упаковать входные параметры в один объект. И при этом не забыть о том, что сеттер в момент инициализации ноды не имеет доступа к своему владельцу, так что его надо затребовать внутри триггера:

module SignalHandler =
    type Main<'node, 'message> = {
        Trigger : 'node -> 'message -> unit
        ConsumeSub : System.IDisposable -> unit
    }

type ButtonBase with
    member this.Pressed'Handler
        with set (value : SignalHandler.Main<_,_>) =
            let handler = System.Action(fun () -> value.Trigger this ())
            button.add_Pressed handler
            value.ConsumeSub ^ IDisposable.create ^ fun () -> button.remove_Pressed handler

let mutable count = 0

FG.Button(
    Text = "Press"
    , Pressed'Handler = {
        Trigger = fun button () ->
            count <- count + 1
            button.Text <- $"Pressed %i{count} times"
        ConsumeSub = disposables.Add
    }
)

Здесь «внезапно» потерялся let pressed = ..., но конкретно в этой ситуации он нам не нужен, поэтому обошлись без него. Когда придёт время для событий или их аналогов, мы ими воспользуемся, но на API это никак не скажется.

Последний штрих:

type CompositeDisposable with
    member this.HandleSignal trigger =
        SignalHandler.create trigger this.Add

let mutable count = 0

FG.Button(
    Text = "Press"
    , Pressed'Handler = disposables.HandleSignal ^ fun button () ->
        count <- count + 1
        button.Text <- $"Pressed %i{count} times"
)

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

Мы соорудили DSL для override и сигналов. В целом они похожи, но между ними есть важные отличия, которые при этом никак нельзя назвать непреодолимыми. С имеющимся запасом знаний мы можем их миксовать. Например:

  • Представить _Process через SignalHandler;

  • Скормить сигналу целую коллекцию SignalHandler-ов;

  • Вынести обработку сигналов во fluent, чтобы заполучить конкретный тип владельца;

  • Нарастить SignalHandler до состояния, когда он осознает самого себя, чтобы он смог ставить себя на паузу и т. д.;

  • Представить привязку хендлера в виде отдельной ноды;

  • Адаптировать нодные представления к размещению в CompositeDisposable;

  • И т. п.

Пространство возможного здесь просто огромно, но оно не может нас напугать, так как мы достаточно подготовлены и должным образом вооружены.

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


В следующей главе мы ещё чуть-чуть поковыряемся в Rx, и выясним, зачем нам нужен Callable. После чего зададимся вопросом: «А что со всем этим многообразием возможностей делать?». Если повезёт, всё уместится в одну заключительную главу.


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

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