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

На мой взгляд, у большинства F#-новичков тактический и стратегический уровень находятся в разных вселенных. Типа вот здесь в локальном пространстве у нас ФП, а на глобальном внезапно тащит только ООП. Это, конечно, хорошо, что мы можем склеивать две парадигмы, но мне кажется, что эта непреодолимая стена на границе сферы деятельности ФП не такая уж непреодолимая. Существование её обусловлено не объективными причинами, а недостатком опыта.

Как и многие, я начинал знакомство с программированием со школьных уроков информатики. Там нам давали Pascal, но потом мы почти самовольно перелезли кто на Delphi, кто на C++. Экстраполируя мой личный опыт, у большинства было так:

  • Сначала мы дёргаем функции и тягаем массивчики;

  • Потом «изобретаем» рекорды, чтобы не синхронизировать данные между несколькими массивами;

  • Далее наделяем рекорды поведением, чтобы было покороче;

  • Наследуем и переопределяем поведение, чтобы не возиться с флагами;

  • Изобретаем интерфейсы, чтобы избежать наследования;

  • Осваиваем паттерны, DI и т. д.

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

Мы не собираемся отказываться от ООП, особенно в свете того, как оно распространено на уровне фреймворков. Но я хочу показать, как и где оно естественным образом (и к месту) возникает внутри ФП и F#, в частности. Я начну с разбора маленькой абстрактной функции, наращу её до состояния, когда она обрушится под собственным весом, разрежу и перестрою её при помощи нового механизма, а затем снова перейду к наращиванию. Этот процесс будет многократно повторён в нескольких главах и в начале он не будет привязан к Godot (кроме примеров). Быть может, мне и стоило вынести пару глав в отдельный цикл, но я не решился выделить время на столь радикальное перекраивание. Тем, кто уже твёрдо стоит на ногах и пришёл сюда за адаптацией Godot, я предлагаю либо потерпеть, либо перемотать на 2 главы вперед. А те, кто только начинает свой путь, могут возрадоваться, ибо в данной главе, по выражению моих тестеров, я откровенно сюсюкаю, пытаясь настроить ваше восприятие кода в нужном ключе.

Всё есть выражение

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

Тема эта значительно шире, но я тоже начну с тернарки, ибо в «привычном» виде её у нас действительно нет. Её можно наколдовать самостоятельно, но в этом нет смысла, так как с задачей вполне справляется обычный if:

let color =
    if block.Position = cellUnderMouse
    then Colors.OrangeRed
    else Colors.White

Прикол в том, что в F# конструкция if ... then ... else является выражением, а выражение всегда возвращает какое-то значение. Даже когда оно «ничего не возвращает», оно на самом деле возвращает экземпляр типа Unit.

Unit (или unit) — это некий тип в моей вольной трактовке обозначающий достижимую пустоту. Он состоит из математики и большого красивого компиляторного хака, но в целом можно считать, что это синглтон, инстанс которого можно добыть через let instance = (). Данных в нём нет. Все его экземпляры/инстансы равны между собой. Поэтому чисто логически, если вы знаете, что где-то размещён (или только будет размещён) Unit, то вы заранее знаете значение в этой точке:

// Абсолютно детерминированный паттерн матчинг,
// ибо `let () = ()`,
// так что компилятор на него не пожалуется.
let () = GD.print "Эта функция реально возвращает `unit`."

Из-за этого в большинстве случаев unit не представляет интерес и им можно пренебречь:

let a =
    () // Игнорируется.
    () // Игнорируется.
    () // Игнорируется.
    GD.print "Что-то будет."
    42

// val a: int = 42

Что ненормально по отношению к другим типам:

let a =
    () // Игнорируется.
    () // Игнорируется.
    42 // Делает предупреждение, так как скорее всего число оказалось здесь по ошибке.
    ()

// ... warning FS0020: Результат этого выражения имеет тип "int" и неявно игнорируется. Используйте "ignore", чтобы явно отклонить это значение, например, 'expr |> ignore', или используйте "let", чтобы привязать результат к имени, например, 'let result = expr'.
// 
// val a: unit = ()

По этой же логике обрабатывается if ... then (без else). Если ветвь then возвращает unit, то компилятор может самостоятельно «дописать» else (), чтобы уровнять варианты:

if pointUnderMouse <> under.Point then
    pointUnderMouse <- under.Point
    dirtyAngle <- true
// else ()

Равенство ветвей классная штука, но она наряду с ещё несколькими пунктами является причиной того, что у нас нет раннего return. Мы не можем написать:

if not ^ canStand obstacles mapSize goal then None
// Продолжаем вычисления для остальных случаев.

Мы обязаны писать:

if not ^ canStand obstacles mapSize goal then None else
// Продолжаем вычисления.

Такой if не страшен, если возникает посреди последовательных вычислений. Но чаще чем хотелось бы, он находится в глубине матчинга, цикла и т. п., из-за чего приходится перестраивать весь алгоритм. В этом месте новички страдают, так как не обладают нужным набором технологий (railway, rec и т. д.). Однако с опытом вы обнаружите, что ранние return-ы — это важные семантические якоря и их хочется собирать в определённых узлах системы. Это не всегда возможно, но когда возможно, от этого выигрывают все.

Вызов метода является выражением, так что он обязан что-то возвращать, поэтому F# считает, что все методы void из C# возвращают unit. Нам также нужно, чтобы и obj.ToString(), и obj.ToString были выражениями, поэтому скобки в первом случае трактуются языком не как особая языковая конструкция, а как передача экземпляра unit в obj.ToString. Там, где это возможно, преобразование работает в обратную сторону, unit в C# будет интерпретирован в зависимости от контекста либо как void, либо как отсутствие параметров. Благодаря такой универсальности у нас нет разделения на Action и Func, так как и то, и то может возвращать unit:

System.Action = System.Func<unit> = unit -> unit
System.Action<int> = System.Func<int, unit> = int -> unit
// Равенство по смыслу не распространяется на типы / экземпляры. Типы, а значит и экземпляры, всё-таки разные.

Если быть точным, то нам просто не нужен Action, так как он является подмножеством Func, которое можно выразить обычным алиасом. Аналогично с «асинхронными функциями». Ни 'a Async (= Async<'a>), ни 'a Hopac.Job с его многочисленными наследниками не имеют необходимости в «пустой» версии, как это произошло с 'a Task, которому для представления unit Task потребовалось изобрести дополнительный слой в виде необобщённого Task. Эта универсальность распространяется дальше на пользовательский код, так что при переносе некоторых алгоритмов и библиотек с других языков можно обнаружить, что иногда число методов и типов сокращается раза в два.

При этом Unit всё ещё остаётся осязаемым типом, его можно хранить поштучно и в коллекциях:

let a = [
    ()
    ()
    ()
    ()
]

// val a: unit list = [(); (); (); ()]

Это, конечно, вырожденный случай, но представьте, что вместо списка речь идёт о канале передачи сообщений между акторами. Даже если вы пишете простой триггер и не планируете передавать какие-либо данные, у вас всё равно остаётся универсальный интерфейс для самого факта обмена. Остаётся одно ограничение, unit невозможно идентифицировать из obj, так как box () равен null. Ситуация аналогична Measure и Option.None, при закидывании в ECS перестрахуйтесь и используйте данные величины только внутри типов с чёткой идентификацией.

Ignore

У выражений есть новая нехарактерная для C# проблема. Если вы вызываете метод, который возвращает результат и даёт сайд-эффект, то велика вероятность, что вам нужен только сайд-эффект. Проблема в том, что результат вы должны обработать в любом случае.

Например, по клику мыши мы хотим удалить или добавить блок препятствия на поле:

if set.Contains block then
    set.Remove block
else
    set.Add block

И set.Remove, и set.Add возвращают true, если операция удалась. Очевидно, что при поставленном в if условии обе ветви вернут true, так что этот bool бесполезен как unit и его надо просто куда-то сплавить. Любой результат всегда можно засунуть в безымянную переменную:

let _ =
    if set.Contains block then
        set.Remove block
    else
        set.Add block

Это универсальный способ, о котором стоит помнить, когда вы работаете с ненужным IDisposable:

use _ = new DisposableSideEffect()
// ...

Или с билдером (CE):

job {
    // ...
    let! _ = // MySystem
        MySubsystem.init // MySubsystem Promise
    // ...
}

Но в большинстве случаев let является чрезмерной мерой. В глобальном скоупе есть функция ignore : 'a -> unit. Как явствует из названия, она просто игнорирует свой аргумент, благодаря чему пример можно переписать так:

if set.Contains block then
    set.Remove block
    |> ignore // : unit
else
    set.Add block
    |> ignore // : unit

Нельзя переоценивать ignore и воспринимать его как универсальное ключевое слово. Это всего лишь функция, которая ничего не знает о билдерах, поэтому она не превратит 'a Job в 'unit Job:

job {
    // Ошибка, так как специальная обработка `unit` в билдере отсутствует.
    do! client.UploadFile file
        |> ignore

    // Нет ошибки, но и загрузки файла не происходит,
    // так как мы просто выкинули 'FileId Job`, а не запустили её.
    do client.UploadFile file
        |> ignore

    // Всё ещё работает, но может не подойти по эстетическим соображениям.
    let! _ = client.UploadFile file

    // ...
}

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

  • Async.Ignore : 'a Async -> unit Async

  • Job.Ignore : 'a Job -> unit Job

  • Alt.Ignore : 'a Alt -> unit Alt

  • Stream.mapIgnore : 'a Stream -> unit Stream

  • и т. д.

Что позволяет писать код так:

job {
    do! client.UploadFile file // : FileId Job
        |> Job.Ignore // : unit Job
}

В практическом плане нет особой разницы между let! _ и do! ... |> Job.Ignore. Об этой тождественности надо помнить, ибо она также означает, что оба варианта дождутся завершения Job. Никакого «fire and forget» от Job.ignore не ожидается, для этих целей есть отдельные функции типа (queue, start и их варианты). Если проводить аналогию с C#, то:

  • let! и !do — аналоги await;

  • Job.Ignore — способ проигнорировать результат работы, но не её саму;

  • queue, start, Job.start и т. д. — способ проигнорировать именно работу («fire and forget»).

Последние два пункта в C# опциональны, но в F# они носят принудительный характер, если не выбран первый пункт. То есть вы обязаны либо явно интегрировать (await-ить) Job-у в билдер, либо явно её сплавить несмотря на Job.Ignore. Эта обязанность возникает благодаря выражениям, так что указанные правила работают для всех билдеров (async, task и т. д.). И это крайне полезное свойство. Оно стоит секунды рабочего времени, а взамен полностью избавляет нас от некоторых типовых ошибок.

Кстати, если let! отвечает за Job «с результатом», а do! за Job «без результата», то мы вроде как имеем две различные синтаксические конструкции, которые приводят к мысли, что хотя бы в билдерах F# возвращается к «привычной» дихотомии void VS все остальные. Это легко проверить на практике, так как билдеры в F# можно написать самостоятельно (вот здесь подробно рассказывают как). Было бы логично ожидать отдельный код для каждого ключевого слова. Однако если мы обратимся к контракту билдеров, то ничего подобного не обнаружим. Он, кажется, вообще не в курсе, что ему надо разбираться с do!. Объясняется это тем, что F# считает do! всего лишь сахаром над let! () =:

job {
    let! () = someUnitJob
    do! someUnitJob
}

Я хожу кругами «в поисках сюжетного твиста» не просто так, а чтобы вы обратили внимание на то, как концепция unit сокращает инфраструктурные расходы. Без неё количество требуемых методов в билдере увеличилось бы в 4 раза (x2 на вход, x2 на выход). Подозреваю, что этот момент был одной из причин, по которым C# отказался от билдеров в пользу ключевых слов async/await. В F# такой проблемы нет, из-за этого мы значительно легче переходим к созданию индивидуальных инфраструктур и очень редко рожаем общие.

Такое поведение пытаются списать на различия в психологии, но я вижу в этом лишь следствие материальной среды. Мы реально могём, поэтому нам чужого не надо, C#-еры не могут, и именно поэтому они так прижимаются к Microsoft. Когда-нибудь мы дойдём до разбора Godot.Callable, и там этот момент всплывёт ещё раз.

Если придираться, то стоит упомянуть, что F# всё же более терпим к do! и разрешает ему быть последним выражением в билдере. let! () = — такой привилегии лишён и после него требуется хоть какая-нибудь деятельность:

async {
    let! () = someUnitAsync
    ()
}

Я не пробовал, но, скорее всего, при должном усердии можно писать код вообще без do!. В этом вряд ли есть смысл, так как do! банально короче и при таком строгом «линейном» определении не может быть угрозой. Если кому-то неймётся «искать крыс в хате», то я бы в первую очередь присмотрелся к неосторожному ignore на каррированных функциях.

Эта штука действительно может отстрелить ногу.

Скоуп и изоляция сайд-эффектов

В F# есть ключевое слово do (без восклицательного знака). По чудесному совпадению оно означает let () = (без восклицательного знака). Есть места, где do надо писать обязательно, но в большинстве случаев его опускают так же, как явную обработку (). Это просто, даже слишком, так что при определённом стечении обстоятельств о его существовании можно не знать достаточно долго, чтобы вас уже перестали воспринимать как неофита (были случаи).

Сила do раскрывается в особых обстоятельствах. Во-первых, оно работает как явный типизатор для компилятора. Здесь f функция int -> string:

let g f = [
    f 42
    "Item"
]

А здесь int -> unit:

let g f = [
    do f 42
    "Item"
]

Этим же качеством обладает do!, хотя его типизирующие свойства могут быть испорчены недоработками конкретного билдера.

Во-вторых, и в главных, do может прятать переменные сайд-эффекта за счёт изоляции локального скоупа. Предположим, что нам таки понадобился «fire and forget». Например, в ходе разработки мы хотим вывести какую-то дополнительную информацию на экран, которая в контексте тайловых миров может быть картой без тумана войны или с целями и решениями искусственного интеллекта. Такое можно реализовать как включаемую опцию основной сцены, но гораздо удобнее, когда такая информация рендерится в отдельном окне, ибо в этом случае на новый вариант карты можно навесить новые кнопки, не усложняя основную сцену. Всю коммуникацию такого окна можно задать в момент спавна, после чего о его существовании можно забыть. Следующий код создаст новое окно вдоль правой границы основного и задаст ему ширину в одну треть родителя:

// По умолчанию окна встраиваются в основное.
// Внешнее существование включается через:
// window/subwindows/embed_subwindows=false

let mainWindow = this.GetWindow()

let size = mainWindow.Size
let debugWindow = new Window(
    Title = "Debug"
    , Transient = true
    , Position = mainWindow.Position + size.X_
    , Size = size.With(X = size.X / 3)
)

debugWindow.AddChild ^ DebugView.create model

this.AddChild debugWindow
debugWindow.Show()
mainWindow.GrabFocus()

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

  • Нужные ниже привязки: model и mainWindow;

  • Ненужные ниже привязки: size иdebugWindow;

  • Набор локальных действий сопровождающих спавн: AddChild, Show, GrabFocus.

Этот код располагается в начале метода _Ready, и если всё оставить как есть, то size и debugWindow останутся доступными на всём его протяжении. Иногда нам это на руку, но в большинстве случаев это не более чем загрязнение общей среды обитания. Если мы загоним этот код под do, то изолированные переменные не попадут в скоуп, хотя общий эффект будет идентичным:

let mainWindow = this.GetWindow()

do  let size = mainWindow.Size
    let debugWindow = new Window(
        Title = "Debug"
        , Transient = true
        , Position = mainWindow.Position + size.X_
        , Size = size.With(X = size.X / 3)
    )

    debugWindow.AddChild ^ DebugView.create model

    this.AddChild debugWindow
    debugWindow.Show()
    mainWindow.GrabFocus()

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

  • do сохраняет доступ к привязкам внешнего скоупа. Блок знает их тип, так что в отличие от функции мне не надо повторно объяснять компилятору, что из себя представляет model. Сейчас это может прозвучать странно, но бывают ситуации, когда тип потенциального параметра настолько трудно выразить, что мы прибегаем к очень хитрым рефакторингам в стремлении указать его непрямо;

  • do уничтожает привязки (не объекты) после закрытия. size и debugWindow после него просто не существуют;

  • Визуально do явно очерчивает зону ответственности, так что намётанный глаз умеет автоматически:

    • Либо отсекать такие блоки как неинтересные;

    • Либо сосредотачиваться на каком-то из них, если становится ясно, что решение задачи прячется именно здесь;

  • Если какая-то привязка внутри потребуется где-то ещё, то её можно оперативно вытащить из блока в общий скоуп (или засунуть обратно, если такая потребность исчезнет);

  • И при этом такой блок всё ещё остаётся хорошей заготовкой под функцию.

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

let <property><part1> = загрузка данных
let <property><part2> = загрузка данных
// Часто наряду с самим <property><part_> используются его части,
// например, <property><part_>.Length/Size и т. п.
this.<property> <- комбинирование <property><part1> и <property><part2>

// Повторить до посинения с небольшими непараметризуемыми отклонениями.

Некоторые свойства и части из разных блоков могут быть связаны между собой по плану, но защитить себя от случайной связи при такой открытой записи почти невозможно. Блок do почти исключает такие связи за счёт принудительной изоляции:

do  let <part1> = загрузка данных
    let <part2> = загрузка данных
    this.<property> <- комбинирование <part1> и <part2>

Имена локальных переменных можно упростить с учётом контекста. Тогда одинаковые имена в разных контекстах будут подчёркивать их схожесть между собой (если она есть). В противовес редкие имена из общего скоупа будут выбиваться из общего ряда даже на уровне периферийного зрения.

Кстати, do поддерживает вложенность для контролируемого распределения, так что возможны иерархии вида:

do  let localSharedPart = ...
    
    do  let <part1> = ...
        let <part2> = ...
        this.<property1> <- ...
    
    do  let <part1> = ...
        let <part2> = ...
        this.<property2> <- ...

Изоляция фабрик

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

f x
g y
h z

Утверждение выглядит зыбко, это всё же не цепочка пайпов (|>), поэтому воспользуемся моделью абстрактного синтаксического дерева (AST) из компилятора. В нём понятию «выражение» соответствует тип SynExpr. Это большое размеченное объединение (DU), в котором каждый кейс соответствует какой-то типовой конструкции в выражении. Конкретно эти три строки будут выражены таким SynExpr:

SynExpr.Sequential(
    f x
    , SynExpr.Sequential(
        g y
        , h z
    )
)

// f x = SynExpr.App(SynExpr.Ident f, SynExpr.Ident x)

И даже когда мы засовываем всё это в do, то мы лишь упаковываем SynExpr.Sequential в SynExpr.Do. В итоге мы видим, что на всех этапах работа идёт с одним и тем же типом нод. Не происходит никакого перехода в принципиально иное пространство, чтобы разложить там комплект из нескольких SynExpr. Всё разрешается в рамках одного древа несмотря на то, что содержимое строк не связано друг с другом явным образом.

Из чего следует, что компилятор видит do 42 и встаёт на дыбы не из-за синтаксиса. Он точно знает, что let () = 42 является ложным утверждением и именно поэтому он на нас орёт. Но если в левой части будет что-то более подходящее, то ошибки не возникнет:

let debugWindow = 
    let size = mainWindow.Size
    new Window(
        Title = "Debug"
        , Transient = true
        , Position = mainWindow.Position + size.X_
        , Size = size.With(X = size.X / 3)
    )

Тот же ход справедлив для мутаций:

this.<property> <-
    let <part1> = загрузка данных
    let <part2> = загрузка данных
    комбинирование <part1> и <part2>

Иногда последний кейс выглядит слишком рыхлым, поэтому его могут написать «по старинке» через do или обернуть в скобки:

this.<property> <- (
    ...
)

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

new Window(
    Title = "Debug"
    , Transient = true
    , Position = mainWindow.Position + mainWindow.Size.X_
    , Size = ( // Без этих скобок код некорректен.
        let size = mainWindow.Size
        size.With(X = size.X / 3)
    )
)

Cкобки являются единственным способом изолировать генерацию элемента внутри списочного билдера ([], [||], seq {} и т. д.). Даже явный yield в этом деле не поможет:

let items = [
    42
    32
    (
        let a = 2
        a * a + a
    )
]

Скобки даже можно использовать вместо do-блока, но лично мне такой код рефакторить неудобно. Парсер компилятора почти всегда считает скобки ключевым ориентиром, поэтому удаление/добавление одной скобки часто оборачивается абсолютно неадекватной разметкой файла ниже области редактирования. Найти вторую половинку в условиях отсутствующих подсказок бывает сложно, начинаются пляски с ctrl+z и т. д. Поэтому скобки остаются только для тех мест, где необходимы именно они (в том числе из-за читаемости). Всё остальное должно разруливаться через односторонние токены.

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

Разбор вариантов укладки выражений частично продолжится в следующей части, но в целом здесь я его прекращаю и признаюсь, что после стольких лет я всё ещё сталкиваюсь с новыми для себя вариантами. Чаще всего меня это радует, но это также значит, что вызубрить всё практически невозможно. Поэтому знакомьтесь с базой, а дальше просто пробуйте, подсматривайте ходы в чужих репозиториях и комбинируйте. Действительность подскажет хорошие варианты, а также подсветит несостоятельность некоторых теорий в контексте F#. Например, критика «pyramid of doom» должна быть подвергнута серьёзной ревизии, так как обозначенный «doom» возникает не всегда и зависит от каких-то других критериев.

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

Есть широко известная статья про то, что человек может держать в локальной памяти не более 7(+-2) объектов. К ней апеллируют по поводу и без, в том числе для защиты откровенно плохих решений, так что озвученное правило уже приобретает негативные коннотации. Я подозреваю, что как обычно в исходнике граница сферы применимости (и особенно неприменимости) задана гораздо конкретнее, а публичная формулировка является очень грубым пересказом оригинала. Тем не менее человеку трудно ориентироваться в большом пуле одноранговых значений. С этим можно бороться по-разному, но на мой взгляд, F# в это деле настолько преуспел, что если бы не аморфность темы, то он мог бы построить на ней свою пиар-компанию.

Менеджмент и его приспешники часто неправильно оценивают любовь джунов к пайпам (|>) как проявление юношеской увлечённости, проводят ложные аналогии от молодёжных субкультур до пословиц про дурака, лоб и Бога. Начальный этап действительно сопровождается не слишком вредными, но всё-таки эксцессами. Однако нужно понимать, что такую тягу вызывает отнюдь не реакция на свеженькое, а резкое и вполне осязаемое увеличение эффективности, не сопровождающееся заметными побочными эффектами.

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

Когда я ковыряю данные в REPL-е и обнаруживаю аномалию, то я раз за разом вклиниваюсь в пайплайн за пару строк до финиша и начинаю закидывать туда новые фильтры, квери и вьюхи. Через час таких занятий у меня образуется конвейер в 20 строк, за которым идёт ещё 100 строк какой-то невалидной белиберды. Причём последнее не всегда мусор, и что-то из него стоит подправить и закинуть в тесты. Пример не совсем честный, так как я здесь явным образом выбираю исполняем код, но он показывает, насколько для нас удобно и важно иметь устойчивую инкрементально наращиваемую базу.

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


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

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


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

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


  1. Dhwtj
    17.10.2024 04:36

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

    человек открыл для себя чистые функции ))


    1. kleidemos
      17.10.2024 04:36

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