В самом простом случае, тест в Rust — это функция, аннотированная атрибутом test .
Cоздадим новый проект Cargo, который будет называться adder :
cargo new adder
cd adder
При этом у меня был сгенерирован файл src/lib.rs следующего содержания
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
}
}
Теперь запускаем сам тест
cargo test
Cargo компилирует и запускает тесты
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured
Мы видим, что ничего не делающий тест был выполнен удачно :-)
Изменим функцию:
assert! — это макрос, определенный в Rust, и принимающий один аргумент: если
аргумент имеет значение true , то ничего не происходит; если аргумент является false , то
вызывается panic!
#[test]
fn it_works() {
assert!(false);
}
И получаем сообщение об ошибке
running 1 test
test tests::it_works ... FAILED
failures:
---- tests::it_works stdout ----
thread 'tests::it_works' panicked at 'assertion failed: false', src/lib.rs:5
note: Run with `RUST_BACKTRACE=1` for a backtrace.
failures:
tests::it_works
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured
error: test failed
Можно инвертировать ожидаемый результат теста с помощью атрибута should_panic -
в этом случае поведение теста изменится с точностью до наоборот:
К атрибуту should_panic может быть добавлен необязательный параметр expected . Тогда тест
также будет проверять, что сообщение об ошибке содержит ожидаемый текст. Ниже
представлен более безопасный вариант приведенного выше примера
Некоторые тесты могу занимать много времени на выполнение. Такие тесты могут быть
отключены по умолчанию с помощью атрибута ignore
#[test]
fn it_works() {
assert_eq!(4, add_two(2));
}
#[test]
#[ignore]
fn expensive_test() {
// code that takes an hour to run
}
Правильнее собирать все тесты в одном модуле
pub fn add_two(a: i32) -> i32 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
assert_eq!(4, add_two(2));
}
}
Здесь есть несколько изменений. Первое — это введение mod test с атрибутом cfg .
Модуль позволяет сгруппировать все наши тесты вместе, а также, если нужно, определить
вспомогательные функции, которые будут отделены от остальной части контейнера. Атрибут
cfg указывает на то, что тест будет скомпилирован, только когда мы попытаемся запустить
тесты. Это может сэкономить время компиляции, а также гарантирует, что наши тесты
полностью исключены из обычной сборки.
Второе изменение заключается в объявлении use . Так как мы находимся во внутреннем
модуле, то мы должны объявить использование тестируемой функции в его области видимости.
Но что если мы хотим написать «интеграционные тесты» (integration tests)? Для этого следует использовать
директорию tests.
Чтобы написать интеграционный тест, давайте создадим директорию tests , и положим
в нее файл src/tests/lib.rs со следующим содержимым:
Диапазоны ( 0..10 ) являются «итераторами». Итератор — это сущность, для
которой мы можем неоднократно вызвать метод .next() , в результате чего мы получим
последовательность элементов.
Более развернуто этот цикл можно расписать в виде
let mut range = 0..10;
loop {
match range.next() {
Some(x) => {
println!("{}", x);
},
None => { break }
}
}
Но цикл for не является единственной конструкцией, которая использует
итераторы. Написание своего собственного итератора заключается в реализации типажа
Iterator . Rust предоставляет ряд полезных итераторов для выполнения различных задач.
Прежде чем мы поговорим о них, мы
должны рассказать о плохой практике в Rust, связанной с использованием диапазонов. Она
продемонстрирована в примере ниже.
Например, если вам нужно перебрать содержимое вектора, у вас может
возникнуть желание написать так
let nums = vec![1, 2, 3];
for i in 0..nums.len() {
println!("{}", nums[i]);
}
Это намного хуже, чем если бы мы использовали итератор непосредственно. Вы можете
пройти по элементам векторов напрямую, как показано ниже
let nums = vec![1, 2, 3];
for num in &nums {
println!("{}", num);
}
Есть две причины предпочесть прямое использование итератора. Во-первых, это яснее
выражает наше намерение. Мы обходим элементы вектора, а не индексы с последующей
индексацией вектора. Во-вторых, эта версия является более эффективной: первая версия будет
выполнять дополнительные проверки границ, потому что используется индексация, nums[i] .
Во втором примере нет никаких проверок границ, поскольку мы получаем ссылки на каждый
элемент вектора, одну за одной, по мере итерирования. Это очень распространенный прием
работы с итераторами: мы можем игнорировать ненужные проверки границ, но все еще быть
уверенными, что мы в безопасности.
Следующий код также работает
let nums = vec![1, 2, 3];
for num in &nums {
println!("{}", *num);
}
Здесь мы явно разыменовываем num . Почему &nums выдает нам ссылки? Во-первых,
потому что мы явно попросили его об этом с помощью & . Во-вторых, если он будет выдавать
нам сами данные, то мы должны быть их владельцем, что подразумевает создание копии
данных и выдачу этой копии нам. Со ссылками же мы просто заимствуем ссылку на данные, и
поэтому будет выдана просто ссылка, без необходимости перемещать данные.
Что же можно использовать вместо диапазонов ?
Есть три основных класса объектов, которые имеют отношение к данному вопросу:
итераторы, адаптеры итераторов и потребители (consumer). Вот некоторые определения:
1 итераторы выдают последовательность значений;
2 адаптеры итераторов применяются к итератору и выдают новый итератор с другой
выходной последовательностью;
3 потребители применяются к итератору, выдающему некоторый конечный набор значений.
Поговорим сначала о консьюмерах.
Консьюмер применяется к итератору, возвращая какое-то значение или значения.
Наиболее распространенным консьюмером является collect()
let one_to_one_hundred = (1..101).collect::<Vec<i32>>();
Не задавая явно тип вектора, мы можем записать и так
let one_to_one_hundred = (1..101).collect::<Vec<_>>();
Другой консьюмер - find()
let greater_than_forty_two = (0..100).find(|x| *x > 42);
match greater_than_forty_two {
Some(_) => println!("Found a match!"),
None => println!("No match found :("),
}
find принимает замыкание, которое обрабатывает ссылку на каждый элемент
итератора. Замыкание возвращает true , если элемент является искомым элементом, и false
в противном случае. Так как нам не всегда удается найти соответствующий элемент, find
возвращает Option , а не сам элемент.
Еще один консьюмер - fold(). Рассмотрим пример
let sum = (1..4).fold(0, |sum, x| sum + x);
Его можно представить в виде
fold(base, |accumulator, element| ...).
Он принимает два аргумента: первый -
это элемент, называемый базой; второй — это замыкание, которое, в свою очередь, само
принимает два аргумента: первый называется аккумулятор, а второй - элемент. На каждой
итерации вызывается замыкание, результат выполнения которого становится значением
аккумулятора на следующей итерации. На первой итерации значение аккумулятора равно базе.
В нашем примере, 0 — это база, sum — это аккумулятор, а x — это элемент. На первой
итерации мы устанавливаем sum равной 0 , а x становится первым элементом nums , 1 .
Затем мы прибавляем x к sum , что дает нам 0 + 1 = 1 . На второй итерации это значение
становится значением аккумулятора, sum , а элемент становится вторым элементом массива,
2 . 1 + 2 = 3 , результат этого выражения становится значением аккумулятора на последней
итерации. На этой итерации, x становится последним элементом, 3 , а значение выражения 3
+ 3 = 6 является конечным значением нашей суммы. 1 + 2 + 3 = 6 — это результат,
который мы получили.
fold подходит для случаев, когда у вас есть список элементов, а вам нужно получить один единственный результат.
Итератор являются сущностью, для которой мы можем
неоднократно вызвать метод .next() , в результате чего мы получим последовательность
элементов. Для получения каждого следующего элемента нужно вызвать метод, а это означает,
что итераторы ленивы — они не обязаны создавать все значения заранее. Например, этот код на
самом деле не генерирует номера 1-99 , а просто создает значение, представляющее эту
последовательность
let nums = 1..100;
Добавим к итератору потребителя
let nums = (1..100).collect::<Vec<i32>>();
Диапазоны — это один из двух основных типов итераторов. Другой часто используемый
итератор — iter() . iter() может преобразовать вектор в простой итератор, который
выдает вам каждый элемент по очереди
let nums = vec![1, 2, 3];
for num in nums.iter() {
println!("{}", num);
}
Адаптеры итераторов получают итератор и изменяют его каким-то образом, выдавая
новый итератор. Простейший из них называется map :
(1..100).map(|x| x + 1);
map вызывается для итератора, и создает новый итератор, каждый элемент которого
получается в результате вызова замыкания, в качестве аргумента которому передается ссылка
на исходный элемент. Так что этот код выдаст нам числа 2-100 .
Есть масса интересных адаптеров итераторов. take(n) вернет итератор,
представляющий следующие n элементов исходного итератора. Обратите внимание, что это не
оказывает никакого влияния на оригинальный итератор
for i in (1..).take(5) {
println!("{}", i);
}
Этот код напечатает
1
2
3
4
5
filter() представляет собой адаптер, который принимает замыкание в качестве
аргумента. Это замыкание возвращает true или false . Новый итератор, полученный
применением filter() , будет выдавать только те элементы, для которых замыкание
возвращает true
for i in (1..100).filter(|&x| x % 2 == 0) {
println!("{}", i);
}
Этот пример будет печатать все четные числа от одного до ста. (Обратите внимание, что
мы используем образец &x , чтобы извлечь само целое число. Это необходимо, поскольку
filter не потребляет элементы, которые выдаются во время итерации, а лишь выдаёт
ссылку.)
Вы можете соединить все три понятия вместе: начать с итератора, адаптировать его
несколько раз, а затем потребить результат. Например
(1..)
.filter(|&x| x % 2 == 0)
.filter(|&x| x % 3 == 0)
.take(5)
.collect::<Vec<i32>>();
Этот код выдаст вектор 6, 12, 18, 24, 30.
Многопоточность
Раст — достаточно низкоуровневый язык, поэтому вся поддержка многозадачности
реализована в стандартной библиотеке, а не в самом языке. Это означает, что если вам не
нравится какой-то аспект реализации многозадачности в раст, вы всегда можете создать
альтернативную библиотеку. mio — реально существующий пример такого подхода.
Раст предоставляет два типажа,
помогающих нам разбираться в любом коде, который вообще может быть многозадачным.
Send
Когда тип T реализует
Send , это указывает компилятору, что владение переменными этого типа можно безопасно
перемещать между потоками.
Это важно для соблюдения некоторых ограничений. Например, это имеет значение, когда
у нас есть канал, соединяющий два потока, и мы хотим отправлять некоторые данные по
каналу из одного потока в другой. Следовательно, мы должны гарантировать, что для
отправляемого типа данных реализован типаж Send .
И наоборот, если мы оборачиваем библиотеку чужого кода (FFI), и она не является
потокобезопасной, то нам не следует реализовывать типаж Send , и компилятор поможет нам
убедиться в невозможности покинуть текущий поток.
Sync
Когда тип T реализует Sync , это указывает
компилятору, что использование переменных этого типа не приводит к небезопасной работе с
памятью в многопоточной среде.
Например, совместное использование неизменяемых данных с помощью атомарного
счетчика ссылок является потокобезопасным. Rust обеспечивает такой тип, Arc<T> , и он
реализует Sync , так что при помощи этого типа можно безопасно обмениваться данными
между потоками.
Стандартная библиотека Rust предоставляет библиотеку многопоточности, которая
позволяет запускать код на Rust параллельно. Вот простой пример использования
std::thread
use std::thread;
fn main() {
thread::spawn(|| {
println!("Hello from a thread!");
});
}
Метод thread::spawn() в качестве единственного аргумента принимает замыкание,
которое выполняется в новом потоке. Он возвращает дескриптор потока, который используется
для ожидания завершения этого потока и извлечения его результата
use std::thread;
fn main() {
let handle = thread::spawn(|| {
"Hello from a thread!"
});
println!("{}", handle.join().unwrap());
}
Многие языки имеют возможность выполнять потоки, но это опасно. Есть целые
книги о том, как избежать ошибок, которые происходят от совместного использования
изменяемого состояния. В расте приходит на помощь система типов, которая предотвращает гонки
данных на этапе компиляции.
В качестве примера приведем программу на Rust, которая входила бы в состояние гонки
по данным на многих языках. На Rust она не скомпилируется
use std::thread;
use std::time::Duration;
fn main() {
let mut data = vec![1, 2, 3];
for i in 0..3 {
thread::spawn(move || {
data[0] += i;
});
}
thread::sleep(Duration::from_millis(50));
}
Она вызовет ошибку
8:17 error: capture of moved value: `data`
data[0] += i;
^~~~
В данном случае мы знаем, что наш код должен быть безопасным, но раст в этом не
уверен. И, на самом деле, он не является безопасным: мы работаем с data в каждом потоке.
При этом, поток становится владельцем того, что он получает как часть окружения замыкания.
А это значит, что у нас есть три владельца! Это плохо. Мы можем исправить это с помощью
типа Arc<T> , который является атомарным указателем со счетчиком ссылок. «Атомарный»
означает, что им безопасно обмениваться между потоками.
Чтобы гарантировать, что его можно безопасно использовать из нескольких потоков,
Arc<T> предполагает наличие еще одного свойства у вложенного типа. Он предполагает, что
T реализует типаж Sync . В нашем случае мы также хотим, чтобы была возможность изменять
вложенное значение. Нам нужен тип, который может обеспечить изменение своего
содержимого лишь одним пользователем одновременно. Для этого мы можем использовать тип
Mutex<T>
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
fn main() {
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
for i in 0..3 {
let data = data.clone();
thread::spawn(move || {
let mut data = data.lock().unwrap();
data[0] += i;
});
}
thread::sleep(Duration::from_millis(50));
}
Теперь мы вызываем clone() для нашего Arc , что увеличивает внутренний счетчик.
Затем полученная ссылка перемещается в новый поток.
Во-первых, мы вызываем метод lock() , который захватывает блокировку мьютекса.
Так как вызов данного метода может потерпеть неудачу, он возвращает Result<T, E> , но,
поскольку это просто пример, мы используем unwrap() , чтобы получить ссылку на данные.
Реальный код должен иметь более надежную обработку ошибок в такой ситуации. После этого
мы свободно изменяем данные, так как у нас есть блокировка.
Под конец мы ждём какое-то время, пока потоки отработают. Это не идеальный способ
дождаться окончания их работы: возможно, мы выбрали разумное время ожидания но, скорее
всего, мы будем ждать либо больше чем нужно, либо меньше чем нужно, в зависимости от
того, сколько на самом деле времени потребуется потокам, чтобы закончить вычисления.
Есть более точные способы синхронизации потоков, и несколько из них реализовано в
стандартной библиотеке раста, в частности каналы.
Вот версия нашего кода, которая использует для синхронизации каналы, вместо того,
чтобы ждать в течение определенного времени
use std::sync::{Arc, Mutex};
use std::thread;
use std::sync::mpsc;
fn main() {
let data = Arc::new(Mutex::new(0));
// `tx` is the "transmitter" or "sender"
// `rx` is the "receiver"
let (tx, rx) = mpsc::channel();
for _ in 0..10 {
let (data, tx) = (data.clone(), tx.clone());
thread::spawn(move || {
let mut data = data.lock().unwrap();
*data += 1;
tx.send(()).unwrap();
});
}
for _ in 0..10 {
rx.recv().unwrap();
}
}
Мы используем метод mpsc::channel() , чтобы создать новый канал. В этом примере
мы в каждом из десяти потоков вызываем метод send , который передает по каналу пустой
кортеж () , а затем в главном потоке ждем, пока не будут приняты все десять значений.
Хотя по этому каналу посылается просто сигнал (пустой кортеж () не несёт никаких
данных), в общем случае мы можем отправить по каналу любое значение, которое реализует
типаж Send!
use std::thread;
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
for i in 0..10 {
let tx = tx.clone();
thread::spawn(move || {
let answer = i * i;
tx.send(answer).unwrap();
});
}
for _ in 0..10 {
println!("{}", rx.recv().unwrap());
}
}
Здесь мы создаем 10 потоков. в каждом вычисляем квадрат числа и затем посылаем назад ответ в канал.
Error handling
Вообще, существует два общих подхода обработки ошибок: с помощью исключений и через возвращаемые значения.
Раст предпочитает возвращаемые значения.
Обработку ошибок можно рассматривать как вариативный анализ того, было ли
некоторое вычисление выполнено успешно или нет.
Вариативный анализ – это один из наиболее
общеприменимых методов аналитического мышления, который заключается в рассмотрении
проблемы, вопроса или некоторой ситуации с точки зрения каждого возможного конкретного
случая. При этом рассмотрение по отдельности каждого такого случая является
достаточным для того, чтобы решить первоначальный вопрос.
В Rust вариативный анализ реализуется с помощью синтаксической конструкции match.
В следующем примере приведена программа, которая принимает число в качестве
аргумента, удваивает его значение и печатает на экране
use std::env;
fn main() {
let mut argv = env::args();
let arg: String = argv.nth(1).unwrap(); // error 1
let n: i32 = arg.parse().unwrap(); // error 2
println!("{}", 2 * n);
}
Если вы запустите эту программу без параметров (ошибка 1) или если первый параметр
будет не целым числом (ошибка 2), программа завершится паникой.
Сначала разберемся с типом Option, у которого есть встроенный метод unwrap
enum Option {
None,
Some(T),
}
Тип Option — это способ выразить возможность отсутствия чего бы то ни было,
используя систему типов Rust. Выражение возможности отсутствия через систему типов
является важной концепцией, поскольку такой подход позволяет компилятору требовать от
разработчика обрабатывать такое отсутствие. Давайте взглянем на пример, который пытается
найти символ в строке
// Searches `haystack` for the Unicode character `needle`. If one is found, the
// byte offset of the character is returned. Otherwise, `None` is returned.
fn find(haystack: &str, needle: char) -> Option {
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, чтобы найти расширение в имени файла
Этот код использует сопоставление с образцом чтобы выполнить вариативный анализ
для возвращаемого функцией find значения Option<usize> . На самом деле, вариативный
анализ является единственным способом добраться до значения, сохраненного внутри
Option<T> . Это означает, что вы, как разработчик, обязаны обработать случай, когда
значение Option равно None , а не Some(t) .
В предыдущем примере мы рассмотрели, как можно воспользоваться find для того,
чтобы получить расширение имени файла. Конечно, не во всех именах файлов можно найти точку ,
так что существует вероятность, что имя некоторого файла не имеет расширения. Эта
возможность отсутствия интерпретируется на уровне типов через использование
Option<T> . Другими словами, компилятор заставит нас рассмотреть возможность того, что
расширение не существует. В нашем случае мы просто печатаем сообщение об этом
// Returns the extension of the given file name, where the extension is defined
// as all characters following the first `.`.
// If `file_name` has no `.`, then `None` is returned.
fn extension_explicit(file_name: &str) -> Option<&str> {
match find(file_name, '.') {
None => None,
Some(i) => Some(&file_name[i+1..]),
}
}
Вариативный анализ в 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)),
}
}
Вооружившись нашим новым комбинатором, мы можем переписать наш метод
extension_explicit так, чтобы избавиться от вариативного анализа
// Returns the extension of the given file name, where the extension is defined
// as all characters following the first `.`.
// If `file_name` has no `.`, then `None` is returned.
fn extension(file_name: &str) -> Option<&str> {
find(file_name, '.').map(|i| &file_name[i+1..])
}
Метод unwrap_or объявлен как метод Option<T> в стандартной
библиотеке, так что мы можем воспользоваться им вместо функции, которую мы объявили ранее
Существует еще один комбинатор, на который, как мы думаем, стоит обратить особое
внимание: and_then . Он позволяет легко сочетать различные вычисления, которые
допускают возможность отсутствия. Пример — большая часть кода в этом разделе, который
связан с определением расширения заданного имени файла. Чтобы делать это, нам для начала
необходимо узнать имя файла, которое как правило извлекается из файлового пути. Хотя
большинство файловых путей содержат имя файла, подобное нельзя сказать обо всех файловых
путях. Примером могут послужить пути . , .. или /
Можно подумать, мы могли бы просто использовать комбинатор map , чтобы уменьшить
вариативный анализ, но его тип не совсем подходит. Дело в том, что map принимает функцию,
которая делает что-то только с внутренним значением. Результат такой функции всегда
оборачивается в Some . Вместо этого, нам нужен метод, похожий map , но который позволяет
вызывающему передать еще один Option . Его общая реализация даже проще, чем map
Т и п Option имеет много других комбинаторов определенных в стандартной
библиотеке. Очень полезно просмотреть этот список и ознакомиться с доступными методами
— они не раз помогут вам сократить количество вариативного анализа. Ознакомление с этими
комбинаторами окупится еще и потому, что многие из них определены с аналогичной
семантикой и для типа Result , о котором мы поговорим далее.
Комбинаторы упрощают использование типов вроде Option , ведь они сокращают
явный вариативный анализ. Они также соответствуют требованиям сочетаемости, поскольку
они позволяют вызывающему обрабатывать возможность отсутствия результата собственным
способом. Такие методы, как unwrap , лишают этой возможности, ведь они будут паниковать в
случае, когда Option<T> равен None . Тип 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 — это макрос
try!
Этот макрос абстрагирует анализ вариантов так же, как и комбинаторы, но в отличие от них, он также
абстрагирует поток выполнения. А именно, он умеет абстрагировать идею досрочного
возврата, которую мы только что реализовали.
Вот упрощенное определение макроса `try!:
Использование макроса try! в следующем примере:
поставим задачу открыть файл, прочесть все его содержимое
и преобразовать это содержимое в число. После этого нужно будет умножить значение на 2 и
распечатать результат.
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!("Error: {}", err),
}
}
Перепишем этот код в следующий вариант:
вместо преобразования ошибок
в строки, мы будем просто конвертировать их в наш тип 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!("Error: {:?}", err),
}
}
Единственное изменение здесь — замена вызова map_err(|e| e.to_string())
(который преобразовывал ошибки в строки) на map_err(CliError::Io) или
map_err(CliError::Parse) .
Теперь вызывающая сторона определяет уровень
детализации сообщения об ошибке для конечного пользователя. В действительности,
использование String как типа ошибки лишает вызывающего возможности выбора, в то
время использование собственного типа enum , на подобие CliError , дает вызывающему
тот же уровень удобства, который был ранее, и кроме этого структурированные данные,
описывающие ошибку.
Стандартная библиотека определяет два встроенных типажа, полезных для обработки
ошибок
std::error::Error
и
std::convert::From
И если Error разработан
специально для создания общего описания ошибки, то типаж From играет широкую роль в
преобразовании значений между различными типами.
Типаж 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 }
}
Этот типаж очень обобщенный, поскольку предполагается, что он должен быть
реализован для всех типов, которые представляют собой ошибки.
Этот типаж, как минимум, позволяет выполнять следующие вещи:
1 Получать строковое представление ошибки для разработчика ( Debug ).
2 Получать понятное для пользователя представление ошибки ( Display ).
3 Получать краткое описание ошибки (метод description ).
4 Изучать по цепочке первопричину ошибки, если она существует (метод cause ).
Первые две возможности возникают в результате того, что типаж Error требует в свою
очередь реализации типажей Debug и Display . Последние два факта исходят из двух
методов, определенных в самом Error . Мощь Еrror заключается в том, что все
существующие типы ошибок его реализуют, что в свою очередь означает что любые ошибки
могут быть сохранены как типажи-объекты (trait object). Обычно это выглядит как
Box , либо &Error . Например, метод cause возвращает &Error , который как раз
является типажом-объектом
use std::io;
use std::num;
// We derive `Debug` because all types should probably derive `Debug`.
// This gives us a reasonable human readable description of `CliError` values.
#[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 {
// Both underlying errors already impl `Display`, so we defer to
// their implementations.
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 {
// Both underlying errors already impl `Error`, so we defer to their
// implementations.
match *self {
CliError::Io(ref err) => err.description(),
CliError::Parse(ref err) => err.description(),
}
}
fn cause(&self) -> Option<&error::Error> {
match *self {
// N.B. Both of these implicitly cast `err` from their concrete
// types (either `&io::Error` or `&num::ParseIntError`)
// to a trait object `&Error`. This works because both error types
// implement `Error`.
CliError::Io(ref err) => Some(err),
CliError::Parse(ref err) => Some(err),
}
}
}
Это очень типичная реализация Error : реализация методов
description и cause в соответствии с каждым возможным видом ошибки.
Типаж std::convert::From объявлен в стандартной библиотеке
trait From {
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 = From::from("foo");
From полезен для выполнения преобразований между строками. Но как насчет
ошибок? Оказывается, существует одна важная реализация
impl<'a, E: Error + 'a> From<E> for Box<Error + 'a>
Эта реализация говорит, что любой тип, который реализует Error , можно
конвертировать в типаж-объект Box<Error> .
Две ошибки, с которыми мы имели дело ранее, а именно, io::Error и
num::ParseIntError ? Поскольку обе они реализуют Error , они также работают с
From
use std::error::Error;
use std::fs;
use std::io;
use std::num;
// We have to jump through some hoops to actually get error values.
let io_err: io::Error = io::Error::last_os_error();
let parse_err: num::ParseIntError = "not a number".parse::<i32>().unwrap_err();
// OK, here are the conversions.
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! :
Здесь есть одно маленькое, но очень важное изменение: значение ошибки пропускается
через вызов 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
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> {
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 не несет никакой информации.
Если мы возвращаем Box вызывающей стороне, нет никакой возможности (легко)
узнать базовый тип ошибки. Ситуация, конечно, лучше, чем со String , поскольку появилась
возможность вызывать методы, вроде description или cause , но ограничение остается:
Box не предоставляет никакой информации о сути ошибки.
Настало время вернуться к нашему собственному типу CliError и связать все в одно
целое.
Мы используем средство, с которым мы уже знакомы: создание
собственного типа ошибки. Давайте вспомним код, который считывает содержимое файла и
преобразует его в целое число:
use std::fs::File;
use std::io::{self, Read};
use std::num;
use std::path::Path;
// We derive `Debug` because all types should probably derive `Debug`.
// This gives us a reasonable human readable description of `CliError` values.
#[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)
}
}