Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)
}