Обработка ошибок
В Python исключение может прилететь из любой строки: вы никогда не знаете заранее, бросит функция ошибку или нет, пока не прочитаете её код целиком вместе со всем, что она вызывает.
В Mojo способность бросать исключение — часть сигнатуры. Компилятор следит за этим так же строго, как за типами.
raises — обещание в сигнатуре
Заголовок раздела «raises — обещание в сигнатуре»Функция, которая может бросить исключение, обязана это объявить:
def parse(text: String) raises -> Int: return Int(text)Слово raises ставится после круглых скобок и перед стрелкой. Забудете —
компилятор не пропустит:
error: 'raise' requires a surrounding 'try' block or the enclosing function to declare 'raises'
Внутри функции есть raise, но в сигнатуре нет raises. Обещание
и поведение разошлись.
Либо допишите raises в сигнатуру и переложите обработку на вызывающий
код, либо обработайте ошибку прямо здесь, в try / except.
Вызов тоже под контролем
Заголовок раздела «Вызов тоже под контролем»Обещание работает в обе стороны. Вызвать бросающую функцию из обычной нельзя:
error: cannot call function that may raise in a context that cannot raise
Вы вызвали функцию с raises из функции без raises и без try.
Ошибке некуда деваться.
Компилятор сам подсказывает два выхода — они же и есть единственные:
note: try surrounding the call in a 'try' blocknote: or mark surrounding function as 'raises'Первое — обработать здесь. Второе — передать ответственность выше.
Именно поэтому в примерах курса нередко встречается def main() raises: —
это верхний этаж, дальше передавать некому.
def load(path): # бросает или нет? return open(path).read()def load(path: String) raises -> String: ... # видно из сигнатурыВ Python, чтобы узнать это, нужно прочитать тело функции и всё, что она вызывает. В Mojo достаточно одной строки сигнатуры — и компилятор не даст ей соврать.
Ошибка — это значение
Заголовок раздела «Ошибка — это значение»raise принимает значение типа Error — или просто строку, которая
в него превратится:
def check(age: Int) raises: if age < 0: raise Error("возраст отрицательный") if age > 150: raise "возраст слишком большой"
def main(): try: check(-5) except e: print(e)возраст отрицательный
У Error есть только текст сообщения. Для многих программ этого
хватает. Но если вызывающему коду нужно больше — какое поле неверно,
в какой позиции сломался разбор, повторять ли попытку, — объявите
свой тип ошибки.
Свой тип ошибки
Заголовок раздела «Свой тип ошибки»Ошибкой может быть любая структура. Структуры подробно разобраны
в следующей главе, «Структуры»; здесь хватит двух
вещей: @fieldwise_init создаёт конструктор из полей по порядку,
а метод write_to делает значение печатаемым.
@fieldwise_initstruct AgeError(Copyable, Writable): var text: String var reason: String
def write_to[W: Writer](self, mut writer: W): writer.write("«", self.text, "» — ", self.reason)
def parse_age(text: String) raises AgeError -> Int: var value: Int try: value = Int(text) except: raise AgeError(text, "не число") if value < 0: raise AgeError(text, "отрицательный возраст") if value > 150: raise AgeError(text, "слишком большой возраст") return value
def main(): for text in ["30", "abc", "-5", "200"]: try: print(text, "->", parse_age(text)) except e: print(text, "-> отклонено:", e.reason)30 -> 30 abc -> отклонено: не число -5 -> отклонено: отрицательный возраст 200 -> отклонено: слишком большой возраст
Что здесь происходит:
raises AgeError -> Int— функция обещает бросать толькоAgeError. Компилятор проверит каждыйraiseв её теле.except e:— типeкомпилятор выводит сам, из вызванной функции. Поэтому доступны поля:e.reason,e.text. Писать тип вexceptне нужно — и нельзя: формыexcept AgeError as eв Mojo нет.- Переупаковка.
Int(text)бросает обычныйError, а наша функция обещалаAgeError. Поэтому чужую ошибку ловим и бросаем свою. Это обычный приём на границе между кодом сErrorи кодом со своими типами.
Такие ошибки называют типизированными (typed errors). Они к тому же лёгкие: компилятор передаёт их как второе, альтернативное возвращаемое значение функции. А если в полях нет строк и других данных в куче, типизированные ошибки работают даже на видеокарте.
Правила
Заголовок раздела «Правила»Один тип ошибки на функцию и один except на try. Если в одном
try вызвать функции с разными типами ошибок, компилятор откажется:
error: cannot call function that may raise 'Error' in context that supports an error type of 'AgeError'
В одном try оказались вызов, бросающий AgeError, и вызов,
бросающий Error. Переменная e в except может быть только
одного типа.
Разнесите вызовы по разным блокам try или переупакуйте Error
в свой тип, как в примере выше.
Голый raises стирает тип. Если функция объявлена просто raises,
а внутри зовёт функцию с raises AgeError, наружу ошибка выйдет как
Error. Текст сообщения сохранится, а поля — нет:
'Error' value has no attribute 'reason'. Объявляйте тип ошибки
по всей цепочке вызовов, где он важен.
Свою структуру можно бросить и из функции с голым raises — если
она умеет себя печатать, то есть реализует Writable. Тогда она
превратится в Error с её текстом. Без Writable превращать не во что:
error: cannot implicitly convert 'ParseError' value to 'Error'
Функция объявлена просто raises, то есть бросает Error, а в raise
стоит ваша структура, которая не умеет печататься.
Объявите тип ошибки в сигнатуре: raises ParseError. Или добавьте
структуре трейт Writable с методом write_to.
Несколько видов ошибок
Заголовок раздела «Несколько видов ошибок»Тип ошибки у функции один, а сломаться она может по-разному. Самый простой способ различать случаи — одна структура с заранее заданными значениями:
@fieldwise_initstruct ConfigError(Equatable, ImplicitlyCopyable, Writable): var code: Int
comptime missing = ConfigError(1) comptime not_a_number = ConfigError(2)
def write_to[W: Writer](self, mut writer: W): if self == ConfigError.missing: writer.write("параметр не задан") else: writer.write("значение — не число")
def read_port(value: String) raises ConfigError -> Int: if value.byte_length() == 0: raise ConfigError.missing try: return Int(value) except: raise ConfigError.not_a_number
def main(): for value in ["8080", "", "http"]: try: print("порт:", read_port(value)) except e: if e == ConfigError.missing: print("берём порт по умолчанию: 80") else: print("ошибка:", e)порт: 8080 берём порт по умолчанию: 80 ошибка: значение — не число
comptime missing = ConfigError(1) — именованная константа прямо внутри
типа, а трейт Equatable даёт сравнение ==. Если каждому виду ошибки
нужны свои поля, в стандартной библиотеке есть Variant — «одно
из нескольких значений»; он разобран в
официальном руководстве.
try / except / else / finally
Заголовок раздела «try / except / else / finally»Все четыре блока работают как в Python, но синтаксис except короче:
def risky(fail: Bool) raises: if fail: raise Error("плановый сбой")
def main() raises: try: print("пробуем") risky(True) except e: print("except:", e) else: print("else: ошибок не было") finally: print("finally: выполняется всегда")пробуем except: плановый сбой finally: выполняется всегда
except e:— ошибка попадает в переменнуюe;except:— без переменной, когда текст не нужен;else:— выполняется, только если исключения не было;finally:— выполняется в любом случае.
error: expected ':' after 'except'
Вы написали питоновское except Error as e:. В Mojo такой формы нет:
тип ошибки в except не пишут, компилятор выводит его из вызванной
функции.
Пишите просто except e: — переменная указывается сразу, без as.
Компилятор знает, что может сломаться
Заголовок раздела «Компилятор знает, что может сломаться»Приятный побочный эффект строгости: try вокруг кода, который не бросает,
не остаётся незамеченным.
warning: 'except' logic is unreachable, try doesn't raise an exceptionЭто предупреждение стоит воспринимать как подсказку: либо вы обернули
не ту строку, либо try здесь лишний. В Python такую ошибку не находит
никто, и мёртвый except живёт в коде годами.
Не всякий сбой — исключение
Заголовок раздела «Не всякий сбой — исключение»Важная граница, которую легко не заметить. Выход за границы списка исключением не является:
var xs: List[Int] = [1, 2, 3]print(xs[10])At: main.mojo:3:13: Assert Error: index 10 is out of bounds, valid range is 0 to 2 … mojo: error: execution crashed
Программа падает сразу, и try / except вокруг такой строки не поможет —
более того, компилятор выдаст на него то самое предупреждение
«try doesn’t raise an exception».
Уровни проверок
Заголовок раздела «Уровни проверок»Свои проверки такого рода пишутся через debug_assert. Но есть тонкость,
которую стоит узнать сразу: по умолчанию они не выполняются.
def main(): var x = -5 debug_assert(x > 0, "x должен быть положительным") print("после проверки")после проверки
Условие ложно, а программа спокойно идёт дальше. Это не ошибка, а настройка:
уровень проверок задаётся флагом -D ASSERT.
| Значение | Что включено | Когда использовать |
|---|---|---|
safe | проверки стандартной библиотеки: границы списков и подобное | по умолчанию, обычная работа |
all | то же плюс ваши debug_assert | отладка и тесты |
none | ничего | замер производительности отлаженного кода |
uv run mojo -D ASSERT=all main.mojoAt: main.mojo:3:17: Assert Error: x должен быть положительным … mojo: error: execution crashed
Optional — когда ошибка не нужна
Заголовок раздела «Optional — когда ошибка не нужна»Часто «значения нет» — это не ошибка, а обычный исход. Для таких случаев
есть Optional, доступный без импорта:
def main(): var found = Optional[Int](42) var missing = Optional[Int]()
if found: print("есть значение:", found.value()) if not missing: print("значения нет")
print(missing.or_else(0))есть значение: 42 значения нет 0
Стандартная библиотека и сама этим пользуется — например, Dict.get()
не бросает исключение, а возвращает Optional:
def main(): var codes = Dict[String, Int]() codes["Москва"] = 495
print(Bool(codes.get("Казань"))) print(codes.get("Москва", 0))False 495
Сравните с обращением по ключу codes["Казань"] — оно бросает исключение
и требует raises. Оба варианта в языке есть, и выбирать вам.
Что выбрать
Заголовок раздела «Что выбрать»| Ситуация | Инструмент |
|---|---|
| Значения может не быть — это нормально | Optional |
| Что-то пошло не так, и вызывающий код должен узнать причину | raise |
| Ошибка в самом коде, программа дальше бессмысленна | debug_assert |
Правило простое: если по итогам нужно объяснение, бросайте исключение.
Если достаточно ответа «нет» — берите Optional, он дешевле и не заражает
raises всю цепочку вызовов.
Контекстные менеджеры
Заголовок раздела «Контекстные менеджеры»with работает как в Python: нужен тип с методами __enter__ и __exit__.
Такой тип — это структура; как они устроены, разобрано в следующей главе,
«Структуры».
@fieldwise_initstruct Section(ImplicitlyCopyable): var title: String
def __enter__(self) -> Self: print("--", self.title) return self
def __exit__(self): print("-- конец:", self.title)
def main(): try: with Section("замер"): print("работаем") raise Error("сбой внутри with") except e: print("поймали:", e)— замер работаем — конец: замер поймали: сбой внутри with
Обратите внимание на порядок: __exit__ вызвался до того, как ошибка
дошла до except. Ради этого with и существует — что бы ни случилось
внутри блока, файл закроется, замок освободится, замер завершится.
Если ошибку никто не поймал
Заголовок раздела «Если ошибку никто не поймал»Программа завершается с ненулевым кодом и печатает две строки — сначала подсказку, потом само сообщение:
stack trace was not collected. Enable stack trace collection with environment variable `MODULAR_DEBUG=stack-trace-on-error`Unhandled exception caught during execution: String is not convertible to integer with base 10: 'не число'Подсказка честная, но с оговорками. Стек действительно собирается, если задать переменную:
MODULAR_DEBUG=stack-trace-on-error uv run mojo main.mojoТолько в таком виде это будут голые адреса без имён функций. Читаемый стек с именами и номерами строк даёт лишь отдельно собранный бинарник с отладочной информацией:
uv run mojo build -debug-level=full main.mojoMODULAR_DEBUG=stack-trace-on-error ./mainВсё вместе
Заголовок раздела «Всё вместе»# Разбор пользовательского ввода: исключения, Optional и with.
@fieldwise_initstruct Section(ImplicitlyCopyable): """Печатает заголовок и подводит черту, что бы ни случилось внутри."""
var title: String
def __enter__(self) -> Self: print("--", self.title) return self
def __exit__(self): print("-- конец:", self.title)
def parse_age(text: String) raises -> Int: """Превращает строку в возраст или объясняет, почему это невозможно.""" var value = Int(text) if value < 0: raise Error("возраст отрицательный") if value > 150: raise Error("возраст слишком большой") return value
def parse_or_default(text: String, fallback: Int) -> Int: """Возвращает разобранное значение или запасное, не роняя программу.""" try: return parse_age(text) except: return fallback
def try_parse(text: String) -> Optional[Int]: """Возвращает значение, если разбор удался, и пустой Optional иначе.""" try: return Optional[Int](parse_age(text)) except: return Optional[Int]()
def main(): var inputs: List[String] = ["30", "abc", "-5", "200"]
with Section("подробный разбор"): for text in inputs: try: print(text, "->", parse_age(text)) except e: print(text, "-> отклонено:", e)
with Section("со значением по умолчанию"): for text in inputs: print(text, "->", parse_or_default(text, 18))
with Section("через Optional"): for text in inputs: var result = try_parse(text) if result: print(text, "-> значение", result.value()) else: print(text, "-> значения нет")
with Section("порядок выполнения"): for text in ["7", "нет"]: try: print("пробуем:", text) print("разобрали:", parse_age(text)) except e: print("except:", e) else: print("else: ошибок не было") finally: print("finally: выполняется всегда")— подробный разбор 30 -> 30 abc -> отклонено: String is not convertible to integer with base 10: ‘abc’ -5 -> отклонено: возраст отрицательный 200 -> отклонено: возраст слишком большой — конец: подробный разбор — со значением по умолчанию 30 -> 30 abc -> 18 -5 -> 18 200 -> 18 — конец: со значением по умолчанию — через Optional 30 -> значение 30 abc -> значения нет -5 -> значения нет 200 -> значения нет — конец: через Optional — порядок выполнения пробуем: 7 разобрали: 7 else: ошибок не было finally: выполняется всегда пробуем: нет except: String is not convertible to integer with base 10: ‘нет’ finally: выполняется всегда — конец: порядок выполнения
🎯 Проверь себя
Чем `raises` в Mojo отличается от исключений в Python?
В Python бросить исключение может любая функция, и узнать об этом
можно только прочитав её код. В Mojo способность бросать исключение —
часть сигнатуры: функция объявляет raises, а вызывающий код обязан
либо обернуть вызов в try, либо тоже объявить raises.
Можно ли поймать выход за границы списка через try/except?
Нет. xs[10] для списка из трёх элементов — это Assert Error,
программа падает сразу. Исключения предназначены для ожидаемых
ситуаций, а выход за границу — ошибка в коде. Компилятор даже
предупредит, что except вокруг такой строки недостижим.
Когда лучше вернуть `Optional`, а когда бросить исключение?
Если «значения нет» — нормальный исход и объяснять нечего, берите
Optional: он не заставляет всю цепочку вызовов объявлять raises.
Если нужно передать причину сбоя — бросайте исключение.
Как объявить собственный тип исключения?
Сделать структуру — лучше с Writable, чтобы ошибку можно было
напечатать, — и указать её в сигнатуре: def f() raises MyError -> Int.
В except e: переменная e сразу получит тип MyError с его полями.
Наследования и иерархии исключений, как в Python, нет: у функции ровно
один тип ошибки.
Функция объявлена просто `raises`, а внутри вызывает функцию с `raises AgeError`. Что получит вызывающий код?
Обычный Error: тип сотрётся. Текст сообщения сохранится, но поля
AgeError станут недоступны. Чтобы их сохранить, объявите
raises AgeError и у внешней функции.
Что дальше
Заголовок раздела «Что дальше»Структуры: собственные типы, поля, методы
и то, чем struct принципиально отличается от питоновского class.
Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0