[Перевод] 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 { ... }
// Для работы `seek` нужен экземпляр `MyFile`.
pub fn seek(&mut self, pos: Seek) -> Result<(), Error> { ... }
// Для работы `read_all` нужен экземпляр `MyFile`.
pub fn read_all(&mut self) -> Result { ... }
// `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 {
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 {
// Как выше.
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 {
// Как выше.
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 {
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: типизированная операция перемещения. Пока все хорошо. Однако мы рассматривали только простой случай, в котором файлы могут только открыты или закрыты.
Давайте посмотрим, можем ли мы работать с более сложными случаями.
Сложные «состояния типов»
Вместо файлов рассмотрим следующий сетевой протокол:
- Отправитель отправляет «HELLO».
- Получатель получает «HELLO», отвечает сообщением «HELLO, YOU».
- Отправитель получает «HELLO, YOU», отвечает случайным числом.
- Получатель получает число отправителя, отвечает тем же числом.
- Отправитель получает то же число от получателя, отвечает «BYE».
- Получатель получает «BYE» отправителя, отвечает «BYE, YOU».
- Возврат к шагу 1.
Все другие сообщения игнорируются.
МЫ можем придумать Sender
(и Receiver
), чтобы убедиться, что операции происходят в правильном порядке. На данный момент нас не беспокоит определение корреспондента или числа.
Объединим типизированные перемещения с другой техникой — фантомными типами — данная техника распространена в строго-типизированных функциональных языках программирования.
// Набор типов, которые не имеют размера, представляют состояния отправителя.
// Значение не имеет значения, только тип (поэтому "фантомный тип").
struct SenderReadyToSendHello;
struct SenderHasSentHello;
struct SenderHasSentNumber;
struct SenderHasReceivedNumber;
struct Sender {
/// Сама реализация сетевого I/O.
inner: SenderImpl;
/// Не имеющее размера поле, которое не существует во время выполнения.
state: S;
}
/// Следующие методы могут быть вызваны независимо от состояния.
impl Sender {
/// Порт для подключения отправителя.
fn port(&self) -> usize;
/// Закрыть отправителя.
fn close(self);
}
/// Следующий метод может быть вызван только в
/// состоянии SenderReadyToSendHello.
impl Sender {
/// Данный метод потребляет отправителя в его текущем состоянии,
/// возвращает его в новом состоянии.
fn send_hello(mut self) -> Sender {
self.inner.send_message("HELLO");
Sender {
/// Переместить реализацию сетевого I/O.
/// Компилятор обычно достаточно умен, чтобы сообразить,
/// что во время выполнения ничего не нужно выполнять.
inner: self.inner,
/// Заменить поле с нулевым размером.
/// Данная операция стирается во время выполнения.
state: SenderHasSentHello
}
}
}
/// Следующий метод может быть вызван только в состоянии SenderHasSentHello.
impl Sender {
/// Подождать, пока получатель не отправит "HELLO, YOU",
/// ответить числом.
///
/// Возвратить отправителя в состоянии `SenderHasSentNumber`.
fn wait_respond_to_hello_you(mut self) -> Sender {
// ...
}
/// 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, Self> {
// ...
}
}
/// The following method may be called only in a state SenderHasSentNumber.
impl Sender {
/// 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`.
///
/// Иначе вернуть неизмененное состояние.
fn try_respond_to_hello_you(mut self) -> Result, 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++ имеет нетипизированную семантику перемещения), думаю, что другие языки в конечном итоге включат такой же механизм, если он будет востребован. К слову, я не могу без него обойтись :)