
В прошлой главе мы ненадолго прервали изучение синтаксиса F#, но в этой всё с лихвой нагоним, так как сегодня у нас в программе первичный конструктор, расширения типов (снова) и их архитектурные следствия. Я попытался описать их в одном тексте, но обычно они раскиданы по разным частям документации, что серьёзно мешает целостному восприятию, в результате чего даже весьма башковитые ребята тупят как мальчики с Википедией при обсуждении истории древнего Рима. То есть формально у них есть доступ ко всем знаниям человечества, но фактически они соображают слабее, чем человек, прочитавший трижды устаревшего Моммзена.
Начиная с этой главы мы будем постепенно отказываться от того сценария разработки, который предлагает Godot по умолчанию. Итогом отказа будет почти полное исчезновение C# (пока только в рамках проекта) и переход на удобную и идиоматически правильную архитектуру.
Оглавление
Приквел
Шестидесятилетний заключённый и лабораторная крыса. F# на Godot
Объективизация
В прошлой главе мы создали функцию createPathFinder
, которая возвращала другую функцию от goal : Vector2I
, а та в свою очередь возвращала Result
со здоровенным анонимным рекордом в Ok
. Этот рекорд был как Анти-Лас-Вегас. Он вытаскивал наружу почти всё, что происходило в алгоритме, за исключением словаря ready
. И как все уже поняли, мне почти сразу приспичило вывести эту коллекцию на игровое поле.
По уровню вложенности ready
является сиблингом функции поиска от goal
, так что их можно упаковать в очередной анонимный рекорд:
let createPathFinder mapSize isObstacle start =
let addStep = ...
let paths = ...
let ready = ...
let readOnlyPaths = ...
let tryFind goal = ...
{|
Ready = ready.AsReadOnly()
Paths = readOnlyPaths
Start = start
IsObstacle = isObstacle
MapSize = mapSize
TryFind = tryFind
|}
Но я этого не делал, так как createPathFinder
слишком сильно напрашивался на полноценный тип. По факту, я даже не рассматривал другие варианты, пока не обнаружил, что мне нужно отложить тему первичных конструкторов для отдельной главы. Однако ещё раз ковыряться в алгоритме мы не станем, так как в силу устройства языка разница между версиями близка к нулю:
// module PathFinder
type Main (mapSize, isObstacle, start) =
let addStep = ...
let paths = ...
let ready = ...
let readOnlyPaths = ...
let tryFind goal = ...
member val Ready = ready.AsReadOnly()
member _.Paths = readOnlyPaths
member _.Start = start
member _.IsObstacle pos = isObstacle pos
member _.MapSize = mapSize
member _.TryFind goal = tryFind goal
Тип PathFinder.Main
в общих чертах повторяет устройство предыдущей функции:
У нас те же самые параметры —
mapSize
,isObstacle
иstart
, хотя записаны они в кортежной форме.Тело функции расположилось между параметрами и
member
-ами (дальше буду называть его «зоной/секциейlet
»).Зона
member
описывает объект результата. Синтаксис выглядит иначе, но смысловая разница исчезающе мала.
Совокупность входных параметров и зоны let
называется первичным конструктором. Это главная фича F# при проектировании «обычных» классов. Благодаря ей, мы сначала пишем функцию, которая разворачивает скоуп, и лишь когда он готов, начинаем «упаковывать» его в member
-ы для внешнего наблюдателя. В большинстве языков (типа C#) мы движемся в обратном направлении, в первую очередь декларируются поля и свойства типа, и лишь когда-то потом определяется процедура их заполнения. Причём иногда она оказывается настолько сбоку, что вываливается из типа. При большом желании на F# можно писать в C#-ной манере, но это будет очень контрпродуктивно, если речь не о хардкорном интеропе с железом. Поэтому я бы рекомендовал новичкам всячески избегать этого, несмотря на кажущуюся привычность. В противном случае вы можете создать себе комплекс проблем, которые будут настолько уникальны, что помочь вам (без серьёзного погружения) будет некому.
Все привязки первичного конструктора и его параметры образуют общий скоуп, который доступен во всех member
-ах типа. Для подчёркивания этого факта компилятор принуждает размещать секцию let
строго в начале типа до объявления member
-ов. Таким образом первичный (или точнее «личный») конструктор повторяет поведение статического (static let
), но отвечает только за конкретный экземпляр, а не весь тип. Логично, что статические привязки должны быть видны в зоне «личного» конструктора, поэтому зона static let
(если она есть) должна предшествовать зоне let
. Скоуп первичного конструктора принудительно приватен и недоступен через this._
-синтаксис, а значит, и за пределами экземпляра. Поэтому, если в Equals
надо сравнить свой myValue
с чужим, то его надо пробросить через member private this.MyValue = myValue
.
Порядок привязок важен. Они подчиняются правилам обычной функции, поэтому нельзя сослаться на привязку, которая будет объявлена позже. При этом на низовом уровне большинство привязок первичного конструктора преобразуются в поля и методы типа, так что речь идёт не о замыканиях, а о раздвигающихся шорах. Поле «всегда» есть, но мы его не увидим, пока оно не будет готово к использованию. Это архитектурное решение позволяет без необходимости не сталкиваться с mutable
или Unchecked.defaultof
. Оно также держит тип в тонусе, синхронно вырезая выбывшие поля. Всё что не было инициализировано, то не существует, и все зависимые member
-ы (если они есть) взвоют об этом на этапе компиляции.
Компилятор может выкинуть из типа отслужившие своё элементы скоупа. Они останутся переменными конструктора, которые будут утилизированы по обычным правилам. Однако компилятор достаточно умён, чтобы не трогать привязки, невостребованность которых остаётся под вопросом. Например, let mutable
останется в виде поля, даже если с ним больше ничего не делается. Шадовинг не поможет. Мне неизвестно, существует ли алгоритм, который позволяет предугадать действия компилятора, но типовые случаи можно поковырять в SharpLab. В целом я предпочитаю просто не засорять скоуп, благо возможностей уйма.
Чтобы исключить недопонимание, компилятор запрещает ключевое слово use
на уровне первичного конструктора. За все годы практики я так и не встретился с доменом, где бы это привело к необходимости переехать на обычный «непервичный» конструктор (разбирать не буду, штука редкая), но по мере погружения в GPU-дебри я допускаю, что такой момент настанет. Пока же мне хватает вложенных скоупов или в исключительных случаях ручного Dispose
:
type MyType (path) =
let content =
use reader = System.IO.File.OpenRead path
...
Just do it
Праздно шатающиеся выражения в первичном конструкторе запрещены. Все выражения должны складываться в привязки, либо явно сбрасываться через do
. Это тот самый случай, когда do
-нотация обязательна. Почему было так сделано, сказать точно не могу, но скорее всего дело в конфликте между статическим (static do
) и первичным (do
) конструкторами. Граница между ними формируется явочным порядком. Если компилятор начнёт относить неявный do
к какой-либо из сторон, мы получим слабый, но стабильный приток багов за счёт unit
выражений на границе конструкторов (а также очередной странный вопрос на собеседованиях). Ситуация изменится лишь с вводом какого-нибудь static end
, что в ближайшей перспективе очень сомнительно.
Обращаю внимание, что правило «одна строка (точнее выражение) — один do
» — это произвол отдельно взятых коллективов. Поэтому в зависимости от закладываемой в группировку логики я пишу и так:
do a ()
do b 42
do c ()
И так:
do // Пустая строка.
let value = 42 // Отступ в 4.
a ()
b value
c ()
Первая строка не заполняется, чтобы никакие выражения или их фрагменты не привязывались к do
, а все остальные строки идут с отступом в 4 знака, чтобы не выделяться на фоне let
. Всё это обеспечивает ускоренное перемещение строк между блоками do
при помощи Alt + Up/Down
(в VS). Нужно это нечасто, в основном для сцен с большим количеством unit
-конфигураций.
Граница между member и let
По умолчанию member
-ы находятся за пределами первичного конструктора. В скоупе их нет, и для доступа к ним потребуется self
-идентификатор (ссылка на самого себя), которая добывается через конструкцию type Main (...) as this =
. При этом до завершения конструктора member
-ы типа (не предков) вызывать нельзя, так как это приводит к исключению (см. главу 7).
При таких вводных ниша автосвойств (member val
) радикально сужается. Там хранится только то, что никогда не понадобится в первичном конструкторе. С методами и обычными свойствами ситуация сильно проще. В большинстве своём они вызываются уже после завершения конструктора, поэтому их можно полностью определить непосредственно в итоговом member
. Скорее всего let tryFind
будет заменён на:
member this.TryFind goal =
if not ^ inMap mapSize goal then Result.Error OutOfBounds
elif isObstacle goal then Result.Error Obstacle else
match ready.TryFind goal with
...
let readOnlyPaths
будучи публичной проекцией будет перенесён в свойство Paths
, а TryFind
, у которого теперь есть доступ к this
, сможет его оттуда забрать:
member val Paths = paths.AsReadOnly()
Сколько логики надо держать в let
, а сколько в member
— вопрос дискуссионный. Итоговый результат для внешнего наблюдателя будет одинаковый. Но у некоторых разрабов есть внутренний KPI на минимизацию числа деклараций, поэтому они замещают member
-ами максимально возможное число привязок. Я нейтрально отношусь к этому подходу применительно к свеженаписанным типам, но активно противодействую перегонке уже существующих let
. Во-первых, это лишняя движуха, которая сама по себе плохо читается из гита, а в мерж-конфликте тем более. Во-вторых, по моим наблюдениям, то, что однажды вынуждено было закинуто в let
, будет постоянно тяготеть к этому несмотря на временные послабления. В-третьих, читаемость и переносимость «вовне» у let
гораздо выше, что связано с основным преимуществом и недостатком member
-ов — они имеют доступ ко всему типу.
Зона member
-ов ведёт себя как группа функций rec ... and
. Все видят всех независимо от порядка определения. Последовательность всё ещё будет сказываться на выводе типов, но в целом мы ничем не ограничены. Писать такие функции проще, но только пока находишься в контексте происходящего, ибо в противном случае их придётся прочитать. Это классическая задачка на экономию времени с противопоставлением сегодня и завтра. С ростом опыта её острота радикально снизится, так как вы превратитесь в let
-бульдозер, но независимо от этого следует беречь время, уже затраченное на последовательное описание типа.
В языке есть способ показать, что конкретный member
автономен и опирается только на скоуп первичного конструктора. Для этого достаточно отказаться от self
-идентификатора:
// Метод, возможно, использует другие `member`-ы.
member this.TryFind goal = ...
// Метод не может использовать другие `member`-ы.
member _.TryFind goal = ...
Доступ к member
-ам можно будет вернуть, если протащить this
из as this
, но это будет серьёзное нарушение конвенции.
Распиливание типа
Мы можем добиться диаметрально противоположного эффекта, если вынесем member
в type extensions
:
type Main with
member this.Find goal =
match this.TryFind goal with
| Ok ok -> ok
| Result.Error err -> failwith $"%A{err}"
В этом случае у this.Find
не будет доступа к скоупу конструкторов и приватным членам. Он сможет использовать только публичное API. У нас также появится гарантия, что Main
при своём определении не использовал Find
. В совокупности это означает вторичность вынесенного member
-а по отношению к ядру типа. Я видел кодовые базы, где это пытались отмечать в комментариях, но через синтаксис такая информация передаётся лучше.
Нам не обязательно выносить вторичные члены только в одно расширение типа. Их может быть много. Тогда описанная ситуация будет повторяться на каждом шаге с поправкой на видимость методов со всех предшествующих стадий. Такая пошаговость позволяет разбить один большой условный rec .. and
на несколько групп поменьше, где, как правило, каждая следующая группа всё меньше относится к типу и всё больше зависит от окружения. Важным преимуществом является то, что в разрыве между двух расширений можно разместить определение чего-то третьего, например DU для заковыристого результата или ещё одно расширение, но для другого типа. Это очень удобно, так как позволяет циклично повторять один и тот же шаг для целой группы синонимичных типов и лишь потом переходить к следующему этапу.
Например, у меня есть набор SCDU
-обёрток над RID
-ами из RenderingServer
:
type [<RequireQualifiedAccess>] CanvasId = { AsRid : Rid }
type [<RequireQualifiedAccess>] TextureId = { AsRid : Rid }
type [<RequireQualifiedAccess>] ViewportId = { AsRid : Rid }
type [<RequireQualifiedAccess>] MaterialId = { AsRid : Rid }
type [<RequireQualifiedAccess>] CanvasItemId = { AsRid : Rid }
Это рекорды с нулевой приватной составляющей. Они в целом самодостаточны, но к ним можно прикрутить набор хелперов:
type TextureId with
member this.Free () = RenderingServer.FreeRid this.AsRid
member this.AsDisposable () = IDisposable.create this.Free
static member Wrap rid = { AsRid = rid }
static member Unwrap id = id.AsRid
type Texture with
member this.TextureId = TextureId.Wrap ^ this.GetRid()
...
type CanvasItemId with
member this.Free () = RenderingServer.FreeRid this.AsRid
member this.AsDisposable () = IDisposable.create this.Free
static member Wrap rid = { AsRid = rid }
static member Unwrap id = id.AsRid
static member Create () = CanvasItemId.Wrap ^ RenderingServer.CanvasItemCreate()
type CanvasItem with
member this.CanvasItemId = CanvasItemId.Wrap ^ this.GetCanvasItem()
Такая пара расширений есть на каждый идентификатор. Они почти идентичны, но TextureId
относится к абстрактному Texture
, который просто так не создашь. При чтении сей факт мог бы от нас ускользнуть, если бы все эти member
-ы были определены непосредственно в типах (включая код ниже), но в череде повторяемых блоков различие бросается в глаза.
Далее к этим идентификаторам можно прикрутить соответствующие операции с RenderingServer
:
type CanvasItemId with
member this.SetParent parent =
RenderingServer.CanvasItemSetParent(this.AsRid, parent.AsRid)
...
member this.Clear () =
RenderingServer.CanvasItemClear this.AsRid
...
member this.AddPolyline (points, colors, ?width, ?antialiased) =
RenderingServer.CanvasItemAddPolyline(this.AsRid, points, colors, ?width = width, ?antialiased = antialiased)
member this.AddPolygon (points, colors, ?uvs, ?textureId) =
let texture = Option.map TextureId.Unwrap textureId
RenderingServer.CanvasItemAddPolygon(this.AsRid, points, colors, ?uvs = uvs, ?texture = texture)
member this.AddLine (a, b, color, ?lineWidth, ?antialiased) =
RenderingServer.CanvasItemAddLine(this.AsRid, a, b, color, ?width = lineWidth, ?antialiased = antialiased)
В этом месте люди, которые не читали Godot — рисование без правил, удивляются тому факту, что методы прикручиваются к типу <Entity>Id
, а не к типу <Entity>
. Дело в том, что наш <Entity>Id
относится к <Entity>
приблизительно так же, как SQL к EntityFramework
. Второй использует первый, но для работы первого нам необязательно нужен второй. Например, CanvasItem
— это наследник Node
, который имеет человеческое API для работы с неким «элементом холста» (н-р, Control :> CanvasItem
). Однако с этим элементом можно взаимодействовать напрямую через процедуры RenderingServer
, если у вас есть RID
этого CanvasItem
(this.GetCanvasItem()
). Более того, можно создать этот элемент без создания одноимённой ноды. Это чуточку дешевле, а главное, позволяет вывести жизненный цикл элемента из-под контроля движка. На этом основано вышеупомянутое «рисование без правил». Так что наш CanvasItemId
является недостающим звеном, чем-то средним между CanvasItem
и RID
. Им можно удобно пользоваться, но при этом он не жрёт ресурсы ноды.
Расширения этого поколения не похожи друг на друга, поэтому сравнивать их между собой не имеет смысла. Однако типы всё равно друг с другом взаимодействуют, иногда двунаправленно. В этом случае одно поколение может породить другое:
// Продолжение блока `RenderingServer`.
type CanvasId with
member this.AttachCanvasItem id = RenderingServer.CanvasItemSetParent((id : CanvasItemId).AsRid, this.AsRid)
// Пауза.
// Блок перекрёстных связей.
type CanvasItemId with
member this.SetCanvasParent canvas = (canvas : CanvasId).AttachCanvasItem this
Если кого-то интересует, что здесь происходит: CanvasItem
-ы могут выстраиваться в иерархии при помощи CanvasItemSetParent
, но они не могут попасть во Viewport
напрямую. Для этого их надо сложить в Canvas
, а его — во Viewport
. Мне потребовалось много времени, чтобы понять, что CanvasItem
можно класть в Canvas
при помощи того же самого метода. Я избавился от этой неочевидности, переупаковав CanvasItemSetParent
в this.SetParent : CanvasItemId -> unit
и this.SetCanvasParent : CanvasId -> unit
.
Всегда можно упороться и выделить по type ... with
на каждый member
, но что-то подобное я встречал только в кодогене, и то в вырожденных случаях. В обычных проектах границы групп напрашиваются сами собой. По крайней мере я не припомню, чтобы хоть раз о них спорил. В любом случае язык не заставляет вас распиливать типы против вашей воли, пока речь не заходит о перекрёстных определениях.
Дробление типа
Расширения типов существуют в отрыве от первичных конструкторов, но они в каком-то роде повторяют их на макроуровне. Мне бы хотелось, чтобы эти фичи могли работать в более тесной связке, но не могу сказать, как именно. Предположу, что лучше всего подошёл бы какой-то вариант синтеза в виде развития первичного конструктора, наподобие тех, что рождаются естественным образом в ходе командных обсуждений.
Сомневаюсь, что мне доведётся увидеть эти нововведения в F#, но мы можем добиться похожего эффекта, если представим крупный тип в виде пачки типов поменьше, которые будут отвечать за отдельные стадии «развития». В качестве лабораторного примера можно взять модель изометрической сетки. Она состоит из нескольких крупных блоков:
Базис, включающий информацию о размерах одного тайла;
Проекция между холстом и координатами;
Операции с тайлами;
Операции с прямоугольными областями из тайлов;
Операции с углами;
Отрисовка сетки.
В сумме они содержат 18 членов, ответственных только за математический аппарат (методы отрисовки вытолкнуты в расширения). Они взаимозависимы, так что при монолитном подходе нам потребуется либо длинный первичный конструктор с последующей проекцией, либо множество перекрёстных вызовов в member
-ах. Если представить каждый блок в качестве отдельного типа, всего этого можно будет избежать.
Обратите внимание, что в следующем коде напрочь отсутствует идентификатор this
. Везде задача решается при помощи привязок, автоматических свойств и автономных member
-ов:
type CellBasis (cellSize, isoScale) =
let cw = floor (cellSize / 2f)
let ch = floor (cw / isoScale)
member val Right = Vector2(cw, ch)
member val Left = Vector2(-cw, ch)
member val One = Vector2(0f, 2f * ch)
member _.Size = cellSize
member _.IsoScale = isoScale
member _.Height = ch
member _.Width = cw
type PositionOps (basis : CellBasis) =
member _.CellBasis = basis
member _.ToPixel position =
basis.Right * (position: Vector2).X
+ basis.Left * position.Y
member _.OfPixel pixel =
let pixel = (pixel : Vector2) * 0.5f
let w = pixel.X / basis.Width
let h = pixel.Y / basis.Height
Vector2(w + h, -w + h)
type CellOps (position : PositionOps) =
let basis = position.CellBasis
let (!) cell = position.ToPixel ^ (cell : Vector2I).AsVector()
member _.Position = position
member _.ToPixel cell =
!cell
member _.ToPixelCenter cell =
!cell + 0.5f * basis.One
member _.OfPixel pixel =
let preCell = position.OfPixel(pixel).Floor()
Vector2I(int preCell.X, int preCell.Y)
member _.CornerPixelsClockwise looped cell =
Corners.clockwise looped !cell basis.Right basis.Left
type AreaOps (cellOps : CellOps) =
let (!) = cellOps.ToPixel
member _.CellOps = cellOps
member _.CornerPixelsClockwise looped rect =
Corners.clockwise looped !(rect : Rect2I).Position !rect.Size.X_ !rect.Size._Y
type AngleOps (cellBasis : CellBasis) =
member _.CellBasis = cellBasis
member _.ToLinear isometricAngle =
(cos isometricAngle * cellBasis.Right + sin isometricAngle * cellBasis.Left)
.Angle()
member _.ToIsometric linearAngle =
let x = cos linearAngle / cellBasis.Width
let y = sin linearAngle / cellBasis.Height
Vector2(x + y, -x + y).Angle()
type GridOps (cellOps : CellOps) =
member this.CellOps = cellOps
Зависимости между блоками/типами можно наблюдать в явном виде, так как они тождественны параметрам первичных конструкторов. Так AngleOps
достаточно исходного базиса (блок 1), блоки с 2 по 4 он не использует. Этот факт было бы трудно выразить на уровне синтаксиса, если бы мы ограничивались одним большим типом.
Экземпляры данных типов можно таскать по отдельности, но в большинстве прикладных задач проще держать их в одном кулаке, для чего пригодится ещё один тип-обёртка:
type Main (cellSize, isoScale) =
let basis = CellBasis(cellSize, isoScale)
let position = PositionOps basis
let cell = CellOps position
member _.Basis = basis
member _.Position = position
member _.Cell = cell
member val Angle = AngleOps basis
member val Area = AreaOps cell
member val Grid = GridOps cell
IsometricGrid.Main
примитивен, но при этом выражает весь граф внутренних связей в очень компактной форме. Его можно использовать вместо схемы.
Он также полезен для DSL, так как имена его свойств работают как префиксы к методам их типов. Интуитивно понятно, что mainGrid.Position.OfPixel
— это Vector2 -> Vector2
, а mainGrid.Cell.OfPixel
— это Vector2 -> Vector2I
. То же самое касается методов отрисовки, mainGrid.Cell.Fill
и mainGrid.Cell.Draw
говорят сами за себя. Лишние суффиксы им не нужны.
Тип GridOps
выглядит странно, потому что в моей редакции не содержит математический аппарат. Все его методы относятся к сфере рисования:
type GridOps with
member this.DrawClosed antialiased lineWidth color surf (gridSize : Vector2I) =
seq {
let inline (!) x y = this.CellOps.ToPixel ^ Vector2I(x, y)
for i in 0..gridSize.X do
!i 0, !i gridSize.Y
for i in 0..gridSize.Y do
!0 i, !gridSize.X i
}
|> Seq.iter ^ fun (a, b) ->
(surf : CanvasItemId).AddLine(a, b, color, lineWidth, antialiased)
member this.DrawOpened antialiased lineWidth color surf (gridSize : Vector2I) =
seq {
let inline (!) x y = this.CellOps.ToPixel ^ Vector2I(x, y)
for i in 1..gridSize.X-1 do
!i 0, !i gridSize.Y
for i in 1..gridSize.Y-1 do
!0 i, !gridSize.X i
}
|> Seq.iter ^ fun (a, b) ->
(surf : CanvasItemId).AddLine(a, b, color, lineWidth, antialiased)
Для рисования нужен CellOps
, поэтому он сохраняется в одноимённом свойстве в GridOps
. Для кого-то это выглядит как неправильная обратная зависимость, которая в обычных условиях может трактоваться как архитектурная ошибка, но важно понимать, что эти типы несамостоятельны. Их можно извлечь из Main
, но по факту это лишь проекция или подмножество основного типа, и без него осмысленность их существования оказывается под вопросом. К блокам не надо подходить с SOLID
и тому подобными критериями (но ко всей структуре в целом — можно). Мы вполне сознательно создаём этакий «микросервисный монолит», несмотря на его отрицательную репутацию, потому что хотим эксплуатировать синтаксические возможности языка для передачи внутренней структуры явления. Это шаг в сторону композиции, но он запланировано тупиковый, так как разбивать клубок дальше мы не собираемся. Но даже если такое и произойдёт, то речь будет идти об обособлении единичных блоков, а не о тотальной атомизации.
Тут я опять должен сказать, что такое дробление на типы нехарактерно для библиотечного кода. По большей части из-за непривычности подхода, но также важна некоторая хрупкость контракта. Пока тип один, методы внутри него можно рефакторить произвольным образом. Когда типов много, аналогичный рефакторинг может потребовать переезд метода из одного типа в другой, что при неучтённом обновлении оборачивается MissingMemberException
. При работе малой группой учёт обновлений командных либ наладить несложно, так что на практике меня этот риск не волнует. А при разработке игры (или прикладного приложения), где код настолько крепко прибит к игровой логике, что о его распространении можно не заикаться, этот риск вовсе не существует.
GodObject

Между замыслом данной главы и её написанием прошло немало времени, за которое я успел много чего понаделать и прийти к выводу, что дробление типа в разработке игр может проявить себя ещё с одной стороны. Оказалось, что мне крайне важно, смогу ли я поддерживать растянутое на несколько файлов и тысяч строк определение некоего GodObject
. Думаю, что всем говорили, что это антипаттерн, и от него необходимо бежать со всех ног, но лично мне иногда не хватает опыта, чтобы сразу обходиться без него. Ситуацию резко осложняет то, что бизнес-логика формируется на ходу и очень часто она требует эпической перекладки руля по несколько раз за вечер, «чисто чтобы посмотреть». За те 2-3 недели, в которые мой подопытный тестер будет недоступен, я успею распихать всё по полочкам (обычно за следующий вечер), но пока он здесь, нам надо успеть закодить, потыкать и переварить озвученное предложение.

При такой организации рабочего процесса внезапное появление GodObject
-грыжи в произвольной точке приложения не так уж и внезапно. К нему следует быть готовым. Нужен способ стравливать сложность, чтобы удерживать божественность в узком диапазоне, где она всё ещё дарует лёгкий доступ ко всему, но ещё не закапывает вас под толщей перекрёстных связей, и дробление для этого подходит.
Проблема Ready
Я пропел дифирамбы первичному конструктору, но они кажутся неуместными в контексте Godot, где инициализация большинства нод вынесена в отдельный метод:
type Main () =
inherit Node2D()
let mutable label = None
override this._Ready () =
label <- Some ^ this.getNode "HUD/MyLabel"
override _._Process delta =
label
|> Option.iter ^ fun label ->
(label : Label).Text <- string System.DateTime.Now
На самом деле именно здесь первичные конструкторы и выстреливают. Для этого мы раздробим ноду на два типа, как делали только что, но не в пространстве, а во времени. Для начала можно вынести содержимое функции _Ready
в отдельный тип, а вместе с ним все зависимые от него поля:
type Ready (main : Node2D) =
let label : Label = main.getNode "HUD/MyLabel"
member _.Label = label
type Main () =
inherit Node2D()
let mutable ready = None
override this._Ready () =
ready <- Some ^ Ready this
override _._Process delta =
ready
|> Option.iter ^ fun ready ->
ready.Label.Text <- string System.DateTime.Now
Основной профит от этого шага заключается в том, что нам не надо проверять каждое опциональное поле перед его использованием (или делать их мутабельными defaultof
). Это критично, если речь идёт об операции с участием нескольких полей. Если _Ready
отработал, то все поля будут инициализированы и сложены в отдельный объект, и лишь его существование нас должно беспокоить.
Дальше имеет смысл перенести зависимые от ready
методы внутрь одноимённого типа:
type Ready (main : Node2D) =
let label : Label = main.getNode "HUD/MyLabel"
override _.Process delta =
label.Text <- string System.DateTime.Now
type Main () =
inherit Node2D()
let mutable ready = None
override this._Ready () =
ready <- Some ^ Ready this
override _._Process delta =
ready
|> Option.iter ^ fun ready -> ready.Process delta
Мы всё так же будем проверять существование ready
, но только для того, чтобы вызвать у него необходимый метод. Вся основная движуха будет происходить там, где необходимые значения уже добыты и лежат в готовом виде. Благодаря этому отпадает необходимость в пропертях, а тип Main
вырождается до состояния обёртки.
При таком подходе необходимость коммуникации с Main
почти пропадает. Она нужна только движку. В коде же нам проще работать с тем, у кого действительно есть власть, а для этого нам нужно иметь его контакты:
type Main () =
inherit Node2D()
let mutable ready = None
member _.Ready = ready
На практике это необходимо только для тех случаев, когда связь устанавливается с абстрактной нодой. Например, при появлении тела в Area2D
:
match body with
// Мы вынуждены работать с неизвестно чем.
| :? Kit.Main as kit ->
// И только здесь мы получаем доступ к ядру типа.
kit.Ready
|> Option.iter ^ fun kit ->
match kit.Kind with
| Kit.Medicine value -> health.Heal value
| Kit.Antidote ilness -> effects.Cure illness
kit.Disappear()
| _ ->
()
Во всех остальных случаях, когда мы точно знаем, с чем имеем дело, можно хранить и перекидывать объекты Ready
напрямую. На всякий случай, если вдруг кому-то потребуется доступ к ноде по объекту Ready
, их можно связать друг с другом намертво при помощи type .. and
:
// Точная типизация.
type Ready (main : Main) =
let label : Label = main.getNode "HUD/MyLabel"
member this.Process delta =
label.Text <- string System.DateTime.Now
// Название сами придумаете.
member _.Main = main
// Типы будут знать друг о друге
and Main () =
inherit Node2D()
let mutable ready = None
member _.Ready = ready
override this._Ready () =
ready <- Some ^ Ready this
override this._Process delta =
ready
|> Option.iter ^ fun ready -> ready.Process delta
type ... and
— по смыслу аналогичен rec ... and
, но для типов, а не для функций. При такой записи Ready
и Main
могут ссылаться друг на друга независимо от порядка определения. Нужно сие нечасто, но, когда дело касается непосредственного хранения, альтернатив не существует.
Можно заметить, что main
в рамках Ready
начинает выполнять функции self
-идентификатора в простонародном понимании термина. Этот момент стоит обмозговать, потому что через него должно прийти понимание, что as this
— это всего лишь ещё один аргумент первичного конструктора. Правда, у main
в сравнении с this
есть одно заметное ограничение. Из-за того, что он является другим типом, в скоупе Ready
нельзя получить доступ к его private
или protected
членам. Их надо будет руками скормить конструктору Ready
вместе с main
.
override this._Ready () =
ready <- Some ^ Ready(this, privateFun)
По мере усложнения нам может понадобиться целый тип (type PrivateAccess
) для упаковки всех нужных методов, но это будет касаться только бизнес-логики, а не типов из Godot.SDK
, так как судя моим изысканиям, движок вообще не содержит protected
-членов. Используется только модификаторы private
и public
, что роднит Godot с F#, в котором protected
существует только для поддержки интеропа.
Промежуточное заключение
Мы начали этот цикл с разбора выражений, которые являлись телом функции. Далее мы спроецировали скоуп функции в некоторый результат (output
). Когда проекция разрослась, нам пришлось завести для неё анонимный рекорд. Когда тот начали перекидывать на слишком большие расстояния, мы завернули его в специальный тип-обёртку, а затем и срастили их друг с другом. Сегодня мы дошли до стадии, где тип буквально определен как проекция скоупа функции-конструктора.
Это уже очень недурно, и на этом можно было бы остановиться, но мы пошли дальше и выяснили, что тип может представляться как череда проекций над одним и тем же скоупом. Более того, он может быть представлен в виде древа типов, каждый из которых будет образован на основе своей функции-конструктора и узлов «предков». Это древо необязательно инициализировать одномоментно. Оно может разрастаться (а значит, и схлопываться) по мере созревания нужных условий, так что мы переходим к симуляции состояний через отдельно взятые типы. Мы продолжим двигаться в этом направлении дальше, но с синтаксисом первичного конструктора мы закончили, правда, только в рамках текущего цикла. Целый пласт редко используемых фич и правил по работе с base
(ссылка на имплементацию предка), альтернативными конструкторами, интерфейсами и так далее, я оставил за границами повествования, так как в Godot я их никак не использую (специально проверил), да и к сути дела они отношения не имеют.
Технически я мог бы не писать все предыдущие главы и перейти сразу к манёвру с Ready
. Он достаточно мал и прост, чтобы его смог понять человек с улицы, но в этом случае все идеологические предпосылки и практические следствия доходили бы до него годами. На мой взгляд, крайне важно, чтобы F#-ист видел в типе результат перемещения из точки А в точку Б, а не сегмент памяти компьютера. В противном случае большую часть времени он будет старательно запихивать сопротивляющегося джина обратно в бутылку несмотря на то, что находится в совершенно другой сказке с совершенно другим джином. Ввиду возможностей языка, этот процесс может продолжаться бесконечно, так как проекты всё равно будут допиливаться, даже если они пишутся в C#-стиле.
Это хорошая новость, если вы боитесь не сдюжить, и вам нужно пространство для отступления, но по факту это каждый раз выбор тары в ущерб её содержимому. Можно выиграть гораздо больше, если пойти за джином с чётким пониманием того, что возврат и даже сама возможность возврата жрут ресурсы, которые можно бросить на дальнейшую экспансию. Если обратиться за аналогией к более примитивным жанрам, то мы можем не тратить очки на повышение защиты/сопротивляемости, если противник не может до нас дотянуться. Для этого мы должны наловчиться не попадать в ситуации, где по нам могли бы ударить.
В следующей главе мы займёмся заполнением первичного конструктора, где я покажу, как избавиться от бутылки, override
-ов, C# и прочих помех.
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.