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

Шестидесятилетний заключённый и лабораторная крыса. F# на Godot. Часть 5. Ошибки и исключения

Уровень сложностиПростой
Время на прочтение13 мин
Количество просмотров835

Мы закончили обсуждать тело функции, и теперь пришло время вывода данных. Простые сценарии мы сразу отбросим, так как по ним уже хорошо прошлись, когда изучали выражения. Мы начнём с косяков выполнения, под которыми я подразумеваю не баги, а непреодолимые препятствия с различной степенью неожиданности. Это может прозвучать странно, так как аварийный выход не подпадает под определения output, codomain и т. д. Я с этим согласен и пересматривать эти термины не собираюсь. Однако меня интересует не только легитимная часть, но и вообще всё, что выходит из функции. Вплоть до того, что в следующих главах я начну включать в это аморфное понятие сайд-эффекты, фоновые процессы и много чего другого.

Я начал с ошибок, потому что Godot эту тему фактически проигнорировал, и вряд ли за ненадобностью, так как несколько раз мне уже было очень больно. У меня всё ещё не дошли руки покопаться в GDScript, так что я понятия не имею, вызван ли этот пробел ограничениями языка или архитектурным решением, но в любом случае нам его надо закрывать.

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

Функции с условиями

В третьей главе я использовал метод-расширение TryNext:

let en = items.GetEnumerator()

let rec waitNext least leastPriority =
    match en.TryNext() with // `TryNext` - type extension
    | None ->

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

type System.Collections.Generic.IEnumerator<'a> with
    member this.TryNext () = 
        if this.MoveNext() then Some this.Current else None

При помощи TryNext мы выдёргиваем из коллекции следующий необработанный элемент. Если коллекция закончится, то метод вернёт None вместо Some item. MoveNext в данном случае выступает как одноразовый флаг, обозначающий корректность находящегося в Current значения. Очевидно, что время жизни свойства Current гораздо больше времени жизни любого отдельного item. Более того, оно превосходит сумму жизней всех item. В переводе на русский это означает, что Current в отрыве от TryNext может быть ошибочно:

  • Взят тогда, когда item не существует,

  • Интерпретирован, как item из другой итерации (в плане обнаружения одна из худших форм бага).

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

Мне редко надо работать с IEnumerator, так что не могу сказать, что Current — это эпическая проблема. Скорее, просто ещё один источник бессмысленной ошибки уровня «один раз вляпаетесь, запомните на всю жизнь». Пока всё идёт хорошо, я выбираю ветку под настроение. Но если что-то пошло не так, то переход на TryNext — хороший способ проверить алгоритм на вшивость.

В C# того же эффекта достигают при помощи out-параметра, как например в TryParse или TryGetValue. При такой структуре флаг корректности и само значение получаются одноразовыми, как и должно быть. В F# out параметры по возможности (алгоритм хитрый) перекидываются в результат, так что сигнатура функции «преобразуется» в -> bool * 'item. Эта пара встречается достаточно часто, чтобы в FsToolkit.ErrorHandling завезли функцию Option.ofPair : bool * 'a -> 'a option. С ней TryNext может выглядеть так:

member this.TryNext () =
    Option.ofPair (this.MoveNext(), this.Current)
    // В целом лишнее обращение к `Current` не рекомендуется.

Схему с out в API Godot не завезли. Большинство методов поиска в случае неудачи просто возвращают null. Некоторые из них могут попутно сообщить об ошибке в логи, что, по-моему, почти бесполезно. Бросать исключения движок не будет.

В F# так не принято. От «добывающих» методов с высокой вероятностью ошибки мы ждём сигнатуру -> 'a option (реже -> Result<'a,_>). Для C#-ных фреймворков такие методы надо дописывать самостоятельно:

type Node with
    member inline this.hasNode path = // string -> bool
        this.HasNode ^ NodePath.op_Implicit path
    member this.tryGetNode path : #Node option =
        if this.hasNode path
        // NB: GetNode -- необобщённый.
        then tryUnbox ^ this.GetNode path
        else None

Вместо GetNode можно взять GetNodeOrNull. Суффикс OrNull вводит в заблуждение, так как оба метода могут вернуть null, просто первый жалуется об этом в логи, а второй — нет. Так что по смыслу GetNodeOrNull лучше было назвать GetNodeMutely. С ним tryGetNode может выглядеть так:

type Node with
    member this.tryGetNode' path : #Node option =
        NodePath.op_Implicit path
        |> this.GetNodeOrNull<_>
        |> Option.ofObj

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

member this.TryGet key = ...
member this.Has key =
    this.TryGet key
    |> Option.isSome
member this.Get key =
    match this.TryGet key with
    | Some value -> value
    | None -> failwith "key not found"

Исключения

Следует понимать, что tryGet нужен для ситуаций, где мы знаем, что делать с None (включая ожидаемый возврат выше по стеку). Если мы его не ждём, а None всё равно происходит, то ситуация становится исключительной и её надо глушить исключениями (все обратили внимание на общий корень?!). Это можно сделать руками на месте:

let camera : Camera2D =
    match node.tryGetNode "Camera" with
    | Some camera -> camera
    | None -> failwith "Camera not found"

let hud : CanvasLayer =
    node.tryGetNode "HUD"
    |> Option.defaultWith ^ fun () -> failwith "HUD not found"

Но если таких мест становится много, стоит завести отдельный метод. getNode из первой главы было легко объяснить, но пользоваться им оказалось неудобно, ибо он не падал. Вместо него падал мой код, причём в большинстве случаев на значительном расстоянии от вызова getNode. Так что я выкинул старую обёртку и вместо неё завёл собственную версию. Она бросается исключениями и может выглядеть так:

member this.getNode' path : 'a when 'a :> Node =
    NodePath.op_Implicit (path : string)
    |> this.GetNodeOrNull // NB: Не дженерик версия.
    |> function
        | null -> failwith $"%s{path} not found"
        | :? 'a as node -> node
        | unexpected ->
            failwith $"Unexpected type. Expected: %s{unexpected.GetType().FullName}. Actual: %s{typeof<'a>.FullName}"

Здесь мы «внезапно» вспоминаем, что None кейс в действительности объединяет сразу два негативных сценария. Искомая нода может отсутствовать, а может иметь иной тип. По канону эти ситуации должны отображаться разными типами исключений, так как вполне возможно, что кто-то попытается их поймать и обработать. Здесь можно поступить как в C#, то есть заменить failwith на raise ^ MyException() и указать возможные исключения в комментариях к методу. Это полностью корректный подход, но иногда стоит поступить чуть иначе.

Если все ожидаемые ошибки оформить в виде DU, то можно вместо выброса исключения вернуть Result<_,_>. Это готовый самодостаточный объект, который можно подержать в руках до того, как он шибанёт (или нет) исключение по стеку:

module GetNode =
    // Вне модуля будет называться `GetNode.Error`
    type Error =
        | NotExists
        | UnexpectedType of Actual : Node

    let verbose node path =
        NodePath.op_Implicit (path : string)
        |> (node : Node).GetNodeOrNull
        |> function
            | null -> Result.Error NotExists
            | :? 'a as node -> Ok node
            | unexpected -> Result.Error ^ UnexpectedType unexpected

Объектное выражение ошибок чрезвычайно удобно в системах, ориентированных на передачу сообщений (ECS в том числе). Но даже если мы такими системами не пользуемся, Result всё равно нам пригодится, ибо в лице GetNode.verbose мы получаем исчерпывающее описание функции поиска ноды. Вся информация о данной операции находится в сигнатуре функции, и ничего важного за её пределами не существует.

Эффект схож с преобразованием непрерывного пространства в дискретное. Пока противник может атаковать вас только с вооон тех 4 гексов, вы имеете просчитываемую задачку. А когда в дело вступает дюймовая линейка, то открывается портал в ад простор для манчкинизма и простых человеческих ошибок.

Так как verbose отображает ситуацию во всей полноте, мы можем выразить остальные методы как его проекцию. Например, заготовка под tryGet выглядит так:

    ...

    let safe node path =
        verbose node path
        |> Result.toOption

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

    ...

    // Вне модуля оно будет называться `GetNode.Exception`
    exception Exception of Error

    let unsafe node path = // _ -> #Node
        verbose node path
        // Методы с суффиксом With обладают ленивостью.
        |> Result.defaultWith ^ fun err -> raise ^ Exception err

С этого момента мы можем вылавливать Error через систему исключений:

type Node with
    member this.getNode path =
        GetNode.unsafe this path

try main.getNode "Camera"
with
| :? GetNode.Exception as err ->
    match err with
    | GetNode.NotExist ->
        let camera = Camera2D.createMainCamera()
        main.AddChild camera
        camera
    | GetNode.UnexpectedType unexpectedNode ->
        reraise()
| _ ->
    reraise()

Тут у меня возникли серьёзные проблемы с компактным и одновременно целесообразным примером. На таких коротких дистанциях надо использовать this.tryGetVerbose, но переходить на другую функцию не хотелось, так что игнорируем бессмысленность и запоминаем синтаксис.

Пока у нас есть доступ к GetNode.verbose, у нас сохраняется возможность влезть в момент между ошибкой и выбросом исключения. Отсюда можно как «прервать» выброс, так и направить его по другой схеме:

type GetNode.Error with
    member this.Raise path =
        match this with
        | GetNode.NotExist -> raise ^ new NodeNotFoundException(path)
        | GetNode.UnexpectedType node -> raise ^ new System.InvalidCastException($"{path} has type {typeof<node>.FullName}")

type Node with
    member this.getNode path =
        GetNode.tryGetVerbose this path
        |> Result.defaultWith ^ fun err -> err.Raise()

Пока черновик этой статьи шёл к своей публикации, на Медиуме вышла статья (на английском, но от русскоязычного автора), где описывается обратный подход. Вместо того, чтобы упаковывать ErrorDU в Exception, её автор предлагает упаковывать Exception в ErrorDU. Этим решается застарелая проблема чрезмерного разбухания древа ошибок. В UI-ных приложениях она стоит не так остро, но всё равно стоит знать о существовании такой «игровой механики», как | UnexpectedError of exn. По идеологическим причинам эту возможность не всегда замечают, но я иногда её использую как быстрорастворимое решение.

Размещение ошибок

Здесь могут возникнуть вопросы по общей организации кода. Так как расширения типов не имеют готовой механики override, то для ремапа желательно называть локальную версию другим именем или вовсе не иметь других определений getNode. Последнее, кстати, не редкость, ибо я могу спокойно положить GetNode.verbose в nuget-пакет и посчитать своё дело сделанным. Самая сложная логика в нём уже есть, а остальные «хелперы», если они понадобятся, будут реализованы на конкретном проекте сообразно мнению его участников. Звучит очень ресурсоёмко, но всё зависит от задач. В .fsx-скрипте нам нужны готовые методы, но чем больше проект, тем больше профит от «переопределения». Скажем, я могу моментально засунуть временный трассер во все вызовы метода, пока у меня есть доступ к его телу.

По этим же соображениям методы в типе были заменены на функции в модуле. Модуль ещё надо дотащить до типа, из-за чего его легко проигнорировать или не заметить. (Это свойство обоюдоострое. Здесь оно нам на руку, но иногда от него бывает больно.) Кроме того, модули позволяют изымать сопутствующие служебные типы из общего скоупа.

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

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

Как правило, это снимает с нас одну из двух главных проблем программирования, но иногда нас подводит инфраструктура. Например, довольно быстро обнаруживается, что некоторые библиотеки используют Type.Name в качестве ключа. Очевидно, что такие ключи неуникальны, и вместо них надо брать хотя бы Type.FullName. Такие либы надо исправлять, но что делать в случае, когда это невозможно, я не знаю. Пока мне везло, всякий раз нам удавалось выкручиваться за счёт особенностей ТВД.

Чисто для понимания масштабов проблемы. По работе я пилю UI, и на каждой странице или в мало-мальски сложном компоненте может зародиться собственная версия какого-нибудь User. Их число на большом проекте может уезжать за сотню. А если использовать схему с Main-типами, то ситуация становится ещё хуже.

У типов в модулях есть ещё одна негативная черта. Они плохо приспособлены для пакетного распространения. Если переименовать модуль или просто перенести определение типа из одного модуля в другой (что случается довольно часто), то все зависимые библиотеки не найдут данный тип в рантайме. Алиасы слегка улучшают положение, но только при повторной компиляции. Таким образом возникает жёсткая привязка к конкретным версиям библиотек (= вместо >=). Если хранить все типы в общем скоупе, то подобная проблема возникает значительно реже.

Формально библиотечный код находится на более высокой стадии развития, но я пишу его нечасто и не вижу смысла в распространении его практик на код обычного приложения. Подходы к их разработке должны быть разными, так как времени на поддержку того же уровня универсальности просто нет. Гораздо лучше потратить его на Hedgehog. Так что не надо избегать модулей, лишь потому что вы когда-то гипотетически опубликуете свой плагин. Во-первых, неизвестно, когда это будет, и будет ли вообще. Во-вторых, к моменту публикации вы, скорее всего, так намахаетесь, что структура типов приобретёт стабильность.

В качестве образца можно посмотреть на Godot, где используется схожий подход. Например, тип Control содержит целый ворох вложенных типов для позиционирования в макете (Control.GrowDirection, Control.LayoutDirectionEnum и т. д.), контроля фокуса (Control.FocusModeEnum) или отслеживания мыши. Его наследник TextEdit, дополняет список режимов каретки, а настройки текста определены в TextServer, который вообще выпадает из иерархии Node. Всё это отлично работает, и этим гораздо удобнее пользоваться, чем свободно болтающимися типами. Говорю, как человек, который вынужден одновременно сидеть на нескольких UI-фреймворках. Я уже старенький. Мне достаточно 10 часов разработки «там» для того, чтобы напрочь забыть название аналога TextBox «здесь». С мелочёвкой ситуация ещё хуже.

Godot.Error

Я верю, что в аду есть особый котёл для тех, кто пихает тип (или кейс DU) с именем Error в общий скоуп. Разрабы Godot в нём тоже будут.

Godot.Error — это enum. Большинство enum-ов в Godot идут с соответствующим суффиксом, но кто-то посчитал, что именно здесь надо этот суффикс убрать. Для F# это решение обернулось серьёзным геморроем. Godot.Error почти всегда затирает уже существующий кейс Result.Error, поэтому его приходится писать в полной форме (как во всех примерах цикла). По идее можно подшаманить с неймспейсами/модулями и добиться обратного перекрытия для большинства случаев, но всё будет держаться на соплях, и каждый перенос кода будет сопровождаться риском некорректной интерпретации (иногда даже компилируемой). До тех пор, пока кто-нибудь не заменит текущий SDK, проще везде перейти на Result.Error.

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

В Godot.Error аж 49 кейсов. Это слишком много для ручного разбора. Первый из них это Ok (полное имя — Error.Ok, задержите дыхание и просмакуйте момент), его возвращают, если всё прошло штатно. Остальные кейсы описывают ошибки, которые находятся в самых разных сегментах приложения:

type Error =
    | Ok = 0L
    | Failed = 1L
    | Unavailable = 2L
    | Unconfigured = 3L
    | Unauthorized = 4L
    | ParameterRangeError = 5L
    | OutOfMemory = 6L
    | FileNotFound = 7L
    ...
    | FileEof = 18L
    ...
    | Timeout = 24L
    ...
    | CompilationFailed = 36L
    | MethodNotFound = 37L
    ...
    | ParseError = 43L
    ...
    | Bug = 47L
    | PrinterOnFire = 48L

Как видим, они не могут оказаться в одном месте, так что на практике получается какой-то издевательский вариант лотереи «угадай 7 из 49». Мы должны выяснить из документации перечень возможных ошибок конкретного метода, а после — обработать их руками. Компилятор в этом деле помочь не сможет, поэтому для удобства некоторые методы я заменил хелперами, которые ремапят Godot.Error в специальные типы ошибок.

На мой взгляд, толку от подобной типизации мало. Метод выглядит как нечто контролируемое (типа GetNode.verbose), но ощущается как классический отлов исключений. Всё равно что добавить Doodle Jump с грифонами посреди Elven Legacy. В целом, подход имеет право на жизнь (у King's Bounty: Legend получилось), но из-за конфликта с Result.Error я хочу его сжечь. Было бы гораздо лучше, если бы Godot.Error вообще не существовал, а вместо него использовались простые числовые коды, как было сделано в нотификациях.

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

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

Я никак не затронул тему дальнейшего существования Resultoption). Кажется, всякий раз они просто сгорали в пламени Exception. Считайте это особенностью представленных примеров и предметной области. Всё-таки трудно исправить ситуацию, если что-то без вашего ведома снесло вашу дочернюю ноду. Сошлюсь на разрабов Godot, у них HScrollBar и VScrollBar в ScrollContainer являются полноценными нодами, поэтому, как сказано в документации, их можно удалить и тем самым вызвать crash. Регенерировать ScrollContainer отказывается.

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

Result и option — очень популярная тема. Если убрать технические аспекты (затронутые в этой и прошлой главах) и оставить только математическую абстракцию, то можно получить одну из ключевых ФП-фишек, которая доступна для понимания новичков. Поэтому через неё активно пиарят F#, и вы можете найти соответствующие материалы, поискав railway oriented programming (ROP). Любители холиваров могут ещё погрузиться в секцию баталий с эксепшенами.

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


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

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

Публикации

Информация

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