- PVSM.RU - https://www.pvsm.ru -

Использовать unwrap() в Rust — это нормально

Предисловие

Сравнительно недавно на Хабре была опубликована статья «Rust: ни в коем случае не используйте unwrap() в продакшене» [1]. Мягко говоря, тезисы, высказываемые в данной статье, спорны и содержат мало обоснования. Предлагаю читателям взглянуть на альтернативную точку зрения: почему использовать unwrap() в Rust — это нормально (в том числе и в продакшене). Автор оригинальной статьи — Эндрю Галлант [2].

Использовать unwrap() в Rust — это нормально

За день до выпуска Rust 1.0 я опубликовал запись в блоге, посвященную основам обработки ошибок [3]. Особенно важный, но небольшой раздел, спрятанный в середине статьи, называется «разматывание стека — это не зло» [4]. В этом разделе кратко описано что, в общем-то, использование unwrap() допустимо, если оно находится в тестовом/демонстрационном коде или когда паника указывает на баг (ошибку программиста).

В целом, я до сих пор придерживаюсь этого убеждения. Это убеждение применяется на практике в стандартной библиотеке Rust и во многих основных крейтах экосистемы. (И эта практика предшествовала моему сообщению в блоге.) Тем не менее, по-прежнему существует широко распространенная путаница в отношении того, когда можно и когда нельзя использовать unwrap(). В этом посте я расскажу об этом более подробно и конкретно отвечу на ряд точек зрений, с которыми я сталкивался.

Этот пост в блоге написан как FAQ, но его следует читать последовательно. Каждый вопрос основывается на предыдущем.

Целевая аудитория: в первую очередь программисты на Rust, но я надеюсь, что предоставил достаточно контекста, чтобы изложенные здесь принципы были применимы к любому программисту. Хотя может быть сложно применить очевидное сопоставление к языкам с другими механизмами обработки ошибок, такими как исключения.

Какова моя точка зрения?

Я думаю, что полезно заранее изложить ряд тезисов, касаемых обработки ошибок и паники, которых я придерживаюсь. Таким образом, читатели точно узнают из чего я исхожу.

  • Паника не должна использоваться для обработки ошибок ни в приложениях, ни в библиотеках.

  • Допустимо использовать панику для обработки ошибок при прототипировании, в тестах, бенчмарках и примерах документации.

  • Если программа на Rust паникует, это сигнализирует о том, что в ней имеется баг. То есть правильные программы на Rust не паникуют.

  • Всегда есть способ определить ответственность, кто «виновен» в панике. Либо это вина функции, которая запаниковала, либо вина вызывающей стороны.

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

  • Поэтому, когда в программе возникают инварианты времени выполнения, есть несколько подходов:

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

    2. Предположим, что инвариант никогда не нарушается, а если нарушается, то функция паникует (т.е. внутренний инвариант). В этом случае, если функция паникует, значит баг на вызываемой строне.

    3. В случае нарушения предусловия можно вернуться к вызывающему коду. (Например, возвращая ошибку через Result::Err) Однако, это никогда не следует использовать в случае внутреннего нарушения инварианта, поскольку это приводит к утечке деталей реализации.

  • В приведенных выше случаях (1) и (2) нормально использовать unwrap(), expect() и синтаксис индексации слайса, среди прочего.

  • Предпочитайте функцию expect() вместо unwrap(), так как при возникновении паники она выдает более наглядные сообщения. Но используйте unwrap(), когда expect() приводит к излишнему шуму.

Остальная часть статьи будет подробнее объяснять эти тезисы.

Что такое unwrap()?

Поскольку идеи, изложенные в этом посте, не специфичны только для Rust, я думаю, важно рассказать что такое unwrap() на самом деле. unwrap() это метод, определенный как для Option<T>, так и для Result<T, E>, который возвращает внутреннее значение T в случае варианта Some или Ok соответственно и вызывает панику в противном случае. Их определения очень просты.

Для Option<T>:

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

Для Result<T, E>:

impl<T, E> Result<T, E> {
    pub fn unwrap(self) -> T
    where
        E: fmt::Debug,
    {
        match self {
            Ok(t) => t,
            Err(e) => panic!("called `Result::unwrap()` on an `Err` value: {:?}", e),
        }
    }
}

Ключевое напряжение, которое я пытаюсь разрешить в этом посте, это то, следует ли вообще использовать unwrap() и в какой степени.

Что значит «паниковать»?

Когда возникает паника, обычно происходит одна из двух вещей:

  • Процесс прерывается.

  • Если целевая архитектура это поддерживает, стек раскручивается. Если раскрутку стека не поймать, то это приведет к прерыванию процесса с сообщением и указанием источника паники.

Что именно произойдет, зависит от того, как программа была скомпилирована. Это можно контролировать с помощью параметра профиля panic [5] в Cargo.toml.

Когда происходит раскрутка стека, можно поймать панику [6] и что-нибудь с ней сделать. Например, веб-сервер может перехватывать панику, возникающую внутри обработчиков запросов, чтобы избежать остановки всего сервера. Другим примером является система тестирования, которая перехватывает панику, возникшую в тесте, чтобы можно было выполнить другие тесты и распечатать результаты вместо того, чтобы немедленно отключать всю систему.

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

Когда паника вызывает раскрутку, которая нигде не перехватывается, вероятно, программа прервется как только весь стек будет раскручен, затем напечатает сообщение, содержащееся в объекте паники. (Я говорю «вероятно», потому что можно установить обработчики паники [7] или ловушки паники [8].) Например:

fn main() {
    panic!("Прощай, жестокий мир");
}

Запустив это, мы увидим:

$ cargo build
$ ./target/debug/rust-panic
thread 'main' panicked at 'Прощай, жестокий мир', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

В примечании говорится, что можно включить трассировку стека:

$ RUST_BACKTRACE=1 ./target/debug/rust-panic
thread 'main' panicked at 'Прощай, жестокий мир', src/main.rs:2:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/2c8cc343237b8f7d5a3c3703e3a87f2eb2c54a74/library/std/src/panicking.rs:575:5
   1: core::panicking::panic_fmt
             at /rustc/2c8cc343237b8f7d5a3c3703e3a87f2eb2c54a74/library/core/src/panicking.rs:64:14
   2: rust_panic::main
             at ./src/main.rs:2:5
   3: core::ops::function::FnOnce::call_once
             at /rustc/2c8cc343237b8f7d5a3c3703e3a87f2eb2c54a74/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

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

fn main() {
    let mut f = std::fs::File::open("foobar").unwrap();
    std::io::copy(&mut f, &mut std::io::stdout()).unwrap();
}

Вот что происходит, когда мы запускаем такую программу:

$ ./target/debug/rust-panic
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: «No such file or directory» }', src/main.rs:2:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

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

Вывод:

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

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

Что такое обработка ошибок?

Обработка ошибок — это то, что делает программист в своем коде, когда что-то «идёт не так». Не вдаваясь в подробности, есть несколько разных способов обработки ошибок в Rust:

  1. Можно прервать выполнение процесса с ненулевым кодом выхода.

  2. Можно запаниковать с ошибкой. Это может прервать процесс, а может и нет. Как описано в предыдущем разделе, это зависит от того, как была скомпилирована программа.

  3. Ошибки можно обрабатывать как значения, обычно с помощью типа Result<T, E>. Если ошибка всплывает на всем пути до main функции, можно напечатать ошибку в stderr [9], а затем прервать выполнение процесса.

Все три являются вполне допустимыми стратегиями обработки ошибок. Проблема в том, что первые два приводят к очень плохому взаимодействию с пользователем приложений в контексте программ на Rust. Поэтому (3) обычно считается лучшей практикой. Стандартная библиотека и все основные библиотеки экосистемы используют (3). Кроме того, насколько мне известно, все «популярные» приложения Rust также используют (3).

Одной из наиболее важных частей (3) является возможность добавлять дополнительный контекст к значениям ошибок, когда они возвращаются вызывающей стороне. Крейт anyhow помогает проще это делать. Вот фрагмент из незавершенного инструмента regex-cli, над которым я работаю:

use anyhow::Context;

if let Some(x) = args.value_of_lossy("warmup-time") {
    let hdur: ShortHumanDuration = x.parse().context("--warmup-time")?;
    margs.bench_config.approx_max_warmup_time = Duration::from(hdur);
}

Важным моментом здесь является x.parse().context("--warmup-time")?. Для тех, кто не знаком с Rust, поясню:

  • x — это Cow<'a, str>, тип, который является «либо владением String, либо заимствованием &str«. Cow означает «copy-on-write» [10] (копирование при записи).

  • parse() это сокращение для вызова FromStr::from_str, который парсит строку в какой-либо другой тип данных. В данном случае этот тип — ShortHumanDuration. Поскольку парсинг строки может завершиться с ошибкой, функция parse() возвращает значение типа Result<T, E>.

  • context() поставляется трейтом anyhow::Context. Это называется «трейтом расширения» [11], который добавляет методы к Result<T, E>. В данном случае context("--warmup-time") добавляет короткое сообщение в контекст ошибки.

  • Суффиксный оператор ? говорит: «Если Result<T, E> является Ok(T), то отдай T в качестве результата выражения, иначе верни E как ошибку из текущей функции». (Обратите внимание, что это не точное описание того, что делает ?. См. раздел «оператор вопросительный знак» [12] справочника по Rust для более подробной информации.)

Конечным результатом является то, что если передать недопустимое значение флагу --warmup-time, то сообщение об ошибке будет включать --warmup-time:

$ regex-cli bench measure --warmup-time '52 minutes'
Error: --warmup-time
Caused by:
    duration '52 minutes' not in '<decimal>(s|ms|us|ns)' format

Это дает понять, какая часть ввода, предоставленного пользователем, была проблематичной.

(Примечание: anyhow отлично подходит для кода, ориентированного на конечное приложение, но если кто-то создаёт библиотеку, предназначенную для использования другими, я бы предложил написать конкретные типы ошибок и предоставить соответствующую реализацию std::fmt::Display. Крейт thiserror избавляет от написания шаблонного кода, связанного с этим, но я бы предпочёл не использовать этот крейт, чтобы избежать зависимостей от процедурных макросов, если кто-то ещё не использует зависимости процедурных макросов для чего-то другого.)

Следует ли использовать unwrap() для обработки ошибок?

Довольно часто можно увидеть, как unwrap() используется для обработки ошибок в следующих трех сценариях:

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

  2. В тестах. В общем, Rust тесты падают, если паникуют, и проходят, если паники нет. Так что с unwrap() в этом контексте всё в порядке, поскольку вполне вероятно, что паника — это именно то, что нужно в любом случае. Обратите внимание, что можно вернуть Result из модульных тестов [13], что позволяет использовать оператор ? в тестах.

  3. В примерах документации. Раньше обработка ошибок как значений в документации требовала немного больше усилий, чем использование паники. Но теперь оператор ? можно использовать в тестах документации [14].

Лично у меня нет твердого мнения о том, следует ли использовать unwrap() в любом из вышеперечисленных случаях. Остановлюсь на каждом из пунктов:

  1. Даже в быстрых программах или программах только для личного использования, я обращаюсь с ошибками как со значениями. anyhow делает это невероятно простым. Просто напишите cargo add anyhow, а затем используйте fn main() -> anyhow::Result<()>. Вот и всё. В данном контексте нет большого эргономического преимущества в использовании паники для обработки ошибок. anyhow будет даже предоставлять трассировку стека.

  2. Я широко использую unwrap() в тестах. Я редко, если когда-либо использую ? в модульных тестах.Возможно, это связано с тем, что я начал писать на Rust до того, как модульные тесты научились возвращать Result<T, E>. Я не вижу убедительного преимущества в том, чтобы изменить то, что я делаю и при этом писать более длинные сигнатуры.

  3. Обычно, в примерах документации, я стремился обращаться с ошибками как со значениями, а не паниковать. В частности, всё что нужно сделать, это добавить # Ok::<(), Box<dyn std::error::Error>>(()) в конец большинства примеров, и тогда можно будет использовать оператор ?. Это легко сделать и при этом, демонстрирует более идиоматичный код. С учетом сказанного, настоящая обработка ошибок имеет тенденцию добавлять контекст к ошибкам. Я бы счёл это идиоматичным, но тем не менее, я не делаю этого в примерах документации. Кроме того, примеры документации, как правило, нацелены на демонстрацию определённого аспекта API, и ожидать, что они будут совершенно идиоматичными во всех остальных аспектах — особенно если это отвлекает внимание от сути примера — кажется нереалистичным. Так что в целом, думаю, что хоть unwrap() в документации — это нормально, я бы предпочёл избежать этого, потому что это легко сделать.

Подводя итог, я бы сказал, что «не использовать unwrap() для обработки ошибок в Rust» — это хорошее первое приближение. Но разумные люди могут не согласиться с тем, следует ли использовать unwrap() в некоторых сценариях (как обсуждалось выше) из-за его краткости.

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

Как насчет «исправимых» и «неисправимых» ошибок?

Глава растбука «Обработка ошибок» [15] популяризировала идею представления об ошибках как об «исправимых» и «неисправимых». То есть, если ошибка исправима, то следует рассматривать её как нормальное значение и использовать Result<T, E>. С другой стороны, если ошибка неисправима, то можно паниковать.

Лично я никогда не считал эту конкретную концепцию полезной. Проблема, на мой взгляд, заключается в неоднозначности определения того, является ли та или иная ошибка «исправимой» или нет. Что это в точности означает?

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

Это все, что нужно знать, чтобы определить, рассматривать ли ошибки как значения или как панику. Некоторые примеры:

  • Является ли ошибкой, если программа не может открыть файл по пути, указанному пользователем? Нет. Поэтому рассматривайте эту ошибку как значение.

  • Является ли ошибкой, если программа не может построить регулярное выражение из статичного строкового литерала? Да. Программист написал это регулярное выражение. Оно должно быть правильным. Так что паника уместна.

Так что, никогда не следует паниковать?

В общем, да, правильные программы на Rust не должны паниковать.

Означает ли это, что если для обработки ошибок в быстром «скрипте» Rust использовалась паника, то это неправильно? Дэвид Толней предположил [16], что это граничит с формой парадокса Рассела [17], и я склонен с ним согласиться. В качестве альтернативы можно думать о скрипте или прототипе как будто его баги помечены как wontfix.

Так что, никогда не следует использовать unwrap() или expect()?

Нет! Такие методы, как unwrap() или expect(), паникуют только в том случае, если их значение не совпадает с ожидаемым. Если значение всегда совпадает с ожидаемым, то из этого следует, что unwrap() и expect() никогда не приведут к панике. Если паника всё-таки возникает, то это, как правило, соответствует нарушению ожиданий программиста. Другими словами, был нарушен инвариант времени выполнения, что привело к возникновению бага.

Это сильно отличается от «не используйте unwrap() для обработки ошибок». Ключевое отличие здесь в том, что мы ожидаем, что ошибки будут регулярно возникать, но мы совсем не ожидаем возникновение бага. И когда баг действительно случается, мы пытаемся его устранить (или объявить о ней как о проблеме, которая не будет исправлена).

Я думаю, что много путаницы вокруг unwrap() возникает из-за того, что люди из лучших побуждений говорят что-то вроде не используйте unwrap(), когда на самом деле они имеют в виду не используйте панику как стратегию обработки ошибок. Это вдвойне сбивает с толку со стороны другой группы людей, которые на самом деле буквально имеют в виду не использовать unwrap(), никогда, ни при каких обстоятельствах, до такой степени, что этого вообще не должно было существовать [18]. Это трижды сбивает с толку со стороны ещё одной группы людей, которые говорят «не используйте unwrap()», но на самом деле имеют в виду «не используйте unwrap() [1], expect(), индексацию слайсов или любую другую паникующую функцию, даже если кто-то доказывает что паника невозможна».

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

Что такое инвариант времени выполнения?

Это то, что всегда должно быть правдой, но гарантия поддерживается во время выполнения, а не подтверждается во время компиляции.

Простым примером инварианта является целое число, которое никогда не равно нулю. Есть несколько способов установить это:

  • Используйте std::num::NonZeroUsize. Это поддерживает инвариант во время компиляции, поскольку конструкция типа гарантирует, что он не может быть равен нулю.

  • Используйте Option<usize> и полагайтесь на то, что вызывающая сторона предоставит None, когда внутренний usize равен 0. Это поддерживает инвариант во время выполнения, поскольку конструкция Option<usize> не инкапсулирована.

  • Используйте usize и полагайтесь на то, что вызывающая сторона никогда не установит его равным 0. Это также поддерживает инвариант во время выполнения.

(Примечание: std::num::NonZeroUsize имеет другие преимущества, помимо принудительного применения этого конкретного инварианта во время компиляции. А именно, он позволяет компилятору выполнять оптимизацию размещения памяти, где Option<NonZeroUsize> имеет тот же размер в памяти, что и usize.)

В этом случае, если вам нужен такой инвариант, как «целое число, которое никогда не равно нулю», то использование такого типа, как NonZeroUsize, является очень убедительным выбором с несколькими недостатками. Это вносит небольшой шум в код, когда необходимо использовать целое число, поскольку нужно вызывать get(), чтобы получить непосредственно usize, который необходим для выполнения таких вещей, как арифметика или использование его для индексации слайсов.

Так почему бы не сделать всё инвариантами времени компиляции?

В некоторых случаях это невозможно сделать. Мы рассмотрим это в следующем разделе.

В других случаях это можно сделать, но этого не делают по некоторым причинам. Одной из таких причин является сложность API.

Рассмотрим один реальный пример из моего крейтаaho-corasick (который предоставляет реализацию алгоритм Ахо-Корасика [19]). Его метод AhoCorasick::find_overlapping_iterвызывает панику, если автомат AhoCorasick не был создан во время выполнения с «типом совпадения» «стандарт» [20]. Другими словами, подпрограмма AhoCorasick::find_overlapping_iter накладывает задокументированное предварительное условие на вызывающую программу, обещая вызывать её только в том случае, если AhoCorasick был построен определенным образом. Я выбрал этот подход по нескольким причинам:

  • Перекрывающийся поиск имеет смысл только в том случае, если «тип соответствия» установлен как «стандартный».

  • Настройка «типа совпадения» почти всегда будет выполняться программистом, а не чем-то, что осуществляется через ввод.

  • Простота API.

Что я имею в виду под «простотой API»? Ну, эту панику можно убрать, переместив этот инвариант времени выполнения в инвариант времени компиляции. А именно, API мог бы предоставить, например, тип AhoCorasickOverlapping, и перекрывающиеся подпрограммы поиска были бы определены только для этого типа, а не для AhoCorasick. Следовательно, пользователи библиотеки никогда не смогут вызвать перекрывающую функцию поиска на неправильно сконфигурированном автомате. Компилятор просто не позволил бы этого.

Но это добавляет API много дополнительной площади соприкосновения. И делает это действительно пагубными способами. Например, тип AhoCorasickOverlapping по-прежнему хотел бы иметь нормальные неперекрывающиеся процедуры поиска, как это делает AhoCorasick. Теперь разумно захотеть иметь возможность писать подпрограммы, которые принимают любой тип автомата Ахо-Корасика и выполняют непересекающийся поиск. В этом случае либо крейт aho-corasick, либо программист, использующий крейт, должен определить какую-то общую абстракцию, чтобы сделать это возможным. Или, что более вероятно, скопировать некоторое количество кода.

Таким образом, я пришел к выводу, что лучше всего иметь один тип, который может делать всё, но может громко дать сбой для определенных методов при определенных конфигурациях. Дизайн API aho-corasick не приведет к тонким логическим ошибкам, которые молча выдают неверные результаты. Если допущена ошибка, то вызывающая сторона всё равно получит панику с чётким сообщением. Её будет легко исправить в этом месте.

Взамен мы получаем более простой API. Существует только один тип, который можно использовать для поиска. Не нужно отвечать на такие вопросы, как «подождите, какой тип я хочу? Теперь мне нужно понять и то, и другое, и попытаться собрать кусочки головоломки воедино». И если кто-то хочет написать одну универсальную подпрограмму, которая принимает любой автомат и выполняет непересекающийся поиск, то ей не нужны дженерики. Потому что есть только один тип.

Что делать, если инварианты нельзя вынести на уровень времени компиляции?

Рассмотрим, как можно реализовать поиск с использованием детерминированного конечного автомата (deterministic finite automaton — DFA). Базовая реализация состоит всего из нескольких строк, поэтому её легко включить сюда:

type StateID = usize;

struct DFA {
    // Идентификатор начального состояния. Каждый поиск начинается здесь.
    start_id: StateID,
    // Таблица переходов по строкам. Для состояния 's' и байта 'b'
    // следующее состояние 's * 256 + b'.
    transitions: Vec<StateID>,
    // Соответствует ли конкретный идентификатор состояния состоянию совпадения.
    // Гарантируется, что длина будет равна количеству состояний.
    is_match_id: Vec<bool>,
}

impl DFA {
    // Возвращает true, если DFA сопоставляет весь `haystack`.
    // Этот метод всегда возвращает либо true, либо false для всех входных данных.
    // Никогда не паникует.
    fn is_match(&self, haystack: &[u8]) -> bool {
        let mut state_id = self.start_id;
        for &byte in haystack {
            // Умножаем на 256, потому что это размер алфавита нашего DFA.
            // Другими словами, каждое состояние имеет 256 переходов. По одному на каждый байт.
            state_id = self.transitions[state_id * 256 + usize::from(byte)];
            if self.is_match_id[state_id] {
                return true;
            }
        }
        false
    }
}

Здесь есть несколько мест, где может возникнуть паника:

  • state_id * 256 + byte может быть недействительным индексом в self.transitions.

  • state_id может быть недопустимым индексом в self.is_match_id.

  • Умножение state_id * 256 может вызывать панику в режиме отладки. В настоящее время в релизной сборке будет выполнено умножение с переполнением, но это может измениться на панику при переполнении в будущей версии Rust.

  • Точно так же сложение + usize::from(byte) может вызывать панику по той же причине.

Как можно гарантировать, что во время компиляции никогда не возникнет паника при выполнении арифметических операций или доступе к элементам слайса? Имейте в виду, что векторы transitions и is_match_id могут быть созданы на основе пользовательского ввода. Поэтому, как бы это ни было сделано, нельзя полагаться на то, что компилятор знает входные данные для DFA. Входными данными, на основе которых был построен DFA, может быть произвольный шаблон регулярного выражения.

Нет осуществимого способа вынести на уровень времени компиляции инвариант о том, что DFA строится и выполняет поиск правильно. Это должен быть инвариант времени выполнения. И кто отвечает за поддержание этого инварианта? Реализация, создающая DFA, и реализация, использующая DFA для выполнения поиска. Обе должны быть согласованы друг с другом. Другими словами, у них есть общий секрет: как DFA размещается в памяти. (Предупреждение: раньше я ошибался насчет невозможности впихнуть инварианты в систему типов. Я признаю здесь такую возможность, мое воображение невелико. Однако я вполне уверен, что это повлечет за собой немало церемоний и/или быть ограниченным в своем применении. Тем не менее, это было бы интересное упражнение, даже если оно не полностью отвечает всем требованиям.)

Если бы где-то возникла паника, что бы это значило? Это должно означать, что где-то в коде есть баг. И поскольку документация этого метода гарантирует, что он никогда не паникует, проблема должна быть связана с реализацией. Дело либо в том, как был построен DFA, либо в том, как выполняется поиск DFA.

Почему бы не вернуть ошибку вместо паники?

Вместо того, чтобы паниковать, когда что-то пошло не так, можно вернуть значение ошибки. Метод is_match из предыдущего раздела можно переписать так, чтобы вместо паники возвращалось сообщение об ошибке:

// Возвращает true, если DFA сопоставляет весь `haystack`.
// Этот метод всегда возвращает либо `Ok(true)`, либо `Ok(false)` для всех входных данных.
// Он никогда не возвращает ошибку `Err(&str)`, если его реализация корректна.
fn is_match(&self, haystack: &[u8]) -> Result<bool, &'static str> {
    let mut state_id = self.start_id;
    for &byte in haystack {
        let row = match state_id.checked_mul(256) {
            None => return Err("слишком большой идентификатор состояния"),
            Some(row) => row,
        };
        let row_offset = match row.checked_add(usize::from(byte)) {
            None => return Err("слишком большой индекс ряда"),
            Some(row_offset) => row_offset,
        };
        state_id = match self.transitions.get(row_offset) {
            None => return Err("неверный переход"),
            Some(&state_id) => state_id,
        };
        match self.is_match_id.get(state_id) {
            None => return Err("неверный идентификатор состояния"),
            Some(&true) => return Ok(true),
            Some(&false) => {}
        }
    }
    Ok(false)
}

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

Какая польза от такой рутины? Если бы мы были сторонниками steelman аргументации [21] в пользу этого стиля написания кода, то думаю, что аргумент, лучше всего было бы ограничить определенной сферой применения высокой надежности. У меня лично нет большого опыта в этих областях, но я могу представить случаи, когда кто-то не хочет иметь какое-либо возможное ветвление в панику в окончательном скомпилированном бинарном файле где бы то ни было. Это даёт большую уверенность в том, в каком состоянии находится код в любой момент времени. Это также означает, что вы, вероятно, не сможете использовать стандартную библиотеку Rust или большинство основных крейтов экосистемы, поскольку все они будут иметь потенциальную панику где-то внутри. Другими словами, это очень дорогой стиль написания кода.

Действительно интересная часть этого подхода — включение инвариантов времени выполнения в значения ошибок — заключается в том, что на самом деле невозможно должным образом задокументировать условия ошибки. Хорошо задокументированные условия ошибки каким-то образом связывают входные данные функции с некоторым случаем отказа. Но это невозможно сделать для данного метода, потому что если бы можно было, то это было бы документированием бага!

Когда следует использовать unwrap(), даже если в этом нет необходимости?

Рассмотрим пример, в котором на самом деле можно было бы избежать использования unwrap(), а стоимость — лишь незначительная сложность кода. Этот адаптированный фрагмент кода был взят из крейта regex-syntax [22]:

enum Ast {
    Empty(std::ops::Range<usize>),
    Alternation(Alternation),
    Concat(Concat),
    // ... и другие
}

// AST-представление регулярного выражения по типу 'a|b|...|z'.
struct Alternation {
    // Байт смещается туда, где эта альтернация
    // встречается в конкретном синтаксисе.
    span: std::ops::Range<usize>,
    // AST каждой альтернации.
    asts: Vec<Ast>,
}

impl Alternation {
    /// Возвращает эту альтернацию как простейшее возможное 'Ast'.
    fn into_ast(mut self) -> Ast {
        match self.asts.len() {
            0 => Ast::Empty(self.span),
            1 => self.asts.pop().unwrap(),
            _ => Ast::Alternation(self),
        }
    }
}

Фрагмент кода self.asts.pop().unwrap() вызовет панику, если вектор self.asts пуст. Но так как мы проверили, что его длина не равна нулю, он не может быть пустым, и поэтому unwrap() никогда не будет паниковать.

Но зачем здесь использовать unwrap()? На самом деле мы могли бы написать это вообще без unwrap():

fn into_ast(mut self) -> Ast {
    match self.asts.pop() {
        None => Ast::Empty(self.span),
        Some(ast) => {
            if self.asts.is_empty() {
                ast
            } else {
                self.asts.push(ast);
                Ast::Alternation(self)
            }
        }
    }
}

Проблема здесь в том, что если pop() оставляет self.asts непустым, то мы на самом деле хотим создать Ast::Alternation, так как есть два или более подвыражения. Если есть ноль или одно подвыражение, то нам доступно более простое представление. Таким образом, в случае более чем одного подвыражения, после того, как мы извлекли одно из них, нам действительно нужно запушить его обратно в self.asts, прежде чем строить альтерацию.

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

Почему бы не использовать expect() вместо unwrap()?

expect() похож на unwrap(), за исключением того, что он принимает параметр сообщения и включает это сообщение в вывод паники. Другими словами, он добавляет немного дополнительного контекста к сообщению паники, если она происходит.

Думаю, что в целом рекомендуется использовать expect() вместо unwrap(). Однако я не думаю, что стоит полностью запрещать unwrap(). Добавление контекста через expect() помогает информировать читателей кода о том, что автор рассмотрел соответствующие инварианты и написал сообщение о том, что именно ожидалось.

Однако сообщения expect(), как правило, короткие и не содержат полного обоснования того, почему использование expect() является корректным. Вот ещё один пример из крейта regex-syntax [23]:

/// Парсит восьмеричное представление кодпоинта Unicode длиной до 3 цифр.
/// Предполагается, что парсер будет расположен на первой восьмеричной цифре и
/// будет продвигаться к первому символу, непосредственно следующему за восьмеричным числом.
/// Также предполагается, что синтаксический разбор восьмеричной escape-последовательности включен.
///
/// Предполагая, что предварительные условия соблюдены, эта функция никогда не может дать сбой.
fn parse_octal(&self) -> ast::Literal {
    // См. задокументированные предварительные условия.
    assert!(self.parser().octal);
    assert!('0' <= self.char() && self.char() <= '7');
    let start = self.pos();
    // Парсим еще две цифры.
    while self.bump()
        && '0' <= self.char()
        && self.char() <= '7'
        && self.pos().offset - start.offset <= 2
    {}
    let end = self.pos();
    let octal = &self.pattern()[start.offset..end.offset];
    // Парсинг восьмеричного числа не может завершиться с ошибкой,
    // поскольку код выше гарантирует валидное число.
    let codepoint =
        std::u32::from_str_radix(octal, 8).expect("валидное восьмеричное число");
    // Максимальное значение для трехзначного восьмеричного числа составляет 0o777 = 511,
    // и [0, 511] не имеет недопустимых скалярных значений Unicode.
    let c = std::char::from_u32(codepoint).expect("скалярное значение Unicode");
    ast::Literal {
        span: Span::new(start, end),
        kind: ast::LiteralKind::Octal,
        c,
    }
}

Есть два варианта использования expect(). В каждом случае сообщение expect() в какой-то мере полезно, но основная суть того, почему в обоих случаях expect() работает, заключается в форме комментариев. Комментарии объясняют, почему операции from_str_radix и from_u32 никогда не вернут значение ошибки. Сообщение expect() просто даёт дополнительную подсказку, которая делает сообщение паники немного более полезным.

Использовать ли unwrap() или expect() — это вопрос личного выбора. В приведенном выше примере into_ast, думаю, expect() добавляет бессмысленный шум, потому что окружающий код и так тривиально показывает, почему unwrap() в данном случае это нормально. В таком случае даже нет смысла писать комментарий, говорящий об этом.

У expect() есть и другие стороны, которые добавляют больше шума. Вот некоторые примеры:

Regex::new("...").expect("регулярное выражение валидно");
mutex.lock().expect("мьютекс не отравлен");
slice.get(i).expect("индекс валиден");

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

fn main() {
    regex::Regex::new(r"foop{glyph}bar").unwrap();
}

И запустим её:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/rust-panic`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Syntax(
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
regex parse error:
    foop{glyph}bar
       ^^^^^^^^^
error: Unicode property not found
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
)', main.rs:4:36
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrac

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

(Примечание: что касается примера с Regex, некоторые люди говорят, что недопустимое регулярное выражение в строковом литерале должно привести к сбою компиляции программы. На самом деле у Clippy есть линт [24] для этого, но в целом для Regex::new невозможно сделать это с помощью средств const Rust. Если бы это было возможно, то большую часть языка Rust нужно было бы использовать внутри const контекста. Вместо этого можно было бы написать процедурный макрос, но Regex::new всё равно должен был бы существовать.)

Стоит ли установить линты против использования unwrap()?

Одним из распространенных аргументов против осмысленного подхода является то, что было бы неплохо убрать из уравнения человеческий фактор. Если кто-то предлагает добавить линт против unwrap() [25], то тем самым он заставляет каждого программиста писать что-то другое, кроме unwrap(). Мысль заключается в том, что если что-то усложняет этот шаг, то программисты могут более глубоко задуматься о том, может ли их код вызывать панику или нет. Необходимость писать expect() и придумывать сообщение, я согласен, задействует больше клеток мозга [26] и, вероятно, приводит к тому, что программист более глубоко задумается о том, может ли возникнуть паника.

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

Во-первых, как я уже упоминал, думаю, что во многих случаях expect() добавляет в код ненужный шум, который загромождает его и делает его более многословным. Во многих случаях либо сразу очевидно, почему unwrap() не запаникует, либо, если требуется более подробное обоснование, его скорее можно найти в комментарии, чем в сообщении expect().

Во-вторых, unwrap() является идиоматичным. Для ясности, я делаю описательное высказывание. Я не говорю, что он должен быть идиоматичным. Я говорю, что он уже таким является, основываясь на его широком использовании как в стандартной библиотеке, так и в основных библиотеках экосистемы. Он широко распространён не только в моем собственном коде. Это свидетельствует о том, что unwrap() не вызывает проблем на практике, хотя я понимаю, что это утверждение имеет некоторые смешанные факторы.

В-третьих, есть много распространённых вещей, которые могут паниковать, при этом не требуя написания unwrap():

  • Синтаксис индексации слайса. Например, slice[i] вызывает панику, когда выходит за пределы. Сообщение о панике немного лучше, чем то, что обычно можно увидеть с помощью slice.get(i).unwrap(), но всё равно это приведет к панике. Если кто-то запрещает unwrap(), потому что его легко бездумно писать, следует ли также запрещать синтаксис индексации слайса?

  • Переполнение в арифметических операциях в настоящее время переполняется в релизной сборке, но вызывает панику в отладочной. Не исключено, что в будущем он будет паниковать в релизной сборке. Если кто-то запрещает unwrap() из-за того, что его легко бездумно писать, следует ли также запрещать использование основных операторов, таких как + и *? (То, что сегодня релизе нет паники, не означает, что ошибок в релизной сборке не возникает! Вполне вероятно, что арифметическое переполнение без вывода сообщения, вероятно, приведет к возникновению бага. Так почему бы не запретить его и не заставить людей использовать, например, wrapping_add и checked_add везде? Помните, мы не пытаемся избежать паники. Мы пытаемся избежать багов.)

  • При использовании RefCell для внутренней изменчивости его методы borrow() и borrow_mut() будут вызывать панику, если во время выполнения произойдет нарушение заимствования. Здесь применим тот же аргумент.

  • Аллокации сами по себе могут завершиться ошибкой, что в настоящее время приведет к прерыванию процесса. Что ещё хуже, чем паника. (Хотя, как я понимаю, желательно, чтобы неудачные аллокации вызывали панику, а не прерывание процесса.) Означает ли это, что нужно быть более осторожным и с аллокациями?

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

Наконец, в-четвертых, запрет unwrap() даёт некоторую ненулевую вероятность того, что вместо этого программисты начнут писать expect(""). Или expect("без паники"), если expect("") запрещено. Я уверен, что большинство людей знакомы с линтами, которые вдохновляют на такое поведение. Сколько раз вы видели комментарий к функции frob_quux, в котором говорилось: «Это frob для quux»? Этот комментарий, вероятно, существует только потому, что линтер сказал программисту поместить его туда.

Но, как я уже сказал, я понимаю, что разумные люди могут здесь не согласиться. У меня нет пуленепробиваемых аргументов против линта unwrap(). Я просто думаю, что игра не стоит свеч.

Чем же паника так хороша?

Паника — единственная причина, по которой баги часто не требуют запуска программ Rust в отладчике. Почему? Потому что многие баги приводят к панике, а паника выдаёт трассировку стека и номера строк, что является одной из самых важных вещей (но не единственной), которую предоставляет отладчик. Но их грандиозность этим не ограничивается. Если программа на Rust паникует в руках конечного пользователя, он может поделиться этим сообщением о панике и, вероятно, будет в состоянии установить RUST_BACKTRACE=1, чтобы получить полную трассировку стека. Это легко сделать, и это особенно полезно в тех случаях, когда трудно воспроизвести ошибку

Поскольку паники очень полезны, имеет смысл использовать их везде, где это возможно:

  • Используйте assert! (и сопутствующие макросы) для агрессивной проверки предварительных условий и инвариантов времени выполнения. При проверке предварительных условий убедитесь, что сообщение паники относится к задокументированному предварительному условию, возможно, путем добавления пользовательского сообщения. Например, assert!(!xs.is_empty(), «ожидается, что параметр 'xs' не будет пустым»).

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

  • Используйте unwrap(), когда expect() добавляет шум.

  • Используйте другие вещи, такие как синтаксис индексации слайса, когда недопустимый индекс указывает на ошибку в коде. (Что очень часто бывает.)

Конечно, когда это возможно, обычно предпочтительнее помещать инварианты времени выполнения в инварианты времени компиляции. Тогда не нужно беспокоиться об unwrap() или об assert! или о чём-то ещё. Инвариант поддерживается за счёт компиляции программы. Rust чрезвычайно хорошо подходит для преобразования множества инвариантов времени выполнения в инварианты времени компиляции. Более того, весь его механизм поддержания безопасности памяти в решающей степени зависит от этого.

Однако, как показано выше, либо не всегда возможно, либо не всегда желательно добавлять инварианты в систему типов. В таком случае, довольствуйтесь паникой.

Автор: Nano

Источник [27]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/programmirovanie/383535

Ссылки в тексте:

[1] «Rust: ни в коем случае не используйте unwrap() в продакшене»: https://habr.com/ru/company/otus/blog/716802/

[2] Эндрю Галлант: https://blog.burntsushi.net/

[3] основам обработки ошибок: https://blog.burntsushi.net/rust-error-handling/

[4] «разматывание стека — это не зло»: https://blog.burntsushi.net/rust-error-handling/#a-brief-interlude-unwrapping-isnt-evil

[5] panic: https://doc.rust-lang.org/cargo/reference/profiles.html#panic

[6] поймать панику: https://doc.rust-lang.org/std/panic/fn.catch_unwind.html

[7] обработчики паники: https://doc.rust-lang.org/nomicon/panic-handler.html

[8] ловушки паники: https://doc.rust-lang.org/std/panic/fn.set_hook.html

[9] напечатать ошибку в stderr: https://github.com/BurntSushi/ripgrep/blob/4dc6c73c5a9203c5a8a89ce2161feca542329812/crates/core/main.rs#L48-L53

[10] «copy-on-write»: https://doc.rust-lang.org/std/borrow/enum.Cow.html

[11] «трейтом расширения»: https://rust-lang.github.io/rfcs/0445-extension-trait-conventions.html

[12] «оператор вопросительный знак»: https://doc.rust-lang.org/stable/reference/expressions/operator-expr.html#the-question-mark-operator

[13] вернуть Result из модульных тестов: https://doc.rust-lang.org/book/ch11-01-writing-tests.html#using-resultt-e-in-tests

[14] оператор ? можно использовать в тестах документации: https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html#using--in-doc-tests

[15] «Обработка ошибок»: https://doc.rust-lang.ru/book/ch09-00-error-handling.html

[16] Дэвид Толней предположил: https://github.com/rust-lang/project-error-handling/issues/50#issuecomment-1092145473

[17] парадокса Рассела: https://ru.wikipedia.org/wiki/%D0%9F%D0%B0%D1%80%D0%B0%D0%B4%D0%BE%D0%BA%D1%81_%D0%A0%D0%B0%D1%81%D1%81%D0%B5%D0%BB%D0%B0

[18] никогда, ни при каких обстоятельствах, до такой степени, что этого вообще не должно было существовать: https://www.thecodedmessage.com/posts/2022-07-14-programming-unwrap/

[19] алгоритм Ахо-Корасика: https://ru.wikipedia.org/wiki/%D0%90%D0%BB%D0%B3%D0%BE%D1%80%D0%B8%D1%82%D0%BC_%D0%90%D1%85%D0%BE_%E2%80%94_%D0%9A%D0%BE%D1%80%D0%B0%D1%81%D0%B8%D0%BA

[20] «типом совпадения» «стандарт»: https://docs.rs/aho-corasick/latest/aho_corasick/struct.AhoCorasickBuilder.html#method.match_kind

[21] steelman аргументации: https://en.wikipedia.org/wiki/Straw_man#Steelmanning

[22] адаптированный фрагмент кода был взят из крейта regex-syntax: https://github.com/rust-lang/regex/blob/159a63c85eb77ec321301bc4c4ebfb90343edc2b/regex-syntax/src/ast/mod.rs#L551-L573

[23] пример из крейта regex-syntax: https://github.com/rust-lang/regex/blob/159a63c85eb77ec321301bc4c4ebfb90343edc2b/regex-syntax/src/ast/parse.rs#L1527-L1562

[24] у Clippy есть линт: https://rust-lang.github.io/rust-clippy/master/index.html#invalid_regex

[25] линт против unwrap(): https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_used

[26] мозга: http://www.braintools.ru

[27] Источник: https://habr.com/ru/post/723434/?utm_source=habrahabr&utm_medium=rss&utm_campaign=723434