Menu

Zero Raises и Check: явный отказ без исключений

Функции Zero объявляют свои режимы отказа через raises, а вызывающие подтверждают их через check. Как работает система, почему нет тихих выбросов и как это взаимодействует с capability World.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Постановка

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

  • raises в сигнатуре функции — «эта функция может упасть».
  • check в месте вызова — «если это упадёт, провали мою функцию той же ошибкой».

Этой комбинации достаточно, чтобы выразить то, ради чего большинство языков тянется к try/catch или к типу Result.

Объявление fallible-функции

Добавьте raises после возвращаемого типа:

fun validate(ok: Bool) -> i32 raises { InvalidInput } {
    if ok == false {
        raise InvalidInput
    }
    return 42
}

Сигнатуру можно прочитать двумя способами:

  • «validate возвращает i32 или выбрасывает InvalidInput».
  • «Набор возможных исходов — { i32, InvalidInput }».

Оба прочтения корректны. Компилятор отслеживает обе возможности и требует, чтобы вызывающие что-то явное сделали с каждой.

Можно написать и голый raises:

pub fun main(world: World) -> Void raises {
    check world.out.write("hello\n")
}

raises (без списка ошибок) означает «эта функция может упасть с любой ошибкой». На main это привычная форма — программа может выйти с ненулевым статусом, если что-то пошло не так, а рантайм возьмёт на себя оповещение об ошибке.

Для функций глубже в стеке вызовов предпочитайте явную форму raises { ErrorA, ErrorB }, чтобы режимы отказа были задокументированы на каждом уровне.

Выброс ошибки

Внутри fallible-функции raise выходит из функции с заданной ошибкой:

fun validate(ok: Bool) -> i32 raises { InvalidInput } {
    if ok == false {
        raise InvalidInput
    }
    return 42
}

raise InvalidInput производит ошибку InvalidInput в этой строке. Функция не продолжает дальше — управление возвращается к вызывающему, и вызывающий видит ошибку вместо i32. Clause raises перечисляет единственные типы ошибок, которые функция вправе выбрасывать; выбросить что-то не из списка — compile error.

Проброс ошибки через check

Вызывающий, дёрнувший fallible-функцию, обязан признать возможный отказ. Самое частое признание — check:

fun run() -> Void raises { InvalidInput } {
    check validate(true)
}

check validate(true) делает две вещи:

  1. Вызывает validate(true).
  2. Если validate выбросила ошибку, пробрасывает её выше — run выбрасывает ту же ошибку своему вызывающему.

Чтобы проброс был разрешён, run обязан в своём raises clause объявить, что может выбрасывать InvalidInput (или что-то совместимое). Компилятор это проверяет. Если бы сигнатура run говорила raises { OtherError }, проброс не скомпилировался бы, потому что InvalidInput не входит в этот набор.

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

Тип ошибки путешествует с сигнатурой функции до самого верха стека вызовов. Голый raises у main принимает всё, что может выбросить run, поэтому проброс приземляется безопасно.

Почему не try/catch?

Дисциплина дизайна за raises/check в том, что отказ никогда не невидим в месте вызова. В языке с try/catch исключение может тихо пройти через функцию, которая даже не знает, что в этом участвует — функция выглядит чистой, но какой-то глубокий вызов в её теле выбрасывает, и исключение раскручивается через неё.

Это удобно автору выбрасывающего кода. Это дорого для всех остальных:

  • Читатели (и агенты) не могут по сигнатуре понять, участвует ли функция в путях отказа.
  • Рефакторинг становится нервным — перенос вызова между функциями может изменить достижимые исключения.
  • Восстанавливающий код живёт далеко от места, которое знает, что делать.

Zero платит цену заранее — аннотации на каждой fallible-функции и check в каждом fallible-вызове — чтобы получить свойство «режимы отказа, в которых участвует функция, видны из её сигнатуры». На это свойство могут полагаться и люди, и агенты.

Почему просто не Result<T, E>?

То же самое можно выразить через choice — тип Result<T, E> с вариантами ok и err. Zero даёт вам и этот паттерн; это полезный инструмент, когда отказ — это данные, которые вы хотите осмотреть, сохранить или передать.

raises/check добавляет синтаксическое соглашение для частого случая: «если это упадёт, провали мою функцию таким же способом». Без него каждый вызов оборачивался бы в match, который почти всегда переупаковывает ошибку в собственный Result вызывающего. check — это сокращение для этого, причём компилятор гарантирует, что проброс хорошо типизирован.

То есть:

  • Result<T, E> (choice) — когда вы хотите инспектировать отказ или нести его как значение.
  • raises + check — когда вы просто хотите пробросить отказ выше по стеку.

Оба доступны; они покрывают разные эргономические нужды.

Несколько типов ошибок

Функция может выбрасывать больше одного типа ошибки:

fun parse(input: String) -> i32 raises { Empty, Malformed } {
    if std.mem.len(input) == 0 {
        raise Empty
    }
    // ... логика парсинга ...
    raise Malformed
}

Вызывающий может:

  • Пробросить через check parse(input), если в его сигнатуре перечислены и Empty, и Malformed (или их супермножество).
  • Обработать одну или обе ошибки явно через match или try-подобные конструкции, которые язык предоставляет для этой цели.

Точный синтаксис тонкой обработки (матчинг по конкретным типам ошибок vs. проброс остальных) — одна из поверхностей, которая может пошевелиться в pre-1.0 Zero. Контракт — каждый тип ошибки, который функция может выбросить, в её сигнатуре — это стабильная часть.

Ментальная модель

Система отказа в Zero — это строго-честная версия того, что есть у каждого императивного языка:

КонцептTry/CatchZero
Пометить функцию как fallibleничего (неявно)raises { ... }
Выбросить ошибкуthrow eraise E
Пробросить вышеподнимается невидимоcheck call(...)
Обработать локальноtry { ... } catch(e) { ... }match по возвращённому Result или типизированная форма обработки

Поведенческая разница маленькая. Аннотационная — большая. И это специально. Эффекты — явные. Отказы — это эффекты.

Стиль

  • Используйте check щедро. Это правильный дефолт, когда у вас нет ничего конкретного, что делать на этом слое.
  • Избегайте голого raises на внутренних помощниках. Чем у́же набор ошибок, тем полезнее сигнатура.
  • Спаривайте fallible-операции с capability, которые им нужны. Функция, использующая World и пишущая в stdout, почти всегда хочет raises, потому что записи могут падать.

Дальше: JSON-диагностика

raises/check работает рядом с диагностиками компилятора Zero — когда вы пишете check против функции, которая не объявляет правильные ошибки, компилятор скажет вам, что именно не так, в структурированной форме. Следующая статья — JSON-диагностика — машинно-читаемый поток, который агент читает, чтобы чинить код.

Часто задаваемые вопросы

Что означает raises в Zero?

raises в сигнатуре функции объявляет, что функция может упасть. Голый raises разрешает любой тип ошибки. Конкретная форма вроде raises { InvalidInput } ограничивает функцию падением только перечисленными типами ошибок. Вызывающие обязаны признать возможность отказа — либо через check, либо через другую явную форму обработки.

Что делает оператор check?

check expr вычисляет expr, и если результат — ошибка, пробрасывает её выше, к вызывающему текущей функции. Думайте о нём как «выполни это, и если упадёт, провали моего вызывающего той же ошибкой». Чтобы проброс был разрешён, у вызывающего clause raises должен сам объявлять, что он может выбросить совместимую ошибку.

Как выбросить ошибку в Zero?

Используйте raise ErrorName внутри функции, в clause raises которой эта ошибка перечислена. Пример: if ok == false { raise InvalidInput }. Функция выходит в этой точке; ошибка становится исходом функции, который вызывающий может перехватить через check.

Почему в Zero нет try/catch?

Try/catch позволяет исключениям тихо проходить через функции, которые о них не знают. Дизайн Zero такой, что сигнатура каждой функции обязана признавать режимы отказа, в которых она участвует. Никакого скрытого потока управления — если функция может упасть, её сигнатура это говорит, и каждый вызывающий обязан явно признать это через check.

Может ли функция в Zero выбрасывать несколько типов ошибок?

Да — перечислите их в clause raises { ... } через запятую (или согласно текущему синтаксису Zero для наборов ошибок). Набор возможных ошибок — часть контракта функции, наравне с типами параметров и возвращаемых значений. Вызывающие могут паттерн-матчить по тому, какая ошибка выброшена, или просто пробрасывать через check.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ