В прошлый раз я в основном говорил о трудностях, которые возникают при попытках совместить F# и Godot. Это была вынужденная мера, так как нас в первую очередь интересовало «стандартное» поведение на случай, когда нестандартное и удобное почему-то не сработало. Можно сказать, что мы учились падать без серьёзных последствий перед тем, как научимся совершать броски и болевые приёмы. Нужный ход, если мы не хотим за пару занятий инвалидизировать большую часть группы, но всё-таки это не то, за чем мы пришли в секцию. Теперь пришло время перейти к рутине, а за ней — и к более агрессивным техникам.

Фон повествования

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

  1. Godot — рисование без правил

  2. Прямоугольные тайловые миры

  3. Гексагональные тайловые миры

  4. Тайловое освещение без боли — не совсем в цикле, но близко.

В статьях используется GDScript, но я переписал всё на F#, вычленил нужные куски, подчистил, упростил и использовал в качестве примера. Благо материал хороший, его много, и применительно к F# он генерирует достаточно много интересных и одновременно типовых кейсов, на которые хочется обратить внимание. С другой стороны, получившаяся предметная область почти не взаимодействует с пользователем, очень мало берёт от движка и имеет состояния, за которыми не надо прибирать. Получается хороший эквивалент учебного консольного проекта с минимальной долей магии.

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

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

За недостатком места я не буду как-либо дублировать текст @Goerging и вдаваться в технические подробности алгоритмов, если они не будут относиться к языку или к движку. Считайте, что я нагло стащил весь лор у другого автора, чтобы сосредоточится на интересных мне деталях, которых оказалось очень много.

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

Лабораторные эксперименты

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

Как говорилось ранее, особенность рантайма Godot лишила нас REPL-а (read-eval-print loop). Лишила, но, как выяснилось, не совсем. Большая часть матаппарата в Godot, типа векторов, матриц и т. д., не требует контактов с ядром движка, из-за чего они остались доступны для работы через REPL. Подключаем нужный пакет, и можно проверять гипотезы:

#r "nuget: Godot.CSharp"
#r "nuget: Hedgehog"

open Godot
open Hedgehog

let inline (^) f x = f x

Property.check ^ property {
    let! point, offsetBefore, offsetAfter =
        [-100f..100f]
        |> Gen.item
        |> Gen.tuple
        |> Gen.map ^ fun (x, y) -> Vector2(x, y)
        |> Gen.tuple3
    let! scale = 
        Range.constant 0.01f 100f
        |> Gen.single
        |> Gen.tuple
        |> Gen.map ^ fun (x, y) -> Vector2(x, y)
    
    let transform = 
        Transform2D
            .Identity
            .Translated(offsetBefore)
            .Scaled(scale)
            .Translated(offsetAfter)
    
    let projected = transform * point
    let reversed = transform.AffineInverse() * projected
    counterexample $"{point} -> {projected} -> {reversed}"
    let delta = (reversed - point).Abs()
    return delta.X <= 0.01f && delta.Y <= 0.01f
}

Графику и UI так проверить не получится, для них нам нужно каким-то образом запустить REPL внутри запущенного движка.

Расширения типов

В F# type extensions — классный механизм, при помощи которого можно дополнять существующие типы косметическими методами и свойствами. Я называю их косметическими, потому что им можно делать только то, что можно сделать при помощи обычной внешней функции. Хранить данные, изначально не предусмотренные типом, никто не даст, так как нельзя добавить ни поля, ни имплементации интерфейсов, ни конструкторы. Доступа к защищённым членам вы также не получите. Для всего этого в любом случае потребуется наследование.

Косметические члены — это быстрорастворимый хелпер, который скрывает всю внутреннюю логистику за удобным фасадом. Синтаксис расширений в F# значительно проще, чем у [<Extension>] из C# (хотя последний тоже можно использовать). Например, операция отбрасывания одной из компонент вектора может выглядеть так:

[<Auto>] // Автоматическое открытие при открытие родительского модуля или пространства.
module Auto // Обязательно нахождение в модуле

type Vector2 with // Синтаксис расширения типа
    member this.X_ = Vector2(this.X, 0f) // Ну или `this * Vector2.Right`
    member this._Y = Vector2(0f, this.Y) // `* Vector2.Down`

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

type RenderingServer with
    static member canvasItemCreateIn parent =
        let res = RenderingServer.CanvasItemCreate()
        RenderingServer.CanvasItemSetParent(res, parent)
        res

Расширения — это очень важная фича при столкновении с большими инородными фреймворками типа WPF, AvaloniaUI или Godot. О ней не надо рассуждать в категориях будем использовать VS не будем. Обязательно будете (если, конечно, у вас не стоит задача подобрать повод для бесконечного нытья в такой же компании).

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

Инициализация свойств

По умолчанию на уровне синтаксиса подавляющее большинство деклараций в F# подразумевает иммутабельность. Это значимый фактор, который оказывает влияние на всю систему, так что иногда мутабельность у нас надо аргументировать.

Больше всего разница ощущается на микроуровне (который к фреймворкам не относится). В F# мутабельные переменные требуют отдельного ключевого слова:

let mutable a = 42

А дальнейшие изменения осуществляются при помощи отдельного «оператора» присваивания <-, который специально сделан отличным от сравнения:

// Присваивание
// : unit
a <- 32

// Проверка на эквивалентность
// : bool
a = 32

В F# нет прямых аналогов i++, ++i, += и т. д., что до изобретения ChatGPT препятствовало механическому переносу алгоритмов с других языков. Однако на алгоритмах, рожденных на местности, отсутствие операторов сказывается слабо, так как их ниша занята более высокоуровневыми механиками (частично затронем в следующих статьях).

На макроуровне мутирование данных в F# отличается незначительно. Для задания свойств и полей используется стрелка:

let camera = new Camera2D()

camera.PositionSmoothingEnabled <- true
camera.PositionSmoothingSpeed <- 4f

У нас могли бы быть некоторые проблемы со всякими +=, но Godot напичкан методами вида Node2D.Translate. И в принципе, чем больше мутаций через методы, тем меньше разницы между языками.

Есть исключительный случай, когда присваивание реализуется через знак равенства:

let camera = new Camera2D(
    PositionSmoothingEnabled = true
    , PositionSmoothingSpeed = 4f
)

Из-за C# этот кейс ошибочно связывают с инициализацией конструктора, но на самом деле свойства можно задавать всякий раз, когда вы работаете с методами (в значении некарируемых функций), если они возвращают мутируемый объект. Достаточно передать параметры метода, после чего в том же месте можно задавать свойства возвращаемого объекта:

match List.ofSeq cell with
| [:? Syntax.ParagraphBlock as singleParagraph]->
    singleParagraph.Convert(
        styleConfig
        , SizeFlagsStretchRatio = table.ColumnDefinitions.[index].WidthAsRatio
    )
| unexpected -> ...

Эта фича работает в сочетании с предыдущей и может давать забавные эффекты. Например, в нашем коде можно встретить такой метод:

type Vector2 with
    member this.With () = this

С точки зрения обывателя этот код абсурден, так как вместо хоть какого-нибудь редактирования мы возвращаем тот же самый объект. Закавыка в том, что Vector2 является структурой, а когда структура проходит через жернова метода, наружу выходит новый экземпляр, но с теми же данными. Благодаря этому код из предыдущего параграфа можно переписать так:

type Vector2 with
    member this.X_ = this.With(Y = 0f)
    member this._Y = this.With(X = 0f)

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

type Transform2D with
    static member NewIdentity () =
        Transform2D.Identity

let shifted = Transform2D.NewIdentity(Origin = 100f * Vector2.One)

Statically Resolved Type Parameters

Statically Resolved Type Parameters (или сокращённо SRTP) — в контексте текущей темы можно трактовать как дактайпинг на стадии компиляции. Синтаксис у этой штуки в ряде случаев очень сложный, так что её лучше изучать отдельно. Вы неявно сталкиваетесь с ней каждый раз, когда используете операторы (+), (-), (*) и т. д., так что можете глянуть на их сигнатуру. С помощью SRTP можно писать вещи более прикладного характера. Например, благодаря SRTP нам не нужен никакой System.Math с тысячей перегрузок метода Abs, так как со всем многообразием справляется функция abs, доступная в глобальном скоупе:

abs -42 // 42
abs -42f // 42f
abs -42I // 42I

abs работает через SRTP. Функция ожидает объект типа 'a со статическим методом Abs с сигнатурой вида : 'a -> 'a. Этот метод есть у всех числовых примитивов со знаком. Определена функция приблизительно следующим образом:

let inline abs (a : 'a when 'a : (static member Abs : 'a -> 'a)) =
    'a.Abs a

В практическом смысле это означает, что в abs можно передать любой someA, удовлетворяющий условию, а не только число. Для наглядности ниже дан пример синтаксически корректного типа с абсолютно некорректной семантикой:

type AbsFriendly = {
    Value : string
}
    with
    static member Abs absFriendly = {
        Value = $"Абузим abs: {absFriendly.Value}"
    }
// type AbsFriendly =
//   { Value: string }
//   static member Abs: absFriendly: AbsFriendly -> AbsFriendly

let before = { AbsFriendly.Value = "Проверка" }
// val before: AbsFriendly = { Value = "Проверка" }

let after = abs before
// val after: AbsFriendly = { Value = "Абузим abs: Проверка" }

Это, конечно же, шарж. В реальности в такие функции почти всегда передаются только числовые типы, реже кастомные комбинации числовых типов. Например, при моделировании настольных игр бывает удобно использовать нетривиальные модели ресурсов (жетонов и т. д.), которые могут отвечать на sign, abs, ceil, truncate или round, так как это позволяет тривиально определять сложные inline-функции над несвязанными типами. По игровым механикам компьютерные игры значительно отстают от настольных, но зато они значительно превосходят их по части линейной алгебры. К сожалению, с этим пунктом у F# и Godot возникли проблемы.

В Godot есть свой «стандартный» набор типов для представления векторов и матриц, а также сопутствующие типы, частично поддерживающие поведение векторов (н-р., цвета). К ним прикрутили операторы и fluent-синтаксис, но почему-то забыли про статические методы. SRTP видит операторы, они всё же стандартны для всего dotnet, но додумать статические методы на основе методов экземпляра он не может. Исправить эту проблему при помощи расширения типов не получится, так как SRTP смотрит только на те члены, что были определены в типе изначально. В какой-то из будущих версий F# этот момент будет исправлен, но сейчас надо либо сидеть в одной лодке с другими godot-разрабами, либо определять свои функции специально под вектора:

let inline abs' (vec : 'vec when 'vec : (member Abs : unit -> 'vec)) =
    vec.Abs()

abs' -Vector2.One // (1f, 1f)
abs' Vector2I.Up // (0f, 1f)

В конце концов у векторов хватает своих операций, не бьющихся с операциями над числами:

let inline distance (a : 'vec when 'vec : (member DistanceTo : 'vec -> float32)) (b : 'vec) =
    a.DistanceTo b

distance Vector2.One Vector2.Zero // 1.414213538f
distance Vector3.One Vector3.Zero // 1.732050776f
distance Vector4.One Vector4.Zero // 2.0f

// Универсальность типов наследуется новыми функциями за счёт инлайна.
let inline nearestTo me vecs =
    vecs
    |> Seq.minBy ^ distance me

Маркированные числа

Units of Measure (или просто Measures) — единицы измерения. Изначально это была простая идея очень практического свойства — дать разработчикам возможность маркировать используемые числа единицами измерения:

type [<Measure>] m
type [<Measure>] s
type [<Measure>] g

// 127_000f<g>
let weight = (105f + 10f + 12f) * 1_000f<g>

// 0.694444418f<m/s>
let speed = 25_000f<m> / 36_000f<s>

// 88194.4375f: float32<g*m/s>
let momentum = weight * speed

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

let f (x : float32) = ...

// Ошибка компиляции:
f momentum

// Корректные варианты:
f (momentum / 1f<g * m / s>)
f (momentum * 1f<s / g / m>)

let g (x : float32<m>) = ...

// Ошибка компиляции:
g 10

// Корректный вариант:
g 10<m>

Вся эта магия существует только на этапе компиляции, так что единицы измерения не оказывают влияния на производительность. Проблема в том, из-за этого нет возможности выяснить единицы измерения у забоксированного (приведённого к object) числа, так как с точки зрения рантайма, float32 и float32<m> будут идентичны. Эта же проблема есть у кейса Option.None, который в рантайме равен null. Из-за этой необратимой деградации ни единицы измерения, ни Option нельзя полноценно использовать в каналах, передающих obj. DataTemplate в WPF не реагируют на None, системы в ECS игнорируют сообщения, DI-контейнеры зарегистрированные значения и т. д. В таких средах надо перестраховываться и использовать данные величины только внутри типов с чёткой идентификацией.

Игры бывают разные, но конкретно моим международная СИ пока не нужна. Я использую measures в бизнес-логике на коротких интервалах для маркировки числовых показателей, которые не хочу неосторожно смешать. Это нечастый случай, как правило, речь идёт о действительно противных выпадающих из сознания вычислениях. Обычно они находятся в разных контекстах, так что объединений вида <m / s> или <g * m> у меня либо вообще нет, либо в редком случае они завязаны на какой-нибудь <localTick>, который не совпадает с обычной delta. Но если говорить о матаппарате графики, то я был бы не против промаркировать вектора и матрицы с точки зрения области их применения, типа <globalPixel>, <localPixel>, <tile> и т. д. К сожалению, в данный момент единственная область, где measures прикручивается без танцев с бубнов, это градусные меры, которые выражаются числами. Это значит, что их можно легко атрибутировать как в СИ (радианами или градусами), так и по области применения (угол на поле или угол на экране). Можно написать собственные типы с поддержкой единиц измерения, что позволяет в отдалённой перспективе рассчитывать на кошерный дубликат Godot-овских векторов, которые будут поддерживать все вышеобозначенные фичи (включая SRTP). Однако прикрутить единицы измерения к чужим типам нельзя.

Ладно, если быть точным, то можно, но с потерей всей обвязки из операторов, функций и т. п. Методику можно подсмотреть в FSharp.UMX, где за счёт unsafe-хаков научились прикреплять единицы измерения к строкам, Guid-ам, датам и т. д. Проблема в том, что строка, помеченная неким <userId>, перестаёт быть строкой с точки зрения языка. Чтобы узнать её длину или конкатенировать её с другой строкой, надо превратить её обратно в обычную строку. В случае UMX это приемлемая жертва, так как задача пакета — изолировать идентификаторы различных таблиц и сущностей, а не производить интенсивные математические операции. В нашем случае содержимое чёрных ящиков так же важно, как и их изоляция по типам, так что UMX-сценарий нас не устраивает.

Однокейсовые размеченные объединения

Раз мы затронули тему UMX, то стоит поговорить и о концепции Single Case Discriminated Union, сокращённо SCDU. Это ещё один архитектурный приём, который применяется, когда надо отгородить какой-то примитив предметной области от остальных. Он тяжелее measures, но встречается чаще, так как может применяться к нечисловым типам и в целом ориентирован на обособление логики, которая значительно отличается от поведения базового типа.

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

type RelativePath =
    | RelativePath of string

let handleRelativePath (RelativePath path) =
    // path : string
    ...

DU бывают очень разными, поэтому их представление в памяти обладает наибольшей вариативностью. Гадать об этом бессмысленно, лучше проверять реальное положение дел на месте. Например, структурное одноместное однокейсовое DU оптимизируется компилятором до состояния близкого к его содержимому. При этом даже после боксирования экземпляр данного типа не выродится в string:

[<Struct>]
type FastRelativePath =
    | FastRelativePath of string

SCDU могут избавить нас от некорректной передачи идентификаторов. Для этого достаточно на уровне бизнес-логики использовать SCDU вместо голых Guid, int и string:

type MyEntityId =
    | MyEntityId of System.Guid

В этом месте встаёт проблема трансляции различных кверей, которая на таких штуках имеют обыкновение ломаться. Чтобы этого не происходило, специально в отношении идентификаторов был написан вышеупомянутый FSharp.UMX. Лично мне этот пакет не зашёл, но не указать на него я не могу. Квери над доменными типами я обычно не пишу, так что для идентификаторов предпочитаю использовать обычный рекорд. Значимых отличий он не приносит, поэтому по инерции эта штука в коллективе тоже называется SCDU, максимум SCDU-рекорд.

RenderingServer работает с объектами по их Rid (Resource ID). При этом есть явно непересекающиеся разновидности объектов, идентификаторы которых можно было бы разнести следующим образом:

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

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

Если значение в SCDU должно строго соответствовать какому-то закону, то его «конструктор» можно заприватить вместе с содержимым. Раздельную блокировку F# не признаёт, так что деприватьте данные самостоятельно:

module DU =
    type Main = 
        private
        | Case of string

        with
        member this.AsString = 
            match this with
            | Case str -> str

    let tryCreate str =
        if (str : string).StartsWith "TILE:"
        then Some ^ Case str
        else None

let (|DU|) (du : DU.Main) = du.AsString

module Record =
    type Main = 
        private { raw : string }
        with
        member this.AsString = this.raw

    let tryCreate str =
        if (str : string).StartsWith "TILE:"
        then Ok ^ Case str
        else Error """Must start with "TILE:"."""

let (|Record|) (record : Record.Main) = record.AsString

Определять фабрику через option, Result или исключение — выбор разраба, но он всё-таки должен быть сделан с учётом сферы использования.

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

Godot не чужд концепции малых типов. Самым распространённым является NodePath, и если бы его писали на F#, то скорее всего использовали SCDU. Тип-обёртка над строкой с небольшим набором доменных функций. Данный тип к тому же позволяет редактору Godot распознавать поле как адрес ноды:

type NodePath = {
    AsString : string
}
    with
    member this.Names : string list = ...
    member this.SubNames : string list = ...
    member this.IsEmpty : bool = ...
    ...

При работе с SCDU надо хорошо понимать сферу распространения конкретного типа. SCDU могут работать как явные маркеры на границе между чужим и родным контекстом, что подразумевает их выпячивание. Но для выпячивания надо, чтобы где-то было, а где-то не было. Кроме того, даже в рамках одного контекста SCDU могут иметь ограниченную сферу применения. Скажем, модель для чтения может быть полностью открытой и выражаться в обычных типах, а модель для записи (чаще Command) будет требовать защищённые SCDU.

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

Перегрузка не к месту — это проблема

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

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

Бывают случаи типа DSL, где перегрузки методов оправданы. А бывают случаи, когда они банально мешают работать. Например, у типа Node есть два метода GetNode:

GetNode<'a> : NodePath -> 'a
GetNode : NodePath -> Node

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

let draw (drawer : Node2D) = ()
draw ^ this.GetNode pathToDrawer

С точки зрения компилятора мы пытаемся засунуть Node вместо Node2D, что безусловно является ошибкой. Чтобы компилятор нас понял, необходимо писать хотя бы так:

draw ^ this.GetNode<_> pathToDrawer

Такая конструкция возникает только из-за того, что у метода есть перегрузка. Если определить следующее расширение самостоятельно, то его вызов никаких угловых скобок не потребует:

type Node with
    member this.getNode path = this.GetNode<_>(path)

draw ^ this.getNode pathToDrawer

Новая функция позволяет дополнительно исправить явную недоработку Godot. Почему-то оригинальный GetNode<'a> не считает 'a наследником Node, хотя устройство AddChild и необобщённого GetNode говорит об обратном. Маловероятно, что мы специально попытаемся взять через GetNode что-то иное, более вероятно, что такое обращение возникнет в ходе рефакторинга, так как часто тип выводится лишь из контекста. Компилятор сможет указать на ошибочный запрос, если будет знать о соответствующих ограничениях:

type Node with
    member this.getNode path : #Node = this.GetNode<_>(path)

В отличие от C#, у нас op_Implicit работает со скрипом. Обычно мы это приветствуем, но конкретно в случае NodePath этот момент куда чаще мешает. Поэтому имеет смысл отказаться от NodePath в качестве параметра по умолчанию и заменить его строкой. Версию для пути лучше оставить, но под другим именем:

type Node with
    member this.getNodeByPath path : #Node = this.GetNode<_>(path)
    member this.getNode path : #Node = 
        use path = new NodePath(path)
        this.GetNode<_>(path)

Таким образом для известных заранее путей мы будем использовать строку. А в более сложных сценариях, где возникает необходимость многошаговой передачи (инфраструктурный код), будем переходить к NodePath.

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

Для комфортной работы следует пренебрегать стандартным API Godot и активно его дублировать. Проблема не в конкретном движке, а в общей идеологии C#, так что вывод справедлив для большинства фреймворков. Вопреки намерениям новичков, практической пользы от столь примитивного единения с остальной частью dotnet-комьюнити вы не получите.

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

Автор статьи @kleidemos


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

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