В самом простом случае, тест в Rust — это функция, аннотированная атрибутом test .
Cоздадим новый проект Cargo, который будет называться adder :
cargo new adder
cd adder
При этом у меня был сгенерирован файл src/lib.rs следующего содержания
#[cfg(test)]mod tests {
#[test]fnit_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]fnit_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]fnit_works() {
assert_eq!(4, add_two(2));
}
#[test]#[ignore]fnexpensive_test() {
// code that takes an hour to run
}
Правильнее собирать все тесты в одном модуле
pubfnadd_two(a: i32) -> i32 {
a + 2
}
#[cfg(test)]mod tests {
use super::*;
#[test]fnit_works() {
assert_eq!(4, add_two(2));
}
}
Здесь есть несколько изменений. Первое — это введение mod test с атрибутом cfg .
Модуль позволяет сгруппировать все наши тесты вместе, а также, если нужно, определить
вспомогательные функции, которые будут отделены от остальной части контейнера. Атрибут
cfg указывает на то, что тест будет скомпилирован, только когда мы попытаемся запустить
тесты. Это может сэкономить время компиляции, а также гарантирует, что наши тесты
полностью исключены из обычной сборки.
Второе изменение заключается в объявлении use . Так как мы находимся во внутреннем
модуле, то мы должны объявить использование тестируемой функции в его области видимости.
Но что если мы хотим написать «интеграционные тесты» (integration tests)? Для этого следует использовать
директорию tests.
Чтобы написать интеграционный тест, давайте создадим директорию tests , и положим
в нее файл src/tests/lib.rs со следующим содержимым:
Диапазоны ( 0..10 ) являются «итераторами». Итератор — это сущность, для
которой мы можем неоднократно вызвать метод .next() , в результате чего мы получим
последовательность элементов.
Более развернуто этот цикл можно расписать в виде
Но цикл for не является единственной конструкцией, которая использует
итераторы. Написание своего собственного итератора заключается в реализации типажа
Iterator . Rust предоставляет ряд полезных итераторов для выполнения различных задач.
Прежде чем мы поговорим о них, мы
должны рассказать о плохой практике в Rust, связанной с использованием диапазонов. Она
продемонстрирована в примере ниже.
Например, если вам нужно перебрать содержимое вектора, у вас может
возникнуть желание написать так
let nums = vec![1, 2, 3];
for i in0..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;
fnmain() {
thread::spawn(|| {
println!("Hello from a thread!");
});
}
Метод thread::spawn() в качестве единственного аргумента принимает замыкание,
которое выполняется в новом потоке. Он возвращает дескриптор потока, который используется
для ожидания завершения этого потока и извлечения его результата
use std::thread;
fnmain() {
let handle = thread::spawn(|| {
"Hello from a thread!"
});
println!("{}", handle.join().unwrap());
}
Многие языки имеют возможность выполнять потоки, но это опасно. Есть целые
книги о том, как избежать ошибок, которые происходят от совместного использования
изменяемого состояния. В расте приходит на помощь система типов, которая предотвращает гонки
данных на этапе компиляции.
В качестве примера приведем программу на Rust, которая входила бы в состояние гонки
по данным на многих языках. На Rust она не скомпилируется
use std::thread;
use std::time::Duration;
fnmain() {
letmut data = vec![1, 2, 3];
for i in0..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;
fnmain() {
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
for i in0..3 {
let data = data.clone();
thread::spawn(move || {
letmut 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;
fnmain() {
let data = Arc::new(Mutex::new(0));
// `tx` is the "transmitter" or "sender"// `rx` is the "receiver"let (tx, rx) = mpsc::channel();
for _ in0..10 {
let (data, tx) = (data.clone(), tx.clone());
thread::spawn(move || {
letmut data = data.lock().unwrap();
*data += 1;
tx.send(()).unwrap();
});
}
for _ in0..10 {
rx.recv().unwrap();
}
}
Мы используем метод mpsc::channel() , чтобы создать новый канал. В этом примере
мы в каждом из десяти потоков вызываем метод send , который передает по каналу пустой
кортеж () , а затем в главном потоке ждем, пока не будут приняты все десять значений.
Хотя по этому каналу посылается просто сигнал (пустой кортеж () не несёт никаких
данных), в общем случае мы можем отправить по каналу любое значение, которое реализует
типаж Send!
use std::thread;
use std::sync::mpsc;
fnmain() {
let (tx, rx) = mpsc::channel();
for i in0..10 {
let tx = tx.clone();
thread::spawn(move || {
let answer = i * i;
tx.send(answer).unwrap();
});
}
for _ in0..10 {
println!("{}", rx.recv().unwrap());
}
}
Здесь мы создаем 10 потоков. в каждом вычисляем квадрат числа и затем посылаем назад ответ в канал.
Error handling
Вообще, существует два общих подхода обработки ошибок: с помощью исключений и через возвращаемые значения.
Раст предпочитает возвращаемые значения.
Обработку ошибок можно рассматривать как вариативный анализ того, было ли
некоторое вычисление выполнено успешно или нет.
Вариативный анализ – это один из наиболее
общеприменимых методов аналитического мышления, который заключается в рассмотрении
проблемы, вопроса или некоторой ситуации с точки зрения каждого возможного конкретного
случая. При этом рассмотрение по отдельности каждого такого случая является
достаточным для того, чтобы решить первоначальный вопрос.
В Rust вариативный анализ реализуется с помощью синтаксической конструкции match.
В следующем примере приведена программа, которая принимает число в качестве
аргумента, удваивает его значение и печатает на экране
Если вы запустите эту программу без параметров (ошибка 1) или если первый параметр
будет не целым числом (ошибка 2), программа завершится паникой.
Сначала разберемся с типом Option, у которого есть встроенный метод unwrap
enumOption {
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.fnfind(haystack: &str, needle: char) -> Option {
for (offset, c) in haystack.char_indices() {
if c == needle {
returnSome(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.fnextension_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 поддерживает параметрический полиморфизм, так что можно очень легко объявить
комбинатор, который абстрагирует это поведение
fnmap<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.fnextension(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