Как стать автором
Обновить

Может, если бы у C++ было больше времени, он стал бы лучше?

Уровень сложностиСредний
Время на прочтение9 мин
Количество просмотров8.4K
Автор оригинала: Gustavo Noronha

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

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+22
Комментарии91

Публикации

Работа

QT разработчик
8 вакансий
Rust разработчик
7 вакансий
Программист C++
95 вакансий

Ближайшие события