The Jerusalem PostReliquary housing bone fragments of Saint Nicholas stolen from French basilica, destroyedPunchAwujale palace not under siege — PoliceInquirer110 endangered hawksbill turtle hatchlings released in RomblonBollywood HungamaRanveer Singh’s grandfather Dr Ram Hingorani passes away at 96, cousin Saumya shares emotional tributeInquirer EntertainmentSB19 joins 2027 Coachella lineupZDF heuteEntdecken Sie das ZDF-NachrichtenstudioCNN TürkFenerbahçe'de Kuyt ile yollar ayrıldı!Business AMWereldwijde CO₂-uitstoot stijgt ondanks lager verbruik van fossiele brandstoffenIl Fatto QuotidianoHelsinki e Tallinn a Natale 2026: cosa vedere in due capitali del Nord Europa collegate da due ore di traghetto, tra mercatini, saune storiche, design e vicoli medievaliSudinfoUn Belge sur trois serait prêt à sacrifier sa valise en vacances pour cette raisonObservador DesportoMais uma tempestade, a mesma pergunta: o tempo mudou mesmo?BBC عربيالقضاء الفيدرالي يسمح بالمضي قدماً في دعوى التشهير التي رفعها ترامب ضد "بي بي سي" بقيمة 10 مليارات دولار
The Daily Newsstand · Free, Always
Wednesday, November 11, 2026

Человек-ракета. Реактивный базис на F# и Godot. Ступень 1. По стопам прогресса

Translate

Пару месяцев назад я закончил здоровенный цикл по неочевидным особенностям F# в декорациях Godot. Под конец я пообещал выпустить ещё несколько статей по реактивному программированию на стыке тех же технологий. Момент исполнения обещания наступил с опозданием. Нас ждут три статьи, в ходе которых я познакомлю вас со своей рабочей лошадкой. Ей суждено появиться во всех моих статьях по Godot.

Именно поэтому я использовал в заглавии слово «базис», однако до этого там стояло слово «эксперименты». Оно появилось там, отнюдь не потому что я не уверен в собственных действиях, а потому что хотел подчеркнуть своеволие и своеобразие последующих шагов. Мне не нравится доминирующая парадигма, зафиксированная в виде библиотеки System.Reactive, и в этом цикле я буду регулярно явно или неявно оспаривать некоторые её положения. При этом у меня нет стремления воздвигнуть собственную парадигму, и уж тем более я не собираюсь публиковать её на nuget.org. Наоборот, я хочу показать, что реактивные системы лучше всего строить на базе «колоды» примитивов, которая подбирается и пишется специально под проект. Создание универсальной библиотеки при таком подходе полностью противопоказано.

Правда, неуниверсальная библиотека нам тоже не понадобится. В конце весь код займёт всего несколько сотен строк. В основном благодаря особенностям F#, о которых мы либо говорили, либо только будем говорить. Но нам также поможет достаточно высокая база, которую дают dotnet и System.Reactive.

Зачем при таких вводных понадобилось целых три статьи? Затем, что этим кодом надо ещё уметь пользоваться, а самое главное, уметь корректировать под конкретные задачи. Иначе получится ещё один System.Reactive с чуть другими базовыми установками, которые могут опять оказаться не к месту. Для этого, как мне кажется, стоит ознакомиться с цепочкой рассуждений, которая когда-то привела меня к такому (или чуть иному) результату.

Godot в этом сценарии сыграет роль чрезвычайно удачного полигона и поставщика иллюстраций. Обработка _Process даст нам «события», а 2D-графика и система контролов удобно визуализируют отклонения от ожиданий. Однако это не означает, что реактивное программирование нельзя использовать в играх. В предпочитаемых мною жанрах оно показывает себя очень хорошо.

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

Устройство проекта

Почти все примеры из этой главы можно будет найти в следующем проекте. Он был написан специально под статью. В нём содержится 9 примеров (+1 интро) — в том же порядке, что и в тексте. Причём «неправильные» примеры тоже попали в проект, чтобы их можно было потыкать без дополнительных приседаний. Экзешник под винду я собрал и опубликовал архивом здесь.

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

Проект написан по тем же лекалам, что и предыдущие, поэтому .tscn-сцена в нём только одна (не считая EntryPoint). В зависимости от её «аргументации» она может запускаться с разным наполнением. Перечень валидных аргументов можно восстановить из EntryPoint.fs или из PrepareLaunchSettings.fsx (который, кстати, надо запустить перед работой), но, скорее всего, вам будет достаточно профиля запуска Sample 1-All. Этот же профиль «выбран» в качестве профиля по умолчанию при запуске из Godot Editor и готового .exe.

Sample 1-All запустит сцену из Chapter1/AllSamples.fs, в которой можно пощупать все имеющиеся примеры:

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

Привязки

1. Управляем через subscribe

В прошлых статьях мы завели две абстракции:

  • Ноды Implements для ограниченной симуляции override родительских нод;

  • SignalHandler для подписки на сигналы.

С их помощью мы можем регулярно забирать данные из ноды и запихивать их в модель:

let deltaSource = Event<_>()
            
let sprite = FG.Sprite2D(
    Parent = center
    , Spritesheet = MiniFolks.Rock.Mason.Styles.Outlined
    , Scale = Vector2.One * 10f
    , Children'AddRange = [
        GD.Implements._process ^ fun _ delta ->
            deltaSource.Trigger ^ float32 delta
    ]
    , TextureFilter = CanvasItem.TextureFilterEnum.Nearest
)

После чего эти данные можно обработать и отослать в обратном направлении:

open System.Reactive

deltaSource.Publish
// Эта штука просуммирует `delta` и текущий `state`
|> Observable.scanInit 0f (+)
|> Observable.subscribe ^ fun progress ->
    sprite.Frame <- MiniFolks.Rock.Mason.Lines.Walk.Circledf (10f * progress)
    let distanceToB = track.DistanceToB progress
    sprite.Position <- track.PositionOf distanceToB
    sprite.FlipH <- distanceToB < 0f
|> disposables.Add
MiniFolks.Rock

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

let MiniFolks =
    let create name nickname plain outlined = 
        let inline (!) uid = {
            Texture = GD.load uid
            Vframes = 3
            Hframes = 6
        }
        {|
            Name = name
            Nickname = nickname
            Styles = {|
                Plain = !plain
                Outlined = !outlined
            |}
        |}
    let lines = 
        let mutable nextRow = 0
        let (!) count =
            let row = nextRow
            nextRow <- nextRow + 1
            SpritesheetLine.create 6 row count
        {|
            Idle = !4
            Walk = !6
            Death = !5
        |}
    {|
        Rock = {|
            Goodspeed = {|
                create "Гудспид" "Лабораторная крыса" "uid://d4afjvspxy5de" "uid://1xaftj1ltjqy" with
                    // Обычно юниты в MiniFolks обладают разным набором анимаций.
                    // То есть проблема не только в различном числе кадров в анимации,
                    // но и в том, что некоторые анимации могут вообще отсутствовать.
                    // По этой причине я могу использовать анонимные рекорды.
                    Lines = lines
            |}
            Mason = {|
                create "Мэйсон" "Шестидесятилетний заключённый" "uid://dy1hpkquldid" "uid://brg3xcgglh8td" with
                    Lines = lines
            |}
        |}
    |}

В нём ничего магического нет. Успокойтесь и двигайтесь дальше.

2. Управляем через 'Bind

Очевидно, загрузка данных в ноду тоже поддаётся DSL-зации. По аналогии с SignalHandler мы можем определить тип Binding:

module Binding =
    type Main<'value> = {
        ConsumeSetter : ('value -> unit) -> unit
    }

Прикрутить его к CompositeDisposable:

type CompositeDisposable with
    member this.Subscribe consume source =
        source
        |> Observable.subscribe consume
        |> this.Add

    member this.Bind source =
        Binding.create ^ fun consume ->
            source
            // Игнорируем повторения.
            |> Observable.distinctUntilChanged
            |> this.Subscribe consume

    member this.BindMap source mapping =
        source
        |> Observable.map mapping
        |> this.Bind

Напичкать ноды соответствующими хелперами:

type Sprite2D with
    member this.Frame'Bind
        with set (binding : _ Binding.Main) =
            binding.ConsumeSetter this.SetFrame

    member this.FlipH'Bind
        with set (binding : _ Binding.Main) =
            binding.ConsumeSetter this.SetFlipH

type Node2D with
    member this.Position'Bind
        with set (binding : _ Binding.Main) =
            binding.ConsumeSetter this.SetPosition

И прийти к чему-то такому:

do  let rec go () =
        spawnUnit MiniFolks.Rock.Goodspeed 0f
        spawnUnit MiniFolks.Rock.Mason 2f
    and spawnUnit unit initialProgress =
        let disposables = disposables.CreateChild()
        let deltaSource = Event<_>()
        let progress = Observable.scanInit initialProgress (+) deltaSource.Publish
        let distanceToB =
            progress
            |> Observable.map patrolTrack.DistanceToB
        center.AddChild ^ FG.Sprite2D(
            Disposables = disposables
            , Position'Bind = disposables.BindMap distanceToB patrolTrack.PositionOf
            , Spritesheet = unit.Styles.Outlined
            , TextureFilter = CanvasItem.TextureFilterEnum.Nearest
            , Scale = Vector2.One * scale
            , Children'AddRange = [
                GD.Implements._process ^ fun this delta ->
                    deltaSource.Trigger ^ float32 delta
            ]
            , Frame'Bind = disposables.BindMap progress ^ fun p ->
                unit.Lines.Walk.Circledf (10f * p)
            , FlipH'Bind = disposables.BindMap distanceToB ^ fun p ->
                p < 0f
        )
    go ()

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

Отмечу, что написание DSL для Godot благополучно завершается на этом параграфе. В рамках этого цикла никаких значимых новых фич с этого конца пайпа я добавлять не буду. Разумеется, иногда придётся дописывать новые PropertyName'Bind, но, ввиду очевидности паттерна, я даже не буду об этом упоминать. Всё последующее повествование будет крутиться вокруг манипулирования реактивными данными.

Работа на дистанции

3. Проецируем вовне

Полученный граф можно отдельно собрать в объект-модель и сохранить на будущее:

let goodspeed, mason =
    let rec go () =
        createUnit MiniFolks.Rock.Goodspeed 0f
        , createUnit MiniFolks.Rock.Mason 2f
    and createUnit props initialProgress =
        let disposables = disposables.CreateChild()
        let deltaSource = Event<_>()
        let model = 
            let progress = Observable.scanInit initialProgress (+) deltaSource.Publish
            let distanceToB =
                progress
                |> Observable.map patrolTrack.DistanceToB
            {|
                Progress = progress
                DistanceToB = distanceToB
                Direction = 
                    distanceToB
                    |> Observable.map ^ fun distance -> if distance > 0f then A2B else B2A
                Frame =
                    progress
                    |> Observable.map ^ fun p -> props.Lines.Walk.Circledf (10f * p)
                Position =
                    distanceToB
                    |> Observable.map patrolTrack.PositionOf
            |}
        let view =
            FG.Node2D(
                Disposables = disposables
                , Parent = center
                , Position'Bind = disposables.Bind model.Position
            ).Add'Children [
                asNode ^ GD.Implements._process ^ fun _ delta ->
                    deltaSource.Trigger ^ float32 delta

                // `Sprite2D` кладётся в `Node2D`, чтобы его Scale не влиял на других детей.
                FG.Sprite2D(
                    Name = "Sprite"
                    , Spritesheet = props.Styles.Outlined
                    , TextureFilter = CanvasItem.TextureFilterEnum.Nearest
                    , Scale = Vector2.One * scale
                    , Frame'Bind = disposables.Bind model.Frame
                    , FlipH'Bind = disposables.BindMap model.Direction ^ fun p -> p.IsB2A
                )
            ]
        {|
            Disposables = disposables
            Props = props
            Model = model
            View = view
        |}
    go ()

После этого к модели можно будет подключиться снаружи:

for unit in [mason;goodspeed] do
    UnitLabel.text unit.Props.Name
    |> unit.View.AddChild
                
    let stylebox = PatrolTrack.stylebox SceneColors.a

    stylebox.BgColor'Bind <- disposables.BindMap unit.Model.Direction ^ function
        | A2B -> SceneColors.b
        | B2A -> SceneColors.a

    FG.Label(
        HorizontalAlignment = HorizontalAlignment.Center
        , Text'Bind = disposables.BindMap unit.Model.Direction ^ fun p ->
            match p with
                | A2B -> "B"
                | B2A -> "A"
            |> sprintf "%s идёт к точке %s" unit.Props.Name
    )
    |> Margin(10, 4).Wrap
    |> FG.PanelContainer(Panel = stylebox).Add'Child
    |> asNode

Подписка происходит фактически синхронно с созданием модели и мало чем отличается от подписки в unit.View. Поэтому наворачиваться здесь нечему.

4. Проецируем вовне по требованию

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

Прежде всего нам нужно реализовать механизм зачистки и пересоздания. Он крутится вокруг локального CompositeDisposable, который респавнится каждый раз, когда происходит смена состояния CheckBox:

let visibilityChange = Event<_>()

asNode ^ FG.CheckButton(
    Text = "Показывать сводку"
    , Pressed'Handler = disposables.HandleSignal ^ fun btn () ->
        visibilityChange.Trigger btn.ButtonPressed
    , FocusMode = Control.FocusModeEnum.None
)
            
FG.ColorRect(
    CustomMinimumSize = Vector2.Down * 4f
    , Color = Colors.White.With(A = 0.2f)
)

// Выступает в роли `ContentControl`.
let slot = Margin(0).AsContainer()
            
slot
            
let spawnStub disposables = slot.AddChild ^ FG.Label(
    Text = "Сводка скрыта"
    , HorizontalAlignment = HorizontalAlignment.Center
    , Disposables = disposables
)
let mutable local = disposables.CreateChild()
// Первичное заполнение.
spawnStub local
visibilityChange.Publish
|> disposables.Subscribe ^ fun isVisible ->
    local.Dispose()
    local <- disposables.CreateChild()
    if not isVisible then spawnStub local else
    // Перекрываем родительский `disposables`,
    // чтобы исключить вероятность ложной подписки, выходящей за границы посылки.
    let disposables = local
    [                
        for unit in [mason;goodspeed] do
            let stylebox = PatrolTrack.stylebox SceneColors.a
                        
            stylebox.BgColor'Bind <- disposables.BindMap unit.Model.Direction ^ function
                | A2B -> SceneColors.b
                | B2A -> SceneColors.a

            FG.Label(
                HorizontalAlignment = HorizontalAlignment.Center
                , Text'Bind = disposables.BindMap unit.Model.Direction ^ fun p ->
                    match p with
                        | A2B -> "B"
                        | B2A -> "A"
                    |> sprintf "%s идёт к точке %s" unit.Props.Name
            )
            |> Margin(10, 4).Wrap
            |> FG.PanelContainer(Panel = stylebox).Add'Child
            |> asNode
    ]
    |> FG.VBoxContainer(
        Disposables = disposables
    ).Add'Children
    |> slot.AddChild 

Когда мы получаем новый isVisible, старый local убивается. Вместе с ним автоматом отключаются все локальные привязки (disposables.Bind) и высвобождаются локальные ноды (Disposables = disposables). Схема с mutable local воспроизводится регулярно и позже будет обобщена до примитива.

Результат выглядит так:

Как видно, периодически показания проекции не соответствуют фактическому состоянию юнита. С точки зрения пользователя, это очевидный баг, но с точки зрения System.Reactive всё работает ровно так, как мы написали. Так что проблема в наших предустановках и ожиданиях, которые очень сильно зависят от индивидуального опыта разработчика. Например, я по умолчанию мыслю категориями, близкими к 'a Hopac.Stream, но заметная часть разработчиков вооружится аналогией с 'a seq, поэтому следующие параграфы могут восприниматься читателями совершенно по-разному.

5. Изучаем проекцию детально

Здесь напрашивается упражнение вида:

Посмотрите на .gif, сделайте предположение о том, почему система ведёт себя именно так, а не иначе.

Однако практическое его исполнение мне видится очень маловероятным, разве что кто-то держит в руках запущенный .exe. Поэтому выведем всю имеющуюся инфу туда же в сводку:

Думается, что визуализация цепочки реактивного пайпа любую загадку превращает в обыденный экземпляр чьей-то тупизны (как правило, моей). При таком ракурсе видно, что новая проекция каждый раз почему-то получает «чистый» Progress вместо того, что используется в оригинальном спрайте. В остальном сводка абсолютно корректна.

Основная проблема кроется в следующей строчке:

let progress = Observable.scanInit initialProgress (+) deltaSource.Publish

Будучи заядлым Hopac-оводом, я мог бы ждать от неё поведения аналогичного Hopac.Stream.scanFromFun. То есть progress при появлении подписчика присосётся к deltaSource один раз, после чего раздаст своё значение всем последующим потребителям:

let progress =
    Stream.ofObservable deltaSource.Publish
    |> Stream.scanFromFun initialProgress (+)

Это поведение вытекает естественным образом из того, что 'a Hopac.Stream построен на базе 'a Hopac.Promise, который чисто физически не может не кешировать (мемоизировать) свои данные. Иногда такой сценарий признаётся нежелательным, и мы прибегаем к различным вариантам фабрик. Но большую часть времени мемоизация в стримах — это краеугольный камень наших систем. Мне до сих пор физически больно думать в иной парадигме (страдаю только ради Хабра). Интерфейс 'a System.IObservable напрочь лишён таких обязательств, поэтому Observable.scanInit чисто технически может как включать кеширование, так и обходиться без него. Однако основное правило System.Reactive предполагает, что никто ничего не кеширует, если не указано обратное.

6. Кешируем прогресс

System.Reactive умеет кешировать Observable, но делает это не совсем так, как нам потребуется в дальнейшем, поэтому Observable.connect (и проч.) мы вообще трогать не будем и соберём фичу руками. Основная идея заключается во вставке промежуточного самостоятельного узла. Его необходимо триггерить заранее и независимо от действий других игроков, тогда каждый активный подписчик получит одно и то же сообщение:

type CompositeDisposable with
    member this.CacheObservable observable =
        let output = Event<_>()
        observable
        |> this.Subscribe output.Trigger
        output.Publish

Во внутреннем устройстве 'a Event всё ещё есть загадочные для меня моменты, хотя код его исходников доступен для изучения. В частности, меня раздражает некоторая недосказанность в области взаимодействия со сборщиком мусора, поэтому на «промышленном» уровне я привык использовать более предсказуемые объекты. Например, тип 'a Subject, который выступает аналогом 'a Event для мира C#-еров. В отличие от Event, я хорошо понимаю, как воспроизвести Subject, и более того, как собрать его аналог, кастомизированный под конкретную задачу.

Обычно объяснение имени Subject мне видится странным, но конкретно здесь оно смотрится к месту. Мы действительно создаём промежуточный субъект, который отвечает за сохранность прогресса.

Обмазавшись хелперами:

type 'a System.IObserver with
    member this.Trigger ev =
        this.OnNext ev

type 'a System.IObservable with
    // Прячет исходный объект за единственным интерфейсом.
    // По аналогии с `paranoid` функциями в `Hopac`.
    // Имя типа включаем в название, 
    // чтобы не было разночтений в наследниках.
    member this.ParanoidObservable () = {
        new System.IObservable<'a> with
            override _.Subscribe p = this.Subscribe p
    }

type 'a System.Reactive.Subjects.Subject with
    member this.Publish = this.ParanoidObservable ()
            
type CompositeDisposable with
    member this.Register p =
        this.Add p
        p

Мы можем получить почти идентичный код, но на базе Subject:

type CompositeDisposable with
    member this.CacheObservable observable =
        let output = this.Register Subject.broadcast
        observable
        |> this.Subscribe output.Trigger
        output.Publish

После чего заменить определение progress:

let progress = 
    Observable.scanInit initialProgress (+) deltaSource.Publish
    |> disposables.CacheObservable

И этого будет достаточно, чтобы сводка снова заработала как надо:

Минимизация шума

7. Кешируем всю модель

После кеширования progress сводка перестала отставать от оригинала, но мы обнаружили, что все наши Observable нещадно и бессмысленно дублируют свои вычисления. В частности, из-за «ленивости» distanceToB метод patrolTrack.DistanceToB вызывается с дюжину раз на одной и той же посылке progress:

let distanceToB =
    progress
    |> Observable.map patrolTrack.DistanceToB

Мы можем закешировать все «свойства» модели и получить работающую сводку:

let model = 
    let progress = 
        Observable.scanInit initialProgress (+) deltaSource.Publish
        |> disposables.CacheObservable
    let distanceToB =
        progress
        |> Observable.map patrolTrack.DistanceToB
        |> disposables.CacheObservable
    {|
        Progress = progress
        DistanceToB = distanceToB
        Direction = 
            distanceToB
            |> Observable.map ^ fun distance -> if distance > 0f then A2B else B2A
            |> disposables.CacheObservable
        Frame =
            progress
            |> Observable.map ^ fun p -> props.Lines.Walk.Circledf (10f * p)
            |> disposables.CacheObservable
        Position =
            distanceToB
            |> Observable.map patrolTrack.PositionOf
            |> disposables.CacheObservable
    |}

8. Оптимизируем наивно

Дальше мы можем вспомнить или узнать о такой штуке, как Observable.distinctUntilChanged. Эта функция блокирует пересылку нового сообщения, если оно не отличается от предыдущего. Неповторяющийся progress и его вещественные производные от этой оптимизации ничего не выиграют, но для дискретных значений, типа индекса фрейма или направления движения (type Direction = A2B | B2A), она сокращает число телодвижений на порядки (X6 для фрейма, X180 для направления). В первом приближении мы можем дополнить модель так:

let model = 
    let progress = 
        Observable.scanInit initialProgress (+) deltaSource.Publish
        |> disposables.CacheObservable
    let distanceToB =
        progress
        |> Observable.map patrolTrack.DistanceToB
        |> disposables.CacheObservable
    {|
        Progress = progress
        DistanceToB = distanceToB
        Direction = 
            distanceToB
            |> Observable.map ^ fun distance -> if distance > 0f then A2B else B2A
            |> Observable.distinctUntilChanged
            |> disposables.CacheObservable
        Frame =
            progress
            |> Observable.map ^ fun p -> props.Lines.Walk.Circledf (10f * p)
            |> Observable.distinctUntilChanged
            |> disposables.CacheObservable
        Position =
            distanceToB
            |> Observable.map patrolTrack.PositionOf
            |> disposables.CacheObservable
    |}

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

Опоздание вызвано одновременным воздействием CacheObservable и distinctUntilChanged. Последний перестал пропускать дубли, из-за чего «поле» Direction стало меняться раз в несколько секунд. Параллельно с этим из-за CacheObservable источник данных обрёл субъектность и начал вычисляться независимо от (а, значит, и «до») своего подписчика. Если подписчик появлялся сразу после разворота, сводка могла сидеть без актуальных данных до тех пор, пока юнит не повернётся второй раз.

9. Оптимизируем правильно

Очевидно, что нам надо как-то загрузить последнее значение в свежего подписчика. В System.Reactive есть тип, который делает именно это. Называется он 'a SubjectBehavior. По сути, это обычный 'a Subject, к которому прикрутили слот с «текущим значением» (Value). Оно меняется при триггере и передаётся в качестве первой посылки каждому новому подписчику. Этого кода хватит, чтобы исправить ситуацию:

type CompositeDisposable with
    member this.CachePrevious initial observable =
        let output =
            Subject.behavior initial
            |> this.Register
        observable
        |> this.Subscribe output.Trigger
        output.Publish

Однако в таком виде мы теряем доступ к Value, хотя оно может нам понадобиться за пределами реактивного графа, например, при быстром обращении из диалогового окна и т. д. Здесь напрашивается некий ReadonlySubjectBehavior, но из коробки его нам не выдали. Заменитель можно набросать руками:

module Observable =
    type 'a CachePrevious (initial, input) =
        let output = Subject.behavior initial
        let sub = Observable.subscribe output.Trigger input

        member this.Previous = output.Value

        interface System.IDisposable with
            override this.Dispose () =
                output.Dispose()
                sub.Dispose()

        interface System.IObservable<'a> with
            override this.Subscribe observer =
                output.Subscribe observer

type CompositeDisposable with
    member this.CachePrevious initial observable =
        new Observable.CachePrevious<_>(initial, observable)
        |> this.Register

    member this.MapCachePrevious mapping (input : _ Observable.CachePrevious) =
        new Observable.CachePrevious<_>(mapping input.Previous, Observable.map mapping input)
        |> this.Register

Обновлённая модель:

let model = 
    let progress = 
        Observable.scanInit initialProgress (+) deltaSource.Publish
        |> disposables.CachePrevious 0f
    let distanceToB =
        progress
        |> disposables.MapCachePrevious patrolTrack.DistanceToB
    {|
        Progress = progress
        DistanceToB = distanceToB
        Direction = 
            distanceToB
            |> Observable.map ^ fun distance -> if distance > 0f then A2B else B2A
            |> Observable.distinctUntilChanged
            |> disposables.CachePrevious A2B
        Frame =
            progress
            |> Observable.map ^ fun p -> props.Lines.Walk.Circledf (10f * p)
            |> Observable.distinctUntilChanged
            |> disposables.CachePrevious ^ props.Lines.Walk.Circledf 0f
        Position =
            distanceToB
            |> disposables.MapCachePrevious patrolTrack.PositionOf
    |}

Так мы получим рабочую сводку:

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

unit.Model.Direction.Previous
|> printfn "%A"

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

Повествование обрывается на моменте, в котором очень сложно подводить какие-либо итоги. Мы прошли путь от «просто событий» до «почти свойств». Направление нашего движения и его характер не изменятся. Мы продолжим дрейфовать в сторону реактивных свойств на примере нескольких сценариев, к которым текущая модель окажется не готовой.

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

Не все реактивные системы должны быть устроены одинаково. Пренебрегая какими-то рисками или, наоборот, сфокусировавшись на каких-то конкретных угрозах или преимуществах, мы можем порождать системы, существенно отличающиеся друг от друга в ту или иную сторону. И, судя по F# Weekly, этот процесс обещает быть бесконечным.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.