В своей предыдущей статье [перевод на Хабре] я говорил о множестве недостатков C++, которые, по сути, устранил Rust. Благодаря этому код теперь легко использовать правильно и сложно использовать неверно. Я не говорил о безопасности по памяти, просто привёл пример того, что пользователь функции не может случайно поменять местами аргументы количества и цены.
На написание статьи меня вдохновил доклад Мэтта Годболта о том, как можно сделать интерфейсы C++ более надёжными: Correct by Construction: APIs That Are Easy to Use and Hard to Misuse. Вам стоит его посмотреть!
В той статье я сказал, что Rust гораздо лучше помогает разработчику, возможно, благодаря тому, что у него были десятки лет, чтобы учиться. В конце концов, первая версия C++ была выпущена в начале 80-х, а Rust — в начале 2010-х. Если дать C++ несколько десятков лет для обучения, то, разумеется, появятся новые структуры, которые будут обладать высоким качеством и которые сложно использовать неправильно.
Но так ли это?
Сегодня мы рассмотрим приведённый Мэттом в его докладе пример по предотвращению неправильного использования класса после деструктивной смены состояний. Приведу контекст: допустим, у нас есть класс для управления компиляцией шейдеров. Мы можем создать объект, добавить любое количество шейдеров, скомпилировать их, а затем запрашивать у объекта скомпилированные шейдеры:
#include <iostream>
#include <stdexcept>
#include <vector>
class CompiledShader {
public:
explicit CompiledShader(std::string name) : m_name(name) {};
std::string m_name;
};
class ShaderRegistry {
public:
// может вызываться только при компиляции
void add(const char *shader);
// после добавления всех шейдеров
// компиляция может быть вызвана только один раз
void compile();
// получаем скомпилированный шейдер по имени.
// он должен быть скомпилирован и скомпонован!
const CompiledShader &get_compiled(const char *name) const;
std::vector m_names;
std::vector m_compiled;
};
От показанного выше кода неприятно попахивает, и причина запаха кроется в комментариях. Он даёт понять, что эти «оправдывающиеся» комментарии — хороший показатель того, что этот API легко использовать неправильно.
Можно представить, что такой реестр может продолжать удерживать определённые сдампленные ресурсы или переместить владение ими после компиляции, из-за чего объект не будет подходить для нового этапа компиляции. Ещё важнее то, что пользователь может попробовать получить скомпилированный шейдер до того, как он будет скомпилирован. В качестве решения Мэтт предложил разбить этот класс на два:
class CompiledShaders {
public:
explicit CompiledShaders(const std::vector &names);
const CompiledShader &get_compiled(const char *name) const;
std::vector m_compiled;
};
class ShaderCompiler {
public:
void add(const char *shader);
// Использованные в компиляции ресурсы
// передаются в CompiledShaders:
// нельзя вызвать compile() дважды!
CompiledShaders compile() const;
std::vector m_names;
};
Этот API гораздо красивее и понятнее! Теперь мы просто не можем вызвать get_compiled()
до компиляции, потому что этот метод доступен только в объекте, который мы получаем благодаря компиляции. Однако всё равно остаётся пара проблем. У нас всё равно есть ShaderCompiler
, который сохраняется после вызова compile()
, поэтому ничто не мешает нам попробовать использовать его снова:
// Неправильно
ShaderCompiler compiler;
compiler.add("alice");
auto registry = compiler.compile();
compiler.add("bob");
auto wat = compiler.compile();
auto shader = registry.get_compiled("bob");
std::cout << shader.m_name << std::endl;
С этим компилятор ничего не может сделать. Он не знает, как читать наши комментарии, и ничто в определении класса не реализует правила, не позволяющие вызвать add()
после compile()
, или выполнять двойной вызов compile()
. Но мы наверняка сможем улучшить ситуацию! И Мэтт пытается это сделать.
На сцене появляется C++11
Настало время C++ проявить всю свою мощь. У него были десятки лет на исследование того, как проектировались другие языки, и почти три десятка лет учёбы на самом C++, так что у комитета были все возможности решить описанные проблемы. Он много лет работал над новой старшей версией стандарта, которую назвали C++11, потому что она была выпущена в 2011 году — всего за несколько месяцев до первого публичного релиза Rust.
В данном случае было бы замечательно, если бы мы могли выразить то, что объект был «перемещён» — это один из способов концептуального рассуждения о происходящем: мы перемещаем состояние из ShaderCompiler
в CompiledShaders
.
Это не только должно упростить нам рассуждения о коде, поскольку компилятор будет знать, с какого момента состояние объекта концептуально изменилось, но и помочь с производительностью, так как компилятор может пользоваться этим знанием и перемещать данные вместо копирования — мы получаем больше контроля за тем, что происходит в памяти, а это важный аспект для системных языков.
Знаете, что добавилось в C++11? Важная новая фича под названием move semantics. Её первый строительный блок — оператор &&
, указывающий ссылку rvalue — если упростить, это ссылка на значение, которое должно быть уничтожено при завершении текущей конструкции. Поверх этого был добавлена std::move()
, которая, как понятно из названия, перемещает... хотя нет, постойте, на самом деле ведь это не так.
На самом деле она преобразует тип того, что мы передаём && — ссылку rvalue. Ну ладно, тогда всё в порядке, ведь мы знаем, что ссылки rvalue позволяют сказать «это будет немедленно уничтожено, так что можно выполнить перемещение». Решение становится простым:
class ShaderCompiler {
public:
void add(const char *shader) { m_names.push_back(shader); };
CompiledShaders compile() && {
CompiledShaders shaders = CompiledShaders(m_names);
return shaders;
}
std::vector m_names;
};
int main(void) {
// Правильно
ShaderCompiler compiler;
compiler.add("alice");
compiler.add("bob");
auto registry = std::move(compiler).compile();
auto shader = registry.get_compiled("bob");
std::cout << shader.m_name << std::endl;
}
Мэтт изменил определение compile()
, чтобы оно стало «методом rvalue», добавив суффикс &&, и мы выполняем std::move()
объекта при вызове, что теперь обязательно из-за этого суффикса метода. Замечательно. То есть теперь компилятор должен защитить нас от подобного ошибочного использования ShaderCompiler
:
// Неправильно
ShaderCompiler compiler;
compiler.add("alice");
auto registry = std::move(compiler).compile();
compiler.add("bob");
auto wat_bang = std::move(compiler).compile();
auto shader = registry.get_compiled("bob");
std::cout << shader.m_name << std::endl;
Мы вызвали compile()
явно, вызвав std::move()
для объекта. Повторное выполнение этого действия стало бы концептуальным эквивалентом use-after-free. Мы делаем всё намеренно и явно; компилятор должен забраковать этот код или, по крайней мере, начать громко жаловаться об использовании после перемещения. Вот, как выглядит вывод компилятора:
kov@couve > clang++ -std=c++23 -Wall -Wextra -Werror -Wpedantic move/move-3.cpp
kov@couve >
Всё верно — код скомпилировался без единого уведомления. Если покопаться, то можно узнать, что от всего этого можно добиться того, чтобы компилятор потенциально заметил возможность избежать копирования, но и это не гарантируется.
Вероятно, всё это не произойдёт, если вы разберётесь во всех тонкостях. Скорее всего, вы проделаете кучу работы, добавите несколько новых конструкторов, разбросаете по коду &&
и std::move()
, но не получите от этого никакого выигрыша, продолжая иметь возможность использовать объекты после того, как они потенциально были освобождены оптимизацией move. Более того, бывали случаи, когда использование std::move() предотвращало применение оптимизации!
Отличная работа, C++: нужно большое умение, чтобы настолько упустить столь очевидную возможность. С тем же успехом можно было оставить дурнопахнущий комментарий.
Мэтт упоминает, что инструмент статической проверки наподобие clang-tidy пожалуется на этот код, но на самом деле это должно быть работой компилятора, особенно в столь очевидном случае.
Когда я думаю о комитете C++, то представляю группу людей, усиленно старающихся сделать так, чтобы новые фичи было сложно использовать корректно и создать при этом максимальное количество ловушек. Это обеспечит для CppCon стабильный поток часовых докладов о том, что чаще всего всё работает не так, как ожидалось. Я сержусь не на Николая, а на то, что ему пришлось выступать с этим докладом!
Вы уже догадалась, что это главная причина моей неприязни к C++?
Как Rust улучшает ситуацию?
Откровенно говоря, чтобы улучшить подобную ситуацию, требуется не так много. Но здесь мы приступаем к хорошо известному аспекту Rust: его модели владения и заимствования. Когда вы ссылаетесь на значение, Rust явным образом заставляет показать, что с ним будет происходить.
Это очень легко увидеть, посмотрев на методы, которые мы добавляем к struct:
impl MyType {
fn read_something(&self) -> Something { ... }
fn modify_something(&mut self) { ... }
fn consume(self) { ... }
}
Просто посмотрев на определения, вы получаете всю необходимую информацию о владении и типе доступа. read_something()
получает обычную ссылку, называемую borrow (заимствованием), которая означает неисключительный, потенциально общий доступ без передачи владения. Это немного похоже на маркер const
в конце объявления метода C++.
Также мы знаем, что возвращаемое Something
— это значение, которым мы владеем, как вызывающая сторона, поскольку к нему не добавлено &
. Оно наше и мы можем делать с ним всё, что нужно, в том числе и передать владение им какой-то другой функции или структуре данных. Если мы выйдем из области видимости, не сделав этого, оно освобождается.
В modify_something()
мы используем изменяемую ссылку на self
, также известную как mutable borrow; это означает, что функция имеет исключительный доступ — мы знаем, что других ссылок не существует, и это проверяет borrow checker во время компиляции. В этом случае владение тоже не передаётся.
Наконец, в consume()
используется голый self
. Это сообщает нам, как в возвращаемом значении Something
, что функция теперь владеет значением, для которого она была вызвана, и которое будет уничтожено после завершения области видимости, если только функция не передаст или не вернёт его.
Стоит отметить, что это не что-то особенное, свойственное только методам и self
. Любое голое значение или тип — это явное перемещение с передачей владения. Эти правила сильно упрощают нам создание необходимой защиты:
struct CompiledShaders {
...
}
impl CompiledShaders {
fn get(&self, name: &str) -> Option<&CompiledShader> {
...
}
}
struct ShaderCompiler {
...
}
impl ShaderCompiler {
fn new() -> Self {
...
}
fn add(&mut self, name: String) {
...
}
fn compile(self) -> CompiledShaders {
...
}
}
fn main() {
let mut shader_compiler= ShaderCompiler::new();
shader_compiler.add("alice".to_owned());
let registry = shader_compiler.compile();
// Неправильно!
shader_compiler.add("bob".to_owned());
}
Так как в качестве аргумента для компиляции используется голый self
, мы знаем, что значение, представленное shader_compiler
, перемещается в эту функцию. Иными словами, владение передаётся в эту область видимости. После её завершения значение теряется и не может быть использовано. Как и можно ожидать, если мы попробуем использовать его после этого, то компилятор чётко сообщит нам, что это недопустимо:
error[E0382]: borrow of moved value: `shader_compiler`
--> move/move-1.rs:57:9
|
51 | let mut shader_compiler = ShaderCompiler::new();
| ------------ move occurs because `shader_compiler` has type `ShaderCompiler`, which does not implement the `Copy` trait
...
55 | let registry = shader_compiler.compile();
| --------- `shader_compiler` moved due to this method call
56 |
57 | shader_compiler.add("bob".to_owned());
| ^^^^^^^^^^^^^^^ value borrowed here after move
|
note: `ShaderCompiler::compile` takes ownership of the receiver `self`, which moves `shader_compiler`
--> move/move-1.rs:30:16
|
30 | fn compile(self) -> CompiledShaders {
| ^^^^
error: aborting due to 1 previous error
Просто, явно, предсказуемо. Почему с семантикой move всё не так?
В заключение
На мой взгляд, именно в этом Rust превращается из хорошего языка в отличный. Именно благодаря этому вы как новичок начинаете получать удовольствие от мер защиты, а не бороться с borrow checker.
Мы, разработчики на C/C++, привыкли просто разбрасывать по коду указатели и самостоятельно проверять соответствие ограничениям на основании нашего понимания кода. Разумеется, это становится главным источником багов памяти и конкурентности. У современного C++ есть множество помогающих инструментов, но, как сказал Мэтт, вам нужно проактивно пользоваться ими, и пользоваться правильно. Работая на C, мы, по сути, предоставлены сами себе. Rust же заставляет нас намеренно формулировать всё так, чтобы это не просто было правильно, но и чтобы можно было доказать, что это правильно.
Обучение правильному структурированию кода и превращение борьбы с borrow checker в приятное сотрудничество с ним уже не относится к теме моей статьи. Но если вы находитесь на этом этапе своего пути, то крайне рекомендую прочитать книгу Learning Rust With Entirely Too Many Linked Lists. Моему мозгу программиста на C она замечательно подошла.
Есть различные видео потрясающего Йона Йенгсета, в которых глубоко разбираются многие из концепций. Также у него есть видео о реализации различных библиотек и приложений, которые очень удобны в качестве конкретных примеров того, как можно рассуждать об архитектуре. Разве не замечательно, что сегодня мы можем учиться на видео, в которых настоящие специалисты показывают свою работу и объяснения? Я был бы счастлив, если бы что-то подобное существовало, когда я начал изучать C в 1999 году. Это отличный способ упрочить свои знания после прочтения Rust Book.
В последней статье по мотивам доклада Мэтта я рассмотрю ещё один случай, с которым очень хорошо справляется Rust, но который поначалу кажется новичкам очень неуклюжим. Для этого случая в C++ есть очень хорошие инструменты; недостаток лишь в том, что их использование опционально. Поэтому обещаю, что следующая статья будет не такой грустной!
Наконец, должен сказать: комитет по стандарту C++, ну перестаньте. Соберитесь с мыслями. Вы способны на хорошее... но это? Это просто печально.
И если уж я начал: комитет по стандарту, пожалуйста, дайте нам качественный строковый тип и срезы с проверкой границ. По этому поводу есть замечательные идеи. Заранее спасибо.
Примечание об обычных ссылках Rust: многие люди называют этот тип ссылок read-only, но это не совсем так, и мы не можем точно знать, что в какое-то внутреннее состояние не были внесены изменения; на самом деле в модели есть разделение на общие и исключительные ссылки.
В Rust есть множество инструментов для изменения общего состояния. Один из способов его реализации — interior mutability, позволяющее более тонко управлять тем, что может изменяться, часто перемещая гарантии исключительного доступа в среду выполнения. У Йона Йенгсета есть видео, в котором этот вопрос рассматривается очень подробно. На случай, если вы не знаете, скажу, что Rust вынуждает делать это намеренно и в явном виде.