Option
К этому моменту мы уже разобрались со всеми основными языковыми конструкциями. Однако чтобы завершить изучение основ языка, нам всё еще нужно рассмотреть несколько стандартных типов, которые очень тесно связаны с самим языком Rust, а также пронизывают всю его стандартную библиотеку.
Первым мы рассмотрим наиболее часто используемый тип — Option.
Предыстория
При написании реальных программ часто возникает ситуация, когда необходимо каким-то образом обозначить отсутствие значения. Например, для хранения полного имени человека (имя, фамилия и отчество) мы можем создать структуру вида:
#![allow(unused)]
fn main() {
struct FullName {
first_name: String,
last_name: String,
middle_name: String,
}
}
Однако есть ситуации, когда отчество может отсутствовать, и это надо как-то отобразить в коде.
Другой вездесущий пример связан с операциями ввода/вывода: читая данные из внешнего источника, мы никогда не можем гарантировать, что получим все ожидаемые значения. Например, мы запрашиваем из базы данных значение записи по её ID, однако такой записи в БД может просто не существовать.
Традиционно в императивных языках предыдущих поколений эта проблема решается одним из трёх способов:
Способ 1. Резервирование одного из значений, чтобы обозначить отсутствие значения. Например, использовать -1 для отсутствующего ID или пустую строку для отсутствующего отчества. Этот подход очень широко распространён в стандартной библиотеке C и различных системных API. Его недостатком является то, что, во-первых, не всегда можно выделить значение, которое будет индикатором отсутствия значения, а во-вторых, пользователь API должен знать о таком соглашении.
Способ 2. Введение дополнительного флага — булевого поля, которое указывает, что другое поле “пусто”. Например:
#![allow(unused)]
fn main() {
struct FullName {
first_name: String,
last_name: String,
middle_name: String,
is_middle_name_empty: bool,
}
}
Этот подход лишён недостатка, связанного с необходимостью выделения “пустого” значения, однако для написания логики программы он крайне неудобен.
Способ 3. Использование нулевого указателя как индикатора отсутствия значения. Этот подход хоть и удобен, но является причиной самой распространённой ошибки в программах на C — ошибка сегментации (и на Java — NullPointerException). К тому же такой подход требует размещения значений в куче, что может негативно сказаться на производительности программы.
Option для “пустых” значений
Для представления отсутствующих значений в Rust используется тип Option, который объявлен так:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
Как мы видим, это обобщённый перечислимый тип, состоящий из:
- обобщённой кортежной структуры
Some(T) - структуры синглтона
None
Давайте разбираться, как работать с Option.
Допустим, мы хотим сделать переменную типа i32, которая может быть “пустой”.
#![allow(unused)]
fn main() {
let mut maybe_i32: Option<i32>;
maybe_i32 = Some(5); // Записываем в переменную значение
maybe_i32 = None; // А теперь переменная "пуста"
}
С помощью Option мы можем переписать наш пример структуры для хранения полного имени следующим образом:
#![allow(unused)]
fn main() {
struct FullName {
first_name: String,
last_name: String,
middle_name: Option<String>,
}
}
Другой пример: функция, которая возвращает запись из базы данных по её ID, может иметь вид:
fn get_record_by_id(id: u64) -> Option<Record> { ... }
Как мы видим, Option позволяет хранить потенциально пустое значение, при этом:
- не выделять какое-то из возможных значений для индикации отсутствия значения
- не вводить дополнительных неудобных флагов, потому что
Option— enum, и он уже содержит в себе этот флаг (дискриминатор) - не бояться получить ошибку обращения по нулевому указателю
Извлечение значения из Option
Теперь давайте разберёмся, как извлекать значение из Option.
Самый прямолинейный способ — метод unwrap, который работает следующим образом:
- если опшион содержит значение, т.е. является объектом типа
Some(T), то методunwrapвернёт значение, хранящееся в нём - в противном случае программа завершится с паникой.
fn main() {
let o: Option<i32> = Some(5);
let i: i32 = o.unwrap();
}
Очевидно, что использовать метод unwrap очень небезопасно, поэтому существует метод unwrap_or, который позволяет задать значение по умолчанию на случай, если опшион “пуст”.
fn main() {
let o: Option<i32> = None;
let i: i32 = o.unwrap_or(1); // 1
}
Другим способом извлечения значения является использование оператора match.
fn main() {
let o: Option<i32> = Some(5);
let i: i32 = match o {
Some(v) => v,
None => 1,
};
}
Разумеется, нам не обязательно возвращать значение из оператора match, если того не требует логика нашей программы. Например, мы можем просто напечатать различный вывод:
fn main() {
let o: Option<i32> = Some(5);
match o {
Some(v) => println!("Number is {v}"),
None => println!("Number is empty"),
};
}
Также вместе с Option очень удобно использовать оператор if-let.
fn main() {
let o: Option<i32> = Some(5);
if let Some(v) = o {
println!("Number is {v}");
} else {
println!("Number is empty");
};
}
Комбинаторы Option
Стиль работы с Option на первый взгляд может показаться слегка громоздким, однако у Option есть целый ряд удобных комбинаторов, которые позволяют сделать работу с ним проще и выразительнее.
Первый комбинатор — метод map (“отобразить”) позволяет преобразовать значение опшиона, если оно существует, или не сделать ничего, если опшион пуст. В качестве аргумента метод map принимает замыкание (или указатель на функцию), которое применяет к значению внутри Option, если оно — Some.
┌─────────┐ ┌─────────┐ │Option │ .map(|x| x+1) │Option │ │ │ │ │ │ │┌───────┐│ V │┌───────┐│ ││Some(5)├───────> 5+1 ────>│Some(6)││ │└───────┘│ │└───────┘│ └─────────┘ └─────────┘ ┌─────────┐ ┌─────────┐ │Option │ .map(|x| x+1) │Option │ │ │ │ │ │┌───────┐│ │┌───────┐│ ││ None ├─────────────────>│ None ││ │└───────┘│ │└───────┘│ └─────────┘ └─────────┘
Пример:
fn main() {
let s1: Option<i32> = Some(5);
let s2: Option<i32> = s1.map(|a| { a + 1 });
println!("{s2:?}"); // Some(6)
let e1: Option<i32> = None;
let e2: Option<i32> = e1.map(|a| { a + 1 });
println!("{e2:?}"); // None
}
Более приближенный к жизни пример: есть функция, которая извлекает из базы данных объект пользователя по его ID. Если объект пользователя с заданным ID существует в БД, то мы берём из него значение поля “имя”.
struct User {
id: u64,
name: String,
}
fn get_user_by_id(id: u64) -> Option<User> {
// Запрос в БД
}
fn get_user_name_by_id(id: u64) -> Option<String> {
get_user_by_id(id)
.map(|user| user.name)
}
Другой комбинатор — метод flatten (“сгладить”) преобразует двойную обёртку Option<Option<T>> в Option<T>.
fn main() {
let o1: Option<i32> = Some(1);
let o2: Option<Option<i32>> = o1.map(|a| Some(a + 1)); // Some(Some(2))
let o3: Option<i32> = o2.flatten(); // Some(2)
}
Этот комбинатор может понадобиться, когда в нашей логике присутствует несколько Option значений. Например, мы хотим получить отчество, которого может не быть, из объекта пользователя в БД, который может отсутствовать.
struct User {
id: u64,
first_name: String,
last_name: String,
middle_name: Option<String>,
}
fn get_user_by_id(id: u64) -> Option<User> {
// Запрос в БД
}
fn get_user_middle_name_by_id(id: u64) -> Option<String> {
get_user_by_id(id)
.map(|user| user.middle_name)
.flatten()
}
Метод and_then работает как комбинация map и flatten: он сначала применяет к содержимому опшиона функцию, которая возвращает Option, а затем “сглаживает” два опшиона в один.
fn main() {
let o1: Option<i32> = Some(1);
let o2: Option<i32> = o1.and_then(|a| Some(a + 1)); // Some(2)
}