В настоящий момент из глав 1 .. 18 вы познакомились с наиболее часто используемыми частями языка программирования Rust. Перед тем, как мы перейдем к описанию разработки более сложного проекта в главе 20, давайте рассмотрим некоторые аспекты языка, с которыми вы можете сталкиваться время от времени, но не каждый день. Вы можете использовать эту главу (это перевод главы 19 обучающего руководства по Rust [1]) как справочник, когда сталкиваетесь с незнакомыми особенностями языка. Функционал, рассмотренный здесь, полезен в очень специфических ситуациях.
В этой главе будут рассмотрены следующие возможности:
• Unsafe Rust: как отказаться от некоторых гарантий Rust, и взять на себя ответственность за поддержку этих гарантий вручную. • Продвинутые трейты: связанные типы, параметры типов по умолчанию, полный синтаксис, супертрейты, паттерн newtype по отношению к трейтам. • Продвинутые типы: дополнительная информация про паттерн newtype, алиасы типа, тип never и типы с динамическим размером. • Продвинутые функции и замыкания: указатели на функции и возврат замыканий. • Макросы: способы определить код, который определяет больше кода во время компиляции.
[Unsafe Rust]
Весь код, который мы обсуждали до сих пор (главы 1 .. 18), имел гарантии безопасности памяти Rust, обеспечиваемые во время компиляции. Тем не менее в Rust есть скрытая фича языка, применение которой не обеспечивает эти гарантии безопасности. Она называется unsafe Rust, и работает почти так же, как обычный язык Rust, но предоставляет дополнительные супервозможности.
Причина существования unsafe Rust в том, что по своей природе статический анализ является консервативным. Когда компилятор пытается определить, поддерживает ли код гарантии безопасности, ему лучше отклонить компиляцию некоторых допустимых программ, чем принять некоторые недопустимые программы. Хотя код может быть корректным, если компилятор Rust не имеет достаточно информации для полной уверенности, он отклонит компиляцию кода. В этих случаях вы можете использовать небезопасный код, сказав компилятору: "Доверься мне, я знаю, что делаю". Однако имейте в виду, что вы используете unsafe Rust на свой страх и риск: если небезопасный код некорректен, то могут возникнуть проблемы безопасности памяти, такие как разыменование null-указателя.
Другая причина, по который в Rust встроено небезопасное альтер-эго, заключается в том, что компьютерное железо по своей сути небезопасно. Если Rust не позволяет вам делать небезопасные операции, то вы не сможете выполнить определенные задачи. Однако нужно для Rust должна быть возможность выполнять низкоуровневые операции системного программирования, такие как прямое взаимодействие с операционной системой, или даже написание своей собственной операционной системы. Низкоуровневое программирование - одна из целей языка. Давайте рассмотрим, что и как можно делать с unsafe Rust.
Супервозможности unsafe. Чтобы переключиться в unsafe Rust, используйте ключевое слово unsafe, и затем начните новый блок, который содержит небезопасный код. Вы можете предпринять 5 действий в unsafe Rust, которые невозможны в safe Rust, эти действия мы называем супервозможности unsafe:
• Разыменование raw-указателя. • Вызов unsafe функции или метода. • Доступ или модификация мутируемой статической переменной. • Реализация unsafe трейта. • Доступ к полям объединения.
Важно понимать, что unsafe не отключает проверку заимствования (borrow checker), и не отключает любую другую проверку безопасности Rust: если вы используете ссылку в unsafe коде, то она все еще будет проверяться. Ключевое слово unsafe только лишь дает вам доступ к этим пяти возможностям, которые не проверяются компилятором на предмет безопасности памяти. Вы все еще получаете некоторую степень безопасности внутри блока unsafe.
Кроме того, unsafe не означает, что код внутри блока обязательно опасный, или определенно будет создавать проблемы безопасности с памятью: подразумевается что вы, как программист, сами отвечаете за код внутри блока unsafe, что он будет обращаться к памяти правильным образом.
Люди ошибаются, и ошибки случаются, но вы будете знать, что скорее всего ошибка произошла в блоке, помеченном как unsafe. Старайтесь делать блоки unsafe как можно меньшими; это поможет вам в будущем, когда вы будете исследовать причину ошибок с памятью.
Чтобы максимально изолировать unsafe код, лучше всего заключить его в безопасную абстракцию и предоставить безопасное API, что мы обсудим позже, когда рассмотрим unsafe функции и методы. Части стандартной библиотеки реализованы как safe-абстракции поверх unsafe кода, который прошел аудит. Обертывание unsafe кода в safe абстракцию предотвращает использование unsafe от проникания во все места, где вы и ваши пользователи хотели бы использовать функциональность, реализованные с unsafe кодом, потому что использование safe абстракции безопасно.
Давайте рассмотрим каждую из пяти супервозможностей unsafe по очереди. Также мы обсудим некоторые абстракции, предоставляющие безопасный (safe) интерфейс к unsafe коду.
Разыменование raw-указателя. В секции "Висящие ссылки" главы 4 [2] мы упоминали, что компилятор гарантирует правильность ссылок. Unsafe Rust содержит 2 новых типа, которые называются raw-указателями; они подобны ссылкам. Как и ссылки, raw-указатели могут быть немутируемыми или мутируемыми, и они записываются как *const T и *mut T соответственно. Звездочка здесь не оператор разыменования; это часть имени типа. В контексте raw-указателей немутируемость означает, что указатель не может быть присвоен напрямую после того, как он был разыменован.
Raw-указатели, в отличие от ссылок и smart-указателей:
• Им разрешается игнорировать правила владения, имея как немутируемые, так и мутируемые указатели, или несколько мутируемых указателей на одно и то же место в памяти. • Не гарантируют, что указывают на допустимую память. • Могут быть null. • Не реализуют автоматическое освобождение памяти (automatic cleanup).
Отказ от этих гарантий безопасности дает вам возможность получить более производительный код, или возможность интерфейса с другими языками или аппаратурой, когда невозможно применить гарантии Rust.
Листинг 19-1 показывает, как создать немутируемый и мутируемый raw-указатель из ссылок.
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
Листинг 19-1. Создание raw-указателей из ссылок.
Обратите внимание, что мы не включили ключевое слово unsafe в этот код. Мы можем создать raw-указатели в безопасном (safe) коде; мы просто не можем разыменовывать raw-указатели вне блока unsafe, как вы впоследствии увидите.
Мы создали raw-указатели, используя as для преобразования (cast) немутируемой и мутируемой ссылки в соответствующие им типы raw-указателя. Поскольку мы создали их непосредственно из гарантированно действительных ссылок, мы знаем, что эти конкретные raw-указатели действительны, но мы не можем делать такое предположение на любом raw-указателе.
Чтобы это продемонстрировать, мы далее создадим raw-указатель, в достоверности которого мы не можем быть так уверены. Листинг 19-2 показывает, как создать raw-указатель в произвольное место памяти. Попытка использовать произвольную память не определена: по адресу указателя как могут быть данные, так их может и не быть, компилятор может оптимизировать (выкинуть) код, в котором нет доступа к памяти, или программа может столкнуться с ошибкой сегментации (segmentation fault). Обычно нет хорошей причины писать код таким образом, однако это возможно.
let address = 0x012345usize;
let r = address as *const i32;
Листинг 19-2. Создание raw-указателя на произвольный адрес памяти.
Напомним, что можно создавать raw-указатели в safe-коде, однако в нем мы не можем разыменовывать raw-указатели и читать данные, на которые они указывают. В листинге 19-3 мы используем оператор разыменования * на raw-указателе, что требует unsafe блока.
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
unsafe {
println!("r1 is: {}", *r1);
println!("r2 is: {}", *r2);
}
Листинг 19-3. Разыменование raw-указателей внутри unsafe блока.
Создание указателя само по себе ничему не вредит; проблема может возникнуть только когда мы пытаемся обратиться к значению, на которое ссылается указатель, потому что мы можем в конечном итоге столкнуться с недопустимым значением.
Обратите также внимание, что в листингах 19-1 и 19-3 мы создали raw-указатели *const i32 и *mut i32, которые оба указывают на одно и то же место в памяти, где сохранена переменная num. Если мы вместо этого попытаемся создать немутируемую и мутируемую ссылку на num, то код не скомпилируется, потому что правила владения Rust не позволяют использовать мутируемую ссылку одновременно с любыми немутируемыми ссылками. Вместе с raw-указателями мы можем создать мутируемый указатель и немутируемый указатель на одно и то же место в памяти, и менять данные в нем через мутируемый указатель, что потенциально может вызвать рейсинг данных. Будьте осторожны!
Со всеми этими опасностями, почему мы все-таки используем raw-указатели? Один из основных сценариев использования - взаимодействие с C-кодом, что вы увидите в следующей секции "Вызов unsafe функции или метода". Другой случай - когда мы создаем safe абстракции, которые не понимает система проверки заимствования (borrow checker). Мы введем unsafe функции и затем рассмотрим пример safe абстракции, которая использует unsafe код.
Вызов unsafe функции или метода. Второй тип операции, который вы можете выполнить в unsafe блоке - вызов unsafe функций. Unsafe функции и методы выглядят точно так же, как и обычные функции и методы, но они имеют дополнительное указание unsafe перед остальной частью своего определения. Ключевое слово unsafe в этом контексте показывает, что у функции есть требования, которые мы должны соблюдать при вызове этой функции, потому что Rust не может гарантировать, чтобы мы выполнили эти требования. Путем вызова unsafe функции в блоке unsafe мы говорим, что прочитали документацию по этой функции и берем на себя ответственность за соблюдение правил её использования.
В этом примере unsafe функция названа dangerous, хотя никаких действий в её теле не производится:
unsafe fn dangerous() {}
unsafe {
dangerous();
}
Мы должны вызывать функцию dangerous из отдельного unsafe блока. Если же мы попытаемся вызвать dangerous без unsafe блока, то получим ошибку:
$ cargo run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires
unsafe function or block
--> src/main.rs:4:5
|
4 | dangerous();
| ^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how
to avoid undefined behavior
For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` (bin "unsafe-example") due
to 1 previous error
С помощью unsafe блока мы утверждаем для Rust, что прочитали документацию по функции, понимаем как её правильно использовать и проверили, что выполняем контракт функции.
Тела unsafe функций фактически являются unsafe блоками, так что не требуется оформлять другие unsafe операции в unsafe функции, и нам не нужно добавлять другой unsafe блок.
Создание safe абстракции поверх unsafe кода. Тот факт, что функция содержит unsafe код, не означает, что нам нужно помечать всю функцию как unsafe. Фактически оборачивание unsafe кода в safe функцию является общей абстракцией. В качестве примера давайте изучим функцию split_at_mut из стандартной библиотеки, которая требует некоторый unsafe код. Мы рассмотрим, как можно его реализовать. Этот safe метод определен на мутируемых слайсах: он берет один слайс и делает из него два путем разделения слайса по индексу, принимаемому в аргументе. Листинг 19-4 показывает, как использовать split_at_mut.
let mut v = vec![1, 2, 3, 4, 5, 6];
let r = &mut v[..];
let (a, b) = r.split_at_mut(3);
assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);
Листинг 19-4. Использование safe функции split_at_mut.
Мы не можем реализовать эту функцию, используя только safe Rust. Попытка может выглядеть примерно как в листинге 19-5, но она не скомпилируется. Для упрощения мы реализуем split_at_mut как функцию вместо метода, и только для слайсов значений i32 вместо generic-типа T.
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
assert!(mid < = len);
(&mut values[..mid], &mut values[mid..])
}
Листинг 19-5. Попытка реализовать split_at_mut, используя только safe Rust (этот код не скомпилируется).
Эта функция сначала получает общую длину слайса. Затем она применяет assert для проверки индекса, указанного в качестве параметра, что он находится внутри слайса, путем сравнения его значения с общей длиной. Здесь assert означает, что если мы передадим индекс mid со значением больше, чем длина разделяемого слайса, то функция будет паниковать до попытки использования этого индекса.
Затем мы возвращаем два мутируемых слайса в кортеже: один в начале исходного слайса до индекса mid, и другой от mid до конца исходного слайса.
Когда попытаемся скомпилировать код в листинге 19-5, мы получим ошибку.
$ cargo run
Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
--> src/main.rs:6:31
|
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
| - let's call the lifetime of this reference `'1`
...
6 | (&mut values[..mid], &mut values[mid..])
| --------------------------^^^^^^--------
| | | |
| | | second mutable borrow occurs here
| | first mutable borrow occurs here
| returning this value requires that `*values` is borrowed for `'1`
|
= help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices
For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error
Система проверки заимствования Rust (borrow checker) не может понять, что мы делаем заимствование разных частей среза; он только лишь знает, что мы делаем дважды заимствование из одного и того же слайса. Заимствование разных частей слайса фундаментально не составляет проблемы, потому что два результирующие слайса не пересекаются, однако Rust недостаточно умен, чтобы это знать. Когда мы знаем, что с кодом все в порядке, но Rust этого не знает, как раз то место, где нужно применить unsafe код.
Листинг 19-6 показывает, как использовать unsafe блок, raw-указатель и некоторые вызовы unsafe-функций, чтобы реализация split_at_mut заработала.
use std::slice;
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
let ptr = values.as_mut_ptr();
assert!(mid < = len);
unsafe {
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
Листинг 19-6. Использование unsafe кода в реализации функции split_at_mut.
Вспомним секцию "Тип Slice" главы 4 [2], что слайс это указатель на некоторые данные и длину этих данных. Мы используем метод len, чтобы получить длину слайса, и метод as_mut_ptr для доступа к raw-указателю слайса. В этом случае, поскольку у нас мутируемый слайс на значения i32, метод as_mut_ptr возвратит raw-указатель с типом *mut i32, который сохранен в переменной ptr.
С помощью assert мы обеспечиваем, соблюдение условия, что индекс mid находится внутри слайса. Затем мы переходим в unsafe код: функция slice::from_raw_parts_mut берет raw указатель и длину, и создает слайс. Мы используем эту функцию для создания слайса, который начинается от ptr, и имеет длину mid элементов. Затем мы вызываем метод add на ptr с аргументом mid, чтобы получить raw указатель, который начинается на mid, и создаем слайс, используя этот указатель и остальную часть длины, оставшуюся после mid.
Функция slice::from_raw_parts_mut является небезопасной (unsafe), потому что она принимает raw указатель, и должна доверять, что этот указатель достоверный. Метод add на raw-указателях также unsafe, потому что он должен доверять тому, что место смещения также будет достоверным указателем. Таким образом, мы должны поместить блок unsafe вокруг наших вызовов slice::from_raw_parts_mut и add, чтобы мы могли их вызвать. Добавлением assert на переменную mid, что она меньше или равна len, мы можем сказать, что все raw-указатели, используемые в блоке unsafe, будут достоверными, указывающими на реальные данные в слайсе. Это является приемлемым и подходящим приемом использования unsafe.
Обратите внимание, что нам не надо помечать результирующую функцию split_at_mut как unsafe, и мы можем вызвать эту функцию их safe Rust. Мы создали safe абстракцию для unsafe кода с реализацией функции, которая использует unsafe код безопасным способом, потому что она создает только допустимые указатели из данных, к которым функция обращается.
Напротив, использование slice::from_raw_parts_mut в листинге 19-7 вероятно приведет к сбою при использовании слайса. Этот код обращается к произвольному месту в памяти, и создает слайс длиной 10000 элементов.
use std::slice;
let address = 0x01234usize;
let r = address as *mut i32;
let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
Листинг 19-7. Создание слайса из произвольного места в памяти.
Мы не владеем памятью в этом произвольном месте, и нет гарантии, что создание слайса в этой памяти будет содержать действительные значения i32. Попытка использовать значения так, как будто это допустимый слайс, приведет к неопределенному поведению программы.
Использование extern функций для вызова внешнего кода. Иногда вашему коду Rust может понадобиться взаимодействие с кодом, написанным на другом языке. Для этого в Rust есть ключевое слово extern, которое облегчает создание и использование интерфейса взаимодействия с внешними функциями (Foreign Function Interface, FFI). FFI это способ для языка программирования определить и разрешить другой (foreign, внешний) язык программирования, чтобы вызывать его функции.
Листинг 19-8 демонстрирует настройку интеграции с функцией abs из стандартной библиотеки языка C. Функции, декларированные с блоками extern, всегда unsafe для вызова из кода Rust. Причина в том, что другие языки не применяют правила и гарантии Rust, и Rust не может их проверить, так что ответственность за обеспечение безопасности ложится на программиста.
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
Листинг 19-8. Декларирование и вызов функции extern, определенной в другом языке (файл src/main.rs).
Внутри блока extern "C" мы перечисляем имена и структуры внешних функций из другого языка, которые мы хотим вызвать. Часть "C" определяет, какой используется двоичный интерфейс приложения (application binary interface, ABI) внешней функции: ABI определяет, как вызывать функцию на уровне ассемблера. "C" ABI наиболее часто используемый интерфейс программирования языка C.
Вызов функций Rust из других языков. Мы можем также использовать extern для создания интерфейса, позволяющего другим языкам вызвать функции Rust. Вместо создания целого блока extern, мы добавляем ключевое слово extern и указываем ABI для использования непосредственно перед ключевым словом fn для соответствующей функции. Нам также надо добавить аннотацию #[no_mangle], чтобы указать компилятору Rust не делать манглинг имен для этой функции. Манглинг это когда компилятор меняет имя имеющейся функции на другое, менее удобное для чтения человеком, которое содержит больше информации для других частей процесса компиляции. Каждый язык программирование реализует манглинг немного по-разному, и чтобы функция Rust именовалась ожидаемо для других языков, мы должны запретить компилятору Rust применять манглинг имен.
В следующем примере мы делаем вызов функции call_from_c доступным из кода C, после того как она будет скомпилирована в shared-библиотеку и прилинкована из C:
#[no_mangle] pub extern "C" fn call_from_c() {
println!("Just called a Rust function from C!");
}
Использование этого extern не требует unsafe.
Доступ к мутируемой статической переменной или её модификация. Пока мы еще не говорили о глобальных переменных, которые Rust поддерживает, однако это может быть проблемным с правилами владения Rust. Если 2 потока обращаются к одной и той же глобальной мутируемой переменной, то это может привести к рейсингу данных.
В Rust глобальные переменные называются статическими. Листинг 19-9 показывает пример декларации и использования статической переменной со слайсом строки в качестве значения.
static HELLO_WORLD: &str = "Hello, world!";
fn main() {
println!("name is: {HELLO_WORLD}");
}
Листинг 19-9. Определение и использование немутируемой статической переменной (файл src/main.rs).
Статические переменные подобны константам, которые мы обсуждали в секции "Константы" главы 3 [3], когда рассматривали различия между константами и переменными. По соглашению имена статических переменных используют стиль SCREAMING_SNAKE_CASE (для имен применяются только большие буквы, с разделением частей имени символом подчеркивания). Статические переменные могут сохранять только ссылки с временем жизни 'static, что означает, что компилятор Rust может выяснить их время жизни, и мы не обязаны применять для этого явную аннотацию. Доступ к неизменяемой статической переменной безопасен (safe).
Тонкое отличие между константами и немутируемыми статическими переменными заключается в том, что значение в статической переменной имеют фиксированный адрес памяти. При использовании значения всегда будут доступны одни и те же данные. Константы, с другой стороны, могут дублировать свои данные всякий раз, когда они используются. Другое отличие заключается в том, что статические переменные могут быть мутируемыми. Доступ к мутируемым статическим переменным и их изменение небезопасны (unsafe). Листинг 19-10 показывает, как декларировать, осуществлять доступ и модифицировать статическую переменную с именем COUNTER.
static mut COUNTER: u32 = 0;
fn add_to_count(inc: u32) {
unsafe {
COUNTER += inc;
}
}
fn main() {
add_to_count(3);
unsafe {
println!("COUNTER: {COUNTER}");
}
}
Листинг 19-10. Чтение или запись мутируемой статической переменной это unsafe код (файл src/main.rs).
Как и в случае с обычными переменными, мы указываем изменчивость (мутируемость), используя ключевое слово mut. Любой код, который читает или записывает COUNTER, должен быть внутри блока unsafe. Этот код скомпилируется и напечатает "COUNTER: 3" как и ожидалось, потому что это работает в одном потоке. Если бы несколько потоков осуществляли доступ к COUNTER на чтение и запись, то это может привести к рейсингу данных.
С мутируемыми данными, доступными глобально, трудно гарантировать отсутствие гонки (рейсинга) данных, поэтому Rust считает мутируемые статические переменные unsafe. Где это возможно, предпочтительно использовать техники безопасной многопоточности и thread-safe smart-указатели, что мы обсуждали в главе 16 [4], поэтому компилятор проверяет, что происходит безопасное обращение к данным из разных потоков.
Реализация unsafe трейта. Мы можем использовать unsafe для реализации небезопасного трейта. Трейт небезопасен, когда по крайней мере один из его методов имеет некоторый инвариант, который компилятор не может проверить. Мы декларируем такой трейт как небезопасный добавлением ключевого слова unsafe перед trait, и также помечает реализацию трейта как unsafe, что показано в листинге 19-11.
unsafe trait Foo {
// здесь начинаются определения (декларации) методов трейта
}
unsafe impl Foo for i32 {
// здесь начинаются реализации методов трейта
}
fn main() {}
Листинг 19-11. Определение и реализация unsafe трейта.
Использованием unsafe impl мы обещаем, что будем поддерживать инварианты, которые компилятор не может проверить.
В качестве примера вспомним marker-трейты Sync и Send в секции "Расширяемый параллелизм с трейтами Sync и Send" главы 16 [4]: компилятор реализует эти трейты автоматически, если наши типы составлены полностью из типов Send и Sync. Если мы реализуем тип, который содержит тип, не являющийся Send или Sync, такой как raw указатели, и мы хотим пометить этот тип как Send или Sync, то мы должны использовать unsafe. Rust не может проверить, что наш тип поддерживает гарантии, что можно посылать данные между потоками, или осуществлять к ним доступ из нескольких потоков; таким образом, нам нужно делать эти проверки вручную, и показывать это как unsafe.
Доступ к полям объединения. Последнее действие, которое работает только с unsafe, это доступ к полям объединения (union). Объединение похоже на структуру, но одновременно в конкретном экземпляре используется только одно объявленное поле. Объединения используют главным образом для взаимодействия с объединениями в коде C. Доступ к полям объединения небезопасен, поскольку Rust не может гарантировать тип данных, хранящийся в экземпляре объединения. Более подробно про объединения в Rust можно узнать в руководстве [5].
Когда следует использовать unsafe код. Использование unsafe для принятия одного из пяти перечисленных выше действий (superpowers), не является неправильным или даже неодобренным. Но гораздо сложнее исправить небезопасный код, потому что компилятор не может помочь поддерживать безопасность памяти. Если у вас есть причина использовать небезопасный код, вы можете сделать это, и наличие явной небезопасной аннотации облегчает отслеживание источника проблем при их возникновении.
[Продвинутые трейты]
Впервые мы обсуждали трейты в секции "Трейты: определение общего поведения" главы 10 [6], но не рассматривали более продвинутые детали. Теперь, когда вы знаете больше о Rust, мы можем это разобрать.
Указание типов местозаполнителей в определениях трейта со связанными типами. Связанные типы соединяют местозаполнитель типа с трейтом таким образом, что определения метода трейта могут использовать эти типы местозаполнителя в своих сигнатурах. Реализатор трейта будет указывать указывать конкретный тип для использования вместо типа местозаместителя для конкретной ситуации. Таким способом мы можем определить трейт, который использует некоторые типы без необходимости точно знать, что это за типы пока трейт не будет реализован.
Большинство описанных в этой главе расширенных функций упоминаются как редко необходимые. Связанные типы в этом контексте находятся посередине: они используются реже, чем основные фичи языка, но более часто, чем функционал, описываемый в этой главе.
Один из примеров трейта со связанным типом это трейт Iterator, который предоставляет стандартная библиотека. Здесь связанный тип это Item и он обозначает тип значения, реализующий трейт Iterator, и который извлекается в процессе итерации. Определение трейта Iterator показано в листинге 19-12.
pub trait Iterator {
type Item;
fn next(&mut self) -> Option< Self::Item>;
}
Листинг 19-12. Определение трейта Iterator, в котором есть связанный тип Item.
Тип Item это местозаполнитель, и определение метода next показывает, как он будет возвращать значения типа Option< Self::Item>. Реализаторы итератора Iterator будут указывать конкретный тип для Item, и метод next будет возвращать Option, содержащее значение этого конкретного типа.
Связанные типы могут выглядеть похоже на универсальные типы generic, поскольку последние позволяют нам определять функцию без указания, какие типы она может обрабатывать. Чтобы изучить разницу между этими двумя концепциями, мы рассмотрим реализацию трейта Iterator на типе Counter, который указал тип Item как u32 (файл src/lib.rs):
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option< Self::Item> {
// -- вырезано --
Этот синтаксис выглядит сопоставимо с синтаксисом generic-ов. Так почему бы просто не определить трейт Iterator через generic-и, как показано в листинге 19-13?
pub trait Iterator< T> {
fn next(&mut self) -> Option< T>;
}
Листинг 19-13. Гипотетическое определение трейта Iterator с использованием generic.
Разница в том, что когда используются generic-и, как в листинге 19-13, мы должны аннотировать типы в каждой реализации; поскольку мы также можем реализовать Iterator< String> для Counter или любого другого типа, у нас может быть несколько реализаций Iterator для Counter. Другими словами, когда trait имеет generic параметр, он может быть реализован для типа несколько раз каждый раз с изменениями конкретных типов параметров generic типа. Когда мы можем использовать метод next на Counter, мы должны были бы предоставить аннотации типа, чтобы указать, какую реализацию Iterator мы хотим использовать.
Со связанными типами нам не нужно аннотировать типы, потому что мы не можем реализовать трейт на типе несколько раз. В листинге 19-12 с определением, которое использует связанные типы, мы можем только выбрать только один тип Item, потому что может быть только один impl Iterator для Counter. Нам не нужно указывать, что нам нужен итератор значений u32 везде, где мы вызываем next на Counter.
Связанные типы также становятся частью контракта трейта: реализаторы трейта должны предоставить тип для местозаполнителя связанного типа. Связанные типы часто имеют имя, которое описывает, как тип будет использоваться, и документирование связанного типа в документации API является хорошей практикой.
Параметры generic типа по умолчанию и перезагрузка оператора. Когда мы используем параметры generic-типа, мы можем указать конкретный тип по умолчанию для generic-типа. Это устраняет необходимость для реализаторов трейта указывать конкретный тип, если работает тип по умолчанию. Вы указываете тип по умолчанию, когда декларируете generic-тип синтаксисом < PlaceholderType=ConcreteType>.
Хороший пример, где эта техника полезна, когда перегружается оператор, в котором вы настраиваете поведение оператора (такого как +) в определенных ситуациях.
Rust не позволяет вам создать свои собственные операторы или перезагружать произвольные операторы. Однако вы можете перезагрузить операции и соответствующие трейты, перечисленные в std::ops путем реализации трейтов, связанных с оператором. Например, в листинг 19-14 мы перезагружаем оператор + для добавления двух экземпляров Point. Мы делаем это реализацией трейта Add на структуре Point:
use std::ops::Add;
#[derive(Debug, Copy, Clone, PartialEq)] struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
assert_eq!(
Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 }
);
}
Листинг 19-14. Реализация трейта Add для перезагрузки оператора + для экземпляров Point (файл src/main.rs).
Метод add добавляет значения x двух экземпляров Point, и значения y двух экземпляров Point для создания нового Point. Трейт Add имеет связанный тип Output, который реализует тип, возвращаемый из метода add.
Generic-тип по умолчанию в этом коде находится внутри трейта Add. Здесь его определение:
trait Add< Rhs=Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
Этот код должен выглядеть в целом знакомо: трейт с одним методом и связанный тип. Новая часть здесь Rhs=Self:, этот синтаксис называется параметрами типа по умолчанию this. Параметр Rhs generic-типа (сокращение от right hand side, "правая сторона") определяет тип параметра rhs в методе add. Если мы не указываем конкретный тип для Rhs, когда реализуем трейт Add, тип Rhs будет по умолчанию Self, который будет типом, реализуемым нами на Add.
Когда мы реализовали Add для Point, мы использовали умолчание для Rhs, потому что хотели добавить два экземпляра Point. Давайте рассмотрим пример реализации трейта Add, где мы хотим настроить тип Rhs вместо использования умолчания.
У нас две структуры, Millimeters и Meters, хранящие значения в различных единицах. Эта тонкая обертка существующего типа в другой структуре известна как newtype-паттерн, который более подробно мы описываем далее в секции "Использование newtype-паттерна для реализации внешних трейтов на внешних типах". Мы хотим добавлять значения в миллиметрах к значениям в метрах, и иметь реализацию Add, которая делает корректное преобразование. Мы можем реализовать Add для Millimeters с Meters в качестве Rhs, как показано в листинге 19-15.
use std::ops::Add;
struct Millimeters(u32); struct Meters(u32);
impl Add< Meters> for Millimeters {
type Output = Millimeters;
fn add(self, other: Meters) -> Millimeters {
Millimeters(self.0 + (other.0 * 1000))
}
}
Листинг 19-15. Реализация трейта Add на Millimeters для добавления Millimeters к Meters (файл src/lib.rs).
Чтобы добавить Millimeters и Meters, мы указываем impl Add< Meters> для установки значения параметра типа Rhs вместо использования умолчания Self.
Вы будете использовать параметры типа по умолчанию в двух случаях:
• Для расширения типа без нарушения существующего кода. • Чтобы позволить настройку в специальных случаях, в которых большинству пользователей это не потребуется.
Трейт Add стандартной библиотеки является примером второй цели: обычно вы добавляете два похожих типа, но трейт Add предоставляет возможность помимо этого. Использование параметра типа по умолчанию в определении трейта Add означает, что вы не должны указывать в большинстве случаев дополнительный параметр. Другими словами, немного шаблонной реализации не требуется, что упрощает использование трейта.
Первое назначение аналогично второму, но наоборот: если вы хотите добавить параметр типа в существующий трейт, то вы можете дать ему значение по умолчанию, чтобы разрешить расширение функциональности трейта без нарушения кода существующей реализации.
Полно квалифицированный синтаксис (FQDN) для разрешения неоднозначности: вызов методов с одинаковым именем. Ничто в Rust не препятствует тому, чтобы у трейта был метод с таким же именем, то и у метода другого трейта, и Rust не мешает вам реализовать оба трейта на одном типе. Также можно реализовать метод напрямую на типе с таким же именем, как методы из трейтов.
Когда вы вызываете методы с одинаковым именем, вам нужно сказать Rust, какой из них хотите использовать. Рассмотрим код в листинге 19-16, где определено два трейта Pilot и Wizard, которые оба имеют метод fly. Мы затем реализуем оба трейта на типе Human, где уже есть реализованный на нем метод fly. Каждый из методов fly делает что-то отличающееся от других.
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
Листинг 19-16. Два трейта определены с методом fly, и реализованы на типе Human, и метод fly реализован на Human непосредственно (файл src/main.rs).
Когда мы вызываем fly на экземпляре Human, компилятор по умолчанию вызывает метод, напрямую реализованный на этом типе, как показано в листинге 19-17.
fn main() {
let person = Human;
person.fly();
}
Листинг 19-17. Вызов fly на экземпляре типа Human (файл src/main.rs).
Запуск этого кода напечатает "*waving arms furiously*", показывая тем самым, что вызывается метод fly, непосредственно реализованный на Human.
Для вызова методов fly либо из трейта Pilot, либо из трейта Wizard, нам нужно использовать более ясный синтаксис, чтобы указать, какой метод подразумевается. Листинг 19-18 демонстрирует этот синтаксис.
fn main() {
let person = Human;
Pilot::fly(&person);
Wizard::fly(&person);
person.fly();
}
Листинг 19-18. Указание, какой из методов fly мы хотим вызвать (файл src/main.rs).
Указания имени трейта перед именем метода разъясняет для Rust, какая реализация fly должна быть вызвана. Мы также могли бы написать Human::fly(&person), что эквивалентно person.fly(), что мы использовали в листинге 19-18, однако это немного длиннее для написания, если нам не нужно устранять неоднозначность.
Запуск этого кода напечатает следующее:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*
Поскольку метод fly принимает параметр self, если бы у нас было два типа, которые оба реализуют один трейт, то Rust мог бы выяснить, какую реализацию трейта использовать в зависимости от типа self.
Однако связанные функции, не являющиеся методами, не имеют параметра self. Если существует несколько типов или трайтов, которые определяют функции, не методы, с таким же именем, то Rust ее всегда знает, какой тип вы подразумеваете, если вы не используете полный (FQDN) синтаксис. Например, в листинге 19-19 мы создаем трейт для приюта животных, который хочет называть всех щенят как Spot. Мы создаем трейт Animal со связанной функцией baby_name, не методом. Трейт Animal реализован для структуры Dog, на которой мы также непосредственно предоставляем соответствующую, не связанную с методом функцию baby_name.
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", Dog::baby_name());
}
Листинг 19-19. Трейт со связанной функцией и тип со связанной функцией с одинаковым именем, которая также реализует трейт (файл src/main.rs).
Мы реализовали код для именования всех щенков как Spot в связанной функции baby_name, которая определена на Dog. Тип Dog также реализует трейт Animal, который описывает характеристики, имеющиеся у всех животных. Маленькие песики называются щенками (puppy), и это выражается в реализации трейта Animal на Dog в функции baby_name, связанной с трейтом Animal.
В main мы вызываем функцию Dog::baby_name, которая напрямую вызывает связанную функцию, определенную напрямую на Dog. Этот код напечатает следующее:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
Running `target/debug/traits-example`
A baby dog is called a Spot
Этот вывод не то, что мы хотели. Мы хотим вызвать функцию baby_name, которая является частью трейта Animal, реализованного нами на Dog, чтобы он напечатал "A baby dog is called a puppy". Техника указания имени трейта, которую мы используем в листинге 19-18 здесь не поможет; если мы поменяем код main на код в листинге 19-20, то получим ошибку компиляции.
fn main() {
println!("A baby dog is called a {}", Animal::baby_name());
}
Листинг 19-20. Попытка вызвать функцию baby_name из трейта Animal, однако Rust не знает, какую реализацию использовать (файл src/main.rs).
Поскольку Animal::baby_name не имеет параметр self, и могут быть другие типы, которые реализуют трейт Animal, то Rust не может выяснить, какую реализацию Animal::baby_name мы хотим. Мы получим ошибку компилятора:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying
the corresponding `impl` type
--> src/main.rs:20:43
|
2 | fn baby_name() -> String;
| ------------------------- `Animal::baby_name` defined here
...
20 | println!("A baby dog is called a {}", Animal::baby_name());
| ^^^^^^^^^^^^^^^^^^^ cannot call
| associated function of trait
|
help: use the fully-qualified path to the only available implementation
|
20 | println!("A baby dog is called a {}", < Dog as Animal>::baby_name());
| +++++++ +
For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error
Чтобы устранить неоднозначность, и указать Rust, что мы хотим использовать реализацию Animal для Dog, а не реализацию Animal для какого-то другого типа, то нам нужно использовать полный FQDN синтаксис, что демонстрирует листинг 19-21.
fn main() {
println!("A baby dog is called a {}", < Dog as Animal>::baby_name());
}
Листинг 19-21. Использование FQDN для указания, что мы хотим вызвать функцию baby_name из трейта Animal, реализованного на Dog (файл src/main.rs).
Мы предоставляем Rust аннотацию типа внутри угловых скобок, что указывает на то, что мы хотим вызвать метод baby_name из трейта Animal, реализованного на Dog, заявив, что мы хотим рассматривать тип Dog как Animal для этого вызова функции. Теперь этот код напечатает то, что нужно:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/traits-example`
A baby dog is called a puppy
Для связанных функций, которые не являются методами, здесь не будет параметра ресивера: останется только список других аргументов. Можно использовать полный синтаксис везде, где вызываются функции или методы. Однако вы можете опустить любую часть этого синтаксиса, которую Rust может выяснить из другой информации в программе. Вам только нужно использовать этот подробный синтаксис только в тех случаях, когда есть несколько реализаций, которые используют одно и то же имя, и Rust нужно помочь определить, какую реализацию вы хотите выбрать.
Использование супертрейта для требования функциональности одного трейта в рамках другого трейта. Иногда вы можете написать определение тейта, которое зависит от другого трейта: для типа, реализующего первый трейт, вы хотите потребовать, чтобы этот тип также реализовал второй трейт. Это необходимо для того, чтобы определение признака могло использовать связанные элементы второго признака. Трейт, опирающийся на ваше определение трейта, называется супертрейтом вашего трейта.
Для примера предположим, что мы хотим сделать трейт OutlinePrint с методом outline_print, который будет печатать заданное значение, отформатированное так, чтобы оно было в рамке из звездочек. Т. е., учитывая структуру Point, которая реализует трейт Display стандартной библиотеки для результата в (x, y), когда мы вызываем outline_print на экземпляре Point, в котором 1 для x и 3 для y, он должен напечатать:
**********
* *
* (1, 3) *
* *
**********
В реализации метода outline_print мы хотим использовать функциональность трейта Display. Таким образом нам нужно указать, что трейт OutlinePrint будет работать только для типов, которые также реализуют трейт Display и предоставляют функциональность, необходимую OutlinePrint. Мы можем сделать это в определении трейта указанием OutlinePrint: Display. Эта техника подобна добавлению trait bound для трейта. Листинг 19-22 показывает реализацию трейта OutlinePrint.
use std::fmt;
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {output} *");
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
Листинг 19-22. Реализация трейта OutlinePrint, которая требует функциональности от Display (файл src/main.rs).
Поскольку мы указали, что OutlinePrint требует трейта Display, мы можем использовать функцию to_string, которая автоматически реализуется для любого типа, который реализует Display. Если бы мы попытались использовать to_string без добавления двоеточия и указания трейта Display после имени трейта, мы получили бы ошибку, сообщающую, что для типа &Self в текущей области не был найден метод to_string.
Давайте посмотрим, что произойдет, когда мы попытаемся реализовать OutlinePrint на типе, который не реализует Display, таком как структура Point (файл src/main.rs, этот код не скомпилируется):
struct Point {
x: i32,
y: i32,
}
impl OutlinePrint for Point {}
Мы получим ошибку, говорящую нам, что требуется Display, но он не реализован:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
--> src/main.rs:20:23
|
20 | impl OutlinePrint for Point {}
| ^^^^^ `Point` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Point`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
--> src/main.rs:3:21
|
3 | trait OutlinePrint: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `OutlinePrint`
error[E0277]: `Point` doesn't implement `std::fmt::Display`
--> src/main.rs:24:7
|
24 | p.outline_print();
| ^^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Point`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint::outline_print`
--> src/main.rs:3:21
|
3 | trait OutlinePrint: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
4 | fn outline_print(&self) {
| ------------- required by a bound in this associated function
For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors
Чтобы это исправить, мы реализуем Display на Point, и удовлетворим ограничению, которое требует OutlinePrint, примерно так (файл src/main.rs):
use std::fmt;
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
Тогда реализация трейта OutlinePrint на Point скомпилируется успешно, и мы можем вызвать outline_print на экземпляре Point для отображения его координат в рамке из звездочек.
Использование newtype-паттерна для реализации внешних трейтов на внешних типах. В секции "Реализация трейта на типе" главы 10 [6] мы упомянули правило сироты, которое гласит, что нам разрешено реализовывать трейт на типе, только если трейт или тип являются локальными для нашего крейта. Можно обойти это ограничение, используя паттерн newtype, который включает в себя создание нового типа в структуре кортежа (мы рассматривали структуры кортежа в секции "Использование структур кортежа без именованных полей для создания других типов" главы 5 [7]). Структура кортежа будет иметь одно поле и будет тонкой оберткой вокруг вокруг типа, для которого мы хотим реализовать трейт. Тогда тип обертки является локальным для нашего трейта, и мы можем реализовать трейт на обертке. Newtype - термин, произошедший из языка программирования Haskell. Использование этого паттерна не приводит к падению runtime-производительности, а тип обертки игнорируется во время компиляции.
В качестве примера предположим, что мы хотим реализовать Display на Vec< T>, и правило сироты не дает нам это сделать напрямую, потому что трейт Display и тип Vec< T> определены вне нашего крейта. Мы можем сделать структуру Wrapper, которая хранит экземпляр Vec< T>; тогда мы можем реализовать Display на Wrapper и использовать значение Vec< T>, как показано в листинге 19-23.
use std::fmt;
struct Wrapper(Vec< String>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Wrapper(vec![String::from("hello"), String::from("world")]);
println!("w = {w}");
}
Листинг 19-23. Создание типа Wrapper вокруг Vec< String> для реализации Display (файл src/main.rs).
Реализация Display использует self.0 для доступа к внутреннему Vec< T>, потому что Wrapper это структура кортежа, и Vec< T> это элемент по индексу 0 в кортеже. Тогда мы можем использовать функционал трейта Display на Wrapper.
Недостаток использования этой техники в том, что Wrapper это новый тип, поэтому у него нет методов значения, которое он содержит. Нам пришлось бы реализовать все методы Vec< T> непосредственно на Wrapper таким образом, чтобы эти методы делегировали self.0, что позволило бы нам рассматривать Wrapper точно так же, как Vec< T>. Если бы мы хотели, чтобы новый тип имел каждый метод внутреннего типа, то решением была бы реализация трейта Deref (обсуждалось в секции "Трейт Deref: работа со smart-указателями как с обычными ссылками" главы 15 [8]) на Wrapper для возврата внутреннего типа. Если мы не хотим, чтобы тип Wrapper имел все методы внутреннего типа - например для ограничения поведения типа Wrapper - нам пришлось бы реализовать только тем методы, которые хотим создать вручную.
Этот newtype-паттерн также полезен даже когда трейты не привлекаются. Давайте теперь рассмотрим некоторые продвинутые способы взаимодействия с системой типов Rust.
[Продвинутые типы]
У системы типов Rust есть некоторые фичи, о которых мы упоминали, но пока не обсуждали. Мы начнем обсуждение с newtype в целом, когда рассмотрим, почему newtype полезны в качестве типов. Затем перейдем к алиасам тиров, фиче подобной newtype, но с несколько другой семантикой. Мы также обсудим тип ! и типы с динамическим размером.
Использование newtype-паттерна для безопасности типов и абстракции. Паттерн newtype также полезен для задач, выходящих за рамки того, что мы обсуждали, включая статическое обеспечение того, что данные никогда не путаются и показывают единицы значения. Вы видели пример использования newtype для индикации единиц в листинге 19-15: вспомните, что структуры Millimeters и Meters обернули значения u32 в newtype. Если мы бы написали функцию с параметром типа Millimeters, то не смогли бы скомпилировать программу, которая случайно попыталась бы вызвать эту функцию со значением типа Meters, или с простым u32.
Мы также можем использовать newtype-паттерн, чтобы абстрагировать некоторые детали реализации типа: новый тип может раскрыть публичный API, который отличается от API приватного внутреннего типа.
Newtype может также скрывать внутреннюю реализацию. Например, мы можем предоставить тип People, чтобы обернуть HashMap< i32, String>, хранящий идентификатор человека, связанный с его именем. Код, использующий People, будет взаимодействовать только с предоставленным нами публичным API, таким как метод добавления строки имени к коллекции People; этот код не должен знать, что мы внутренне присваиваем i32 ID именам. Паттерн newtype это легковесный способ достичь инкапсуляции, чтобы скрыть детали реализации, о чем мы говорили в секции "Инкапсуляция и скрытие деталей реализации" главы 17 [9].
Создание синонимов типов с алиасами типов. Rust предоставляет возможность декларировать псевдоним типа (alias), чтобы дать существующему типу другое имя. Для этого используется ключевое слово type. Например, мы можем создать алиас Kilometers для i32 таким образом:
type Kilometers = i32;
Теперь алиас Kilometers это синоним для i32; в отличие от типов Millimeters и Meters, созданных нами в листинге 19-15, здесь Kilometers не является отдельным новым типом. Значения, которые имеют тип Kilometers, будут обрабатываться точно так же, как значения типа i32:
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
Поскольку Kilometers и i32 это один и тот же тип, мы можем добавлять значения к обоим этим типам, и можем передавать значения Kilometers в функции, которые принимают параметры i32. Однако с использованием этого метода мы не получаем выгоды от проверки типов, что мы имели от паттерна newtype, который обсуждали выше. Другими словами, если мы где-нибудь перепутаем значения Kilometers и i32, то компилятор не выдаст нам ошибку.
Основное использование синонимов для типа - снижение повторения. Например, мы можем иметь длинный тип наподобие следующего:
Box< dyn Fn() + Send + 'static>
Написание этого длинного типа в сигнатурах функции и аннотациях типов по всему коду может быть утомительным и подверженным ошибкам. Представим себе проект, у которого есть вот такой полный листинг 19-24.
let f: Box< dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));
fn takes_long_type(f: Box< dyn Fn() + Send + 'static>) {
// -- вырезано --
}
fn returns_long_type() -> Box< dyn Fn() + Send + 'static> {
// -- вырезано --
}
Листинг 19-24. Использование длинного типа в нескольких местах.
Алиас типа сделает этот код более управляемым, снижая повторение. В листинге 19-25 мы ввели алиас Thunk для подробного имени, и можем заменить все использования длинного типа более коротким алиасом Thunk.
type Thunk = Box< dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
// -- вырезано --
}
fn returns_long_type() -> Thunk {
// -- вырезано --
}
Листинг 19-25. Введение алиаса типа Thunk для уменьшения повторения.
Этот код намного проще читать и писать! Выбор осмысленного имени для алиаса типа может помочь сообщить о вашем намерении (thunk - преобразование - это слово для кода, которое будет оценено позже, так что оно будет подходящим именем для сохраняемого замыкания).
Алиасы типа обычно используются с типом Result< T, E> для уменьшения повторения. Рассмотрим модуль std::io module в стандартной библиотеке. Операции I/O часто возвращают Result< T, E> для обработки ситуаций, когда операции не могут выполнить свою работу. Эта библиотека имеет структуру std::io::Error, которая представляет все возможные ошибки I/O. Многие функции в std::io будут возвращать Result< T, E>, где E это std::io::Error, такие как эти функции в трейте Write:
use std::fmt; use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result< usize, Error>;
fn flush(&mut self) -> Result< (), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result< (), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result< (), Error>;
}
Здесь Result< ..., Error> повторяются несколько раз. Таким образом, std::io содержит декларацию алиаса этого типа:
type Result< T> = std::result::Result< T, std::io::Error>;
Поскольку эта декларация находится в модуле std::io module, то мы можем использовать полный псевдоним std::io::Result< T>; то есть Result< T, E> с заполненным E как std::io::Error. В конечном итоге функции трейта Write выглядят следующим образом:
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result< usize>;
fn flush(&mut self) -> Result< ()>;
fn write_all(&mut self, buf: &[u8]) -> Result< ()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result< ()>;
}
Псевдоним типа помогает нам двумя способами: он делает код проще для написания и дает нам согласованный интерфейса по всему std::io. Поскольку это алиас, то это еще один Result< T, E>, который означает, что мы можем использовать любые методы, которые работают с Result< T, E>, а также специальный синтаксис, такой как оператор ?.
Тип never, который никогда не возвращается. В Rust есть специальный тип с именем !, который известен в теории типов как пустой тип, поскольку он не имеет значений. Мы предпочитаем его называть типом never, потому что он вставляется вместо возвращаемого типа, когда функция никогда ничего не возвращает. Вот пример:
fn bar() -> ! {
// -- вырезано --
}
Этот код читается как "функция bar, из которой не бывает возврата". Функции, которые никогда не делают возврат, называются расходящимися функциями. Мы не можем создать значения типа !, так что из bar никогда не может быть возврат.
Но для какого использования вы никогда не сможете создать значения? Вспомните код из листинга 2-5, который был частью игры угадайки; мы его немного воспроизвели в листинге 19-26.
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
Листинг 19-26. Выражение match с веткой, которая завершается в continue.
Тогда мы опустили некоторые подробности в этом коде. В секции "Управление потоком с помощью match" главы 6 [10] мы обсуждали, что ветки оператора match должны всегда возвращать один и тот же тип. Так что следующий код не сработает:
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
Тип guess в этом коде должен быть целым числом и строкой, а Rust требует, чтобы guess имел только один тип. Так что возвращает continue? Как нам удалось возвратить u32 из одной ветки, и получить другую ветку, которая завершается на continue в листинге 19-26?
Как вы вероятно догадались, здесь continue имеет значение !. Т. е., когда Rust вычисляет тип для guess, он смотрит на обе ветки оператора match, где первая со значением u32, и вторая со значением !. Поскольку ! никогда не может иметь значение, Rust решает, что тип guess это u32.
Формальный способ описать это поведение - выражение типа ! можно принудить к преобразованию в любой другой тип. Нам разрешено завершить эту ветку оператора match на continue, потому что continue не возвращает значение; вместо этого оно передает управление обратно в начало тела цикла, так что в случае Err мы никогда не присваиваем значение для guess.
Тип never полезен также для использования вместе с макросом panic!. Вспомним функцию unwrap, которую мы вызывали на значениях Option< T>, чтобы генерировать значение или панику с таким определением:
impl< T> Option< T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
В этом коде происходит то же самое, что в операторе match листинга 19-26: Rust видит, что val имеет тип T и panic! имеет тип !, так что результат всего выражения match будет T. Этот код работает, потому что макрос panic! не генерирует значение; он завершает программу. В случае None мы не возвращаем значение из unwrap, так что этот код допустим.
Последнее выражение, которое имеет тип !, это бесконечный цикл:
print!("forever ");
loop {
print!("and ever ");
}
Здесь цикл никогда не завершается, так что ! это будет значение выражения. Однако это не было бы верным, если бы мы добавили break, поскольку цикл закончится, когда он попадет в break.
Типы с динамическими размерами и трейт Sized. Rust должен знать определенные детали про своим типы, такие как сколько памяти выделить для значения определенного типа. Это оставляет несколько запутанным одну из концепций системы типов: типы с динамическими размерами. Иногда такие типы называют DST (сокращение от Dynamically Sized Type) или безразмерными (unsized) типами, эти типы позволяют нам писать код с использованием значений, размер которых известен только во время выполнения программы (runtime).
Давайте погрузимся в изучение подробностей DST-типа str, который мы уже использовали. Правильно, ее &str, а str сам по себе, это DST. Мы не можем знать, какая будет длина строки, пока не запустим код, т. е. мы не можем создать переменную типа str, и не можем передать аргумент типа str. Следующий код не сработает:
let s1: str = "Hello there!";
let s2: str = "How's it going?";
Rust должен знать, сколько памяти выделить для любого значения определенного типа, и все значения типа должны использовать одинаковый объем памяти. Если бы Rust разрешил нам написать такой код, то эти два значения должны были бы получить одинаковое по размеру место в памяти. Однако у них разные размеры: для s1 нужно 12 байт памяти, а для s2 нужно 15. По этой причине невозможно создать переменную, хранящую DST.
Так что же делать? Для этого случая ответ вы уже знаете: сделать типы s1 и s2 как &str вместо str. Вспомним секцию "String Slices" главы 4 [2], что структура данных слайса просто сохраняет начальную позицию и длину слайса. Таким образом, хотя &T является единственным значением, которое хранит адрес памяти, где находится T, &str представляет 2 значения: адрес данных str и их длина. Таким образом, мы можем знать размер значения &str во время компиляции: это двойная длина usize. То есть мы всегда знаем размер &str, независимо от того, какова длина строки, на которую &str ссылается. В общем это способ для использования типов с динамическим размером в Rust: имеется дополнительное значение метаданных, где хранится размер динамической информации. Золотое правило типов с динамическим размером заключается в том, что мы всегда должны помещать значения динамически масштабируемых типов за каким-нибудь указателем.
Мы можем комбинировать str со всеми видами указателей: например Box< str> или Rc< str>. На самом деле вы видели это раньше, но с другим типом динамического размера: трейт. Каждый трейт это тип динамического размера, на который мы можем ссылаться, используя имя трейта. В секции "Использование трейт-объектов, допускающих значения различных типов" главы 17 [9] мы упоминали, что для использования трейтов в качестве трейт-объектов мы должны поместить их за указатель, такой как &dyn Trait или Box< dyn Trait> (Rc< dyn Trait> тоже будет работать).
Чтобы работать с DST-типами Rust предоставляет трейт Sized, чтобы определить, известен или нет размер типа во время компиляции. Этот трейт автоматически реализован для всего, чей размер известен во время компиляции. Дополнительно Rust неявно добавляет ограничение на Sized для каждой generic функции. Т. е. generic-функция с определением наподобие:
fn generic< T>(t: T) {
// -- вырезано --
}
.. на самом деле рассматривается, как будто было написано следующее:
fn generic< T: Sized>(t: T) {
// -- вырезано --
}
По умолчанию generic-функции будут работать только на типах, которые имеют известный размер во время компиляции. Однако вы можете использовать следующий синтаксис, чтобы ослабить это ограничение:
fn generic< T: ?Sized>(t: &T) {
// -- вырезано --
}
Здесь trait bound на ?Sized означает "T может или не может быть Sized", и эта нотация переназначает умолчание, что generic-типы должны иметь известный размер во время компиляции. Синтаксис ?Trait в этом смысле доступен только для Sized, но не для любых других трейтов.
Также обратите внимание, что мы переключили тип параметра t от T на &T. Поскольку тип может быть не Sized, то нам нужно использовать его за каким-то указателем. В этом случае мы выбрали ссылку.
Далее мы поговорим про функции и замыкания.
[Продвинутые функции и замыкания]
В этой секции мы рассмотрим некоторые продвинутые фичи, относящиеся к функциям и замыканиям, включая указатели на функции и возврат замыканий.
Указатели на функции. Мы уже говорили о том, как передать замыкания в функции; вы также можете передать обычные функции в функции! Эта техника полезна, когда вы хотите передать функцию, которую вы уже определили, вместо того, чтобы определять новое замыкание. Функции относятся к типу fn (здесь f в нижнем регистре), не путайте это ключевое слово с трейтом замыкания Fn. Тип fn называется указателем на функцию. Передача функций с помощью указателей на функцию позволит вам использовать функции как аргументы для других функций.
Синтаксис указания параметра как указателя на функцию подобен замыканиям, как показано в листинге 19-27, где мы определили функцию add_one, которая добавляет 1 к своему параметру. Функция do_twice принимает 2 параметра: первый параметр это указатель на любую функцию, которая принимает параметр i32 и возвращает i32, и второй параметр это значение i32. Функция do_twice вызывает функцию f дважды, передавая ей значение arg, затем складывает друг с другом результаты двух вызовов функции. Функция main вызывает do_twice с аргументами add_one и 5.
fn add_one(x: i32) -> i32 {
x + 1
}
fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
f(arg) + f(arg)
}
fn main() {
let answer = do_twice(add_one, 5);
println!("The answer is: {answer}");
}
Листинг 19-27. Использование типа fn для того, чтобы принять указатель на функцию в качестве аргумента (файл src/main.rs).
Этот код напечатает "The answer is: 12". Мы указали параметр f для функции do_twice в значении функции, которая принимает в одном параметре i32, и возвращает i32. Затем мы вызывали f в теле do_twice. В main мы передали имя функции add_one в качестве первого аргумента для do_twice.
В отличие от замыканий, fn это тип вместо трейта, так что мы указываем fn в качестве параметра напрямую, вместо того чтобы декларировать параметр generic-типа с одним трейтом Fn в качестве trait bound.
Указатели на функции реализуют все три трейта замыкания (Fn, FnMut и FnOnce). Это означает, что вы всегда можете передать указатель на функцию в качестве аргумента для функции, которая ожидает замыкание. Лучше всего писать функции, используя generic тип и один из трейтов замыкания, чтобы функции могли принимать либо функции, либо замыкания.
Тем не менее, одним из примеров того, где вы хотели бы принимать только fn и не замыкания, является взаимодействие с внешним кодом, который не имеет замыканий: C-функции могут принимать функции в качестве аргументов, однако на C нет замыканий.
В качестве примера, где бы вы использовали замыкание, определенного inline, или именованной функции, рассмотрим использование метода map, предоставленного трейтом Iterator в стандартной библиотеке. Для использования функции map для превращения вектора чисел в вектор строк мы могли бы использовать замыкание, примерно так:
let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec< String> =
list_of_numbers.iter().map(|i| i.to_string()).collect();
Или мы могли бы указать имя функции в качестве аргумента, чтобы применить её для map вместо замыкания, примерно так:
let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec< String> =
list_of_numbers.iter().map(ToString::to_string).collect();
Обратите внимание, что мы должны использовать полный (FQDN) синтаксис, о чем мы говорили выше в секции "Продвинутые трейты", потому что здесь есть несколько функций to_string. Здесь мы используем функцию to_string, определенную в трейте ToString trait, которую стандартная библиотека реализовала для любого типа, который реализовал Display.
Вспомним из секции "Значения enum" главы 6 [10], что имя каждого варианта enum, которое мы определяем, также становится функцией инициализатора. Мы можем использовать эти функции инициализатора как указатели на функции, которые реализуют трейты замыкания, что означает, что мы можем указать функции инициализатора в качестве аргументов для методов, которые принимают замыкания, примерно так:
enum Status {
Value(u32),
Stop,
}
let list_of_statuses: Vec< Status> = (0u32..20).map(Status::Value).collect();
Здесь мы создали экземпляры Status::Value, используя каждое значение u32 в диапазоне, который вызывает map путем использования функции инициализатора Status::Value. Некоторые программисты предпочитают этот стиль, а некоторые предпочитают использовать замыкания. Они скомпилируются в один и тот же код, а какой стиль использовать - выбирайте сами.
Возврат замыканий. Замыкания представлены трейтами, что означает, то вы не можете возвратить замыкания напрямую. В большинстве случаев, где вы можете захотеть возвратить трейт, вы можете вместо этого использовать конкретный тип, который реализует трейт как возвращаемое значение функции. Однако вы не можете делать то же самое с замыканиями, потому что у них нет конкретного типа, который был бы возвращаемым; например, вам не разрешено использовать указатель на функцию fn в качестве возвращаемого типа.
Следующий код пытается возвратить замыкание напрямую, но он не скомпилируется:
fn returns_closure() -> dyn Fn(i32) -> i32 {
|x| x + 1
}
Ошибка компилятора будет следующей:
$ cargo build
Compiling functions-example v0.1.0 (file:///projects/functions-example)
error[E0746]: return type cannot have an unboxed trait object
--> src/lib.rs:1:25
|
1 | fn returns_closure() -> dyn Fn(i32) -> i32 {
| ^^^^^^^^^^^^^^^^^^ doesn't have a size known at compile-time
|
help: return an `impl Trait` instead of a `dyn Trait`, if all returned values are the same type
|
1 | fn returns_closure() -> impl Fn(i32) -> i32 {
| ~~~~
help: box the return type, and wrap all of the returned values in `Box::new`
|
1 ~ fn returns_closure() -> Box< dyn Fn(i32) -> i32> {
2 ~ Box::new(|x| x + 1)
|
For more information about this error, try `rustc --explain E0746`.
error: could not compile `functions-example` (lib) due to 1 previous error
Снова ошибка ссылается на трейт Sized! Rust не знает, сколько места нужно выделить для сохранения замыкания. Мы раньше видели решение этой проблемы, можно использовать трейт-объект:
fn returns_closure() -> Box< dyn Fn(i32) -> i32> {
Box::new(|x| x + 1)
}
Этот код скомпилируется нормально. Для дополнительной информации по трейт-объектам см. секцию "Использование трейт-объектов, допускающих значения различных типов" в главе 17 [9].
Далее мы рассмотрим макросы.
[Макросы]
Мы уже использовали много раз макросы наподобие println!, но не рассматривали подробно, что такое макрос и как он работает. Термин макрос относится к семейству фич в Rust: декларативные (declarative) макросы вместе с macro_rules!, и три вида процедурных (procedural) макросов:
• Пользовательские макросы #[derive], которые задают код, добавляемый атрибутом derive, используемом на структурах и перечислениях. • Макросы наподобие атрибутов, которые определяют пользовательские атрибуты, которые можно использовать на любом элементе. • Макросы наподобие функций, которые выглядят как функции, но работают на токенах, указанных в их аргументах.
Мы поговорим про каждый из них по очереди, однако сначала давайте посмотрим, зачем нам вообще нужны макросы, когда у нес уже есть функции.
Различия между макросами и функциями. Фундаментально макросы это способ написания кода, вместо которого подставляется другой код, что известно как метапрограммирование. Мы обсудим атрибут derive, который генерирует для вас реализацию различных трейтов. Мы часто использовали макросы println! и vec!. Все эти макросы разворачиваются для генерации дополнительного кода, создавая больше кода, чем написанный вручную код.
Метапрограммирование полезно для уменьшения количества кода, который вы должны писать и поддерживать, что также является одной из роли функций. Однако у макросов есть дополнительные сильные возможности, которых нет у функций.
Сигнатура функции должна декларировать определенное количество и тип своих параметров. Макросы, с другой стороны, могут принимать переменное количество параметров: мы можем вызывать println!("hello") с одним аргументом, или println!("hello {}", name) с двумя аргументами. Также макросы расширяются перед тем, как компилятор интерпретирует смысл кода, так что макрос может, например, реализовать трейт на указанном типе. Функция этого не может, потому что она вызывается runtime, а трейт должен быть реализован во время компиляции.
Недостаток реализации макроса вместо функции в том, что определения макроса более сложные, чем определения функции, потому что вы пишете код Rust, который генерирует код Rust. Из-за этой косвенности определения макросов обычно труднее для чтения, понимания и поддержки, чем определения функций (то же самое можно сказать и про макросы языка C).
Другое важно отличие макросов и функций в том, что вы должны определить макросы или привести их в область действия до того, как вы их вызываете в файле, в отличие от функций, которые определяются где угодно и вызываются где угодно.
Декларативные макросы с macro_rules! для общего метапрограммирования. Наиболее широко используемая форма макросов в Rust это декларативные макросы. Их также иногда называют "макросы по примеру", "макросы macro_rules!", или просто чистыми макросами. По своей сути декларативные макросы позволяют написать нечто похожее на выражение match. Как обсуждалось в главе 6 [10], выражения match управляют структурами, которые принимают выражение, сравнивают полученное значение выражения с паттернами, и затем запускают порцию кода, связанную с совпавшим паттерном. Макрос также сравнивает значение, которое связано с определенным кодом: в этой ситуации значение это литеральный исходный код Rust, переданный в макрос; паттерны сравниваются со структурой этого исходного кода; и код, связанный с каждым паттерном, когда он совпал, заменяется кодом, переданном в макрос. Это все происходит во время компиляции.
Для определения макроса используется конструкт macro_rules!. Давайте рассмотрим, как использовать macro_rules! на примере определения макроса vec!. В главе 8 [11] показано, как использовать макрос vec! для создания нового вектора с определенными значениями. Например, следующий макрос создает новый вектор, где содержатся три целых числа:
let v: Vec< u32> = vec![1, 2, 3];
Мы могли бы также использовать макрос vec! для создания вектора из двух целых чисел, или вектора из пяти строковых слайсов. Мы не можем использовать для той же цели функцию, потому что не знаем количество или тип значений, передаваемых в аргументах.
Листинг 19-28 показывает несколько упрощенное определение макроса vec!.
#[macro_export] macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
Листинг 19-28. Упрощенная версия определения макроса vec! (файл src/lib.rs).
Замечание: фактическое определение макроса vec! в стандартной библиотеке включает код для предварительного выделения корректного количества памяти для входных данных. Этот код оптимизирован, так что мы его здесь не приводим, чтобы сделать пример проще.
Аннотация #[macro_export] показывает, что этот макрос должен быть сделан доступен всякий раз, когда крейт, в котором определен макрос, попадает в область действия. Без этой аннотации макрос не может быть включен в область действия.
Затем мы начинаем определение макроса с macro_rules! и имени макроса, которое указывается без знака восклицания. За именем, в этом случае vec, идут фигурные скобки, обозначающие тело определения макроса.
Структура в теле vec! подобна структуре выражения match. Здесь у нас одна ветка с паттерном ( $( $x:expr ),* ), за которой идет => и блок кода, связанный с паттерном. Если паттерн соответствует, то выпускается связанный с ним блок кода. С учетом того, что здесь единственный паттерн в этом макросе, здесь есть только один допустимый путь для совпадения паттерна; любой другой паттерн приведет к ошибке. Более сложные макросы будут содержать больше одной ветви.
Допустимый синтаксис паттерна определений макроса отличается от синтаксиса паттерна, показанного в главе 18 [12], потому что паттерны макроса соответствуют структуре кода Rust вместо значений. Давайте прогуляемся по частям паттерна в листинге 19-28 и разберемся, что все это значит; для полного описания синтаксиса паттерна макроса см. руководство Rust [13].
Сначала мы используем набор скобок, чтобы охватить весь паттерн. Знак доллара ($) используется для декларации переменной в системе макроса, которая будет содержать код Rust, соответствующий паттерну. Знак доллара ясно отличает эту переменную макроса от обычной переменной Rust. Далее идет набор круглых скобок, которые захватывают значения, соответствующие шаблону в круглых скобках для использования в коде замены. В пределах $() это $x:expr, что соответствует любому выражению Rust и дает выражению имя $x.
Запятая после $() показывает на то, что после кода, соответствующего коду $(), может появиться символ-разделитель литералов. Звездочка * указывает, что паттерн соответствует нулю или больше того, что предшествует *.
Когда мы вызовем этот макрос как vec![1, 2, 3];, паттерн $x соответствует три раза с тремя выражениями 1, 2 и 3.
Теперь давайте посмотрим на паттерн в теле кода, связанного с веткой: temp_vec.push() в пределах $()* генерируется для каждой части, совпадающей $() в паттерне ноль или более раз в зависимости от того, сколько раз паттерн соответствует. $x заменяется совпавшим выражением. Когда мы вызовем этот макрос как vec![1, 2, 3];, генерируемый код заменит этот макрос на следующее:
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
Мы определили макрос, который может принять любое количество аргументов любого типа, и генерирует код вектора, содержащего указанные элементы.
Чтобы узнать больше о том, как писать макросы, обратитесь к онлайн-документации или другим ресурсам, такой как "The Little Book of Rust Macros", начатый Daniel Keep и продолженный Lukas Wirth.
Процедурные макросы для генерации кода из атрибутов. Вторая форма макросов это процедурная, которая работает больше как функция (и является типом процедуры). Процедурные макросы принимают некоторый код на входе, работают на этом коде, и генерируют некоторый код на выходе вместо сопоставления с паттернами и замены кода другим кодом, как это делают декларативные макросы. Три вида процедурных макросов это пользовательские производные, атрибут-подобные и функция-подобные, и все они работают похожим образом.
Когда создается процедурный макрос, определение должно находиться в своем крейте специального типа. Это происходит по сложным техническим причинам, которые разработчики надеются устранить в будущем. В листинге 19-29 мы покажем, как определить процедурный макрос, где some_attribute является заполнителем для использования определенного макроса.
use proc_macro;
#[some_attribute] pub fn some_name(input: TokenStream) -> TokenStream {
}
Листинг 19-29. Пример определения процедурного макроса (файл src/lib.rs).
Функция, определяющая процедурный макрос, принимает TokenStream на входе, и генерирует TokenStream на выходе. Тип TokenStream type определен крейтом proc_macro, который входит в Rust и представляет последовательность токенов. Это ядро макроса: исходный код, с которым работает макрос составляет входной TokenStream, а код, производимый макросом, является выходным TokenStream. К функции также присоединен атрибут, определяющий тип процедурного макроса, который мы создаем. Мы можем иметь несколько видов процедурных макросов в одном крейте.
Давайте рассмотрим разные виды процедурных макросов. Начнем с пользовательского производного макроса, а затем объясним небольшие отличия других форм.
Пользовательский производный макрос. Давайте создадим крейт hello_macro, который определяет трейт HelloMacro с одной связанной функцией hello_macro. Вместо того, чтобы наши пользователи реализовали трейт HelloMacro для каждого своего типа, мы предоставим процедурный макрос, чтобы пользователи могли аннотировать свой тип с помощью #[derive(HelloMacro)], чтобы получить реализацию по умолчанию функции hello_macro. Реализация по умолчанию напечатает "Hello, Macro! My name is TypeName!", где TypeName это имя типа, на котором определен этот трейт. Другими словами, мы напишем крейт, который позволит другому программисту с помощью нашего крейта написать код как в листинге 19-30.
use hello_macro::HelloMacro; use hello_macro_derive::HelloMacro;
#[derive(HelloMacro)] struct Pancakes;
fn main() {
Pancakes::hello_macro();
}
Листинг 19-30. Пользователь нашего крейта может написать код с помощью процедурного макроса (файл src/main.rs, этот код не скомпилируется).
Этот код напечатает "Hello, Macro! My name is Pancakes!". Первый шаг для создания нового библиотечного крейта:
$ cargo new hello_macro --lib
Далее определим трейт HelloMacro и связанную функцию (файл src/lib.rs):
pub trait HelloMacro {
fn hello_macro();
}
У нас есть трейт и его функция. В этом месте пользователь нашего крейта может реализовать трейт для достижения нужной ему функциональности, примерно так:
use hello_macro::HelloMacro;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
println!("Hello, Macro! My name is Pancakes!");
}
}
fn main() {
Pancakes::hello_macro();
}
Однако пользователю нужно будет написать блок реализации для каждого типа, который он хочет использовать с hello_macro; наша цель - избавить пользователя от необходимости выполнять эту работу.
Кроме того, мы еще не можем предоставить функцию hello_macro с реализацией по умолчанию, которая будет печатать имя типа, на котором трейт реализован: Rust не имеет возможностей отражения, поэтому он не может искать имя типа во время выполнения (rubtime). Нам нужен макрос, который генерирует код во время компиляции.
Следующий шаг состоит в определении процедурного макроса. В момент написания этой документации процедурный макрос должен быть в своем собственном крейте. Когда-нибудь это ограничение возможно будет снято. Соглашение по структурирования крейтов и крейтов макроса следующее: для крейта с именем foo имя крейта для пользовательского процедурного макроса будет foo_derive. Давайте начнем новsй крейт hello_macro_derive внутри нашего проекта hello_macro:
$ cargo new hello_macro_derive --lib
Наши два крейта тесно связаны, так что мы создадим процедурный макросный крейт в директории нашего крейта hello_macro. Если мы поменяем определение трейта в hello_macro, то мы должны также поменять реализацию процедурного макроса в hello_macro_derive. Два крейта должны быть опубликованы отдельно, и программисты, использующие эти крейты, должны будут добавить их оба как зависимости, и привести их оба в область действия. Вместо этого мы могли бы сделать так, чтобы крейт hello_macro использовал hello_macro_derive как зависимость, и повторно экспортировал код процедурного макроса. Тем не менее то, как мы структурировали проект, позволит другим программистам использовать hello_macro, даже если им не нужна производная функциональность.
Нам нужно декларировать крейт hello_macro_derive как крейт процедурного макроса. Нам также понадобится функционал из крейтов syn и quote, как вы скоро увидите, и поэтому нужно добавить их как зависимости. Добавьте следующее в файл Cargo.toml для hello_macro_derive (файл hello_macro_derive/Cargo.toml):
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
Чтобы начать определять процедурный макрос, поместите код в листинге 19-31 в ваш файл src/lib.rs для крейта hello_macro_derive. Обратите внимание, что этот код не скомпилируется, пока мы не добавим определение для функции impl_hello_macro.
use proc_macro::TokenStream; use quote::quote;
#[proc_macro_derive(HelloMacro)] pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Создайте представление кода Rust в виде синтаксического дерева,
// которым вы можете манипулировать:
let ast = syn::parse(input).unwrap();
// Постройте реализацию трейта:
impl_hello_macro(&ast)
}
Листинг 19-31. Код, который потребуется большинству крейтов процедурных макросов для обработки кода Rust (файл hello_macro_derive/src/lib.rs, этот код не скомпилируется).
Обратите внимание, что мы разделили код на функцию hello_macro_derive, которая отвечает за парсинг TokenStream, и на функцию impl_hello_macro, которая отвечает за трансформирование синтаксического дерева: это делает написание процедурного макроса более удобным. Код во внешней функции (в нашем случае hello_macro_derive) будет такой же для большинства крейтов процедурного макроса, которые вы увидите или создадите. Код, указываемый вами в теле внутренней функции (в нашем случае impl_hello_macro) будет отличаться в зависимости от назначения вашего процедурного макроса.
Мы предоставили три новых крейта: proc_macro, syn и quote. Крейт proc_macro поставляется вместе с Rust, так что нам не нужно добавлять его в зависимости файла Cargo.toml. Крейт proc_macro это API компилятора, позволяющий нам манипулировать кодом Rust из нашего кода.
Крейт syn парсит код Rust из строки в структуру данных, с которой мы можем выполнять операции. Крейт quote превращает структуры данных syn обратно в код Rust. Эти крейты значительно упрощают анализ любого кода Rust, который мы возможно захотим обработать: написание полного парсера кода Rust непростая задача.
Функция hello_macro_derive будет вызываться, когда пользователь нашей библиотеки указывает #[derive(HelloMacro)] на типе. Это возможно, потому что мы здесь аннотировали функцию hello_macro_derive с помощью proc_macro_derive, и указали имя HelloMacro, которое соответствует имени нашего трейта; именно этому соглашению следуют большинство процедурных макросов.
Функция hello_macro_derive сначала преобразует входные данные из TokenStream в структуру данных, которую мы затем можем интерпретировать, и выполнять на ней операции. Здесь в игру вступает syn. Функция parse в syn берет TokenStream и возвращает структуру DeriveInput, представляющую прошедший парсинг код Rust. Листинг 19-32 показывает соответствующие части структуры DeriveInput, которые мы получаем от разбора структуры Pancakes; string:
DeriveInput {
// -- вырезано --
ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}
Листинг 19-32. Экземпляр DeriveInput, который мы получаем при разборе кода, снабженного атрибутом макроса в листинге 19-30.
Поля в этой структуре показывают, что код Rust, который мы проанализировали, является единицей структуры с ident (идентификатором, что обозначает имя) Pancakes. На этой структуре есть еще поля для описания всевозможных кодов Rust; дополнительную информацию см. в документации syn для DeriveInput [14].
Вскоре мы определим функцию impl_hello_macro, где мы создадим новый код Rust, который хотим подключить. Но прежде чем мы это сделаем, обратите внимание, что выход для нашего производного макроса также TokenStream. Возвращенный TokenStream добавляется к коду, который пишут наши пользователи крейтов, поэтому, когда они скомпилируют свой крейт, получат дополнительную функциональность, которую мы предоставляем в модифицированном TokenStream.
Возможно вы заметили, что вызывается функция unwrap, чтобы вызвать панику функции hello_macro_derive, если здесь потерпит неудачу вызов функции syn::parse. Наш процедурный макрос должен паниковать по поводу ошибок, потому что функции proc_macro_derive должны возвращать TokenStream вместо Result, чтобы соответствовать API процедурного макроса. Мы упростили этот пример с помощью unwrap; в коде релиза вы должны будете предоставить более конкретные сообщения об ошибках о том, что пошло не так, используя panic! или expect.
Теперь, когда у нас есть код для превращения аннотированного кода Rust из TokenStream в экземпляр DeriveInput, давайте создадим код, который реализует трейт HelloMacro на аннотированном типе, как показано в листинге 19-33.
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}
Листинг 19-33. Реализация трейта HelloMacro с использованием прошедшего парсинг кода Rust (файл hello_macro_derive/src/lib.rs).
Мы получили экземпляр структуры Ident, содержащей имя (идентификатор) аннотированного типа с помощью ast.ident. Структура в листинге 19-32 показывает, что когда мы запускаем функцию impl_hello_macro для кода в листинге 19-30, получаемый нами ident будет иметь поле ident со значением "Pancakes". Таким образом, переменная name в листинге 19-33 будет содержать экземпляр структуры Ident, которая при печати будет строкой "Pancakes", именем структуры в листинге 19-30.
Макрос quote! позволяет нам определить код Rust, который мы хотим возвратить. Компилятор ожидает нечто, отличающееся от прямого результата выполнения макроса quote!, так что нам нужно преобразовать его в TokenStream. Мы делаем это путем вызова метода into, который потребляет эту промежуточное представление и возвращает значение требуемого типа TokenStream.
Макрос quote! также предоставляет очень крутые механики шаблона: мы можем ввести #name, и quote! заменит его значением в переменной name. Можно даже сделать некоторое повторение, похожее на то, как работают обычные макросы. Для дополнительной информации см. документацию крейта quote [15].
Мы хотим, чтобы наш процедурный макрос генерировал реализацию нашего трейта HelloMacro для типа, который пользовал аннотировал. Эту реализацию мы можем получить, используя #name. Реализация трейта имеет одну функцию hello_macro, тело которой содержит функциональность, которую хотим предоставить: печать "Hello, Macro! My name is" и затем имя аннотированного типа.
Используемый здесь макрос stringify! встроен в Rust. Он принимает выражение, такое как 1 + 2, и во время компиляции преобразует его в строковый литерал, такой как "1 + 2". Это отличается от поведения макросов format! или println!, которые оценивают выражение и затем превращают результат в String. Существует вероятность того, что ввод #name может быть выражением для литеральной печати, так что мы используем stringify!. Использование stringify! также экономит выделение памяти путем конвертации #name в строковый литерал во время компиляции.
В этой точке команда cargo build должна завершиться успешно как для hello_macro, так и для hello_macro_derive. Давайте соединим эти крейты с кодом в листинге 19-30, чтобы увидеть процедурный макрос в действии! Создайте новый двоичный проект в вашей директории projects командой cargo new pancakes. Нам нужно добавить hello_macro и hello_macro_derive в качестве зависимостей в файле Cargo.toml крейта pancakes. Если вы публикуете свои версии hello_macro и hello_macro_derive на crates.io, то они будут регулярными зависимостями; если нет, то вы можете указать их как зависимости пути следующим образом:
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
Поместите код листинга 19-30 в src/main.rs, и запустите cargo run: это должно напечатать "Hello, Macro! My name is Pancakes!". Реализация трейта HelloMacro из процедурного макроса была подключена без крейта pancakes, требующая его реализации; #[derive(HelloMacro)] добавил реализацию трейта.
Далее рассмотрим, чем отличаются другие виды процедурного макроса от пользовательского производного макроса.
Макросы наподобие атрибутов. Атрибутоподобные макросы подобны пользовательским производным макросам, но вместо генерации кода для атрибута derive, они позволят вам создавать новые атрибуты. Они также более гибкие: derive работает только для структур и перечислений; атрибуты также могут быть применены также к другим элементам, таким как функции. Здесь показан пример использования attribute-like макроса: скажем, у вас есть атрибут с именем route, который аннотирует функции при использовании платформы web-приложений:
#[route(GET, "/")] fn index() {
Этот атрибут #[route] будет определен фреймворком как процедурный макрос. Сигнатура функции определения макроса выглядела бы следующим образом:
#[proc_macro_attribute] pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
Здесь у нас два параметра типа TokenStream. Первый для содержимого части атрибута: GET, "/". Второй параметр это тело элемента присоединенного атрибута: в этом случае fn index() {} и остальной части тела функции.
Кроме того, атрибутоподобные макросы работают так же, как пользовательские производные макросы: вы создаете крейт типа proc-macro, и реализуете функцию, которая генерирует то, что вы хотите!
Макросы наподобие функции. Макросы как функции определяют макрос, который выглядит как вызов функции. Подобно макросам macro_rules!, они более гибкие, чем функции; например, они могут принять неизвестное количество аргументов. Однако макросы macro_rules! могут быть определены только с использованием синтаксиса наподобие match, что мы обсуждали выше в секции "Декларативные макросы с macro_rules! для общего метапрограммирования". Функциональные макросы берут параметр TokenStream, и их определение манипулирует этим TokenStream, используя код Rust, как это делают другие два типа процедурных макросов. Пример функционального макроса это макрос sql!, который может быть вызван примерно так:
let sql = sql!(SELECT * FROM posts WHERE id=1);
Этот макрос будет анализировать оператор SQL внутри него и проверять, что его синтаксис корректен, что является гораздо более сложной обработкой, чем может делать макрос macro_rules!. Макрос sql! будет определен следующим образом:
#[proc_macro] pub fn sql(input: TokenStream) -> TokenStream {
Это определение похоже на сигнатуру пользовательского производного макроса: мы получаем токены, которые находятся внутри круглых скобок, и возвращаем код, который хотим сгенерировать.
[Общие выводы]
Теперь в вашем инструментарии есть некоторые фичи Rust, которые вы вероятно не будете часто использовать, но будете знать, что они доступны в очень конкретных обстоятельствах. Мы ввели несколько сложных тем, чтобы когда вы столкнетесь с ними в предложениях сообщений об ошибках или в коде других людей, то могли распознать их концепции и синтаксис. Используйте эту главу в качестве справочного материала для поиска решений.
[Ссылки]
1. Rust Advanced Features site:rust-lang.org. 2. Rust: что такое Ownership. 3. Rust: общая концепция программирования. 4. Rust: безопасная многопоточность. 5. Rust Unions site:rust-lang.org. 6. Rust: generic-типы, traits, lifetimes. 7. Rust: использование структуры для взаимосвязанных данных. 8. Rust: умные указатели. 9. Rust: объектно-ориентированное программирование. 10. Rust: перечисления (enum) и совпадение шаблонов (match). 11. Rust: коллекции стандартной библиотеки. 12. Rust: паттерны и выражение match. 13. Rust Macros By Example site:rust-lang.org. 14. Rust Struct syn::DeriveInput site:docs.rs. 15. Rust Crate quote site:docs.rs. |