
Пару месяцев назад я закончил здоровенный цикл по неочевидным особенностям 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.ReactivedeltaSource.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 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 = { 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.SetFlipHtype 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 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 localvisibilityChange.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 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 evtype 'a System.IObservable with // Прячет исходный объект за единственным интерфейсом. // По аналогии с `paranoid` функциями в `Hopac`. // Имя типа включаем в название, // чтобы не было разночтений в наследниках. member this.ParanoidObservable () = { new System.IObservable 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 with override this.Subscribe observer = output.Subscribe observertype 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.
Положение об акции
Источник: habr.com