[Перевод] Сопоставление с образцом, изменения и перемещения в Rust
Одной из основных целей проекта Rust является безопасное системное программирование. Эта область обычно подразумевает императивную парадигму, что означает присутствие побочных эффектов, необходимость думать о разделяемом состоянии, и т.д. Для того, чтобы в таких условиях можно было обеспечить безопасность, программы и типы данных на Rust должны быть структурированы таким образом, чтобы их можно было статически проверить. Элементы и ограничения языка Rust совместно облегчают написание программ, проходящих эти проверки и, таким образом, обеспечивают безопасность. Например, в Rust глубоко интегрирована концепция владениям данными.Выражение match — это специальная конструкция, в которой эти особенности и ограничения сочетаются интересным образом. match-выражение принимает входное значение, классифицирует его и затем передаёт выполнение коду, который обрабатывает соответствующий класс данных.
В этой статье мы рассмотрим, как работает match в Rust. Вот основные элементы, которые match и его дополнение, enum, объединяют в единое целое:
Структурное сопоставление с образцом: анализ вариантов и удобство использования гораздо лучше, чем при использовании switch в C или Java. Исчерпывающий анализ: match гарантирует, что ни один вариант не пропущен. match поддерживает и императивный, и функциональный стили: вы можете и дальше использовать оператор break, присваивания и прочее, и вам совершенно не нужно переучиваться на стиль, основанный на выражениях; match умеет как «заимствовать», так и «перемещать»: Rust поощряет программиста думать о владении и заимствовании данных. Выражение match спроектировано в том числе с возможностью только заимствования части структуры вместо её перемещения. Это нужно для того, чтобы не передать право владения какими-либо данными раньше, чем нужно. Мы рассмотрим каждый из этих пунктов по отдельности ниже, но для начала нам следует заложить фундамент дальнейшего обсуждения — как match выглядит и работает? Основы matchВыражение match в Rust выглядит следующим образом: match INPUT_EXPRESSION { PATTERNS_1 => RESULT_EXPRESSION_1, PATTERNS_2 => RESULT_EXPRESSION_2, … PATTERNS_n => RESULT_EXPRESSION_n } где каждое из PATTERNS_i содержит как минимум один образец (шаблон, паттерн — pattern). Образец описывает подмножество всех возможных значений, в которые может вычислиться выражение INPUT_EXPRESSION. Синаксис PATTERN => RESULT_EXPRESSION называется «ветка сопоставления», или просто «ветка» («arm»).Шаблоны могут соответствовать простым значениям, таким, как числа или символы; также они могут соответствовать специальным символьным данным, определённым пользователем с помощью enum.
Код ниже генерирует догадку (плохую) в игре на загадывание чисел на основании предыдущего ответа:
enum Answer { Higher, Lower, Bingo, }
fn suggest_guess (prior_guess: u32, answer: Answer) { match answer { Answer: Higher => println!(«maybe try {} next», prior_guess + 10), Answer: Lower => println!(«maybe try {} next», prior_guess — 1), Answer: Bingo => println!(«we won with {}!», prior_guess), } }
#[test] fn demo_suggest_guess () { suggest_guess (10, Answer: Higher); suggest_guess (20, Answer: Lower); suggest_guess (19, Answer: Bingo); } (Почти весь код в этой статье непосредственно выполняем — вы можете скопировать куски кода в файл demo.rs, скомпилировать его с аргументом --test, запустить получившийся исполняемый файл и наблюдать результаты тестов.)Шаблоны также могут сопоставляться со структурированными данными (т.е. с кортежами, срезами, пользовательскими типами данных). В таких шаблонах части входного куска данных обычно привязываются к отдельным локальным переменным, которые потом могут быть использованы при вычислении результата.
Специальный паттерн _ соответствует любому значению и часто используется для ветки по умолчанию; специальный паттерн … обобщает поведение _ для сопоставления с последовательностью значений или пар ключ-значение.
Кроме того, несколько шаблонов можно использовать в одной ветке, объединяя их через вертикальную черту. В этом случае данная ветка выполнится, если данные подойдут под любой из объединённых шаблонов.
Всё вышеописанное можно увидеть в обновлённой версии стратегии генерации ответов в «угадайке»:
struct GuessState { guess: u32, answer: Answer, low: u32, high: u32, }
fn suggest_guess_smarter (s: GuessState) { match s { // Первая ветка сработает только для `answer`, равного `Bingo`, // при этом значение последней догадки связывается с `p`. GuessState { answer: Answer: Bingo, guess: p, … } => { // ~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~ ~~ // | | | | // | | | Игнорируем оставшиеся поля // | | | // | | Скопировать значение поля `guess` // | | в локальную переменную `p` // | | // | Проверить, что поле `answer` равно `Bingo` // | // Сопоставить значение `s` со структурой `GuessState`
println!(«we won with {}!», p); }
// Вторая ветка выполнится, если ответ на самом деле // меньше или больше предыдущей догадки. // Нам нужно сделать новое предположение в отрезке (l…h), причём: // — если догадка меньше ответа, то нам нужно большее число, // и поэтому мы кладём предыдущую догадку в `l`, а последний раз // использованную верхнюю границу --- в `h`; // — если догадка больше ответа, то нам нужно меньшее число, // и поэтому мы связываем `h` с предыдущей догадкой, а `l` --- // с последний раз использованной нижней границей. GuessState { answer: Answer: Higher, low: _, guess: l, high: h } | GuessState { answer: Answer: Lower, low: l, guess: h, high: _ } => { // ~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~ ~~~~~~ ~~~~~~~~ ~~~~~~~ // | | | | | // | | | | Скопировать или // | | | | проигнорировать // | | | | поле `high`, // | | | | в зависимости от // | | | | шаблона // | | | | // | | | Скопировать поле `guess` // | | | в локальную переменную `l` // | | | или `h`, в зависимости // | | | от шаблона // | | | // | | Скопировать или проигнорировать поле `low`, // | | в зависимости от шаблона // | | // | Проверить, что поле `answer` равно // | `Higher` или `Lower`, в зависимости // | от шаблона // | // Сопоставить значение `s` со структурой `GuessState`
let mid = l + ((h — l) / 2); println!(«lets try {} next», mid); } } }
#[test] fn demo_guess_state () { suggest_guess_smarter (GuessState { guess: 20, answer: Answer: Lower, low: 10, high: 1000 }); } Возможность одновременно проверять варианты перечисления и одновремено связывать подструктуры входных данных с переменными позволяет писать мощный, ясный и простой код, который сосредотачивает внимание своего читателя на данных, важных для конкретной ветки.В двух словах, так и работает match.
Итак, как именно эта конструкция взаимодействует с концепцией владения и безопасностью в целом?
Исчерпывающий анализ вариантов Обычно я начинаю с того, что исключаю все, что невозможно. То, что остается, должно быть правдой, сколь бы невероятной она ни казалась.— Шерлок Холмс (Артур Конан Дойль, «Побелевший воин», пер. Т. Левич)
Один из удобных подходов к решению сложной проблемы — это разбить её на составные части и проанализировать каждую по отдельности. Для того, чтобы этот способ работал, разбиение должно быть исчерпывающим: те части, которые вы выделили, должны покрывать все возможные сценарии.Использование enum и match в Rust может в этом помочь, потому что match обеспечивает исчерпыващий анализ вариантов: каждое возможное входное значение должно покрываться шаблоном хотя бы в одной ветке match. Это помогает отлавливать ошибки в логике программы и гарантирует, что результат match-выражения чётко определён.
Поэтому, например, следующий код компилятором отвергается:
fn suggest_guess_broken (prior_guess: u32, answer: Answer) { let next_guess = match answer { Answer: Higher => prior_guess + 10, Answer: Lower => prior_guess — 1, // ERROR: non-exhaustive patterns: `Bingo` not covered }; println!(«maybe try {} next», next_guess); } Многие другие языки предоставляют какую-либо конструкцию для сопоставления с образцом (например, ML и различные реализации match на макросах в Scheme), но не все из них обладают таким ограничением.В Rust же это ограничение присутствует по трём причинам:
Во-первых, как отмечено выше, разбиение проблемы на части даёт общее решение, только если это разбиение покрывает все возможные сценарии. Проверка на полноту делает явными логические ошибки. Во-вторых, проверка на полноту помогает при рефакторинге. Во время процесса разработки я часто добавляю новые варианты в отдельные определения enum’ов. Проверка на полноту помогает найти все те match-выражения, где я использовал варианты из предыдущей версии enum-типа. В-третьих, поскольку match — это выражение, проверка на полноту обеспечивает, что все его ветки либо вычисляются в значение одного типа, либо передают управление в какую-либо другую часть программы. Выход из match Следующий код — это исправленный вариант функции suggest_guess_broken, которая приведена выше; он непосредственно демонстрирует «передачу управления в другую часть программы»: fn suggest_guess_fixed (prior_guess: u32, answer: Answer) { let next_guess = match answer { Answer: Higher => prior_guess + 10, Answer: Lower => prior_guess — 1, Answer: Bingo => { println!(«we won with {}!», prior_guess); return; } }; println!(«maybe try {} next», next_guess); }
#[test] fn demo_guess_fixed () { suggest_guess_fixed (10, Answer: Higher); suggest_guess_fixed (20, Answer: Lower); suggest_guess_fixed (19, Answer: Bingo); } Функция suggest_guess_fixed показывает, что match может обработать какие-то из вариантов и сразу же выйти из функции, а в оставшихся вариантах вычислить соответствующие значения и передать их дальше в тело функции.Мы можем использовать подобные конструкции в match, не боясь потерять один из вариантов, потому что в match их анализ исчерпывающий.
Алгебраические типы данных и структурные инварианты Алгебраические типы данных сжато и точно описывают классы данных и позволяют закодировать множество структурных инвариантов. В Rust для этого используются определения struct и enum.enum-тип позволяет определять взаимоисключающиее классы значений. Примеры выше используют перечисления для создания простых символьных меток, но в Rust перечисления применяются и для гораздо более сложных классов данных.
Например, бинарное дерево это либо лист, либо внутренний узел со ссылками на два дочерних дерева. Вот один из способов представить бинарное дерево целых чисел:
enum BinaryTree {
Leaf (i32),
Node (Box
Проверять, является ли данное значение BinaryTree вариантом Leaf или Node, всё же нужно, но компилятор статически гарантирует, что эти проверки будут сделаны: вы не сможете случайно прочитать значение вида Leaf, как если бы это был Node, и наоборот.
Вот функция, которая складывает всё числа в дереве с помощью match:
fn tree_weight_v1(t: BinaryTree) → i32 { match t { BinaryTree: Leaf (payload) => payload, BinaryTree: Node (left, payload, right) => { tree_weight_v1(*left) + payload + tree_weight_v1(*right) } } }
/// Возвращает дерево, которое выглядит так: /// /// ±---(4)---+ /// | | /// ±(2)-+ [5] /// | | /// [1] [3] /// fn sample_tree () → BinaryTree { let l1 = Box: new (BinaryTree: Leaf (1)); let l3 = Box: new (BinaryTree: Leaf (3)); let n2 = Box: new (BinaryTree: Node (l1, 2, l3)); let l5 = Box: new (BinaryTree: Leaf (5));
BinaryTree: Node (n2, 4, l5) }
#[test] fn tree_demo_1() { let tree = sample_tree (); assert_eq!(tree_weight_v1(tree), (1 + 2 + 3) + 4 + 5); } Алгебраические типы данных устанавливают структурные инварианты, которые строго поддерживаются языком. (Ещё более широкие возможности по определению инвариантов предлагает система модулей и приватности, но мы не будем отклоняться от темы.)Ориентированность и на выражения, и на операторы В отличие от многих других языков, предоставляющих сопоставление с образцом, в Rust поддерживается и стиль, основанный на выражениях, и стиль, основанный на операторах.Многие функциональные языки, в которых есть сопоставление с образцом, поощряют написание кода с использованием выражений, который сфокусирован на значениях, возвращаемых комбинацией выражений, а использование побочных эффектов не рекомендуется. В императивных языках всё с точностью до наоборот — в них предполагается всеобъемлющее использование операторов, т.е. последовательностей команд, выполняемых только ради их побочных эффектов.
Rust превосходно поддерживает оба стиля.
Рассмотрим функцию, которая преобразует неотрицательное целое число в строку, представляя его как числительное (»1st»,»2nd»,»3rd», …). Следующий код использует диапазонные шаблоны для простоты, но при этом он написан в стиле, похожем на использование switch в императивных языках вроде C (или C++, или Java, и т.д.), когда ветки match выполняются только для их побочных эффектов:
fn num_to_ordinal (x: u32) → String { let suffix; match (x % 10, x % 100) { (1, 1) | (1, 21…91) => { suffix = «st»; } (2, 2) | (2, 22…92) => { suffix = «nd»; } (3, 3) | (3, 23…93) => { suffix = «rd»; } _ => { suffix = «th»; } } return format!(»{}{}», x, suffix); }
#[test] fn test_num_to_ordinal () { assert_eq!(num_to_ordinal (0),»0th»); assert_eq!(num_to_ordinal (1),»1st»); assert_eq!(num_to_ordinal (12),»12th»); assert_eq!(num_to_ordinal (22),»22nd»); assert_eq!(num_to_ordinal (43),»43rd»); assert_eq!(num_to_ordinal (67),»67th»); assert_eq!(num_to_ordinal (1901),»1901st»); } Эта программа скомпилируется, что весьма замечательно, потому что статический анализ обеспечивает одновременно то, что: suffix всегда инициализируется до того, как мы вызываем format! в конце функции, и значение переменной suffix присваивается только один раз во время выполнения функции (если бы это было не так, то компилятор заставил бы нас отметить suffix как изменяемую переменную). Ясно, что эту программу можно написать и в стиле, основанном на выражениях, например, так: fn num_to_ordinal_expr (x: u32) → String { format!(»{}{}», x, match (x % 10, x % 100) { (1, 1) | (1, 21…91) => «st», (2, 2) | (2, 22…92) => «nd», (3, 3) | (3, 23…93) => «rd», _ => «th» }) } Иногда такой стиль помогает написать очень чёткий и точный код, но иногда наоборот, и тогда лучше воспользоваться операторами с побочными эффектами (возможность вернуть значение из функции из одной ветки match в функции suggest_guess_fixed, определённой ранее, как раз это демонстрирует).Для обоих стилей есть свои применения. Самое главное заключается в том, что переход на операторный стиль не заставляет вас жертвовать другими фичами Rust, такими, как гарантия, что значение не-mut-переменной можно присвоить только один раз.
Важным случаем, где это важно, является инициализация некоторого состояния и последующее его заимствование, которое происходит только в некоторых ветках потока выполнения:
fn sometimes_initialize (input: i32) { let string: String; // динамически создающееся строковое значение let borrowed: &str; // ссылка на строку match input { 0…100 => { // Создаём строку на лету… string = format!(«input prints as {}», input); // … и затем заимствуем её часть borrowed = &string[6…]; } _ => { // Строковые литералы уже являются ссылками borrowed = «expected between 0 and 100»; } } println!(«borrowed: {}», borrowed);
// Строка ниже вызовет ошибку компиляции, если её раскомментировать:
// println!(«string: {}», string);
// …а именно: error: use of possibly uninitialized variable: `string` }
#[test] fn demo_sometimes_initialize () { sometimes_initialize (23); // этот вызов проинициализирует `string` sometimes_initialize (123); //, а этот — нет } Здесь интересно то, что код после match не может непосредственно обращаться к string, потому что компилятор требует, чтобы переменная была проинициализирована на всех путях выполнения программы до того, как к ней осуществляется доступ. В то же время, мы можем (с помощью borrowed) обращаться к данным, которые содержатся внутри string, потому что ссылка на них записывается в borrowed в первой ветке, и мы сделали так, что borrowed инициализируется на всех путях выполнения, ведущих к println!, который и использует borrowed.(Компилятор гарантирует, что никакие заимствования string не могут жить дольше, чем string, и в сгенерированном коде по окончанию области видимости значение string автоматически деаллоцируется, если оно было на самом деле создано.)
Вкратце, для обеспечения корректности, язык Rust обеспечивает то, что данные всегда инициализируются до их использования, но его разработчики старались избегать необходимости в искусственных шаблонах написания когда, необходимых исключительно для «задабривания» статического анализатора (например, требование инициализации string пустыми данными или необходимость в использовании стиля на основе выражений).
Сопоставление с образцом без перемещения Сопоставление шаблона со значением может заимствовать подструктуру, не забирая права владения. Это важно при применении сопоставления с образцом к ссылкам (т.е. к значениям типа &T).В раздел «Алгебраические типы данных» выше приведены тип бинарного дерева и программа, вычисляющая сумму всех чисел в экземпляре дерева.
Та версия tree_weight обладает существенным недостатком: она принимает дерево по значению. Как только вы передаёте дерево в tree_weight_v1, это дерево исчезает (т.е. деаллоцируется).
#[test] fn tree_demo_v1_fails () { let tree = sample_tree (); assert_eq!(tree_weight_v1(tree), (1 + 2 + 3) + 4 + 5);
// Если вы раскомментируете следующую строчку …
// assert_eq!(tree_weight_v1(tree), (1 + 2 + 3) + 4 + 5);
// … то получите ошибку: error: use of moved value: `tree` } Такое поведение происходит, однако, не из-за использования match, а, скорее, из-за выбора сигнатуры функции: fn tree_weight_v1(t: BinaryTree) → i32 { 0 } // ^~~~~~~~~~ это означает, что функция забирает // право владения `t` На самом деле, match в Rust отлично работает и там, где не требуется получение права владения. В частности, на вход к match поступает выражение L-value, то есть, что результатом входного выражения должно быть значение, находящееся в некоторой области памяти. match вычисляет это выражение и затем проверяет данные в соответствующей области памяти.(Если входное выражение — это имя переменной, разыменование ссылки или поля, то L-value будет соответствовать области памяти, в которой содержится эта переменное или поле или куда указывает ссылка. Если же входное выражение является вызовом функции или другой операцией, которая приводит к созданию временного анонимного значения, то это значение формально будет сохранено во временную область памяти, и именно её будет обрабатывать match.)
Поэтому если мы хотим сделать такой вариант tree_weight, который просто заимствует дерево, а не забирает его совсем, то нам следует воспользоваться соответствующей возможностью выражения match.
fn tree_weight_v2(t: &BinaryTree) → i32 { // ^~~~~~~~~~~ `&` означает, что мы *заимствуем* дерево match *t { BinaryTree: Leaf (payload) => payload, BinaryTree: Node (ref left, payload, ref right) => { tree_weight_v2(left) + payload + tree_weight_v2(right) } } }
#[test] fn tree_demo_2() { let tree = sample_tree (); assert_eq!(tree_weight_v2(&tree), (1 + 2 + 3) + 4 + 5); }
Функция tree_weight_v2 очень похожа на tree_weight_v1. Различия следующие: во-первых, она принимает t по ссылке (& в типе), во-вторых, мы добавили разыменование *t, и в-третьих, что важно, мы используем ref-шаблоны для left и right в случае Node.Разыменование ссылки *t, будучи интерпретированным как выражение L-value, просто получает адрес в памяти, где находится BinaryTree (потому что t: &BinaryTree — это просто ссылка на эти данные). Здесь *t ни создаёт копию дерева, ни перемещает его в новое место в памяти потому что match рассматривает его как L-value.
И самой важной частью того, как работает деструктуризация L-value-выражений, являются ref-шаблоны.
Сначала чётко определим, что означает не-ref-шаблон:
Когда значение типа T сопоставляется с шаблоном, состоящим из одного идентификатора i, при успешном сопоставлении оно перемещается в i. Поэтому мы можем считать, что i имеет тип T (или, более кратко, «i: T»). Для некоторых типов T, которые могут быть автоматически скопированы (что можно переформулировать как «T реализует [трейт] Copy»), входное значение на самом деле будет скопировано в i в таких шаблонах (отметим, что в общем случае произвольный тип T не является автоматически копируемым).Так или иначе, не-ref-паттерны означают, что переменная i получит право владения значением типа T.
Поэтому обе переменные payload в tree_weight_v2 имеют тип i32, а так как i32 реализует Copy, значение веса копируется в payload в обеих ветках.
Теперь мы можем сказать, что такое ref-шаблон:
Когда L-value-выражение типа T сопоставляется с паттерном ref i, при успешном сопоставлении создастся всего лишь заимствование входного значения или его части. Другими словами, успешное сопоставление с ref i значения типа T означает, что i является ссылкой на значение типа T (или, более кратко, i: &T). Поэтому в ветке Node в tree_weight_v2 переменная left будет содержать ссылку на левый Box (содержащий поддерево), а right, соответственно, на правое поддерево.Мы можем передать эти позаимствованные ссылки в рекурсивные вызовы tree_weight_v2, что можно видеть в коде.
Аналогично, ref mut-паттерн (ref mut i) при успешном сопоставлении создаст мутабельную ссылку во входное значение: i: &mut T. Такой паттерн позволяет изменять исходное значение и гарантирует, что других ссылок на это значение не существует. Деструктурирующее присваивание, такое, как в match, позволяет одновременно создавать различные мутабельные ссылки на отдельные части одного значения.
Следующий код демонстрирует применение ref mut, увеличивая на единицу все значения в переданном дереве:
fn tree_grow (t: &mut BinaryTree) { // ^~~~~~~~~~~~~~~ `&mut`: получаем эксклюзивный доступ к дереву match *t { BinaryTree: Leaf (ref mut payload) => *payload += 1, BinaryTree: Node (ref mut left, ref mut payload, ref mut right) => { tree_grow (left); *payload += 1; tree_grow (right); } } }
#[test] fn tree_demo_3() { let mut tree = sample_tree (); tree_grow (&mut tree); assert_eq!(tree_weight_v2(&tree), (2 + 3 + 4) + 5 + 6); } Заметим, что код выше использует ref mut-шаблон для переменной payload. Если бы ref не использовался, то payload содержала бы локальную копию числа, что нам не подходит, потому что мы хотим изменить значение внутри самого дерева. Поэтому нам нужна на него ссылка.Также стоит отметить, что мы смогли одновременно получить мутабельные ссылки в left и right в ветке Node. Компилятор знает, что эти ссылки никогда не будут указывать на одни и те же данные, поэтому он разрешает одновременное создание обеих &mut-ссылок.
Заключение В Rust алгебраические типы данных и сопоставление с образцом — идеи, впервые появившиеся в функциональных языках программирования, — адаптированы к императивному стилю и специфичным для Rust системам владения данными и их заимствования. Конструкции enum и match повышают выразительность языка и дают возможность удобно определять типы данных, а статический анализ гарантирует, что получившиеся программы безопасны.Чтобы узнать больше о том, про что не было сказано в этой статье, например:
как писать Higher вместо Answer: Higher в шаблонах, как определять новые именованные константы, как использовать шаблоны вида ident @ pattern, в чём заключается тонкое различие между { let id = expr; … } и match expr { id => { … } }, Обратитесь к документации или спросите у нашего потрясающего сообщества (на канале #rust в IRC или на форуме).(Большое спасибо всем тем, кто помогал вычитывать эту статью, особенно Aaron Turon, Niko Matsakis, а также Mutabah, proc, libfud, asQuirrel и annodomini из #rust.)