Программирование PC Rust: итераторы и замыкания Sat, October 12 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.

Rust: итераторы и замыкания Печать
Добавил(а) microsin   

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

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

А именно, здесь мы обсудим:

• Замыкания (closures), это похожая на функцию конструкция, которая может быть сохранена в переменной.
• Итераторы (iterators), способ обработки последовательности однотипных элементов (значений векторов, строк, массивов и т. д.).
• Как использовать замыкания и итераторы для улучшения проекта примера консольной программы поиска из главы 12 [2].
• Производительность замыканий и итераторов (спойлер: они быстрее, чем вы могли бы подумать).

Мы уже рассмотрели некоторый другой функционал Rust, такой как ветвление по совпадению шаблону (pattern matching) и перечисления (enum), на которые также оказывает влияние функциональный стиль программирования. Поскольку освоение замыканий и итераторов это важная часть для написания идиоматического, быстрого кода Rust, то мы посвятим таким темам целую эту главу.

[Closures: анонимные функции, захватывающие свое окружение]

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

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

Это можно реализовать многими способами. Для этого примера мы решили использовать перечисление ShirtColor, у которого варианты Red и Blue (ограниченное количество цветов для упрощения). Мы представили запасы компании структурой Inventory, в которое есть поле shirts, содержащее Vec< ShirtColor>, где представлены цвета футболок, находящиеся в настоящий момент на складе. Метод giveaway, определенный в Inventory, получает опциональное предпочтение цвета для победителя получения бесплатной рубашки, и возвращает цвет рубашки, которую получит персона. Эта установка показана в листинге 13-1:

#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor { Red, Blue, }

struct Inventory { shirts: Vec< ShirtColor>, }

impl Inventory { fn giveaway(&self, user_preference: Option< ShirtColor>) -> ShirtColor { user_preference.unwrap_or_else(|| self.most_stocked()) }
fn most_stocked(&self) -> ShirtColor { let mut num_red = 0; let mut num_blue = 0;
for color in &self.shirts { match color { ShirtColor::Red => num_red += 1, ShirtColor::Blue => num_blue += 1, } } if num_red > num_blue { ShirtColor::Red } else { ShirtColor::Blue } } }

fn main() { let store = Inventory { shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue], };
let user_pref1 = Some(ShirtColor::Red); let giveaway1 = store.giveaway(user_pref1); println!( "Пользователь, предпочитающий {:?}, получает {:?}", user_pref1, giveaway1 );
let user_pref2 = None; let giveaway2 = store.giveaway(user_pref2); println!( "Пользователь, предпочитающий {:?}, получает {:?}", user_pref2, giveaway2 ); }

Листинг 13-1. Ситуация компании, раздающей рубашки (файл src/main.rs).

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

Опять-таки, этот код может быть реализован несколькими способами, и здесь, чтобы сфокусироваться на использовании замыканий, мы придерживаемся концепции, которой уже научились, за исключением того, что тело метода giveaway, использующего closure. В методе giveaway мы берем предпочтение пользователя user_preference как параметр типа Option< ShirtColor> и вызываем метод unwrap_or_else на user_preference. Метод unwrap_or_else на Option< T> определен в стандартной библиотеке. Она принимает один аргумент: closure без каких-либо аргументов, возвращающее значение T (такой же тип сохраняется в варианте Some типа Option< T>, в этом случае ShirtColor). Если Option< T> это вариант Some, то unwrap_or_else возвратит значение из Some. Если Option< T> это вариант None, то unwrap_or_else вызовет closure, и возвратит значение, возвращаемое из closure.

Мы указываем closure-выражение || self.most_stocked() в качестве аргумента unwrap_or_else. Это closure, которое не принимает сама параметры (если у closure есть параметры, то они появляются между двумя вертикальными палками). Тело closure вызывает self.most_stocked(). Мы здесь определили closure, и реализация unwrap_or_else будет вычислять closure позже, если нужен результат.

Если этот код запустить, то напечатается следующее:

$ cargo run
   Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/shirt-company`
Пользователь, предпочитающий Some(Red), получает Red
Пользователь, предпочитающий None, получает Blue

Интересный аспект здесь в том, что мы передали closure, которое вызвало self.most_stocked() на текущем экземпляре Inventory. Стандартной библиотеке не нужно знать что-либо о типах Inventory или ShirtColor, определенных нами, или о логике, которую мы хотим использовать в этом сценарии. Closure захватывает немутируемую ссылку на сам экземпляр Inventory, и передает его в код, который мы указали, в метод unwrap_or_else method. Функции, с другой стороны, не могут захватывать свое окружение таким способом.

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

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

Как и в случае с переменными, мы можем добавлять аннотации типов, если хотим повысить ясность за счет большей детализации, чем это строго необходимо. Аннотирование типов для замыкания будет выглядеть как определение, показанное в листинге 13-2. В этом примере мы определяем замыкание и сохраняем его в переменной, а не определяем замыкание в месте, которое мы передаем в качестве аргумента, как мы это делали в листинге 13-1.

let expensive_closure = |num: u32| -> u32 {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

Листинг 13-2. Добавление в closure опциональных аннотаций типа параметра и типа возвращаемого значения (файл src/main.rs).

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

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x| { x + 1 };
let add_one_v4 = |x| x + 1 ;

Первая строка это определение функции, в вторая строка показывает определение замыкания с полной аннотацией типов. На третьей строке мы удалили аннотации типа из определения замыкания. На четвертой строке были удалены фигурные скобки, которые являются опциональными, потому что в теле замыкания находится только одно выражение. Все эти определения являются допустимыми, и будут обладать одинаковым поведением при своем вызове. Строки add_one_v3 и add_one_v4 требуют вычисления замыканий, чтобы они скомпилировались, потому что используемые в них типы будут выведены из их контекста использования. Это похоже на let v = Vec::new();, где требуется либо аннотация типа, либо вставка значения некоторого типа в Vec, чтобы Rust мог вывести тип.

Для определений closure компилятор будет выводить конкретный тип для каждого из их параметров и для их возвращаемого значения. Для примера листинг 13-3 показывает определение короткого closure, которое просто возвращает значение, полученное в качестве параметра. Это замыкание не очень полезно, кроме как для примера. Обратите внимание, что мы не добавили любые определения в это определение. Поскольку аннотации типа отсутствуют, мы можем вызвать это замыкание с любым типом, что мы делаем первый раз со строкой String. Если мы попытаемся вызвать example_closure с целым числом, то получим ошибку.

let example_closure = |x| x;
let s = example_closure(String::from("hello")); let n = example_closure(5);

Листинг 13-3. Попытка вызвать closure, когда компилятор выведет два разных типа (файл src/main.rs).

Компилятор на код листинга 13-3 выдаст следующую ошибку:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |             --------------- ^- help: try using a conversion method: `.to_string()`
  |             |               |
  |             |               expected `String`, found integer
  |             arguments to this function are incorrect
  |
note: expected because the closure was earlier called with an argument of type `String`
 --> src/main.rs:4:29
  |
4 |     let s = example_closure(String::from("hello"));
  |             --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
  |             |
  |             in this closure call
note: closure parameter defined here
 --> src/main.rs:2:28
  |
2 |     let example_closure = |x| x;
  |                            ^
For more information about this error, try `rustc --explain E0308`. error: could not compile `closure-example` (bin "closure-example") due to 1 previous error

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

Захват ссылок, или передача владения. Замыкания могут захватывать значения из своего окружения тремя способами, которые напрямую отображаются на то, как функция может принимать параметр: заимствование немутируемого, заимствование мутируемого и принятие во владение [3]. Closure будет решать, какой из них использовать на основе того, что тело функции делает с захваченными значениями.

В листинге 13-4 мы определили closure, которое захватывает немутируемую ссылку на вектор с именем list, потому что для печати значения нужна только немутируемая ссылка:

fn main() {
    let list = vec![1, 2, 3];
    println!("Перед определением closure: {list:?}");
let only_borrows = || println!("Из closure: {list:?}");
println!("Перед вызовом closure: {list:?}"); only_borrows(); println!("После вызова closure: {list:?}"); }

Листинг 13-4. Определение и вызов замыкания, которое захватывает немутируемую ссылку (файл src/main.rs).

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

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

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Перед определением closure: [1, 2, 3]
Перед вызовом closure: [1, 2, 3]
Из closure: [1, 2, 3]
После вызова closure: [1, 2, 3]

Далее в листинге 13-5, мы поменяем тело closure так, что оно добавляет элемент к вектору list. Теперь closure захватывает мутируемую ссылку:

fn main() {
    let mut list = vec![1, 2, 3];
    println!("Перед определением closure: {list:?}");
let mut borrows_mutably = || list.push(7);
borrows_mutably(); println!("После вызова closure: {list:?}"); }

Листинг 13-5. Определение и вызов замыкания, которое захватывает мутируемую ссылку (файл src/main.rs).

Этот код скомпилируется, запустится и напечатает:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Перед определением closure: [1, 2, 3]
После вызова closure: [1, 2, 3, 7]

Обратите внимание, что больше нет println! между определением и вызовом замыкания borrows_mutably: когда определено borrows_mutably, оно захватывает мутируемую ссылку на list. Мы не используем замыкание снова после его вызова, так что мутируемое заимствование заканчивается. Между определением замыкания и вызовом замыкания не дозволяется печатать немутируемое заимствование, потому что не дозволяются другие заимствования, когда существует мутируемое заимствование. Попробуйте добавить println! перед вызовом замыкания, и увидите, что получится сообщение об ошибке!

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

Эта техника наиболее полезна, когда замыкание передается в новый поток, чтобы переместить данные, которыми владеет замыкание, в новый поток. Про потоки, и про то, почему вы захотите использовать потоки, мы поговорим в главе 16, когда будем обсуждать параллельное выполнение. Однако сейчас давайте просто кратко рассмотрим порождение нового потока (thread) с использованием замыкания, для которого нужно применить ключевое слово move. Листинг 13-6 показывает измененный листинг 13-4, чтобы напечатать вектор в новом потоке вместо того, чтобы это делать в потоке main:

use std::thread;

fn main() { let list = vec![1, 2, 3]; println!("Перед определением closure: {list:?}");
thread::spawn(move || println!("Из потока: {list:?}")) .join() .unwrap(); }

Листинг 13-6. Использование move, чтобы принудить замыкание взять во владение list для потока (файл src/main.rs).

Мы порождаем новый поток (thread::spawn) передаем ему замыкание в качестве аргумента для запуска. Тело замыкания печатает list. В листинге 13-4 замыкание только захватывает list, используя немутируемую ссылку, потому что это наименьшее количество доступа, которое требуется для печати. В это примере несмотря на то, что тело замыкания по-прежнему нуждается только в немутируемой ссылке, нам нужно указать, что list должен быть перемещен в замыкание путем вставки ключевого слова move на начало определения замыкания. Новый поток может завершиться до того, как завершится остальная часть потока main, либо поток main может завершиться раньше. Если бы поток main сохранял бы владение объектом list, но завершился раньше, чем новый поток завершит свою работу, и основной поток отбросит list, то немутируемая ссылка в потоке была бы недействительной. Таким образом, компилятор требует, чтобы list был перемещен в замыкание, переданное новому потоку, чтобы ссылка оставалась действительной. Попробуйте удалить ключевое слово move, или используйте list в основном потоке после определения замыкания, чтобы увидеть, какие ошибки ошибки компилятора вы получите!

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

Способ, которым замыкание захватывает и обрабатывает значения из окружения, влияет на то, какие трейты реализует замыкание, а трейты - то, как функции и структуры могут определять, какие виды замыканий они могут использовать. Замыкания будут автоматически реализовывать один, два или все три из этих Fn-трейтов аддитивным образом, в зависимости от того, как тело замыкания обрабатывает значения:

1. FnOnce применяется к замыканиям, которые могут быть вызваны один раз. Все замыкания реализуют как минимум этот трейт, потому что все замыкания могут быть вызваны. Замыкание, которое перемещает захваченные значения из своего тела, будут реализовывать только FnOnce и никакие другие Fn-трейты, потому что они могут быть вызваны только один раз.

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

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

Давайте посмотрим на определение метода unwrap_or_else на Option< T>, которое мы использовали листинге 13-1:

impl< T> Option< T> {
    pub fn unwrap_or_else< F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

Вспомним, что T это generic-тип, представляющий тип значения в варианте Some перечисления Option. Этот тип T является возвращаемый тип функции unwrap_or_else: например код, который вызывает unwrap_or_else на Option< String>, получит String.

Далее обратите внимание, что функция unwrap_or_else имеет дополнительный параметр generic-типа F. Тип F это тип параметра с именем f, который является замыканием, которое предоставляется при вызове unwrap_or_else.

Привязанный трейт, указанный для generic-типа F, это FnOnce() -> T, что означает: F должен быть вызван однократно, не принимая аргументов, и возвратить T. Использование FnOnce в границе трейта выражает ограничение, что unwrap_or_else собирается вызвать f только один раз. В теле unwrap_or_else мы можем видеть, что если Option это Some, то f не будет вызвано. Если Option это None, то f будет вызвано один раз. Поскольку все замыкания реализуют FnOnce, unwrap_or_else принимает все три вида замыканий, и является насколько гибким, насколько это возможно.

Замечание: функции также могут реализовать все три Fn-трейта. Если то, что мы хотим сделать, не требует захвата значения из окружения, то мы можем использовать имя функции вместо замыкания, где нам нужно что-то что реализует один из Fn-трейтов. Например, на значении Option< Vec< T>> мы можем вызвать unwrap_or_else(Vec::new) чтобы получить новый пустой вектор, если значение None.

Давайте теперь посмотрим на метод стандартной библиотеки sort_by_key, который определен на слайсах, чтобы увидеть, чем он отличается от unwrap_or_else, и почему sort_by_key использует FnMut вместо FnOnce для привязки трейта (trait bound). Замыкание получает один аргумент в форме ссылки на текущий элемент в рассматриваемом слайсе, и возвращает значение типа K, которое можно упорядочить. Эта функция полезна, когда вы хотите сортировать слайс по определенному атрибуту каждого элемента. В листинге 13-7 у нас есть список экземпляров Rectangle, и мы используем sort_by_key, чтобы упорядочить их по атрибуту width от низкого до высокого значения:

#[derive(Debug)]
struct Rectangle { width: u32, height: u32, }

fn main() { let mut list = [ Rectangle { width: 10, height: 1 }, Rectangle { width: 3, height: 5 }, Rectangle { width: 7, height: 12 }, ];
list.sort_by_key(|r| r.width); println!("{list:#?}"); }

Листинг 13-7. Использование sort_by_key, чтобы сортировать прямоугольники но ширине width (файл src/main.rs).

Этот код напечатает:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/rectangles`
[
    Rectangle {
        width: 3,
        height: 5,
    },
    Rectangle {
        width: 7,
        height: 12,
    },
    Rectangle {
        width: 10,
        height: 1,
    },
]

Причина, по которой sort_by_key определен для получения замыкания FnMut, состоит в том, что он вызывает замыкание несколько раз: один раз для каждого элемента в слайсе. Замыкание |r| r.width не делает захват, мутацию или перемещение чего-либо из своего окружения, так что это удовлетворяет требованиям trait bound.

В отличие от этого листинг 13-8 показывает пример замыкания, которое реализует только трейт FnOnce, потому что оно перемещает значение из своего окружения. Компилятор не позволит нам использовать это замыкание вместе с sort_by_key:

#[derive(Debug)]
struct Rectangle { width: u32, height: u32, }

fn main() { let mut list = [ Rectangle { width: 10, height: 1 }, Rectangle { width: 3, height: 5 }, Rectangle { width: 7, height: 12 }, ];
let mut sort_operations = vec![]; let value = String::from("вызвано замыкание");
list.sort_by_key(|r| { sort_operations.push(value); r.width }); println!("{list:#?}"); }

Листинг 13-8. Попытка использовать замыкание FnOnce вместе с sort_by_key (файл src/main.rs).

Это замысловатый, запутанный способ (который не работает) попытаться подсчитать количество раз, которое sort_by_key вызывает closure при сортировке list. Этот код пытается делать этот подсчет, вставляя значение value — строку String из окружения замыкания — в вектор sort_operations. Замыкание захватывает value, когда перемещается value из замыкания путем передачи владения value вектору sort_operations. Это замыкание может быть вызвано один раз; попытка его вызова второй раз не сработает, потому что value больше не будет находиться в среде, чтобы снова быть перенесенным в sort_operations! Поэтому это замыкание реализует только FnOnce. Когда мы пытаемся скомпилировать этот код, получим ошибку, что value не может быть перемещено из замыкания, потому что замыкание должно реализовать FnMut:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
  --> src/main.rs:18:30
   |
15 |     let value = String::from("closure called");
   |         ----- captured outer variable
16 |
17 |     list.sort_by_key(|r| {
   |                      --- captured by this `FnMut` closure
18 |         sort_operations.push(value);
   |                              ^^^^^ move occurs because `value` has type `String`,
   |                              which does not implement the `Copy` trait
For more information about this error, try `rustc --explain E0507`. error: could not compile `rectangles` (bin "rectangles") due to 1 previous error

Ошибка указывает на строку в теле замыкания, которое перемещает value из окружения. Чтобы это исправить, нам нужно поменять тело замыкания так, чтобы чтобы оно не перемещало значения из окружения. Для подсчета количества раз, сколько было вызвано замыкание, содержание счетчика в окружении и приращение его значения в теле замыкания является более простым способом. Замыкание в листинге 13-9 работает с sort_by_key, потому что оно только захватывает мутируемую ссылку в счетчик num_sort_operations, и может таким образом быть вызвано больше одного раза:

#[derive(Debug)]
struct Rectangle { width: u32, height: u32, }

fn main() { let mut list = [ Rectangle { width: 10, height: 1 }, Rectangle { width: 3, height: 5 }, Rectangle { width: 7, height: 12 }, ];
let mut num_sort_operations = 0; list.sort_by_key(|r| { num_sort_operations += 1; r.width }); println!("{list:#?}, сортировано в {num_sort_operations} операциях"); }

Листинг 13-9. Использование замыкания FnMut вместе с sort_by_key допускается (файл src/main.rs).

Fn-трейты важны, когда определяются или используются функции или типы, использующие замыкания. В следующей секции мы обсудим итераторы. Многие методы итератора берут аргументы замыкания, поэтому имейте в виду эти детали замыкания, когда мы продолжим!

[Обработка последовательности элементов с итераторами]

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

В Rust итераторы ленивые, т. е. они не действуют, пока вы не вызовете методы, которые используют итератор по своему назначению. Например, код в листинге 13-10 создает итератор по вектору v1 путем вызова метода iter, определенного на Vec< T>. Этот код сам по себе не делает ничего полезного.

    let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();

Листинг 13-10. Создание итератора.

Итератор сохранен в переменной v1_iter. Как только мы создали итератор, он может использоваться различными способами. В листинге 3-5 главы 3 (см. [4]), мы осуществляли итерацию по массиву, используя цикл for для выполнения некоторого кода на каждом из его элементов. Внутренне этот код неявно создавал и затем использовал итератор, но до сих пор мы замалчивали, как это реально работает.

В примере листинга 13-11 мы отделили создание итератора от его использования в цикле for. Когда цикл for вызывается использованием итератора в v1_iter, каждый элемент в итераторе используется в одной итерации цикла, которая печатает каждое значение.

    let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
for val in v1_iter { println!("Got: {val}"); }

Листинг 13-11. Использование итератора в цикле for.

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

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

Трейт Iterator и метод next. Все итераторы реализуют трейт с именем Iterator, который определен в стандартной библиотеке. Определение этого трейта выглядит примерно так:

pub trait Iterator {
    type Item;
fn next(&mut self) -> Option< Self::Item>;
// методы с удаленными реализациями по умолчанию }

Обратите внимание, что в этом определении используется новый синтаксис: type Item и Self::Item, которые определяют связанный тип с этим трейтом. Мы поговорим про связанные типы подробнее в главе 19. Пока все что вам нужно знать - этот код говорит про реализацию трейта Iterator, что он также требует определить тип Item, и этот тип Item используется в возвращаемом типе метода next. Другими словами, тип Item будет типом, возвращаемым из итератора.

Трейт Iterator только требует, чтобы реализаторы определили только один метод: метод next, который возвратит один элемент итератора за один раз, обернутый в Some и, когда итерация закончена, возвратит None.

Мы можем вызвать метод next на итераторах напрямую; листинг 13-12 демонстрирует, какие значения возвращаются из повторяющихся вызовов next на итераторе, созданном из вектора.

#[test]
    fn iterator_demonstration() {
        let v1 = vec![1, 2, 3];
let mut v1_iter = v1.iter();
assert_eq!(v1_iter.next(), Some(&1)); assert_eq!(v1_iter.next(), Some(&2)); assert_eq!(v1_iter.next(), Some(&3)); assert_eq!(v1_iter.next(), None); }

Листинг 13-12. Вызов метода next на итераторе (файл src/lib.rs).

Обратите внимание, что нам нужно сделать v1_iter мутируемым: вызов метода next на итераторе меняет его внутреннее состояние, которое итератор использует для отслеживания, где он находится в последовательности. Другими словами, этот код потребляет, или использует итератор. Каждый вызов next съедает элемент из итератора. Нам не нужно делать v1_iter мутируемым, когда мы используем цикл for, потому что цикл стал владельцем v1_iter, и внутренне сделал его изменчивым.

Также обратите внимание, что значения, которые мы получаем из вызовов next, это немутируемые ссылки на значения в векторе. Метод iter создает итератор над немутируемыми ссылками. Если мы хотим создать итератор, который берет во владение v1 и возвращает собственные значения, то мы можем вызвать into_iter вместо iter. Подобным образом, если мы хотим выполнить итерацию над мутируемыми ссылками, то мы можем вызвать iter_mut вместо iter.

Методы, которые потребляют итератор. Трейт Iterator имеет ряд различных методов с реализациями по умолчанию, предоставляемыми стандартной библиотекой; вы можете найти информацию про эти методы, просмотрев API-документацию стандартной библиотеки для трейта Iterator. Некоторые из этих методов вызывают метод next в своем определении, и по этой причине вам нужно реализовать метод next при реализации трейта Iterator.

Методы, которые вызывают next, называются потребляющими адаптерами (consuming adaptors), потому что их вызов использует итератор. Один из примеров это метод sum, который берет на во владение итератор и производит итерацию по элементам повторными вызовами next, потребляя таким образом итератор. По мере прогресса итерации метод sum добавляет каждый элемент к общему значению суммы и возвратит это общее значение, когда итерация завершится. Листинг 13-13 содержит тест, иллюстрирующий использование метода sum:

#[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
let total: i32 = v1_iter.sum();
assert_eq!(total, 6); }

Листинг 13-13. Вызов метода sum для получения общей суммы от всех элементов в итераторе (файл src/lib.rs).

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

Методы, которые создают другие итераторы. Адаптеры итератора это методы, определенные в трейте Iterator, которые не потребляют итератор. Вместо этого они создают другие итераторы путем изменения некоторого аспекта оригинального итератора.

Листинг 13-14 показывает пример вызова метода адаптера итератора map, который принимает замыкание для вызова на каждом элементе по мере прогресса итерации. Метод map возвратит новый итератор, который создает модифицируемые элементы. Здесь замыкание создает новый итератор, в котором каждый элемент из вектора будет инкрементироваться на 1:

let v1: Vec< i32> = vec![1, 2, 3];
v1.iter().map(|x| x + 1);

Листинг 13-14. Вызов адаптера итератора map для создания нового итератора (файл src/main.rs).

Однако этот код при запуске покажет предупреждение:

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
4 |     let _ = v1.iter().map(|x| x + 1);
  |     +++++++
warning: `iterators` (bin "iterators") generated 1 warning Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s Running `target/debug/iterators`

Код в листинге 13-14 ничего не делает; замыкание, которое мы указали, никогда не вызывается. Предупреждение напоминает нам, почему так: адаптеры итератора ленивые, и нам нужно устроить здесь потребление итератора.

Чтобы исправить это предупреждение и потребить итератор, мы будем использовать метод collect, который мы использовали в главе 12 вместе с env::args, см. листинг 12-1 [2]. Этот метод потребляет итератор и собирает (collect) результирующие значения в тип данных коллекции.

В листинге 13-15 мы собираем результаты итерации, возвращаемые из вызова map, в вектор. Этот вектор будет содержать каждый элемент исходного вектора, увеличенный на 1.

let v1: Vec< i32> = vec![1, 2, 3];
let v2: Vec< _> = v1.iter().map(|x| x + 1).collect();
assert_eq!(v2, vec![2, 3, 4]);

Листинг 13-15. Вызов метода map для создания нового итератора и затем вызова метода collect, чтобы потребить новый итератор и создать вектор (файл src/main.rs).

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

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

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

Для этого примера мы будем использовать метод filter, который принимает замыкание. Замыкание берет элемент из итератора и возвращает bool. Если замыкание возвратит true, то значение будет включено в итерацию, производимую filter. Если замыкание вернет false, то значение не будет включено.

В листинге 13-16 мы используем filter вместе с замыканием, которое захватывает переменную shoe_size из своего окружения, чтобы выполнить итерацию по коллекции экземпляров структуры Shoe. Фильтр вернет только обувь указанного размера.

#[derive(PartialEq, Debug)]
struct Shoe { size: u32, style: String, }

fn shoes_in_size(shoes: Vec< Shoe>, shoe_size: u32) -> Vec< Shoe> { shoes.into_iter().filter(|s| s.size == shoe_size).collect() }

#[cfg(test)]
mod tests { use super::*;
#[test] fn filters_by_size() { let shoes = vec![ Shoe { size: 10, style: String::from("sneaker"), }, Shoe { size: 13, style: String::from("sandal"), }, Shoe { size: 10, style: String::from("boot"), }, ];
let in_my_size = shoes_in_size(shoes, 10);
assert_eq!( in_my_size, vec![ Shoe { size: 10, style: String::from("sneaker") }, Shoe { size: 10, style: String::from("boot") }, ] ); } }

Листинг 13-16. Использование метода filter с замыканием, которое захватывает размер обуви shoe_size (файл src/lib.rs).

Функция shoes_in_size берет во владение вектор shoes и размер обуви в качестве своих параметров. Она возвратит вектор, содержащий только экземпляры обуви указанного размера.

В теле функции shoes_in_size мы вызываем into_iter для создания итератора, который берет во владение vector. Затем мы вызываем filter, чтобы адаптировать итератор в новый итератор, который будет содержать только элементы, для которых замыкание возвратит true.

Замыкание захватывает параметр shoe_size из окружения и сравнивает его значение с размером каждого экземпляра обуви, сохраняя только обувь указанного размера. И в завершении вызов collect соберет значения, возвращенные адаптированным итератором, в вектор, возвращаемый функцией.

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

[Улучшение нашего I/O проекта]

 ооруженные этими новыми знаниями об итераторах, мы можем улучшить наш проект ввода/вывода из главы 12 [2] использованием итераторов, чтобы сделать код более понятным и лаконичным. Давайте посмотрим, как итераторы могут улучшить нашу реализацию функцию Config::build и функцию search.

Удаление clone с помощью итератора. В листинге 12-6 мы добавили код, который брал слайс значений String и создавал экземпляр структуры Config путем индексации в слайс и клонирования значений, позволяя структуре Config брать во владение эти значения. В листинге 13-17 мы воспроизвели реализацию функции Config::build, как это было в листинге 12-23:

impl Config {
    pub fn build(args: &[String]) -> Result< Config, &'static str> {
        if args.len() < 3 {
            return Err("недостаточное количество аргументов");
        }
let query = args[1].clone(); let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config { query, file_path, ignore_case, }) } }

Листинг 13-17. Репродукция функции Config::build из листинга 12-23 (файл src/lib.rs).

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

Нам нужен был здесь clone, потому что у нас есть слайс с элементами String в параметре args, однако функция build не владеет args. Чтобы возвратить владение экземпляром Config, мы должны клонировать значения из полей query и file_path структуры Config, так чтобы экземпляр Config владел своими значениями.

С нашими новыми знаниями про итераторы, мы можем поменять функцию build, чтобы взять во владение итератор в качестве аргумента вместо заимствования слайса. Мы будем использовать функционал итератора вместо кода, который проверяет длину слайса и индексы в определенные места памяти. Это прояснит, что делает функция Config::build, потому что итератор получит доступ к значениям.

Как только Config::build берет во владение итератор и перестает использовать операции индексации, которые делают заимствование, мы можем переместить значения String из итератора в Config вместо вызова clone и создания нового выделения памяти.

Прямое использование возвращенного итератора. Откройте наш файл src/main.rs проекта ввода/вывода [2], который должен выглядеть так:

fn main() {
    let args: Vec< String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| { eprintln!("Проблема парсинга аргументов: {err}"); process::exit(1); });
// -- вырезано -- }

Сначала мы поменяем начало функции main, который был в листинге 12-24, на код в листинге 13-18, где на этот раз используется итератор. Это не скомпилируется, пока мы не обновим также Config::build.

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Проблема парсинга аргументов: {err}");
        process::exit(1);
    });
// -- вырезано -- }

Листинг 13-18. Передача возвращаемого значения env::args в Config::build.

Функция env::args возвратит итератор. Вместо того, чтобы собирать значения итератора в вектор, и затем передавать слайс в Config::build, теперь мы передаем владение итератором, возвращенным из env::args напрямую в Config::build.

Затем нам нужно обновить определение Config::build. В вашем файле src/lib.rs проекта ввода/вывода поменяйте сигнатуру Config::build, чтобы она выглядела как в листинге 13-19. Это все еще не скомпилируется, потому что также нужно обновить и тело функции.

impl Config {
    pub fn build(
        mut args: impl Iterator< Item = String>,
    ) -> Result< Config, &'static str> {
        // -- вырезано --

Листинг 13-19. Обновление сигнатуры функции Config::build, чтобы она ожидала итератор (файл src/lib.rs).

Документация стандартной библиотеки для функции env::args показывает, что тип итератора, который она возвращает, будет std::env::Args, и этот тип реализует трейт Iterator, и возвращает значения String.

Мы обновили сигнатуру функции Config::build, так что параметр args имеет generic-тип с привязкой трейта impl Iterator< Item = String> вместо &[String]. Этот синтаксис impl Trait мы обсуждали в секции "Трейты в качестве параметров" главы 10 [5] означает, что args может быть любого типа, который реализует трейт Iterator и возвращает элементы String.

Поскольку мы берем во владение args, и будем мутировать args путем итерации по нему, то мы можем добавить ключевое слово add в спецификацию параметра args, чтобы сделать его мутируемым.

Использование методов трейта Iterator вместо индексации. Далее мы исправим тело функции Config::build. Поскольку args реализует трейт Iterator, то мы знаем, что можем вызвать на нем метод next! Листинг 13-20 обновляет код из листинга 12-23 для использования метода next:

impl Config {
    pub fn build(
        mut args: impl Iterator< Item = String>,
    ) -> Result< Config, &'static str> {
        args.next();
let query = match args.next() { Some(arg) => arg, None => return Err("Не была получена строка запроса поиска"), };
let file_path = match args.next() { Some(arg) => arg, None => return Err("Не был получен путь до файла"), };
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config { query, file_path, ignore_case, }) } }

Листинг 13-20. Изменение тела Config::build для использования методов итератора (файл src/lib.rs).

Помните, что первое значение в возвращаемом env::args это имя программы. Мы хотим его игнорировать и получить следующее значение, так что сначала мы вызовем next, и ничего не делаем в возвращенным значением. Второй раз мы вызовем next, чтобы получить значение, которое хотим поместить в поле query структуры Config. Если next возвратит Some, то сработает match, где из Some извлекается значение. Если next возвратит None, то это означает недостаточное количество аргументов в командной строке, и мы выполним ранний возврат со значением Err. То же самое мы делаем для значения file_path.

Как сделать код понятнее с помощью адаптеров итератора. Мы можем воспользоваться преимуществом адаптера итераторов в функции search нашего I/O проекта [2], представленной в листинге 13-21 как содержимое листинга 12-19:

pub fn search< 'a>(query: &str, contents: &'a str) -> Vec< &'a str> {
    let mut results = Vec::new();
for line in contents.lines() { if line.contains(query) { results.push(line); } }
results }

Листинг 13-21. Реализация функции search из листинга 12-19 (файл src/lib.rs).

Мы можем написать этот код более лаконично, используя методы адаптера итератора. Поступая таким образом, мы можем избежать промежуточного мутируемого вектора results. Стиль функционального программирования предпочитает минимизировать количество изменяемого состояния, чтобы сделать код понятнее. Удаление мутируемого состояние может помочь в будущих улучшениях, чтобы поиск происходил параллельно, потому что нам не нужно будет управлять параллельным доступом к вектору results. Листинг 13-22 показывает это изменение:

pub fn search< 'a>(query: &str, contents: &'a str) -> Vec< &'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

Листинг 13-22. Использование методов адаптера итератора в реализации функции search (файл src/lib.rs).

Вспомним, что назначение функции search в том, чтобы возвратить все строки, в которых находится искомая строка query. Подобно примеру фильтра в листинге 13-16, этот код использует адаптер filter, чтобы сохранить только те строки, для которых line.contains(query) возвратит true. Затем мы собираем совпавшие строки в другой вектор с помощью collect. Намного проще! Не стесняйтесь внести такие же изменения для использования методов итератора также в функции search_case_insensitive.

Выбор между циклами или итераторами. Следующий логический вопрос: какой стиль следует выбрать для вашего собственного кода, и почему: оригинальная реализация в листинге 13-21, или версия с итераторами в листинге 13-22. Большинство программистов Rust предпочтут использовать стиль итератора. Поначалу немного сложнее разобраться, но как только вы почувствуете удобство использования различных адаптеров итераторов и поймете, что они делают, с итераторами будет намного легче. Вместо того, чтобы возиться с различными битами цикла и строить новые векторы, код фокусируется на высокоуровневой задаче цикла. Это дает абстракцию от шаблонного кода, поэтому легче увидеть концепции, которые уникальны для этого кода, такие как условие фильтрации, которое должен пройти каждый элемент в итераторе.

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

[Сравнение производительности цикла и итератора]

Чтобы определить, что использовать - циклы или итераторы, нам нужно знать какая реализация работает быстрее: версия функции search, где явно используется цикл loop, или версия с итераторами.

Мы запустили бенчмарк путем загрузки полного содержимого книги "Приключения Шерлока Холмса" сэра Артура Конан Дойля в String с поиском слова the в тексте. Ниже приведены результаты этого бенчмарка двух версий функции search - на основе цикла for и на основе использования итераторов:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

Оказалось, что версия с итератором работает несколько быстрее! Мы не будем здесь объяснять код бенчмарка, поскольку дело не в том, чтобы доказать, что эти две версии эквивалентны, а в том, чтобы получить общее представление как эти две версии сравнивают производительность.

Для более полного теста производительности следует проверить различные тексты разных размеров как contents, так и различных запросов разной длины поиска query, и всевозможные другие вариации. Дело тут в следующем: несмотря на то, что итераторы являются высокоуровневой абстракцией, они компилируются практически в такой же код, как если бы мы писали низкоуровневый код самостоятельно. Итераторы это одна из абстракций Rust нулевой стоимости (zero-cost, zero-overhead abstractions), под этим подразумевается, что использование абстракции не вводит никаких дополнительных затрат на выполнение в реальном времени. Это аналогично тому, как Bjarne Stroustrup, оригинальный изобретатель и реализатор C++, определил zero-overhead в “Foundations of C++” (2012): "В целом реализации C++ придерживаются принципа нулевых накладных расходов: за то, что вы не используете, вы не платите. И еще: то, что бы вы ни используете, вы не могли бы написать код лучше.".

В качестве другого примера следующий код взят из декодера звука. Алгоритм декодирования использует операцию линейного математического предсказания, чтобы вычислить будущие значения на основе линейной функции предыдущих выборок. Этот код использует итератор chain чтобы выполнить некоторые математические операции над тремя переменными в области действия: buffer слайса данных, массив из 12 коэффициентов coefficients, и величину сдвига данных в qlp_shift. Мы декларировали переменные в этом примере, но не присвоили им никаких значений; хотя этот код не имеет смысла вне своего контекста, это все еще лаконичный пример из реального мира, как Rust транслирует высокоуровневые идеи в низкоуровневый код.

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() { let prediction = coefficients.iter() .zip(&buffer[i - 12..i]) .map(|(&c, &s)| c * s as i64) .sum::< i64>() >> qlp_shift; let delta = buffer[i]; buffer[i] = prediction as i32 + delta; }

Для вычисления значения предсказания этот код выполняет итерацию по каждому из 12 значений в coefficients, и использует метод zip для сопряжения значений коэффициентов с предыдущими 12 значениями в buffer. Затем для каждой пары мы умножаем значения друг с другом, суммируем все результаты, и сдвигаем биты вправо в биты qlp_shift.

Вычисления в приложениях наподобие декодеров звука часто наиболее высоко приоритезируют производительность. Здесь мы создаем итератор, используя два адаптера, и затем потребляем значение. К какому коду ассемблера будет собран этот код Rust после компиляции? На момент написания [1] он компилируется почти в тот же код, который вы бы написали вручную. Нет никакого цикла, соответствующего итерации по значениям в coefficients: Rust знает, что здесь 12 итераций, так что он "разворачивает" цикл. Разворачивание цикла это оптимизация, которая устраняет накладные расходы на код управления циклом, когда генерируется повторяющийся код для каждой итерации цикла.

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

[Общие выводы]

Замыкания и итераторы это фичи Rust, вдохновленные идеями функционального программирования. Они способствую четко выражать идеи высокоуровневого алгоритма на языке Rust с сохранением высокой производительности низкоуровневого кода. Реализации замыканий и итераторов таковы, что они не влияют на runtime-производительность программы. Это часть цели Rust - стремиться обеспечить абстракции с нулевыми затратами.

Теперь, когда мы улучшили выразительность кода нашего примера приложения ввода/вывода [2], давайте разберем некоторые другие возможности cargo, которые помогают поделиться проектом с внешним миром.

[Ссылки]

1. Rust Functional Language Features Iterators and Closures site:rust-lang.org.
2. Rust: пример консольной программы ввода/вывода.
3. Rust: что такое Ownership.
4. Rust: общая концепция программирования.
5. Rust: generic-типы, traits, lifetimes.

 

Добавить комментарий


Защитный код
Обновить

Top of Page