Как и многие языки программирования, Rust призывает разработчика определенным способом обрабатывать ошибки. Вообще, существует два общих подхода обработки ошибок: с помощью исключений и через возвращаемые значения. И Rust предпочитает возвращаемые значения.

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

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

Содержание


Эта статья очень длинная, в основном потому, что мы начнем с самого начала — рассмотрения типов-сумм (sum type) и комбинаторов, и далее попытаемся последовательно объяснить подход Rust к обработке ошибок. Так что разработчики, которые имеют опыт работы с другими выразительными системами типов, могут свободно перескакивать от раздела к разделу.


Основы


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

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

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

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

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

// Попробуйте угадать число от 1 до 10.
// Если заданное число соответствует тому, что мы загадали, возвращается true.
// В противном случае возвращается false.
fn guess(n: i32) -> bool {
    if n < 1 || n > 10 {
        panic!("Неверное число: {}", n);
    }
    n == 5
}

fn main() {
    guess(11);
}

Если попробовать запустить этот код, то программа аварийно завершится с сообщением вроде этого:

thread '<main>' panicked at 'Неверное число: 11', src/bin/panic-simple.rs:6

Вот другой, менее надуманный пример. Программа, которая принимает число в качестве аргумента, удваивает его значение и печатает на экране.

use std::env;

fn main() {
    let mut argv = env::args();
    let arg: String = argv.nth(1).unwrap(); // ошибка 1
    let n: i32 = arg.parse().unwrap();      // ошибка 2
    println!("{}", 2 * n);
}

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

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

Объяснение unwrap


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

Вызывать unwrap в Rust подобно тому, что сказать: «Верни мне результат вычислений, а если произошла ошибка, просто паникуй и останавливай программу». Мы могли бы просто показать исходный код функции unwrap, ведь это довольно просто, но перед этим мы должны разобратся с типами Option и Result. Оба этих типа имеют определенный для них метод unwrap.

Тип Option


Тип Option объявлен в стандартной библиотеке:

enum Option<T> {
    None,
    Some(T),
}

Тип Option — это способ выразить возможность отсутствия чего бы то ни было, используя систему типов Rust. Выражение возможности отсутствия через систему типов является важной концепцией, поскольку такой подход позволяет компилятору требовать от разработчика обрабатывать такое отсутствие. Давайте взглянем на пример, который пытается найти символ в строке:

// Поиск Unicode-символа `needle` в `haystack`. Когда первый символ найден,
// возвращается побайтовое смещение для этого символа. Иначе возвращается `None`.
fn find(haystack: &str, needle: char) -> Option<usize> {
    for (offset, c) in haystack.char_indices() {
        if c == needle {
            return Some(offset);
        }
    }
    None
}

Обратите внимание, что когда эта функция находит соответствующий символ, она возвращает не просто offset. Вместо этого она возвращает Some(offset). Some — это вариант или конструктор значения для типа Option. Его можно интерпретировать как функцию типа fn<T>(value: T) -> Option<T>. Соответственно, None — это также конструктор значения, только у него нет параметров. Его можно интерпретировать как функцию типа fn<T>() -> Option<T>.

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

fn main() {
    let file_name = "foobar.rs";
    match find(file_name, '.') {
        None => println!("Расширение файла не найдено."),
        Some(i) => println!("Расширение файла: {}", &file_name[i+1..]),
    }
}

Этот код использует сопоставление с образцом чтобы выполнить вариативный анализ для возвращаемого функцией find значения Option<usize>. На самом деле, вариативный анализ является единственным способом добраться до значения, сохраненного внутри Option<T>. Это означает, что вы, как разработчик, обязаны обработать случай, когда значение Option<T> равно None, а не Some(t).

Но подождите, как насчет unwrap, который мы до этого использовали? Там не было никакого вариативного анализа! Вместо этого, вариативный анализ был перемещен внутрь метода unwrap. Вы можете сделать это самостоятельно, если захотите:

enum Option<T> {
    None,
    Some(T),
}

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

Метод unwrap абстрагирует вариативный анализ. Это именно то, что делает unwrap удобным в использовании. К сожалению, panic! означает, что unwrap неудобно сочетать с другим кодом: это слон в посудной лавке.

Совмещение значений Option<T>


В предыдущем примере мы рассмотрели, как можно воспользоватся find для того, чтобы получить расширение имени файла. Конечно, не во всех именах файлов можно найти ., так что существует вероятность, что имя некоторого файла не имеет расширения. Эта возможность отсутствия интерпретируется на уровне типов через использование Option<T>. Другими словами, компилятор заставит нас рассмотреть возможность того, что расширение не существует. В нашем случае мы просто печатаем сообщение об этом.

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

// Возвращает расширение заданного имени файла, а именно все символы,
// идущие за первым вхождением `.` в имя файла.
// Если в `file_name` нет ни одного вхождения `.`, возвращается `None`.
fn extension_explicit(file_name: &str) -> Option<&str> {
    match find(file_name, '.') {
        None => None,
        Some(i) => Some(&file_name[i+1..]),
    }
}

(Подсказка: не используйте этот код. Вместо этого используйте метод extension из стандартной библиотеки.)

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

На самом деле, вариативный анализ в extension_explicit является очень распространенным паттерном: если Option<T> владеет определенным значением T, то выполнить его преобразование с помощью функции, а если нет — то просто вернуть None.

Rust поддерживает параметрический полиморфизм, так что можно очень легко объявить комбинатор, который абстрагирует это поведение:

fn map<F, T, A>(option: Option<T>, f: F) -> Option<A> where F: FnOnce(T) -> A {
    match option {
        None => None,
        Some(value) => Some(f(value)),
    }
}

В действительности, map определен в стандартной библиотеке как метод Option<T>.

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

// Возвращает расширение заданного имени файла, а именно все символы,
// идущие за первым вхождением `.` в имя файла.
// Если в `file_name` нет ни одного вхождения `.`, возвращается `None`.
fn extension(file_name: &str) -> Option<&str> {
    find(file_name, '.').map(|i| &file_name[i+1..])
}

Есть еще одно поведение, которое можно часто встретить — это использование значения по-умолчанию в случае, когда значение Option равно None. К примеру, ваша программа может считать, что расширение файла равно rs в случае, если на самом деле оно отсутствует.

Легко представить, что этот случай вариативного анализа не специфичен только для расширений файлов — такой подход может работать с любым Option<T>:

fn unwrap_or<T>(option: Option<T>, default: T) -> T {
    match option {
        None => default,
        Some(value) => value,
    }
}

Хитрость только в том, что значение по-умолчанию должно иметь тот же тип, что и значение, которое может находится внутри Option<T>. Использование этого метода элементарно:

fn main() {
    assert_eq!(extension("foobar.csv").unwrap_or("rs"), "csv");
    assert_eq!(extension("foobar").unwrap_or("rs"), "rs");
}

(Обратите внимание, что unwrap_or объявлен как метод Option<T> в стандартной библиотеке, так что мы воспользовались им вместо функции, которую мы объявили ранее. Не забудьте также изучить более общий метод unwrap_or_else).

Существует еще один комбинатор, на который, как мы думаем, стоит обратить особое внимание: and_then. Он позволяет легко сочетать различные вычисления, которые допускают возможность отсутствия. Пример — большая часть кода в этом разделе, который связан с определением расширения заданного имени файла. Чтобы делать это, нам для начала необходимо узнать имя файла, которое как правило извлекается из файлового пути. Хотя большинство файловых путей содержат имя файла, подобное нельзя сказать обо всех файловых путях. Примером могут послужить пути ., .. или /.

Таким образом, мы определили задачу нахождения расширения заданного файлового пути. Начнем с явного вариативного анализа:

fn file_path_ext_explicit(file_path: &str) -> Option<&str> {
    match file_name(file_path) {
        None => None,
        Some(name) => match extension(name) {
            None => None,
            Some(ext) => Some(ext),
        }
    }
}

fn file_name(file_path: &str) -> Option<&str> {
  unimplemented!() // опустим реализацию
}

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

fn and_then<F, T, A>(option: Option<T>, f: F) -> Option<A>
        where F: FnOnce(T) -> Option<A> {
    match option {
        None => None,
        Some(value) => f(value),
    }
}

Теперь мы можем переписать нашу функцию file_path_ext без явного вариативного анализа:

fn file_path_ext(file_path: &str) -> Option<&str> {
    file_name(file_path).and_then(extension)
}

Тип Option имеет много других комбинаторов определенных в стандартной библиотеке. Очень полезно просмотреть этот список и ознакомиться с доступными методами — они не раз помогут вам сократить количество вариативного анализа. Ознакомление с этими комбинаторами окупится еще и потому, что многие из них определены с аналогичной семантикой и для типа Result, о котором мы поговорим далее.

Комбинаторы делают использование типов вроде Option более удобным, ведь они сокращают явный вариативный анализ. Они также соответствуют требованиям сочетаемости, поскольку они позволяют вызывающему обрабатывать возможность отсутствия результата собственным способом. Такие методы, как unwrap, лишают этой возможности, ведь они будут паниковать в случае, когда Option<T> равен None.

Тип Result


Тип Result также определен в стандартной библиотеке:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Тип Result — это продвинутая версия Option. Вместо того, чтобы выражать возможность отсутствия, как это делает Option, Result выражает возможность ошибки. Как правило, ошибки необходимы для объяснения того, почему результат определенного вычисления не был получен. Строго говоря, это более общая форма Option. Рассмотрим следующий псевдоним типа, который во всех смыслах семантически эквивалентен реальному Option<T>:

type Option<T> = Result<T, ()>;

Здесь второй параметр типа Result фиксируется и определяется через () (произносится как «unit» или «пустой кортеж»). Тип () имеет ровно одно значение — (). (Да, это тип и значение этого типа, которые выглядят одинаково!)

Тип Result — это способ выразить один из двух возможных исходов вычисления. По соглашению, один исход означает ожидаемый результат или "Ok", в то время как другой исход означает исключительную ситуацию или "Err".

Подобно Option, тип Result имеет метод unwrap, определенный в стандартной библиотеке. Давайте объявим его самостоятельно:

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

Это фактически то же самое, что и определение Option::unwrap, за исключением того, что мы добавили значение ошибки в сообщение panic!. Это делает отладку проще, но это вынуждает нас требовать от типа-параметра E (который представляет наш тип ошибки) реализации Debug. Поскольку подавляющее большинство типов должны реализовывать Debug, обычно на практике такое ограничение не мешает. (Реализация Debug для некоторого типа просто означает, что существует разумный способ печати удобочитаемого описания значения этого типа.)

Окей, давайте перейдем к примеру.

Преобразование строки в число


Стандартная библиотека Rust позволяет элементарно преобразовывать строки в целые числа. На самом деле это настолько просто, что возникает соблазн написать что-то вроде:

fn double_number(number_str: &str) -> i32 {
    2 * number_str.parse::<i32>().unwrap()
}

fn main() {
    let n: i32 = double_number("10");
    assert_eq!(n, 20);
}

Здесь вы должны быть скептически настроены по-поводу вызова unwrap. Если строку нельзя распарсить как число, вы получите панику:

thread '<main>' panicked at 'called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }', /home/rustbuild/src/rust-buildbot/slave/beta-dist-rustc-linux/build/src/libcore/result.rs:729

Это довольно неприятно, и если бы подобное произошло в используемой вами библиотеке, вы могли бы небезосновательно разгневаться. Так что нам стоит попытаться обработать ошибку в нашей функции, и пусть вызывающий сам решит что с этим делать. Это означает необходимость изменения типа, который возвращается double_number. Но на какой? Чтобы понять это, необходимо посмотреть на сигнатуру метода parse из стандартной библиотеки:

impl str {
    fn parse<F: FromStr>(&self) -> Result<F, F::Err>;
}

Хмм. По крайней мере мы знаем, что должны использовать Result. Вполне возможно, что метод мог возвращать Option. В конце концов, строка либо парсится как число, либо нет, не так ли? Это, конечно, разумный путь, но внутренняя реализация знает почему строка не распарсилась как целое число. (Это может быть пустая строка, или неправильные цифры, слишком большая или слишком маленькая длина и т.д.) Таким образом, использование Result имеет смысл, ведь мы хотим предоставить больше информации, чем просто «отсутствие». Мы хотим сказать, почему преобразование не удалось. Вам стоит рассуждать похожим образом, когда вы сталкиваетесь с выбором между Option и Result. Если вы можете предоставить подробную информацию об ошибке, то вам, вероятно, следует это сделать. (Позже мы поговорим об этом подробнее.)

Хорошо, но как мы запишем наш тип возвращаемого значения? Метод parse является обобщенным (generic) для всех различных типов чисел из стандартной библиотеки. Мы могли бы (и, вероятно, должны) также сделать нашу функцию обобщенной, но давайте пока остановимся на конкретной реализации. Нас интересует только тип i32, так что нам стоит найти его реализацию FromStr (выполните поиск в вашем браузере по строке «FromStr») и посмотреть на его ассоциированный тип Err. Мы делаем это чтобы определить конкретный тип ошибки. В данном случае, это std::num::ParseIntError. Наконец, мы можем переписать нашу функцию:

use std::num::ParseIntError;

fn double_number(number_str: &str) -> Result<i32, ParseIntError> {
    match number_str.parse::<i32>() {
        Ok(n) => Ok(2 * n),
        Err(err) => Err(err),
    }
}

fn main() {
    match double_number("10") {
        Ok(n) => assert_eq!(n, 20),
        Err(err) => println!("Error: {:?}", err),
    }
}

Неплохо, но нам пришлось написать гораздо больше кода! И нас опять раздражает вариативный анализ.

Комбинаторы спешат на помощь! Подобно Option, Result имеет много комбинаторов, определенных в качестве методов. Существует большой список комбинаторов, общих между Result и Option. И map входит в этот список:

use std::num::ParseIntError;

fn double_number(number_str: &str) -> Result<i32, ParseIntError> {
    number_str.parse::<i32>().map(|n| 2 * n)
}

fn main() {
    match double_number("10") {
        Ok(n) => assert_eq!(n, 20),
        Err(err) => println!("Error: {:?}", err),
    }
}

Все ожидаемые методы реализованы для Result, включая unwrap_or и and_then. Кроме того, поскольку Result имеет второй параметр типа, существуют комбинаторы, которые влияют только на значение ошибки, такие как map_err (аналог map) и or_else (аналог and_then).

Создание псевдонима типа Result


В стандартной библиотеке можно часто увидеть типы вроде Result<i32>. Но постойте, ведь мы определили Result с двумя параметрами типа. Как мы можем обойти это, указывая только один из них? Ответ заключается в определении псевдонима типа Result, который фиксирует один из параметров конкретным типом. Обычно фиксируется тип ошибки. Например, наш предыдущий пример с преобразованием строк в числа можно переписать так:

use std::num::ParseIntError;
use std::result;

type Result<T> = result::Result<T, ParseIntError>;

fn double_number(number_str: &str) -> Result<i32> {
    unimplemented!();
}

Зачем мы это делаем? Что ж, если у нас есть много функций, которые могут вернуть ParseIntError, то гораздо удобнее определить псевдоним, который всегда использует ParseIntError, так что мы не будем повторяться все время.

Самый заметный случай использования такого подхода в стандартной библиотеке — псевдоним io::Result. Как правило, достаточно писать io::Result<T>, чтобы было понятно, что вы используете псевдоним типа из модуля io, а не обычное определение из std::result. (Этот подход также используется для fmt::Result)

Короткое отступление: unwrap — не обязательно зло


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

Тем не менее, unwrap все-таки можно использовать разумно. Факторы, которые оправдывают использование unwrap, являются несколько туманными, и разумные люди могут со мной не согласиться. Я кратко изложу свое мнение по этому вопросу:

  • Примеры и «грязный» код. Когда вы пишете просто пример или быстрый скрипт, обработка ошибок просто не требуется. Для подобных случаев трудно найти что-либо удобнее чем unwrap, так что здесь его использование очень привлекательно.
  • Паника указывает на ошибку в программе. Если логика вашего кода должна предотвращать определенное поведение (скажем, получение элемента из пустого стека), то использование panic также допустимо. Дело в том, что в этом случае паника будет сообщать о баге в вашей программе. Это может происходить явно, например от неудачного вызова assert!, или происходить потому, что индекс по массиву находится за пределами выделенной памяти.

Вероятно, это не исчерпывающий список. Кроме того, при использовании Option зачастую лучше использовать метод expect. Этот метод делает ровно то же, что и unwrap, за исключением того, что в случае паники напечатает ваше сообщение. Это позволит лучше понять причину ошибки, ведь будет показано конкретное сообщение, а не просто «called unwrap on a None value».

Мой совет сводится к следующему: используйте здравый смысл. Есть причины, по которым слова вроде «никогда не делать X» или «Y считается вредным» не появятся в этой статье. У любых решений существуют компромиссы, и это ваша задача, как разработчика, определить, что именно является приемлемым для вашего случая. Моя цель состоит только в том, чтобы помочь вам оценить компромиссы как можно точнее.

Теперь, когда мы рассмотрели основы обработки ошибок в Rust и разобрались с unwrap, давайте подробнее изучим стандартную библиотеку.

Работа с несколькими типами ошибок


До этого момента мы расматривали обработку ошибок только для случаев, когда все сводилось либо только к Option<T>, либо только к Result<T, SomeError>. Но что делать, когда у вас есть и Option, и Result? Или если у вас есть Result<T, Error1> и Result<T, Error2>? Наша следующуя задача — обработка композиции различных типов ошибок, и это будет главной темой на протяжении всей этой статьи.

Совмещение Option и Result


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

Конечно, в реальном коде все происходит не так гладко. Иногда у вас есть сочетания типов Option и Result. Должны ли мы прибегать к явному вариативному анализу, или можно продолжить использовать комбинаторы?

Давайте на время вернемся к одному из первых примеров в этой статье:

use std::env;

fn main() {
    let mut argv = env::args();
    let arg: String = argv.nth(1).unwrap(); // ошибка 1
    let n: i32 = arg.parse().unwrap(); // ошибка 2
    println!("{}", 2 * n);
}

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

Ньюанс заключается в том, что argv.nth(1) возвращает Option, в то время как arg.parse() возвращает Result. Они не могут быть скомпонованы непосредственно. Когда вы сталкиваетесь одновременно с Option и Result, обычно наилучшее решение — преобразовать Option в Result. В нашем случае, отсутствие параметра командной строки (из env::args()) означает, что пользователь не правильно вызвал программу. Мы могли бы просто использовать String для описания ошибки. Давайте попробуем:

use std::env;

fn double_arg(mut argv: env::Args) -> Result<i32, String> {
    argv.nth(1)
        .ok_or("Please give at least one argument".to_owned())
        .and_then(|arg| arg.parse::<i32>().map_err(|err| err.to_string()))
}

fn main() {
    match double_arg(env::args()) {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Error: {}", err),
    }
}

Раcсмотрим пару новых моментов на этом примере. Во-первых, использование комбинатора Option::ok_or. Это один из способов преобразования Option в Result. Такое преобразование требует явного определения ошибки, которую необходимо вернуть в случае, когда значение Option равно None. Как и для всех комбинаторов, которые мы рассматривали, его объявление очень простое:

fn ok_or<T, E>(option: Option<T>, err: E) -> Result<T, E> {
    match option {
        Some(val) => Ok(val),
        None => Err(err),
    }
}

Второй новый комбинатор, который мы использовали — Result::map_err. Это то же самое, что и Result::map, за исключением того, функция применяется к ошибке внутри Result. Если значение Result равно Оk(...), то оно возвращается без изменений.

Мы используем map_err, потому что нам необходимо привести все ошибки к одинаковому типу (из-за нашего использования and_then). Поскольку мы решили преобразовывать Option<String> (из argv.nth(1)) в Result<String, String>, мы также обязаны преобразовывать ParseIntError из arg.parse() в String.

Ограничения комбинаторов


Работа с IO и анализ входных данных — очень типичные задачи, и это то, чем лично я много занимаюсь с Rust. Так что мы будем использовать IO и различные процедуры анализа как примеры обработки ошибок.

Давайте начнем с простого. Поставим задачу открыть файл, прочесть все его содержимое и преобразовать это содержимое в число. После этого нужно будет умножить значение на 2 и распечатать результат.

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

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> i32 {
    let mut file = File::open(file_path).unwrap(); // ошибка 1
    let mut contents = String::new();
    file.read_to_string(&mut contents).unwrap();   // ошибка 2
    let n: i32 = contents.trim().parse().unwrap(); // ошибка 3
    2 * n
}

fn main() {
    let doubled = file_double("foobar");
    println!("{}", doubled);
}

(Замечание: Мы используем AsRef по тем же причинам, почему он используется в std::fs::File::open. Это позволяет удобно использовать любой тип строки в качестве пути к файлу.)

У нас есть три потенциальные ошибки, которые могут возникнуть:

  1. Проблема при открытии файла.
  2. Проблема при чтении данных из файла.
  3. Проблема при преобразовании данных в число.

Первые две проблемы определяются типом std::io::Error. Мы знаем это из типа возвращаемого значения методов std::fs::File::open и std::io::Read::read_to_string. (Обратите внимание, что они оба используют концепцию с псевдонимом типа Result, описанную ранее. Если вы кликните на тип Result, вы увидите псевдоним типа, и следовательно, лежащий в основе тип io::Error.) Третья проблема определяется типом std::num::ParseIntError. Кстати, тип io::Error часто используется по всей стандартной библиотеке. Вы будете видеть его снова и снова.

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

Первое, что мы должны решить: какой из типов использовать: Option или Result? Мы, конечно, могли бы с легкостью использовать Option. Если какая-либо из трех ошибок происходит, мы могли бы просто вернуть None. Это будет работать, и это лучше, чем просто паниковать, но мы можем сделать гораздо лучше. Вместо этого, мы будем сообщать некоторые детали о возникшей проблеме. Поскольку мы хотим выразить возможность ошибки, мы должны использовать Result<i32, E>. Но каким должен быть тип E? Поскольку может возникнуть два разных типа ошибок, мы должны преобразовать их к общему типу. Одним из таких типов является String. Давайте посмотрим, как это отразится на нашем коде:

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
    File::open(file_path)
         .map_err(|err| err.to_string())
         .and_then(|mut file| {
              let mut contents = String::new();
              file.read_to_string(&mut contents)
                  .map_err(|err| err.to_string())
                  .map(|_| contents)
         })
         .and_then(|contents| {
              contents.trim().parse::<i32>()
                      .map_err(|err| err.to_string())
         })
         .map(|n| 2 * n)
}

fn main() {
    match file_double("foobar") {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Ошибка: {}", err),
    }
}

Выглядит немного запутанно. Может потребоваться довольно много практики, прежде вы сможете писать такое. Написание кода в таком стиле называется следованием за типом. Когда мы изменили тип возвращаемого значения file_double на Result<i32, String>, нам пришлось начать подбирать правильные комбинатороы. В данном случае мы использовали только три различных комбинатора: and_then, map и map_err.

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

Комбинатор map используется, чтобы применить функцию к значению Ok(...) типа Result. Например, в самом последнем вызове, map умножает значение Ok(...) (типа i32) на 2. Если ошибка произошла до этого момента, эта операция была бы пропущена. Это следует из определения map.

Комбинатор map_err — это уловка, которая позволяют всему этому заработать. Этот комбинатор, такой же, как и map, за исключением того, что применяет функцию к Err(...) значению Result. В данном случае мы хотим привести все наши ошибки к одному типу — String. Поскольку как io::Error, так и num::ParseIntError реализуют ToString, мы можем вызвать метод to_string, чтобы выполнить преобразование.

Не смотря на все сказанное, код по-прежнему выглядит запутанным. Мастерство использования комбинаторов является важным, но у них есть свои недостатки. Давайте попробуем другой подход: преждевременный возврат.

Преждевременный return


Давайте возьмем код из предыдущего раздела и перепишем его с применением раннего возврата. Ранний return позволяет выйти из функции досрочно. Мы не можем выполнить return для file_double внутри замыкания, поэтому нам необходимо вернуться к явному вариативному анализу.

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
    let mut file = match File::open(file_path) {
        Ok(file) => file,
        Err(err) => return Err(err.to_string()),
    };
    let mut contents = String::new();
    if let Err(err) = file.read_to_string(&mut contents) {
        return Err(err.to_string());
    }
    let n: i32 = match contents.trim().parse() {
        Ok(n) => n,
        Err(err) => return Err(err.to_string()),
    };
    Ok(2 * n)
}

fn main() {
    match file_double("foobar") {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Ошибка: {}", err),
    }
}

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

Разве это не шаг назад? Ранее мы говорили, что ключ к удобной обработке ошибок — сокращение явного вариативного анализа, но здесь мы вернулись к тому, с чего начинали. Оказывается, существует несколько способов его уменьшения. И комбинаторы — не единственный путь.

Макрос try!


Краеугольный камень обработки ошибок в Rust — это макрос try!. Этот макрос абстрагирует анализ вариантов так же, как и комбинаторы, но в отличие от них, он также абстрагирует поток выполнения. А именно, он умеет абстрагировать идею досрочного возврата, которую мы только что реализовали.

Вот упрощенное определение макроса `try!:

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(err),
    });
}

(Реальное определение выглядит немного сложнее. Мы обсудим это далее).

Использование макроса try! может очень легко упростить наш последний пример. Поскольку он выполняет анализ вариантов и досрочной возврат из функции, мы получаем более плотный код, который легче читать:

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
    let mut file = try!(File::open(file_path).map_err(|e| e.to_string()));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents).map_err(|e| e.to_string()));
    let n = try!(contents.trim().parse::<i32>().map_err(|e| e.to_string()));
    Ok(2 * n)
}

fn main() {
    match file_double("foobar") {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Ошибка: {}", err),
    }
}

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

Объявление собственного типа ошибки


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

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

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

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

Например, тип io::Error включает в себя тип io::ErrorKind, который является структурированными данными, представляющими то, что пошло не так во время выполнения операции ввода-вывода. Это важно, поскольку может возникнуть необходимость по-разному реагировать на различные причины ошибки. (Например, ошибка BrokenPipe может изящно завершать программу, в то время как ошибка NotFound будет завершать программу с кодом ошибки и показывать соответствующее сообщение пользователю.) Благодаря io::ErrorKind, вызывающая сторона может исследовать тип ошибки с помощью вариативного анализа, и это значительно лучше попытки вычленить детали об ошибке из String.

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

Идеальным способом представления одного варианта из многих является определение нашего собственного типа-суммы с помощью enum. В нашем случае, ошибка представляет собой либо io::Error, либо num::ParseIntError, из чего естественным образом вытекает определение:

use std::io;
use std::num;

// Мы реализуем `Debug` поскольку, по всей видимости, все типы должны реализовывать `Debug`.
// Это дает нам возможность получить адекватное и читаемое описание значения CliError
#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Parse(num::ParseIntError),
}

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

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut file = try!(File::open(file_path).map_err(CliError::Io));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents).map_err(CliError::Io));
    let n: i32 = try!(contents.trim().parse().map_err(CliError::Parse));
    Ok(2 * n)
}

fn main() {
    match file_double("foobar") {
        Ok(n) => println!("{}", n),
        Err(err) => println!("Ошибка: {:?}", err),
    }
}

Единственное изменение здесь — замена вызова map_err(|e| e.to_string()) (который преобразовывал ошибки в строки) на map_err(CliError::Io) или map_err(CliError::Parse). Теперь вызывающая сторона определяет уровень детализации сообщения об ошибке для конечного пользователя. В действительности, использование String как типа ошибки лишает вызывающего возможности выбора, в то время использование собственного типа enum, на подобие CliError, дает вызывающему тот же уровень удобства, который был ранее, и кроме этого структурированные данные, описывающие ошибку.

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

Типажи из стандартной библиотеки, используемые для обработки ошибок


Стандартная библиотека определяет два встроенных типажа, полезных для обработки ошибок std::error::Error и std::convert::From. И если Error разработан специально для создания общего описания ошибки, то типаж From играет широкую роль в преобразовании значений между различными типами.

Типаж Error


Типаж Error объявлен в стандартной библиотеке:

use std::fmt::{Debug, Display};

trait Error: Debug + Display {
  /// A short description of the error.
  fn description(&self) -> &str;

  /// The lower level cause of this error, if any.
  fn cause(&self) -> Option<&Error> { None }
}

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

  • Получать строковое представление ошибки для разработчика (Debug).
  • Получать понятное для пользователя представление ошибки (Display).
  • Получать краткое описание ошибки (метод description).
  • Изучать по цепочке первопричину ошибки, если она существует (метод cause).

Первые две возможности возникают в результате того, что типаж Error требует в свою очередь реализации типажей Debug и Display. Последние два факта исходят из двух методов, определенных в самом Error. Мощь Еrror заключается в том, что все существующие типы ошибок его реализуют, что в свою очередь означает что любые ошибки могут быть сохранены как типажи-объекты (trait object). Обычно это выглядит как Box<Error>, либо &Error. Например, метод cause возвращает &Error, который как раз является типажом-объектом. Позже мы вернемся к применению Error как типажа-объекта.

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

use std::io;
use std::num;

// Мы реализуем `Debug` поскольку, по всей видимости, все типы должны реализовывать `Debug`.
// Это дает нам возможность получить адекватное и читаемое описание значения CliError
#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Parse(num::ParseIntError),
}

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

Реализация Error довольно прямолинейна и главным образом состоит из явного анализа вариантов:

use std::error;
use std::fmt;

impl fmt::Display for CliError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            // Оба изначальных типа ошибок уже реализуют `Display`,
            // так что мы можем использовать их реализации
            CliError::Io(ref err) => write!(f, "IO error: {}", err),
            CliError::Parse(ref err) => write!(f, "Parse error: {}", err),
        }
    }
}

impl error::Error for CliError {
    fn description(&self) -> &str {
        // Оба изначальных типа ошибок уже реализуют `Error`,
        // так что мы можем использовать их реализацией
        match *self {
            CliError::Io(ref err) => err.description(),
            CliError::Parse(ref err) => err.description(),
        }
    }

    fn cause(&self) -> Option<&error::Error> {
        match *self {
            // В обоих случаях просходит неявное преобразование значения `err`
            // из конкретного типа (`&io::Error` или `&num::ParseIntError`)
            // в типаж-обьект `&Error`. Это работает потому что оба типа реализуют `Error`.
            CliError::Io(ref err) => Some(err),
            CliError::Parse(ref err) => Some(err),
        }
    }
}

Хочется отметить, что это очень типичная реализация Error: реализация методов description и cause в соответствии с каждым возможным видом ошибки.

Типаж From


Типаж std::convert::From объявлен в стандартной библиотеке:

trait From<T> {
    fn from(T) -> Self;
}

Очень просто, не правда ли? Типаж From чрезвычайно полезен, поскольку создает общий подход для преобразования из определенного типа Т в какой-то другой тип (в данном случае, «другим типом» является тип, реализующий данный типаж, или Self). Самое важное в типаже Fromмножество его реализаций, предоставляемых стандартной библиотекой.

Вот несколько простых примеров, демонстрирующих работу From:

let string: String = From::from("foo");
let bytes: Vec<u8> = From::from("foo");
let cow: ::std::borrow::Cow<str> = From::from("foo");

Итак, From полезен для выполнения преобразований между строками. Но как насчет ошибок? Оказывается, существует одна важная реализация:

impl<'a, E: Error + 'a> From<E> for Box<Error + 'a>

Эта реализация говорит, что любой тип, который реализует Error, можно конвертировать в типаж-объект Box<Error>. Выглядит не слишком впечатляюще, но это очень полезно в общем контексте.

Помните те две ошибки, с которыми мы имели дело ранее, а именно, io::Error and num::ParseIntError? Поскольку обе они реализуют Error, они также работают с From:

use std::error::Error;
use std::fs;
use std::io;
use std::num;

// Получаем значения ошибок
let io_err: io::Error = io::Error::last_os_error();
let parse_err: num::ParseIntError = "not a number".parse::<i32>().unwrap_err();

// Собственно, конвертация
let err1: Box<Error> = From::from(io_err);
let err2: Box<Error> = From::from(parse_err);

Здесь нужно разобрать очень важный паттерн. Переменные err1 и err2 имеют одинаковый тип — типаж-объект. Это означает, что их реальные типы скрыты от компилятора, так что по факту он рассматривает err1 и err2 как одинаковые сущности. Кроме того, мы создали err1 и err2, используя один и тот же вызов функции — From::from. Мы можем так делать, поскольку функция From::from перегружена по ее аргументу и возвращаемому типу.

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

Настало время вернуться к нашему старому другу — макросу try!.

Настоящий макрос try!


До этого мы привели такое определение try!:

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(err),
    });
}

Но это не настоящее определение. Реальное определение можно найти в стандартной библиотеке:

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(::std::convert::From::from(err)),
    });
}

Здесь есть одно маленькое, но очень важное изменение: значение ошибки пропускается через вызов From::from. Это делает макрос try! очень мощным инструментом, поскольку он дает нам возможность бесплатно выполнять автоматическое преобразование типов.

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

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
    let mut file = try!(File::open(file_path).map_err(|e| e.to_string()));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents).map_err(|e| e.to_string()));
    let n = try!(contents.trim().parse::<i32>().map_err(|e| e.to_string()));
    Ok(2 * n)
}

Ранее мы говорили, что мы можем избавиться от вызовов map_err. На самом деле, все что мы должны для этого сделать — это найти тип, который работает с From. Как мы увидели в предыдущем разделе, From имеет реализацию, которая позволяет преобразовать любой тип ошибки в Box<Error>:

use std::error::Error;
use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, Box<Error>> {
    let mut file = try!(File::open(file_path));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents));
    let n = try!(contents.trim().parse::<i32>());
    Ok(2 * n)
}

Мы уже очень близки к идеальной обработке ошибок. Наш код имеет очень мало накладных расходов из-за обработки ошибок, ведь макрос try! инкапсулирует сразу три вещи:

  1. Вариативный анализ.
  2. Поток выполнения.
  3. Преобразование типов ошибок.

Когда все эти три вещи объединены вместе, мы получаем код, который не обременен комбинаторами, вызовами unwrap или постоянным анализом вариантов.

Но осталась одна маленькая деталь: тип Box<Error> не несет никакой информации. Если мы возвращаем Box<Error> вызывающей стороне, нет никакой возможности (легко) узнать базовый тип ошибки. Ситуация, конечно, лучше, чем со String, посольку появилась возможность вызывать методы, вроде description или cause, но ограничение остается: Box<Error> не предоставляет никакой информации о сути ошибки. (Замечание: Это не совсем верно, поскольку в Rust есть инструменты рефлексии во время выполнения, которые полезны при некоторых сценариях, но их рассмотрение выходит за рамки этой статьи).

Настало время вернуться к нашему собственному типу CliError и связать все в одно целое.

Совмещение собственных типов ошибок


В последнем разделе мы рассмотрели реальный макрос try! и то, как он выполняет автоматическое преобразование значений ошибок с помощью вызова From::from. В нашем случае мы конвертировали ошибки в Box<Error>, который работает, но его значение скрыто для вызывающей стороны.

Чтобы исправить это, мы используем средство, с которым мы уже знакомы: создание собственного типа ошибки. Давайте вспомним код, который считывает содержимое файла и преобразует его в целое число:

use std::fs::File;
use std::io::{self, Read};
use std::num;
use std::path::Path;

// Мы реализуем `Debug` поскольку, по всей видимости, все типы должны реализовывать `Debug`.
// Это дает нам возможность получить адекватное и читаемое описание значения CliError
#[derive(Debug)]
enum CliError {
    Io(io::Error),
    Parse(num::ParseIntError),
}

fn file_double_verbose<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut file = try!(File::open(file_path).map_err(CliError::Io));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents).map_err(CliError::Io));
    let n: i32 = try!(contents.trim().parse().map_err(CliError::Parse));
    Ok(2 * n)
}

Обратите внимание, что здесь у нас еще остались вызовы map_err. Почему? Вспомните определения try! и From. Проблема в том, что не существует такой реализации From, которая позволяет конвертировать типы ошибок io::Error и num::ParseIntError в наш собственный тип CliError. Но мы можем легко это исправить! Поскольку мы определили тип CliError, мы можем также реализовать для него типаж From:

use std::io;
use std::num;

impl From<io::Error> for CliError {
    fn from(err: io::Error) -> CliError {
        CliError::Io(err)
    }
}

impl From<num::ParseIntError> for CliError {
    fn from(err: num::ParseIntError) -> CliError {
        CliError::Parse(err)
    }
}

Все эти реализации позволяют From создавать значения CliError из других типов ошибок. В нашем случае такое создание состоит из простого вызова конструктора значения. Как правило, это все что нужно.

Наконец, мы можем переписать file_double:


use std::fs::File;
use std::io::Read;
use std::path::Path;

fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, CliError> {
    let mut file = try!(File::open(file_path));
    let mut contents = String::new();
    try!(file.read_to_string(&mut contents));
    let n: i32 = try!(contents.trim().parse());
    Ok(2 * n)
}

Единственное, что мы сделали — это удалили вызовы map_err. Они нам больше не нужны, поскольку макрос try! выполняет From::from над значениями ошибок. И это работает, поскольку мы предоставили реализации From для всех типов ошибок, которые могут возникнуть.

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

use std::io;
use std::num;

enum CliError {
    Io(io::Error),
    ParseInt(num::ParseIntError),
    ParseFloat(num::ParseFloatError),
}

И добавить новую реализацию для From:


use std::num;

impl From<num::ParseFloatError> for CliError {
    fn from(err: num::ParseFloatError) -> CliError {
        CliError::ParseFloat(err)
    }
}

Вот и все!

Рекомендации для авторов библиотек


Если в вашей библиотеке могут возникать специфические ошибки, то вы наверняка должны определить для них свой собственный тип. На ваше усмотрение вы можете сделать его внутреннее представление публичным (как ErrorKind), или оставить его скрытым (подобно ParseIntError). Независимо от того, что вы предпримете, считается хорошим тоном обеспечить по крайней мере некоторую информацию об ошибке помимо ее строкового представления. Но, конечно, все зависит от конкретных случаев использования.

Как минимум, вы скорее всего должны реализовать типаж Error. Это даст пользователям вашей библиотеки некоторую минимальную гибкость при совмещении ошибок. Реализация типажа Error также означает, что пользователям гарантируется возможность получения строкового представления ошибки (это следует из необходимости реализации fmt::Debug и fmt::Display).

Кроме того, может быть полезным реализовать From для ваших типов ошибок. Это позволит вам (как автору библиотеки) и вашим пользователям совмещать более детальные ошибки. Например, csv::Error реализует From для io::Error и byteorder::Error.

Наконец, на свое усмотрение, вы также можете определить псевдоним типа Result, особенно, если в вашей библиотеке определен только один тип ошибки. Такой подход используется в стандартной библиотеке для io::Result и fmt::Result.

Заключение


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

  • Если вы пишете короткий пример кода, который может быть перегружен обработкой ошибок, это, вероятно, отличная возможность использовать unwrap (будь-то Result::unwrap, Option::unwrap или Option::expect). Те, для кого предназначен пример, должны осознавать, что необходимо реализовать надлежащую обработку ошибок. (Если нет, отправляйте их сюда!)
  • Если вы пишете одноразовую программу, также не зазорно использовать unwrap. Но будьте внимательны: если ваш код попадет в чужие руки, не удивляйтесь, если кто-то будет расстроен из-за скудных сообщений об ошибках!
  • Если вы пишете одноразовый код, но вам все-равно стыдно из-за использования unwrap, воспользуйтесь либо String в качестве типа ошибки, либо Box<Error + Send + Sync> (из-за доступных реализаций From.)
  • В остальных случаях, определяйте свои собственные типы ошибок с соответствующими реализациями From и Error, делая использование try! более удобным.
  • Если вы пишете библиотеку и ваш код может выдавать ошибки, определите ваш собственный тип ошибки и реализуйте типаж std::error::Error. Там, где это уместно, реализуйте From, чтобы вам и вашим пользователям было легче с ними работать. (Из-за правил когерентности в Rust, пользователи вашей библиотеки не смогут реализовать From для ваших ошибок, поэтому это должна сделать ваша библиотека.)
  • Изучите комбинаторы, определенные для Option и Result. Писать код, пользуясь только ними может быть немного утомительно, но я лично нашел для себя хороший баланс между использованием try! и комбинаторами (and_then, map и unwrap_or — мои любимые).

Эта статья была подготовлена в рамках перевода на русский язык официального руководства «The Rust Programming Language». Переводы остальных глав этой книги можно найти здесь. Так же, если у вас есть любые вопросы, связанные с Rust, вы можете задать их в чате русскоязычного сообщества Rust.

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


  1. ozkriff
    09.11.2015 01:18
    +5

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


    1. VadimVP
      09.11.2015 03:00
      +2

      RFC есть (https://github.com/rust-lang/rfcs/pull/243), но оно не движется.
      Автор не хотел вставлять неявный вызов `Into::into` в `?`, человек из core team с ним не согласился, так всё пока и заглохло, потому что особо и не горит. Но рано или поздно будет, можно их попинать в комментах для ускорения.


    1. defuz
      09.11.2015 11:27
      +1

      Коробит, потому что непривычно? Макросы в Rust реализованы достаточно неплохо для того, чтобы быть хорошим инструментом для решения более-менее повседневных задач, не вызывая при этом боли, которая может возникнуть например в C++. Тем более, обработка ошибок через try! получается гораздо короче, чем использование try-catch блока в тех же сценариях.

      Когда я только начинал писать на Rust, меня тоже поначалу удивило отсутствие специального инструментария для обработки ошибок. Но набравшись некоторого опыта, я совершенно точно могу сказать, что не жду никакого «нормального решения» для обработки ошибок, и скорее всего буду против такого новшества. И это вопрос не привычки, а реального удобства.


      1. mkpankov
        09.11.2015 11:33
        +1

        Тем не менее, макросы, меняющие поток управления — это действительно неожиданно и поэтому может быть плохо.

        try! спасает только то, что он всего один, достаточно прост и быстро узнается в процессе изучения языка.


        1. defuz
          09.11.2015 12:06
          +3

          Я думаю, именно потому, что вызовы макроса могут вести себя не так тривиально, как вызовы функции, они обязательно требуют специального обозначения вроде foo!. Это что-то вроде «будь внимателен, здесь происходит что-то необычное».

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

          try!, println!, format!, vec!, assert! и assert_eq!

          Наверное можно было бы расширить синтаксис языка специльно для исключений, но такое расширение все-равно будет следовать логике макроса try. Например, что-то вроде foo(try bar()). Для остальных случаев есть более универсальная конструкция match, которая уже и так умеет все то, что в обычных языках умеет try-catch блок. Единственное отличие – match работает для каждого конкретного вызова, в то время как try-catch может оборачивать целый блок кода. Но я бы поспорил о том, что это недостаток для языка программирования, который делает ставку на надежность.


      1. ozkriff
        10.11.2015 19:45
        +1

        Ну как, я относительно давно на ржавчине пытаюсь писать, так что дело не просто в непривычности.

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

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

        Повторюсь, каких-то жутких практических недостатков в данном конкретном случае я не вижу — согласен с Михаилом, что ситуацию спасает одиночество `try!`. Но мне было бы комфортнее, если бы вышеуказанный RFC довели до ума и реализовали, пускай оно даже один в один как `try!` работало бы. Или даже что бы таки реализовали HKT и ввели в язык do-нотацию :).


        1. defuz
          10.11.2015 19:49

          А как код выглядел бы с do-нотацией? Не получится ли слишком громоздко?


          1. senia
            11.11.2015 13:09

            Полагаю как-то так: ideone.com/DlGYHp со строки 32.
            Всё, что выше — просто чтоб компилировалось.


            1. ozkriff
              11.11.2015 23:04

              В общем-то да, примерно то же самое.


          1. ozkriff
            11.11.2015 13:21

            > А как код выглядел бы с do-нотацией?

            Сейчас есть накостыленый на макросах mdo! — https://github.com/TeXitoi/rust-mdo — лучше без HTK особо не сделаешь. С ним как-то так выходит:

            fn write_to_file_using_try() -> io::Result<()> {
                let mut file = try!(File::create("my_best_friends.txt"));
                try!(file.write_all(b"This is a list of my best friends."));
                println!("I wrote to the file");
                Ok(())
            }
            
            fn write_to_file_using_mdo() -> io::Result<()> {
                mdo! {
                    mut file =<< File::create("my_best_friends.txt");
                    ign file.write_all(b"This is a list of my best friends.");
                    ign Ok(println!("I wrote to the file"));
                    ret Ok(())
                }
            }
            


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

            > Не получится ли слишком громоздко?

            Может, от конкретного кода зависит. Функции, возвращающие чего-то левое (как println! в примере), которые не выходит вынести за рамки do, конечно, придется «втягивать».

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


  1. SOLON7
    09.11.2015 07:43

    Я Все хотел задать вопрос, Вот Rust И GO, высоконагруженные платформы, А есть ли succes story от их использования,
    Я например хочу написать веб приложение, что лучше использовать ??(Best practices)? у Го и Rust есть предпосылки для роста, это не NodeJs со своей лапшей (Callbacki)


    1. AlexWinner
      09.11.2015 08:02

      New York Times вроде как переписали кучу всего на Go
      https://www.youtube.com/watch?v=bAQ9ShmXYLY


    1. Xlab
      09.11.2015 09:02
      -4

      Можно здесь подсмотреть github.com/golang/go/wiki/GoUsers
      То есть, Go используется дофига где. Насчёт применения Rust в продакшене пока рано говорить.


      1. mkpankov
        09.11.2015 12:12
        +6

        Да нет, не рано:


        Все ссылки, кроме последней — на релевантные статьи или код.


        1. Xlab
          09.11.2015 17:58
          -5

          Кроме OpenDNS чего не слышал. И то обвязочка для сишной либы, вы серьёзно называете это продакшеном?


          1. mkpankov
            09.11.2015 18:14
            +3

            Логика на уровне «я не слышал, значит этого нет». А я слышал про Skylight и MaidSafe, и чего, кто теперь прав?

            Да, это боевой код, который работает в реальных условиях — это production. Его размер здесь не важен, но если хотите, то вот цифры (это честные SLOC, без пробелов и комментариев):

            У MaidSafe всё ядро распределённой сети на Rust (14000 строк).

            Redox — это активно разрабатываемая ОС на Rust. 40000 строк кода на Rust. И пока вы не возразили, что этим «никто не пользуется» или «вы про это не слышали» — вспомните, как начинался Linux.


            1. Xlab
              09.11.2015 18:24
              -8

              Логика на уровне, что в списке про Go присутствует индустрия. А ваш список — карта песочниц в округе.

              Его размер здесь не важен, но если хотите, то вот цифры (это честные SLOC, без пробелов и комментариев)
              Вообще говоря важен, особенно не в абсолютных цифрах, а в степени вовлечённости в архитектуре. MaidSafe ещё не релизнулся, вы не компетентны если называете opens-source поделку до первого RC «продакшеном». 14000 LOC это смешно, у меня хобби-проект на Go за полтора месяца вышел на 8000. 14000 с учётом специфики задач это уровень концепта.

              Redox — это активно разрабатываемая ОС
              Я не буду вас ловить и проверять, насколько активно и какие там люди (больше, чем полтора ботана, надеюсь). По моему опыту разработки модулей и драйверов Linux — раст там вообще ничем не поможет. А до продакшена этой поделке лет 5 в лучшем случае, 20 в обычном.

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


              1. mkpankov
                09.11.2015 18:26
                +5

                На Skylight-то посмотрите, прежде чем обвинять в некомпетентности.


                1. Xlab
                  09.11.2015 18:36
                  -3

                  А что там? Главный разработчик растовых хаков для Skylight — Yehuda Katz, который и написал эти две статьи в блоге про то, какой Rust офигенный, он же входит в rust core team. Думаете, я случайно про «полтора ботана» пошутил?


                  1. mkpankov
                    09.11.2015 18:37
                    +5

                    Деньги зарабатывают? Зарабатывают. Нормально для языка, который стабилизовался вот ещё меньше, чем полгода назад.


                    1. Xlab
                      09.11.2015 18:37
                      -3

                      Вы меня не слышите.


              1. mkpankov
                09.11.2015 18:36
                +4

                больше, чем полтора ботана, надеюсь
                Линус вон вообще один начинал, в чём проблема-то?

                Да и, что меня ловить-то, я и сам поймаюсь.

                Redox SLOCs


              1. VolCh
                10.11.2015 07:59
                +6

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


                Можно узнать источник этого определения?


                1. Xlab
                  10.11.2015 12:48
                  -6

                  То есть, к определению

                  боевой код, который работает в реальных условиях — это production
                  товарища mkpankov у вас вопросов нет? Или вы согласны, что он неадекват и интересуетесь именно этим определением? Что-ж почитайте вот jdevelop.blogspot.ru/2013/03/blog-post.html


                  1. mkpankov
                    10.11.2015 13:21
                    +2

                    Нехорошо людей за глаза неадекватами называть.


                  1. VolCh
                    11.11.2015 09:13

                    Практически нет вопросов. Можно уточнять что такое «боевой», что такое «реальные условия», но в целом вопросов нет.


    1. Xlab
      09.11.2015 09:03

      Я например хочу написать веб приложение, что лучше использовать
      Ruby on Rails, потому что быстрее и проще, а узкие места переписать на Go всегда успеете. Всё равно их переписывать придётся.


    1. mkpankov
      09.11.2015 11:00
      +7

      Best practices для Rust пока нет.

      История успеха — это Servo. Он рисует страницы в 2 раза быстрее Gecko в одном потоке и ещё быстрее в многопоточном режиме.

      А из коммерческих пользователей есть как минимум Maidsafe, Skylight, OpenDNS. У них в разных местах есть посты о том, почему они выбрали Раст.


      1. faiwer
        09.11.2015 11:46

        История успеха — это Servo.

        Заинтересовал, погуглил. Наткнулся многократно на то, что его пока не собираются использовать в Firefox. Задумался ещё больше. Ведь разработка такой большой вещи занятие весьма эпохальное (это работа не для 5 программистов). С какой целью тогда они его делают?


        1. mkpankov
          09.11.2015 12:06
          +4

          Не совсем так.

          Вот план реализации Servo: github.com/servo/servo/wiki/Roadmap. Моё понимание таково: Servo пока просто не готов к боевому применению. Хотя некоторые сайты уже отрисовываются так же хорошо, как и в Gecko.

          Насколько я помню, в целом Mozilla действует так: постепенно заменяют компоненты в Firefox на те, что используется в Servo, так что в конце они будут использовать одно и то же, а затем можно и движок заменить.


    1. hell0w0rd
      09.11.2015 11:06
      +1

      это не NodeJs со своей лапшей (Callbacki)

      Лапша у вас на ушах, уважаемый. Вы внимательно погуглите магические 'javascript promise', 'javascript async/await', 'javascript es2015', чтобы глупостей не говорить.


      1. SOLON7
        09.11.2015 14:04

        Это конечно все хорошо, просто отладка осложняется, и код не очевиден, Я хотел уже осваивать Node.JS но потом увидел что на код надо смотреть не линейно а сначала код и заглушка(Callback) которая после нее. Конечно я понимаю таково преимущество Асинхронности, может быть проблема старого поколения что на код они смотрят линейно ???


        1. zim32
          09.11.2015 15:08

          Это не проблема в данном случае языка или технологии


      1. VolCh
        10.11.2015 08:01

        javascript es2015

        Его V8 уже поддерживает стабильно?


  1. zim32
    09.11.2015 12:35
    -12

    Rust, go, erlang, haskell, node, r&r, python, php, scala, groovy, dart, java, kill, kill, kill me…


    1. iroln
      09.11.2015 18:51

      r&r, ага


  1. beduin01
    09.11.2015 19:07

    К сожалению очень сложно представить выгоду от всех этих сложностей языка в реальных проектах. Там где нужен жесткий real time или крайне стабильный драйвера Rust наверно и крут. Но вот касательно всех других проектов — думаю для массового рынка он будет слишком невыгоден скоростью разработки.


    1. burjui
      10.11.2015 17:46
      +4

      Почему-то все очень любят оценивать только скорость разработки, забывая про скорость отладки. И проблема в том, что эти две величины связаны между собой не так просто, как хотелось бы: почти любой многопоточный алгоритм значительно сложнее отлаживать, чем идентичный ему однопоточный, при этом оба могут быть записаны в одну строку (а-ля records.map(processFn) и records.parallelMap(processFn)). В языках без GC к этому добавляются проблемы использования объектов после разрушения и т.п.

      Баги в программе — это ещё и удар по репутации. Уж лучше потратить в два раза больше времени на написание кода, чем написать код в два раза быстрее, а потом столько же его отлаживать (ну, это как повезёт), вдобавок теряя клиентов и получая негативные отзывы.


      1. VolCh
        11.11.2015 09:43

        Уж лучше потратить в два раза больше времени на написание кода, чем написать код в два раза быстрее, а потом столько же его отлаживать (ну, это как повезёт), вдобавок теряя клиентов и получая негативные отзывы.


        Нельзя сказать однозначно. Как минимум, отлаживать нужно не всегда.


    1. Fedcomp
      10.11.2015 22:19
      +3

      Вам никто ведь не мешает делать unwrap везде, а затем постепенно рефакторить. Стабильность теряется разумеется.


  1. nehaev
    09.11.2015 23:53

    Распространенность в стандартной библиотеке возвратов структуры Result вместе с рекомендованным способом его обработки через макрос try! (который по сути пробрасывает ошибку вверх по стеку вызовов) весьма напоминает эмуляцию throw exception из Java и подобных языков.


    1. PHmaster
      10.11.2015 05:48

      Нечто вроде эмуляции. Эксепшены более тяжеловесны, так как в них (как минимум) сохраняется весь stack trace. (Про другие причины их тяжеловесности в разных языках я когда-то читал, но уже многое подзабыл, и ссылку на статью найти не могу).
      Похожая эмуляция исключений, кстати, недавно появилась в swift 2.0, и меня это очень сильно порадовало. Все же не очень удобно и читабельно плодить кучу if-ов для проверки ошибок после вызова каждой функции. Но решение в Rust мне видится более гибким, так как можно делать всякие функциональные чудеса с помощью map, map_err и проч., полностью отказавшись от try! при необходимости.


  1. yomayo
    10.11.2015 17:53
    +1

    Можно автору статьи несколько вопросов?

    Какой размер имеет в Rust экземпляр типа i32? В Си i32 – элементарный тип. Скорее всего в Rust i32 тоже элементарный, т.е. не класс. Поэтому его размер, скорее всего, 4 байта. И какой размер имеют экземпляры типов Option и Result<i32, E>? И ещё вопрос – указатель в Rust является элементарным типом или нет? И каков его размер?

    Как обработать в Rust возникающие при сложении переполнения, т.е. ошибки?


    1. defuz
      10.11.2015 19:18
      +4

      Автор статьи вам скорее всего не ответит, поскольку не читает Хабр. Отвечу я как переводчик.

      i32 – элеменентарный тип, его размер 4 байта. В Rust вообще нет понятия класса.
      Размер Option<T> равен размеру T + дискриминант (обычно 1 байт) + выравнивание.
      Размер Option<&T> равен размеру указателя для вашей архитектуры. Дискриминант не нужен, поскольку значение None кодируется как нулевой указатель. В Rust нет нулевых указателей, так что сам &T никогда не будет равен null.
      Размер Result<T, E> равен размеру дискриминанта (1 байт) + размеру наибольшего возможного значения (или размер T, или размер E) + выравнивание.

      Арифметические операции по умолчанию игнорируют переполнение, но если вам нужно их обрабатывать, можно воспользоваться методами checked_add, checked_mul и т.д. В случае с i32 они возвращают результат Option<i32>.


    1. defuz
      10.11.2015 19:27

      Простите, я вас обманул по-поводу переполнения:

      Обычное сложение 150u8 + 150u8 паникует в случае переполнения (и корректно завершает поток).
      Метод 150u8.checked_add(150u8) возвращает Option<T> со значением None.
      Метод 150u8.saturating_add(150u8) возвращает граничное значение (255).
      Метод 150u8.wrapping_add(150u8) игнорирует переполнение (44).


      1. ozkriff
        10.11.2015 19:32
        +3

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


        1. defuz
          10.11.2015 19:42
          +1

          Да, точно, для release оператор + ведет себя аналогично wrapping_add.


          1. yomayo
            10.11.2015 21:30

            Если Option имеет дискриминант (или дескриптор, т.е. описание? В языке Алгол-68 применялся термин «паспорт», этот паспорт дополнительно описывал объекты), то из функции нельзя вернуть Option в 32-разрядном регистре. Некоторая потеря эффективности в пользу надёжности.
            Установил Rust себе на Windows XP – не работает. Потом узнал, что эта платформа не поддерживается. Тогда поставил на 32-разрядную Windows 7. Компилятор сообщает о синтаксической ошибке на «hello world», взятый из учебника. Наверно, нужен Linux… Компилятор Rust выдаёт не исполняемый код, а код в LLVM IR. А под Windows единственный инструмент, который превращает код LLVM IR в исполняемый – Visual C++. Как-то не по фэншую – сочетание свободного ПО и проприетарного… Или я неправильно информирован?


            1. Zelgadis
              10.11.2015 23:08
              -1

              > Установил Rust себе на Windows X
              Но сегодня 2015 год же!

              > А под Windows единственный инструмент, который превращает код LLVM IR в исполняемый – Visual C++. Как-то не по фэншую – сочетание свободного ПО и проприетарного… Или я неправильно информирован?
              Если бы вы реально хотели посмотреть, то увидели, что у раста два варианта под windows. Через MSVC и Mini-GW.

              > Компилятор сообщает о синтаксической ошибке на «hello world», взятый из учебника.
              Может програмирование это не для вас?


              1. mkpankov
                11.11.2015 00:23
                +1

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


                1. Zelgadis
                  11.11.2015 00:30

                  Человек не задает нормальные вопросы. Человек язвит и не совсем понимает, что же такое этот LLVM.

                  Задавал бы он нормальные выпросы, он бы показал код и ошибку. И точно бы не требовал, чтобы компилятор работает на 32 разрядной ХР.


            1. defuz
              10.11.2015 23:22
              +1

              Некоторая потеря эффективности
              Я не могу понять, в чем потеря эффективности? Не сущесвует никакой возможности впихнуть тип «i32 или ничего» в 4 байта. По-любому нужен еще хотя-бы один бит, чтобы кодировать это «ничего». Как раз для этого необходим дискриминант. Никто вам не мешает возвращать из функции просто i32, размером в 4 байта.


              1. mkpankov
                11.11.2015 00:29

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


              1. qw1
                11.11.2015 11:33

                Я не могу понять, в чем потеря эффективности? Не сущесвует никакой возможности впихнуть тип «i32 или ничего» в 4 байта.

                В Си эта проблема решалась значениями-исключениями
                hFile = CreateFile(...);
                if (hFile == INVALID_NAHDLE_VALUE) ...


                Никто вам не мешает возвращать из функции просто i32, размером в 4 байта.

                и потерять всю эту безопасность, паттерн-матчинг и вернуться на ступень назад


                1. ozkriff
                  11.11.2015 11:37
                  +4

                  > В Си эта проблема решалась значениями-исключениями

                  И это печально.


                1. mkpankov
                  11.11.2015 11:42
                  +1

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


                  1. qw1
                    11.11.2015 12:02
                    -1

                    Мы хотели бескомпромиссно, в этом идеология си/си++
                    Если для этого нужно усложнять концепцию, вставив в описание Option ручное указание значения-исключения для NONE, такое усложение стоит выигрыша в скорости.


                    1. mkpankov
                      11.11.2015 12:24
                      +2

                      Это не «бескомпромиссно», это небезопасно.

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

                      Позвольте вопрос: вы на Си пишете или так, сопереживаете?


                      1. qw1
                        11.11.2015 21:46

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

                        Наверное плохо объяснил. Всё остаётся по-прежнему, и возвращается Option, но компилятору даётся хинт, что значение INVALID_HANDLE_VALUE трактуется как None, таким образом результат помещается в int32


                        1. defuz
                          11.11.2015 22:00
                          +1

                          Такой хинт уже реализован в Rust для типа Option<&T>. Такой тип занимает 4 байта (для 32-битной архитектуры), и значение 0 трактуется как None.


                        1. mkpankov
                          11.11.2015 23:08

                          Да, это интересная идея. Я думаю, что сейчас это требует поддержки в LLVM, поэтому сложно делать это для любых типов.


                          1. defuz
                            11.11.2015 23:18

                            Разве тут что-то требуется от LLVM? Сейчас для реализации хака с Option<&T> достаточно обернуть данные в обертку NonZero. Можно себе представить, что нечто подобное можно сделать для произвользного диапазона значений. Но это все начинает напоминать оптимизации в стиле «давайте хранить 3 разных значения в двух байтах». Можно, но стоит ли?


                            1. mkpankov
                              11.11.2015 23:35

                              Я попробовал посмотреть на разницу в LLVM IR для ссылки и указателя: is.gd/EyTrrF

                              Как видим, ссылка явно dereferenceable. Я думаю, что для LLVM указатели с возможностью обнуления — особый случай, и они специально поддержаны. Обобщённого же механизма нет. Да и в core только NonZero, а общего типа с возможностью указать, какие значения особые, нет.


                1. defuz
                  11.11.2015 16:38

                  А давайте поговорим о том, как приведенный вами пример позволяет узнать причину ошибки?


                  1. splav_asv
                    11.11.2015 18:35

                    В качестве примера, в glibc есть костыль с глобальной errno. Про многопоточность естественно тут приходится забыть.
                    Сам я за подход rust. Обычно накладные расходы не столь критичны. А если уж очень надо — можно как в C сделать, кто мешает то…


                    1. qw1
                      11.11.2015 21:53

                      Про многопоточность естественно тут приходится забыть.

                      errno давно уже объявляется как __thread (для MSVC __declspec(thread))
                      у каждого потока своя переменная errno.


                      1. splav_asv
                        11.11.2015 22:08

                        Под многопоточностью я подразумеваю возможность прокинуть прозрачно ошибку в другой поток и обрабатывать ее там.
                        В rust, очевидно, можно передать Option и Result<T, E> из одного потока в другой совершенно прозрачно.

                        P.S. если уж говорить про errno, то много где(если не везде) это не переменная.


                  1. qw1
                    11.11.2015 21:50

                    как приведенный вами пример позволяет узнать причину ошибки?

                    пример скорее был для Option, а не Result, а с Result можно сделать хинты, мапящие разные ошибки на константы, которые не может вернуть ф-ция (например, если возвращается pointer таких недопустимых результатов очень много)


                    1. defuz
                      11.11.2015 22:05

                      Насколько я знаю, единственным «специальным» значением для pointer является 0 (т.е. null). Все остальные значения могут быть валидными указателями. А вот специально для null в компиляторе Rust уже реализован хак (ответил вам выше), оптимизирующий размер Option<&T> до размера указателя.


            1. mkpankov
              11.11.2015 00:26

              Компилятор сообщает о синтаксической ошибке на «hello world», взятый из учебника.
              Какая ошибка у вас?
              Компилятор Rust выдаёт не исполняемый код, а код в LLVM IR.
              Это не так, компилятор Rust как исполняемый файл rustc выдаёт исполняемый код. LLVM IR используется внутри и скрыто от пользователя. Visual C++ используется не здесь, а при компоновке с системными библиотеками и нужен если вам нужно MSVC ABI — из всего Visual C++ нужен лишь link.exe.