В своей предыдущей статье [перевод на Хабре] я говорил о множестве недостатков 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 вынуждает делать это намеренно и в явном виде.
