Как стать автором
Обновить
201.82
FirstVDS
Виртуальные серверы в ДЦ в Москве и Амстердаме

Шестидесятилетний заключённый и лабораторная крыса. F# на Godot. Часть 4. Дефолты, option и дженерики

Уровень сложностиСредний
Время на прочтение16 мин
Количество просмотров769

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

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

Nullable reference types

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

Так получилось, что ни в рабочих проектах, ни в петах новые фичи не взлетели. В моей команде мы пишем так, как писали до этого, и в ближайшие месяцы это положение меняться не будет. Я считаю такое поведение оптимальным для новоприбывших. Поэтому в статье я поступлю так же, то есть не буду давать оценку нововведениям и просто их проигнорирую. Будем считать, что, если F# 9.0 и выходил, то без этой фичи. Разумеется, в течение ближайших лет (и версий языка) пара пунктов в статье подвергнется ревизии (или нет), но до этого момента ещё надо дожить.

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

Значение по умолчанию

F# особым образом смотрит на значение «по умолчанию» (так называемый Default). Если в C# оно всегда есть у любого типа или переменной, то наша презумпция диаметрально противоположна — значение «по умолчанию» не существует, пока его не обозначили явным образом. Этот терминологический казус отдаёт идиотией, поэтому его не особо выставляют на свет, но я считаю его очень забавным и поучительным, так как в таком виде он наглядно показывает тот круг, который описала человеческая мысль в поисках лучшего решения. Мы не будем повторять этот путь, а вместо этого сосредоточимся лишь на его практических следствиях.

F# заставляет указывать значения переменных, полей и свойств в момент их определения. Обычно это требование легко выполнить, просто расположив декларацию в нужном месте, и к этому сценарию следует стремиться при прочих равных. Если по каким-то причинам совместить определение и инициализацию нельзя, то мы можем явно обозначить неинициализированное состояние. Чаще всего оно выражается через None (реже ValueNone), но можно взять Result.Error или создать собственный тип. Это потребует замены типа слота (с 'a на 'a option), что заставит всех последующих игроков явно обработать отсутствующее значение. Это безопасно, иногда даже чересчур, но принципиально невозможно, если поведение этих игроков было определено задолго до нашей встречи, как в случае с редактором Godot и нашими нодами.

В такой ситуации, чтобы компилятор от нас отстал, мы таки можем заполнить проблемную ячейку «фейковым» значением. Это нетрудно, если речь идёт о конкретном типе с тривиальным дефолтом вида 0, null или [], но если мы работаем с обобщением, то создание фейка обрастает сложностями. Разобраться с ними можно при помощи свойства Unchecked.defaultof<'a> : 'a (в модулях можно определять генерализованные привязки, в F# они будут вести себя как свойства, а в C# как методы, см. List.empty).

Unchecked.defaultof — это машиноориентированный хелпер, вызов которого эквивалентен default(T) в C#. Он дёрнет дефолтный конструктор для структур или просто вернёт null для ссылочных типов, что может показаться наиболее универсальным решением, перекрывающим альтернативы выше. Формально это так, но по факту эта универсальность идёт с пометкой unsafe, так что эту штуку следует использовать только под давлением обстоятельств, типа интеропа или сверхоптимизаций.

Опасность в том, что F# не признаёт существование null-ов для своих ссылочных типов, если они не помечены [<AllowNullLiteral>]. Более того, язык не даёт вешать этот атрибут на алгебраические типы (рекорды и DU). dotnet об этом ничего не знает, как следствие, defaultof может вернуть null там, где он синтаксически не поддерживается. То есть мы получим переменную, в которой находится null, но мы не можем её ни проматчить, ни сравнить (= null), ни проверить (isNull), потому что компилятор считает это действие абсолютно бессмысленным.

Обычно подобная опека со стороны компилятора удобна. Он просит нас не беспокоиться, и мы не беспокоимся. Но когда причины для тревоги есть, а способов передать их компилятору нет, защиту от null приходится организовывать собственными силами.

Мы можем:

  • Сравнить актуальный объект с другим дефолтом (defualtOf<'a> = item).

  • Использовать System.Object.ReferenceEquals(null, item).

  • Скастовать объект к null-типу и затем проверить (isNull ^ box item).

  • Повторно заанбоксить тип через tryUnbox<'a> item, чтобы получить None вместо null.

Все перечисленные варианты многословны и неустойчивы к рефакторингу, поэтому их прячут в функции-хелперы вида:

isWildNull : 'a -> bool
Option.ofWild : 'a -> 'a option

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

option и DU полностью перекрывают область, в которой null пытается принести пользу. Из-за бесполезности последнего мы им фактически не пользуемся, и поэтому почти никогда не учитываем его в качестве возможного значения. Причём речь идёт не о защите, а о тотальном игнорировании. Мы хронически не проверяем объекты на null (разве что string).

Делать вид, что null-ов не существует — удобно и экономически оправдано. Но мы вынуждены постоянно помнить о них при взаимодействии с C#-фреймворками. Это здорово напрягает. С тем же эффектом начальство может подсадить к вам в команду зэка.

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

Граница

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

К примеру, экземпляр Godot.Theme может быть null, так как тип определяли в C#. По той же причине null-ом может быть свойство Control.Theme : Theme. На это завязана определённая логика в самом Godot, мы ею пользуемся и поэтому с ней не боремся. Однако, если речь касается F#-типа (любого, не только алгебраического), то установки и ожидания разработчиков меняются. К примеру, в рекорде ниже свойство BodyTheme может быть null-ом лишь формально:

type CardConfig = {
    BodyTheme : Theme
    ...
}

Если кто-то реально засунет в него null, то у принимающей стороны возникнут неожиданные проблемы, после чего она пойдёт вправлять мозги создателю экземпляра. Поэтому, если мы предполагаем отсутствие BodyTheme, то по конвенции нам нужен Theme option или CardConfig option:

type CardConfig = {
    BodyTheme : Theme option
    ...
}

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

Некоторые считают, что C#-типы налагают на нас обязательства по соблюдению C#-конвенций в своих наследниках. Меня этот подход раздражает, но в WPF (и ему подобных фреймворках) он время от времени окупается за счёт монструозных DependencyProperty и их обвязки. Однако в случае Godot это почти бессмысленно, так как эта часть инфраструктуры движка крайне слаба. Целесообразнее исчерпывающе и без всяких скидок описать тип (точнее, свойства и методы наследника) в категориях F#, и лишь потом, при необходимости, добавлять свойства-адаптеры для других языков, как это было сделано в An incursion under C#.

Причём я настаиваю, что в этом вопросе не должно быть скидок даже там, где они вроде как напрашиваются. Скажем, если наша нода KeyboardToScroll должна размещаться непосредственно в ScrollContainer, обрабатывать _UnhandledKeyInput и транслировать нажатия пользователя в прокрутку контента (обычные стрелки + навигация по пунктам), то по соображениям производительности и здравого смысла у нас появится свойство ParentScroll. Его значение нужно нам в момент обработки инпута, и оно должно пересчитываться из GetParent() по при изменении родителя:

parentScroll <-
    match this.GetParent() with
    | :? ScrollContainer as p -> Some p
    | _ -> None

Но его сеттер тоже можно использовать, например, как типобезопасную замену (scroll : ScrollContainer).AddChild. По канону это свойство должно иметь тип ScrollContainer option, где None обозначает либо отсутствие родителя, либо то, что он не принадлежит типу ScrollContainer. С другой стороны, ParentScroll является лишь проекцией GetParent(), который ни разу не option, что для кого-то служит доводом в пользу того, что и ParentScroll должен придерживаться null-модели. По мне аргументация чрезвычайно слабая, но несколько лет назад у нас в команде была очень изнурительная и бестолковая полемика по аналогичным ситуациям с кистями и табами, и я периодически натыкаюсь на схожие соображения в публичных репозиториях. В нашем случае хорошие парни победили, но не посредством аргументации, а через банальное самоизматывание (<- здесь была цензура) противника. При больших объёмах, да ещё с развитыми DSL, об особых случаях предпочитают забывать даже их авторы.

Более того, присутствует обратное движение, когда наиболее употребимые свойства F#-изируются через расширения типов:

type Control with
    // Именование выбирайте сами, канона нет.
    member this.ThemeOption
        with get () = Option.ofObj this.Theme
        and set value = this.Theme <- Option.toObj value

option vs null

Ниши option и null сильно пересекаются, из-за чего приходится периодически сталкиваться с ошибочным мнением, что option является лишь более вербозной альтернативой null. Учитывая, что в рантайме None и null действительно равны, это выглядит до поры рационально. Но в целом обсуждать здесь нечего, так как всё доказательство, что это не так, сводится к одному неравенству — None <> Some None (а также Some None <> Some (Some None), Some null <> null и т. д.) Эта неэквивалентность очень важна, несмотря на то что тип 'a option option (в C#-нотации option<option<'a>>) как самостоятельная единица очень редок. Не удивлюсь, если у вас возникнут сложности с быстрым придумыванием ситуации, где он сможет вам понадобиться. Это нормально, и этому есть объяснение.

Напомню, что option — это алгебраический тип суммы. В нём два кейса, множества возможных значений которых складываются (отсюда «сумма») и образуют итоговое множество типа. У None есть лишь одно значение (неявный unit), а у Some — все возможные значения типа 'a. Если последнее в количественном выражении взять за n, то итоговое число 'a option будет равно n + 1, соответственно 'a option optionn + 2. По жизни мы встречаем оба представленных числа в виде законов, формул или принципов, но n + 1 встречается значительно чаще, чем n + 2. Природа частотного распределения одинарного и двойного option во многом аналогична природе данных функций.

Двойной option редко используется в моделях, но он часто возникает при сопряжении нескольких моделей с одинарным option. Есть хороший пример, который стоит упомянуть до того, как вы успеете машинально воспроизвести что-то своё. Это контрол-коллекция (CollectionView или ListBox) с возможностью выбора конкретного элемента, скажем, в виде свойства SelectedItem : 'a (ну или SelectedValue, если вы в курсе, чем они отличаются). В Godot такой не завезли, но F# от этого только выигрывает, потому что обычно такие контролы не дружат с дженериками, из-за чего из их API не всегда понятно, это у нас селект пустой или действительно выбран элемент коллекции со значением, равным defaultof. Приходится подглядывать в другие поля (если они есть) типа SelectedIndex (= -1) или SelectedView (= null), что привязывает код к конкретному контролу сильнее, чем нам бы хотелось.

Если писать такой контрол с нуля на F#, то указанная проблема просто не возникает, так как очень сложно перепутать None (пустой селект) и Some None (селект значения «по умолчанию»). И для нас, и для компилятора это будут совершенно разные значения, сводить которые друг к другу надо вручную.

Сигнатура 'a option option складывается из сигнатур SelectedItem : 'item option и 'item = 'a option. Если разработчик объявит 'item как = 'a, то селект дефолтного значения окажется технически невозможным, что будет видно всем заранее. В идеале SelectedItem тоже задаётся конечным разработчиком:

  • SelectedItem : 'item option — если выбор опционален и единственен;

  • SelectedItem : 'item — если выбор обязателен (на уровне контрола, не валидатора) и единственен;

  • SelectedItems : 'item list — если выбор множественен;

  • SelectedItems : 'item NonEmptyList — если выбор обязан включать в себя не менее одного элемента;

Список можно продолжить, но все варианты должны идти в комплекте с соответствующими свойствами (например, SelectedIndex), методами и сигналами (событиями), так как нет никакого смысла самостоятельно высчитывать дельту между двумя коллекциями SelectedItems или разбираться с None-кейсами там, где они принципиально невозможны. Синтаксические различия можно реализовать только через разные типы, но следует делать не разные типы CollectionView, а разные типы менеджеров селекта. Есть несколько способов их прикрепления, но Godot аналогичную схему в ButtonGroup (замена RadioButton) реализует вообще без участия CollectionView. Мне нравится такой подход, так как он гораздо ближе к композиции, но не могу рекомендовать Godot-реализацию в качестве образца для F#, так как она очень ограничена возможностями GDScript. Там нет ни дженериков, ни option, а неатомарная инициализация (требования .tscn и редактора Godot) вынужденно допускает неполные состояния.

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

  1. Изменились требования — изменили модель.

  2. Половину файла завалило ошибками компиляции ('a <> 'a option и т. д).

  3. Зачищаем их сверху вниз — сразу получаем рабочее приложение.

Этот процесс составляет милую рабочую рутину, так как руки делают всё на автомате, и сознание может сосредоточится на чём-то ином. Иногда зачистка может упереться в логическую дыру-будильник уровня «Ах, вот почему...». Но обычно это не проблема, а преимущество, так как мы успеваем откатить ошибочные изменения (и требования) до их попадания в гит.

Я никогда не пытался облечь эти менеджеры в библиотечный код, потому что не верю в возможность универсального решения. ES, CQRS, MVVM, MVU, ECS, Hopac и прочие страшные слова требуют разную информацию и в совершенно разное время, что позволяет строить модель (читай «филонить») каждый раз по-разному. Мы ограничиваемся очень абстрактными заготовками, которые совмещаем и дописываем на конкретном проекте.

Двойной option здесь важен не тем, что он может выразить большее число состояний, чем связка с null, а тем, что он не допускает незапланированного смешения разных слоёв логики. Когда мы работаем с 'item option, нам плевать на то, чем именно 'item является. Когда мы работаем с 'a option, нам плевать на то, кто и как этот 'a option хранит и распространяет. Мы описываем эти слои на очень локальных, практически лабораторных отрезках, где компилятор и любой человек может предсказать все возможные сценарии заранее. Полученные слои можно безбоязненно сшивать и, главное, пересшивать друг с другом под произвольными углами. Они всё выдержат. При этом у нас всё ещё остаётся возможность наблюдать за всей системой в целом, так что мы при необходимости сможем проматчить Some (Some 42).

Или нет. К моему великому неудовольствию, неэквивалентность 'a option option и 'a option часто ускользает даже от практикующих F#-истов. Я согласен с тем, что раскидывать двойные option-ы по API — чаще всего плохая идея. Но надо чётко отличать случаи критической потери данных. Мне приходилось форкать либы только из-за того, что их авторы, повинуясь стадному чувству, раньше времени схлопывали option. В долгосрочной перспективе я от этого выиграл, но момент форка ощущался как голодная смерть посреди океана, когда у вас в руках здоровенная консерва. Так что на будущее, если при проектировании API встаёт выбор между «противным» двойным option и потенциальной логической дырой, то надо выбирать двойной option или делать два API. Одно простое для смертных, другое тяжёлое для продолжателей.

option vs obj

option не смешивается со своим содержимым, потому что чётко знает, что в нём хранится. Но эта чёткость может работать как в плюс, так и в минус. К примеру, объект типа 'a option нельзя хранить как объект типа 'b option независимо от их отношений наследования. Этот изъян не является проблемой в обычном коде из-за того, что типизация почти всегда происходит из контекста, а не из ручных деклараций.

Компилятор ничего не требует от нас при паттерн-матчинге выражений с известным типом:

type MyTheme() =
    inherit Theme()
    ...
    
// Обычно речь идёт о готовом значении полученном из независимой области.
let myThemeOption = Some ^ MyTheme() // : MyTheme option

match myThemeOption with
// theme : MyTheme
| Some theme ->
    theme.HasColor("highlight_color", "CardHeader")
...

match control.ThemeOption with
| Some theme ->
    theme.HasColor("highlight_color", "CardHeader")
...

Их код идентичен, пока мы не используем методы наследника.

В обратном направлении компилятор способен самостоятельно вывести нужный тип option, если знает итоговый тип выражения:

control.ThemeOption <-
    // Theme option
    Some ^ new MyTheme()

Но он ничего не может сделать с типом, который был зафиксирован заранее:

// Ошибка, так как MyTheme option <> Theme option
control.ThemeOption <- myThemeOption

Здесь потребуется ручная пересборка:

control.ThemeOption <-
    myThemeOption
    |> Option.map ^ fun p -> p

Выглядит странно, но с точки зрения компилятора происходит следующее:

control.ThemeOption <-
    // MyTheme option
    match myThemeOption with
    | None -> // MyTheme option по типу переменной
        None // Theme option по месту требования
    | Some theme -> // MyTheme option по типу переменной
        Some theme // Theme option по месту требования

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

Сложностей добавляет то, что F# не позволяет объявлять дженерики вида #'a (пары 'a, 'b when 'b :> 'a это тоже касается). Компилятор скажет, что тип 'a запечатан (Sealed) и потому наследников иметь не может. Из-за этого единственная «функция» в F#, которая может иметь сигнатуру #'a -> 'a — это upcast, которая вообще не функция, а ключевое слово. Оно не тиражируется. Соответственно невозможно сбацать Option.upcast : #'a option -> 'a option и далее по списку.

К счастью, проблема с переупаковкой возникает лишь при загрузке готовых значений в готовые объекты, что требует наличия двух систем с полностью независимым происхождением. В естественных условиях контакт таких систем случается нечасто, но, к сожалению, переговорные группы новички с C#-бэкграундом постоянно работают над ростом этого показателя. Формально и по инерции с целью достижения большей гибкости, однако того же эффекта можно достичь дешевле при помощи правильно выстроенной цепочки деклараций. То есть мы не рвём связи, а грамотно их подсовываем. Больше контекста => меньше типизаций => (легче поддерживать) много типов => выше специализация => больше контекста.

Хранение vs применение

Если проблема переложения option стоит остро, то лучше всего её решает сокращение дистанции между объектом и его использованием. Компилятор будет значительно лояльнее, если вместо свойства ThemeOption использовать следующий метод:

type Control with
    member this.SetThemeOption value =
        this.Theme <- Option.toObj value

// Валидный код:
control.SetThemeOption myThemeOption

Тело функции идентично телу сеттера:

type Control with
    member this.ThemeOption
        with set value =
            this.Theme <- Option.toObj value

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

type Control with
    member this.SetThemeOption (value : #Theme option) =
        this.Theme <- Option.toObj value
    member this.ThemeOption
        with set (value : Theme option) =
            this.Theme <- Option.toObj value
  • value в SetThemeOption — это option, который типизирован Theme или любым из его наследников.

  • value в сеттере ThemeOption — это строго Theme option.

Решётка в сеттере (with set (value : #Theme option) =) синтаксически бесполезна. Компилятор её просто выкинет и сообщит об этом в ворнинге. Объяснений этому компилятор не даёт, но суть сводится к тому, что проперти повторяют поведение переменных. И поэтому они могут описываться только в конкретных типах и не могут использовать локальные для функции дженерики.

Это очень сильное ограничение. Думаю, что в первую очередь оно было вызвано необходимостью интеропа с C#/VB. defaultof демонстрирует то, как генерализованный геттер мог бы выглядеть в других языках, но проекция генерализованного сеттера вызывает вопросы, которые лучше явно переложить на плечи разработчика. Кроме того, лаконичность большинства сеттеров/геттеров без таких ограничений может обернуться очень широкими трактовками кода, что не всегда идёт в плюс. Следует признать, что это ограничение сохранило тысячи невинных ног от незапланированных расстрелов.

С другой стороны, дженерики в F# распространены шире, чем null в C#, так что от них нельзя отмахнуться. И проще всего их поддерживать при помощи архаичных сеттер-методов. При таком ракурсе Godot с его избыточными связками из .Value with get, set, .SetValue и .GetValue выглядит вполне современно.

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

Из статьи видно, какие ограничения на наш код может наложить попытка встроиться в редактор сцен Godot. Собственная инфраструктура F# входит в противоречие с «обычной» инфраструктурой, ориентированной на более примитивные системы типов. Это противоречие будет сохраняться, пока их уровни не сравняются, на что не стоит рассчитывать на текущем технологическом витке. Поэтому мы должны отказаться либо от некоторых возможностей языка, либо от части инфраструктуры. Я предпочитаю жертвовать инфраструктурой, так как её всегда можно допилить самому. Причём допиливать её я буду в любом случае, на любом мало-мальски длительном проекте. Для людей, далёких от разработки, это может звучать немного экстремально, поэтому поясню на примере.

Я начинал как разработчик на WPF, основной UI-платформе для Windows. Одним из её ключевых элементов является язык разметки XAML. В комплекте с ним всегда шёл редактор, который умел писать XAML за нас, приблизительно так же, как редактор Godot заполняет файл .tscn. Все редакторы UI выглядят почти одинаково, так что если вы собирали UI в Godot, то ваш опыт почти идентичен. В районе 2012-2013 годов в моей среде и C#, и WPF были сравнительно малоизвестными технологиями. И весь предыдущий опыт говорил, что UI надо редактировать в редакторе, а логику кодить в коде (привет WinForms). Этого мнения придерживался как я, так и большинство встреченных мною новичков.

Однако на практике оказалось, что эта схема вообще не работает. Как только разработка достигала полезных объёмов, выяснялось, что править .xaml напрямую многократно быстрее и удобнее, чем редактировать мышкой. Да, структура .xaml для большинства задач удобнее, чем .tscn, особенно если приплюсовать к ней поддержку со стороны IDE. Но тем не менее, я закрыл редактор и никогда не включал его по собственной воле. Последние лет 6 я вместо XAML использую F#, но я всё ещё контактирую с C#-ерами и за все прошедшие года ни разу не видел, чтобы кто-то из практикующих коллег заходил в редактор для чего-то большего, чем простая проверка гипотезы или чтение документации. Складывается ощущение, что эта штука нужна только залётным персонажам или как обучающий элемент, но не более.

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

Технологических ограничений подобному подходу нет. Если глянуть на содержимое .tscn, то можно обнаружить, что оно состоит из списка задач, описывающих порядок развёртывания сцены, а не её структуру. Этим .tscn отличается от .xaml и прочих языков разметки и куда больше напоминает .yml с якорями. Причём .tscn не имеет синтаксиса для быстрого создания узла в узле, поэтому применяет якоря даже там, где копии отсутствуют. F# этих технологических недостатков лишён. Хронологическая ориентация .tscn сильно упрощает нам задачу, так как её можно переписать один в один уже с имеющимся API. А с учётом того, что DSL можно накрутить любое, то F# может повторять структуру гипотетического .xaml. Всё это произвольно смешивается, а значит, мы сможем выжать максимум из декларативного и «хронологического» подходов, причём всё это будет сделано с нормальной типизацией.

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

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


В следующей главе мы вернёмся к поэтапному наращиванию функции и начнём с исправления API Godot.SDK.


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

Теги:
Хабы:
+4
Комментарии0

Публикации

Информация

Сайт
firstvds.ru
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия
Представитель
FirstJohn