Rust: состояния типов

в 9:34, , рубрики: open source, Rust, безопасность, классы, Компиляторы, Программирование, системное программирование, типы, функциональное программирование

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

Прошу под кат.

Постойте, что такое "состояния типов"?

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

fn read_contents_of_file(path: &Path) -> String {
let mut my_file = MyFile::new(path);
my_file.open();  // Ошибка: может не открыться.

// Некорректно, если вызов `my_file.open()` завершился неуспешно.
let result = my_file.read_all();

my_file.close();

my_file.seek(Seek::Start); // Ошибка: мы закрыли `my_file`.
result
}

В этом примере мы сделали две ошибки:

  • читали из файла, который, возможно, не был успешно открыт;
  • переместили указатель текущего места в файле, который закрыт.
    В большинстве языков программирования мы можем легко составить API у MyFile, который сделает первую ошибку невозможной, бросив исключение, когда файл не получается успешно открыть. Некоторые стандартные библиотеки решили отклониться от этого правила для гибкости, однако такая возможность существует в самом языке.

Вторую ошибку, однако, гораздо сложнее отловить. Большинство языков программирования поддерживают возможности, направленные на затруднение совершения подобной ошибки. Чаще всего это делается посредством закрытия файла в конце области жизни объекта. Единственный мне известный "не-академический" язык, который может предотвратить эту ошибку — Rust.

Простые "состояния типов" в Rust

Как мы это реализуем в Rust?

Самым простым способом является введение подходящих типов для представления операций над MyFile.

impl MyFile {
// `open` - единственный способ получения `MyFile`.
pub fn open(path: &Path) -> Result<MyFile, Error> { ... }

// Для работы `seek` нужен экземпляр `MyFile`.
pub fn seek(&mut self, pos: Seek) -> Result<(), Error> { ... }

// Для работы `read_all` нужен экземпляр `MyFile`.
pub fn read_all(&mut self) -> Result<String, Error> { ... }

// `close` принимает `self`, не `&self` и не `&mut self`,
// это значит, что функция "потребляет" объект (который перемещается
// (moves) в нее).
pub fn close(self) -> Result<(), Error> { ... }
}
impl Drop for MyFile {
// Деструктор, который сам закрывает экземпляр `MyFile`, если мы
// сами этого не сделаем.
fn drop(&mut self) { ... }
}

Перепишем верхний пример:

fn read_contents_of_file(path: &Path) -> Result<String, Error> {
let mut my_file = MyFile::open(path)?;
// Обратите внимание на `?` выше. Это простой оператор, который
// просит компилятор удостовериться, что операция прошла успешно.
// *Единственным* способом получить экземпляр `MyFile` является
// успешное выполнение `MyFile::open`.

// Здесь `my_file` представлен как экземпляр `MyFile`, это значит,
// что мы можем его использовать.

let result = my_file.read_all()?; // Корректно.
my_file.close(); // Корректно.

// Так как `my_file.close()` "потребляет" `my_file`, данная переменная
// больше не существует.

my_file.seek(Seek::Start)?; // Ошибка: выявляется компилятором.
result
}

Это работает и в более сложных случаях:

fn read_contents_of_file(path: &Path) -> Result<String, Error> {
// Как выше.
let mut my_file = MyFile::open(path)?;
let result = my_file.read_all()?; // Корректно.

if are_we_happy_yet() {
  my_file.close(); // Корректно.
}

// Так как `my_file.close()` "съел" `my_file`, данная переменная больше
// не существует в одном сценарии выполнения (если `are_we_happy_yet()`
// вернула true).

my_file.seek(Seek::Start)?; // Ошибка: выявляется компилятором.
result

// Если мы не закрыли `my_file`, деструктор сделает это сейчас.
}

Система типов Rust проверяет, чтобы убедиться, что переменная не используется после того, как она была "потреблена" (consumed, moved). Например, my_file.close() съела переменную.

Даже если бы мы попытались скрыть переменную где-то и попытаться снова её использовать после вызова my_file.close(), мы бы были остановлены компилятором:

fn read_contents_of_file(path: &Path) -> Result<String, Error> {
// Как выше.
let mut my_file = MyFile::open(path)?;
let result = my_file.read_all()?;

let mut my_file_sneaky_backup = my_file;
// Мы переместили `my_file` в `my_file_sneaky_backup`, так что
// теперь мы больше не можем использовать `my_file`.

my_file.close(); // Ошибка: выявляется компилятором.

my_file_sneaky_backup.seek(Seek::Start)?;
result

// Если мы не закрыли `my_file`, деструктор сделает это сейчас.
}

Давайте попробуем обмануть компилятор, сделав файл доступным, после того, как он был закрыт:

fn read_contents_of_file(path: &Path) -> Result<String, Error> {
let my_shared_file = Rc::new(RefCell::new(MyFile::open(path)?));
// `my_shared_file` - разделяемый (shared) указатель на изменяемый экземпляр
// `MyFile`, это похоже на ссылки в Java, C#, Python.

let result = my_shared_file.borrow_mut()
.read_all()?; // Valid

let my_shared_file_sneaky_backup = my_shared_file.clone();
// Мы склонировали указатель, получив возможность обращаться к
// `my_shared_file` другим способом.

// Убедимся, что можем использовать резервный и основной файлы.
my_shared_file_sneaky_backup.seek(Seek::Start)?; // Корректно.
my_shared_file.seek(Seek::Start)?; // Тоже корректно.

// Ахах, сейчас можем с уверенностью закрыть `my_shared_file`,
// после работать с `my_shared_file_sneaky_backup` так же,
// как мы бы делали это в Java, C#, Python!

// Однако мы не можем вызвать `my_shared_file.close()`, ибо к
// экземпляр `MyFile` возможен доступ через разные пути, это значит,
// что никто не может "переместить" его.
my_shared_file.close(); // Error, detected by the compiler

my_shared_file_sneaky_backup.seek(Seek::Start)?;
result

// Если мы не закрыли файл, деструктор сделает это сейчас.
}

Мы в очередной раз были остановлены компилятором: не используя unsafe, мы не можем обойти инвариант — seek не может быть вызван после close.

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

Давайте посмотрим, можем ли мы работать с более сложными случаями.

Сложные "состояния типов"

Вместо файлов рассмотрим следующий сетевой протокол:

  1. Отправитель отправляет "HELLO".
  2. Получатель получает "HELLO", отвечает сообщением "HELLO, YOU".
  3. Отправитель получает "HELLO, YOU", отвечает случайным числом.
  4. Получатель получает число отправителя, отвечает тем же числом.
  5. Отправитель получает то же число от получателя, отвечает "BYE".
  6. Получатель получает "BYE" отправителя, отвечает "BYE, YOU".
  7. Возврат к шагу 1.

Все другие сообщения игнорируются.

МЫ можем придумать SenderReceiver), чтобы убедиться, что операции происходят в правильном порядке. На данный момент нас не беспокоит определение корреспондента или числа.

Объединим типизированные перемещения с другой техникой — фантомными типами — данная техника распространена в строго-типизированных функциональных языках программирования.

// Набор типов, которые не имеют размера, представляют состояния отправителя.
// Значение не имеет значения, только тип (поэтому "фантомный тип").
struct SenderReadyToSendHello;
struct SenderHasSentHello;
struct SenderHasSentNumber;
struct SenderHasReceivedNumber;

struct Sender<S> {
/// Сама реализация сетевого I/O.
inner: SenderImpl;
/// Не имеющее размера поле, которое не существует во время выполнения.
state: S;
}

/// Следующие методы могут быть вызваны независимо от состояния.
impl<S> Sender<S> {
/// Порт для подключения отправителя.
fn port(&self) -> usize;

/// Закрыть отправителя.
fn close(self);
}

/// Следующий метод может быть вызван только в
/// состоянии SenderReadyToSendHello.
impl Sender<SenderReadyToSendHello> {
/// Данный метод потребляет отправителя в его текущем состоянии,
/// возвращает его в новом состоянии.
fn send_hello(mut self) -> Sender<SenderHasSentHello> {
    self.inner.send_message("HELLO");
    Sender {
        /// Переместить реализацию сетевого I/O.
        /// Компилятор обычно достаточно умен, чтобы сообразить,
        /// что во время выполнения ничего не нужно выполнять.
        inner: self.inner,
        /// Заменить поле с нулевым размером.
        /// Данная операция стирается во время выполнения.
        state: SenderHasSentHello
    }
}
}

/// Следующий метод может быть вызван только в состоянии SenderHasSentHello.
impl Sender<SenderHasSentHello> {
/// Подождать, пока получатель не отправит "HELLO, YOU",
/// ответить числом.
///
/// Возвратить отправителя в состоянии `SenderHasSentNumber`.
fn wait_respond_to_hello_you(mut self) -> Sender<SenderHasSentNumber> {
    // ...
}

/// If the receiver has sent "HELLO, YOU", respond with number and
/// return the sender in state `SenderHasSentNumber`.
///
/// Otherwise, return the unchanged state.
fn try_respond_to_hello_you(mut self) -> Resuklt<Sender<SenderHasSentNumber>, Self> {
    // ...
}
}

/// The following method may be called only in a state SenderHasSentNumber.
impl Sender<SenderHasSentNumber> {
/// Wait until the receiver has sent number, respond "BYE".
///
/// Return the sender in state `SenderReadyToSendHello`
fn wait_respond_to_hello_you(mut self) -> Sender<SenderReadyToSendHello> {
    // ...
}

/// Если получатель отправил число, ответить и возвратить отправителя
/// в состоянии `SenderReadyToSendHello`.
///
/// Иначе вернуть неизмененное состояние.
fn try_respond_to_hello_you(mut self) -> Result<Sender<SenderReadyToSendHello>, Self> {
    // ...
}
}

Ясно, что Sender может работать только в соответствии со следующим протоколом:

  • из шага 1 (SenderReadyToSendHello, может перейти на шаг 3);
  • из шага 3 (SenderHasSentHello, может только оставаться на шаге 3 или
    перейти на шаг 5);
  • из шага 5 (SenderHasSentNumber, может только оставаться на шаге 5 или
    вернуться обратно на шаг 1).
    Любые попытки отклониться от протокола будут заблокированы системой типов.

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

Добро пожаловать в мир состояний типов.

Быстрая заметка: за "состояниями типов"

Кстати, продолжая наш пример с сетью, что, если мы захотим сохранить число, отправленное Server, чтобы проверить, чтобы ответ совпал? Мы можем сохранить числом в SenderHasSentNumber:

struct SenderHasSentNumber {
number_sent: u32,
}

Компилятор будет проверять то, что код будет обращаться к number_sent только когда отправитель находится в состоянии SenderHasSentNumber.

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

Завершающие слова

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

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

Автор: Булат Мусин

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js