Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top

Вебинар: Подводные камни регулярных выражений: катастрофический возврат, ReDoS-атаки и выявление уязвимостей - 30.04

>
>
>
Ревью вайб-кода с гнильцой, который...

Ревью вайб-кода с гнильцой, который притворяется оптимизированным С++ кодом

21 Апр 2026

Ценность квалифицированного программиста смещается в сторону умения проводить обзоры кода. Генерировать код становится проще, но всё так же важно проверять его с точки зрения качества декомпозиции, корректности реализации, эффективности, безопасности. Посмотрим на примере маленького проекта markus, созданного с помощью Claude Opus, почему важно понимать сгенерированный код и уметь видеть, что скрывает красивый текст программы.

Проект markus

Я искал какой-то проект, сгенерированный с помощью ИИ, и мне попался markus.

Это single-header библиотека для парсинга текста и конвертации его в HTML. Создана с помощью Claude Opus 4.5.

Мне понравилось, что библиотека крошечная. Можно её просмотреть и даже погрузиться в изучение отдельных фрагментов кода. Если перед вами сразу много вайб-кода, то как-то даже не хочется начинать его смотреть.

Библиотека состоит из одного файла markus.h, содержащего 6484 строки на C++20. По существу кода ещё меньше. Некоторые алгоритмы в целях оптимизации используют табличные методы. Четыре таблицы представлены в коде бессмысленно вытянуто. Пример:

static constexpr uint8_t kWhitespaceTable[256] = {
      0,
      0,
      0,
      0,
      .... // и так 256 строк

Если таблицы не считать, то, собственно, кода ещё на 1000 строк меньше. Анализатор PVS-Studio ничего особенного в проекте не нашёл, но проверка стала поводом посмотреть некоторые фрагменты поподробнее. И вот уже специалист, то есть я, могу выделить моменты, которые стоит обсудить.

Проблематика вайб-кодинга

Генерация кода с помощью ИИ может очаровать, особенно неискушённого программиста, который не работал с большими старыми проектами.

Кто сталкивался, тот понимает, что главная задача — не написать код, а сделать так, чтобы его можно было сопровождать и развивать. Об этом хорошо написал Роберт Мартин:

Без присмотра AI будет нагромождать код поверх кода. У него нет чувства большой картины. Архитектура, вероятно, за пределами его возможностей.

Так что изучение принципов декомпозиции, умение правильной постановки задач и т. д. становятся только более актуальной компетенцией программиста.

Но я хочу посвятить статью другой особенности вайб-кодинга, про которую мало говорят: это очарование процессом генерации кода и, как следствие, излишнее доверие к нему. Редкое исключение — заметка "ИИ пишет код быстрее. Но не понимает, что пишет".

Убедившись, что простые задачи решаются хорошо или даже лучше, чем это бы сделали вы сами, теряется критичность взгляда. Кажется, что и в больших задачах качество будет высоким, код оптимизированным и т. д.

За примером далеко ходить не надо. Я ради интереса просил ИИ написать код для быстрого подсчёта количества единичных бит в больших массивах. Я был очарован предложенными алгоритмами. Про многие интересные решения я даже не знал, хотя в своё время прочитал в книге разбор целого ряда алгоритмов на эту тему.

Смотришь на предложенный код подсчёта бит и думаешь: "А не пора ли податься в фермеры?" Сам бы я такой код не написал и не догадался бы его поискать, т. к. просто не знаю про такие алгоритмы.

Это восхищение проецируется на весь код, созданный ИИ. Когда я только заглянул в код markus, то подумал, что анализировать его смысла нет: всё красиво, оптимизированные алгоритмы и т. д:

  • Тонкая настройка оптимизации компилятора с помощью подсказок наиболее вероятных веток исполнения [[unlikely]];
  • Использование условной компиляции на основе if constexpr;
  • Использование оптимизированных табличных алгоритмов;
  • Генерация SIMD-Friendly Helper Functions;
  • Использование контейнеров из пространства имён std::pmr для задания собственной стратегии выделения памяти.

Код солиден, что там смотреть? Оказывается, есть что. Ощущение, что код оптимизирован, — ложное. Вернее, местами он действительно оптимизирован, а местами всё плохо.

К чести автора проекта хочу сказать, что он молодец и взвешенно подходит к описанию проекта. Райан явно предупреждает:

DO NOT USE IN PRODUCTION. This is completely vibe coded and has not undergone any reviews for memory safety. Its performance is also 2-3x slower than cmark.

Какую проблему я хочу подсветить статьёй? Код может выглядит педантичным, оптимизированным и безопасным, но таковым не является.

Хорошо, что работу проекта markus можно сравнить с другим решением и понять, что он получился не очень быстрым. А если сравнивать не с чем? Нужно экспертное мнение человека. Беда в том, что многим кажется, что теперь эксперт и обзор кода не нужны.

Нужен ли эксперт?

А может быть, оценка кода экспертом действительно не нужна?

Вы создали код. Он выглядит красиво и опрятно. Он выглядит оптимизированным и работает. Может, этого достаточно?

На мой взгляд, без оценки можно обойтись, если выполняются все четыре условия:

  • Никто не пострадает и не понесёт убытки, если код работает не так, как задумывалось, из-за багов или неправильной интерпретации ТЗ.
  • Код безответственный в плане безопасности. Никто не будет его ломать, так как это никому не нужно.
  • Скорость работы кода неважна.
  • Не предполагается, что у проекта будет долгий жизненный цикл. Его не потребуется развивать десятилетиями, сопровождать, адаптировать для других платформ и т. д.

Во всех остальных случаях, извините, все требования к проверке качества кода в силе и ещё более актуальны. К вайб-коду должны применяться все те же процессы, что описываются в SSDLC / стандарте РБПО.

Почему я строг к ИИ

Я не строг, а требователен. Для реализации проекта взят язык С++. Суть использования C++ — построение быстрых, эффективных приложений, экономно использующих память. Если вам не нужна быстрая программа, не надо использовать C++.

Раз уж выбран C++ для создания быстрого приложения, то задача именно написать быстрый код. Неважно, пишет его человек или это делает ИИ. Если быстрый код не получился — задача не решена.

Мой личный интерес — изучать, что такое вайб-код и как статические анализаторы могут помочь контролировать его надёжность. Вайб-коду, как и людям, свойственно антипаттерны, но другие. Есть смысл развивать анализатор PVS-Studio с учётом этих особенностей.

Попутно, раз я всё равно смотрю проекты, есть повод написать статью. У меня нет задачи ругать вайб-код и в целом вайб-кодинг. Это глупо, это просто инструмент. Но в отличие от евангелистов ИИ у меня нет задачи продвигать эти технологии, поэтому если я вижу, что код — хрень, я так и напишу.

SIMD-friendly код

Комментарий к функции IsSpanBlank обещает, что код составлен из расчёта облегчить компилятору задачу его векторизации, например, с помощью SSE-инструкций.

Суть оптимизации — несколько элементов обрабатываются одновременно, чтобы определить, хранят ли они пробельные символы.

// Check if a span contains only blank characters (space, tab, \r, \n)
// SIMD-friendly: processes 8 bytes at a time
inline bool IsSpanBlank(const char* data, size_t len) {
  size_t i = 0;
  // Process 8 bytes at a time
  for (; i + 8 <= len; i += 8) {
    // For each byte, check if it's NOT a blank character
    // Blank chars: space(0x20), tab(0x09), \n(0x0A), \r(0x0D)
    bool all_blank = true;
    for (size_t j = 0; j < 8; ++j) {
      unsigned char c = static_cast<unsigned char>(data[i + j]);
      bool is_blank = (c == ' ' || c == '\t' || c == '\n' || c == '\r');
      all_blank = all_blank && is_blank;
    }
    if (!all_blank) return false;
  }
  // Handle remaining bytes
  for (; i < len; ++i) {
    unsigned char c = static_cast<unsigned char>(data[i]);
    if (c != ' ' && c != '\t' && c != '\n' && c != '\r') {
      return false;
    }
  }
  return true;
}

Код создаёт впечатление, что ИИ знает, что делает. Внутренний цикл всегда обрабатывает по 8 символов, и предполагается, что эта часть может быть оптимизирована с помощь SIMD-инструкций. Оставшийся хвост символов обрабатывается отдельным простым циклом.

Мне этот код показался подозрительным тем, что на самом деле байты обрабатываются всё так же последовательно. Вложенный цикл не несёт чего-то полезного. На мой взгляд, этот код не лучше и не хуже простейшего варианта его реализации.

inline bool IsSpanBlank_Simple(const char* data, size_t len) {
  for (size_t i = 0; i < len; ++i) {
    unsigned char c = static_cast<unsigned char>(data[i]);
    if (c != ' ' && c != '\t' && c != '\n' && c != '\r') {
      return false;
    }
  }
  return true;
}

Я бы написал именно такой код. Если бы я понимал, что это узкое место и нужно непременно сделать код как можно быстрее, я бы пошёл искать решение, построенное на интринсиках.

Какой-то другой вариант, например, с вложенным циклом, я бы делать не решился. Я хоть что-то и понимаю в оптимизации, но не настолько, чтобы отважиться писать хитрый код с целью подсказать компилятору.

Можно сказать: "Вот! Ты слаб! А Claude Opus взял и сделал. Взял и написал более умный вариант".

Давайте попробуем сравнить скорость. Мой коллега Филипп провёл небольшой эксперимент с GCC и Clang:

  • Формируем одинаковый набор данных для обоих бенчмарков: генерируем 1 000 000 строк случайной длины от 1 до 127 символов с произвольным содержимым. Генерация наборов не учитывается в замерах.
  • Подаём на вход алгоритму набор данных и для каждой строки вычисляем, содержит ли она пробельные символы. Замеряем общее время.

В обоих вариантах код компилируется с агрессивными оптимизациями -O3. Можно заметить, что для обоих компиляторов простой вариант функции лучше, чем с вложенным циклом.

Быть может, просто надо какой-то волшебный ключ оптимизации указать, чтобы вайб-код оптимизировался лучше? Нет такого ключа. А если будет, компилятор и без подсказки сам сделает столь же эффективный вариант из простого кода с одним циклом.

Параллельно с коллегой тоже поэкспериментировал с двумя рассмотренными и тремя новыми вариантами алгоритма. Я использовал компилятор MSVC. Давайте посмотрим, какие результаты замеров получились.

Во-первых, мне подсказали, что ситуацию можно улучшить, заменив в изначальном варианте || на |. Short-circuit evaluation в нашем случае не помогает, а только вредит. Улучшенная проверка:

bool is_blank = (c == ' ' | c == '\t' | c == '\n' | c == '\r');
inline bool IsSpanBlank_BinOR(const char* data, size_t len) {
  size_t i = 0;
  // Process 8 bytes at a time
  for (; i + 8 <= len; i += 8) {
    // For each byte, check if it's NOT a blank character
    // Blank chars: space(0x20), tab(0x09), \n(0x0A), \r(0x0D)
    bool all_blank = true;
    for (size_t j = 0; j < 8; ++j) {
      unsigned char c = static_cast<unsigned char>(data[i + j]);
      bool is_blank = (c == ' ' | c == '\t' | c == '\n' | c == '\r');
      all_blank = all_blank && is_blank;
    }
    if (!all_blank) return false;
  }
  // Handle remaining bytes
  for (; i < len; ++i) {
    unsigned char c = static_cast<unsigned char>(data[i]);
    if (c != ' ' && c != '\t' && c != '\n' && c != '\r') {
      return false;
    }
  }
  return true;
}

Во-вторых, я спросил DeepSeek. Он предложил вариант оптимизации, построенный на битовых операциях:

inline bool IsSpanBlank_Deepseek(const char* data, size_t len) noexcept {
    // Создаём битовую маску для допустимых символов
    // Используем 128-битную маску для ASCII
    static constexpr uint64_t blank_mask_low = []() {
        uint64_t mask = 0;
        mask |= 1ULL << ' ';
        mask |= 1ULL << '\t';
        mask |= 1ULL << '\n';
        mask |= 1ULL << '\r';
        return mask;
    }();
    
    for (size_t i = 0; i < len; ++i) {
        unsigned char uc = static_cast<unsigned char>(data[i]);
        if (uc >= 64) return false;  // Все blank-символы в ASCII < 64
        if (!((blank_mask_low >> uc) & 1)) return false;
    }
    return true;
}

И напоследок я попросил у DeepSeek оптимизированную функцию, по-настоящему использующую векторизацию. Вот что получилось:

inline bool IsSpanBlank_Deepseek_SSE(const char* data, size_t len) noexcept {
  // Создаём векторы с допустимыми символами
  const __m128i space = _mm_set1_epi8(' ');
  const __m128i tab = _mm_set1_epi8('\t');
  const __m128i newline = _mm_set1_epi8('\n');
  const __m128i carriage = _mm_set1_epi8('\r');
    
  size_t i = 0;
    
  // Обрабатываем по 16 байт
  for (; i + 15 < len; i += 16) {
    __m128i chunk = _mm_loadu_si128(reinterpret_cast<const __m128i*>(data + i));
       
    // Проверяем, что каждый байт является одним из допустимых
    __m128i eq_space = _mm_cmpeq_epi8(chunk, space);
    __m128i eq_tab = _mm_cmpeq_epi8(chunk, tab);
    __m128i eq_nl = _mm_cmpeq_epi8(chunk, newline);
    __m128i eq_cr = _mm_cmpeq_epi8(chunk, carriage);
       
    __m128i is_blank = _mm_or_si128(_mm_or_si128(eq_space, eq_tab),
                                         _mm_or_si128(eq_nl, eq_cr));
        
    // Получаем маску и проверяем
    int mask = _mm_movemask_epi8(is_blank);
    if (mask != 0xFFFF) return false;
}
    
  // Обрабатываем остаток
  for (; i < len; ++i) {
    char c = data[i];
    if (c != ' ' && c != '\t' && c != '\n' && c != '\r') return false;
  }
    
  return true;
}

Теперь результаты. Хотя 32-битные сборки сейчас неактуальны, ради интереса я и её запустил. Замеры выполнялись на массиве размером 212 мегабайт, заполненном случайными пробельными символами. Выполнение потока привязывается к одному предварительно прогретому ядру. Если непонятно, о чём идёт речь, загляните в статью "Вечный вопрос измерения времени".

Win32:
1. Самый простой вариант с одним циклом:                        1.39 сек.
2. Изначальный "оптимизированный код" с вложенным циклом:       1.86 сек.
3. В изначальном варианте операторы || заменены на | :          0.351 сек.
4. Вариант от Deepseek с оптимизацией на битовых масках:        0.817 сек.
5. Вариант от Deepseek с оптимизацией на SSE2 интристиках:      0.0449 сек.

Win64:
1. Самый простой вариант с одним циклом:                        0.175 сек.
2. Изначальный "оптимизированный код" с вложенным циклом:       0.309 сек.
3. В изначальном варианте операторы || заменены на | :          0.253 сек.
4. Вариант от Deepseek с оптимизацией на битовых масках:        0.207 сек.
5. Вариант от Deepseek с оптимизацией на SSE2 интристиках:      0.0396 сек.

Хорошо заметно, что 64-битный код получился намного эффективнее, кроме случая, где в ход идёт SSE2, но и там в любом случае всё быстро.

Код, сгенерированный Claude Opus для проекта markus, худший из всех вариантов.

Даже самый простой вариант с обыкновенным циклом не только быстрее, но ещё и короче. Дополнительные строки кода пошли только во вред.

В лидерах код на интристиках, но платой тому — ограничение переносимости.

Кто-то скажет, что я зря переживаю. Да, Claude Opus выбрал неудачно решение, но ведь DeepSeek предложил вполне хорошее, быстрое и переносимое решение.

Согласен. Вот только в проекте мы видим неудачный код. Код выглядит оптимизированным, но таковым не является. Задача не решена. Нужен эксперт, который это поймёт и выберет другое решение.

В будущем проблема будет нарастать. Сгенерированного кода будет всё больше, а людей, которые могут делать обзор кода, останется столько же. Затем системы начнут учиться на таком же "оптимизированном" коде... Ох, интересные времена нас ждут.

Сравнение строк: цветочки

Моё внимание привлекло предупреждение PVS-Studio: V590 [CWE-571] Consider inspecting this expression. The expression is excessive or contains a misprint. markus.h 5987

Действительно, код содержит избыточную проверку:

BlockNode ParseParagraph() {
  ....
  auto trimmed = detail::TrimLeft(line);  // trimmed имеет тип std::string_view
  ....
  if (trimmed.size() >= 2 && trimmed[1] == '!' && trimmed.size() >= 9) {  
  ....
}

Тут, на самом деле, ничего страшного. Ну лишняя проверка, которую и так выкинет компилятор. Для красоты код, конечно, стоит поправить, но не более того:

if (trimmed.size() >= 9 && trimmed[1] == '!') // вот теперь хорошо

Однако глаза скользнули на код ниже if. И вот там избыточность, которая достойна вашего внимания:

std::pmr::string upper;
for (size_t j = 0; j < 9; ++j) {
  upper += static_cast<char>(
      std::toupper(static_cast<unsigned char>(trimmed[j])));
}
if (upper.starts_with("<!DOCTYPE")) {
  goto html_interrupt;
}

Здесь выполняется проверка, что строка начинается с "<!doctype", причём без разницы, используются строчные буквы или заглавные. Чтобы игнорировать верхний/нижний регистр, создаётся временная строчка в верхнем регистре и уже она сравнивается с шаблоном.

Так себе решение с точки зрения эффективности. Создание временной строки — это тяжёлая операция, несмотря на то, что используется std::pmr::string. Всё равно ведь надо символы копировать в память, да ещё по одному. Кстати, здесь простой std::string будет не хуже, так как строка не более 15 символов и сработает Small String Optimization.

Лучше написать функцию, выполняющую сравнение без учёта регистра. Например, такую:

bool starts_with_insensitive(std::string_view str,
                             std::string_view prefix_lower_case)
{
  if (str.size() < prefix.size()) return false;

  const auto pred = [](unsigned char lhs, unsigned char rhs)
  {
    return lhs == std::tolower(rhs);
  };

  return std::equal(prefix_lower_case.begin(), prefix_lower_case.end(),
                    str.begin(),
                    pred);
}

Это просто пример. Можно реализовать алгоритм по-другому или сделать istring_view, написав свои char_traits, и так далее. Главное — мы избавляемся от создания временной строки и упрощаем код, выполняющий проверку:

if (starts_with_insensitive(trimmed, "<!doctype")) {
  goto html_interrupt;
}

Ещё ярче необходимость создания функции сравнения станет видна в следующей главе. Как говорится, это только цветочки, а ягодки будут впереди.

Сравнение строк: ягодки

Просматривая другие предупреждения PVS-Studio, я обратил внимание на этот вектор строк:

std::optional<HtmlBlock> TryParseHtmlBlock() {
  ....
  static const std::pmr::vector<std::pmr::string> type1_tags = {
      "script", "pre", "style", "textarea"
  };
  ....
}

Анализатору он не нравится: V1096 The 'type1_tags' variable with static storage duration is declared inside the inline function with external linkage. This may lead to ODR violation. markus.h 4781

Нет смысла рассматривать это предупреждение в статье. Куда серьёзнее то, как работают с этим вектором. Анализатор не может оперировать высокоуровневыми понятиями и заметить неладное, зато это может заметить человек и сказать всё, что думает про эту дичь.

Код ниже проверяет наличие этих тегов в строке. Но ведь в теге ещё есть открывающая угловая скобка <, поэтому ИИ, не стыдясь, создаёт эти временные строки, содержащие тег со скобкой:

for (const auto& tag : type1_tags) {
  std::pmr::string open_tag = "<" + tag;

Далее нужно проверить наличие этого тега в строке без учёта регистра. Собственно, почему бы для этого не создать ещё одну временную строчку. Вайб-код генерировать — не мешки ворочать.

std::pmr::string lower;
for (size_t j = 0; j < open_tag.size(); ++j) {
   lower += static_cast<char>(
       std::tolower(static_cast<unsigned char>(trimmed[j])));
}

Весь этот кринж выглядит следующим образом:

static const std::pmr::vector<std::pmr::string> type1_tags = {
    "script", "pre", "style", "textarea"
};

for (const auto& tag : type1_tags) {
  std::pmr::string open_tag = "<" + tag;
  if (trimmed.size() >= open_tag.size()) {
    std::pmr::string lower;
    for (size_t j = 0; j < open_tag.size(); ++j) {
      lower += static_cast<char>(
        std::tolower(static_cast<unsigned char>(trimmed[j])));
    }
    if (lower == open_tag) {
      // ....
    }
  }
}

В худшем случае (если тега в тексте нет), создаётся восемь временных строк! Ситуацию лишь немного спасает то, что формируемые строки небольшие по размеру, и Small String Optimization внутри std::pmr::string устранит динамические аллокации.

И ведь можно достаточно легко сделать код быстрее. Во-первых, можно сразу сформировать нужные префиксы и записать их в вектор. Во-вторых, вектор можно смело заменить на массив std::string_view, что вовсе уберёт динамические аллокации.

using namespace std::literals;

static constexpr std::array type1_tags = {
  "<script"sv, "<pre"sv, "<style"sv, "<textarea"sv
};

В-третьих, теперь можно найти префикс с помощью написанной ранее функции starts_with_insensitive. В результате весь говнокод, приведённый выше, сокращается до:

using namespace std::literals;

static constexpr std::array type1_tags = {
  "<script"sv, "<pre"sv, "<style"sv, "<textarea"sv
};
for (auto tag : type1_tags) {
  if (starts_with_insensitive(trimmed, tag.c_str(), tag.size()) {

Код стал в два раза короче и в N раз быстрее.

Можно и дальше улучшения делать. Например, не создавать строку с тегом в скобах end_condition = "</" + tag + ">";, а брать уже готовую из массива. Но хватит уже это место мучить.

Цена красоты

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

Функций сериализации четыре: GetAltTextFromIds, GetAltText, RenderInlines, DebugAst, но для примера приведу только одну, которая покороче:

using InlineNode = std::variant<....>;

std::pmr::string GetAltText(const std::pmr::vector<InlineNode>& nodes) {
  std::pmr::string result;
  for (const auto& node : nodes) {
    std::visit(
        [&result, this](auto&& arg) {
          using T = std::decay_t<decltype(arg)>;
          if constexpr (std::is_same_v<T, Text>) {
            result += arg.content;
          } else if constexpr (std::is_same_v<T, Code>) {
            result += arg.content;
          } else if constexpr (std::is_same_v<T, SoftBreak>) {
            result += ' ';
          } else if constexpr (std::is_same_v<T, HardBreak>) {
            result += ' ';
          } else if constexpr (std::is_same_v<T, Emphasis>) {
            result += GetAltTextFromIds(arg.children);
          } else if constexpr (std::is_same_v<T, Strong>) {
            result += GetAltTextFromIds(arg.children);
          } else if constexpr (std::is_same_v<T, Link>) {
            result += GetAltTextFromIds(arg.children);
          } else if constexpr (std::is_same_v<T, Image>) {
            result += arg.alt_text;
          }
        },
        node);
  }
  return result;
}

Выглядит элегантно. Изначально из-за этого кода я думал назвать статью как-то менее вызывающе. После рассмотрения недостатков я планировал привести этот код как положительный пример. Мол, вот здесь ИИ написал лучше, чем это сделает не очень начитанный C++ программист. Итого: местами не очень получилось, а где-то прям здорово.

Перед написанием хвалебной части мне, естественно, понадобилось посмотреть на код внимательнее и попробовать разобраться, что к чему. Зачем я только это сделал... Остался бы и дальше очарованным паттернами, ключевыми словами constexpr и кодом, красиво оформленным в столбик.

Оказывается, и здесь плохо. Сейчас и вам расскажу почему.

Итак, стоит задача хранить массив неких элементов, которые по-разному сериализуются в строки.

Простым подходом будет создать базовый класс и наследовать от него разные сущности. В контейнере тогда будут храниться указатели на него. А для сериализации понадобится в разных случаях вызывать разные виртуальные функции. Получилась бы приблизительно следующая иерархия классов:

using namespace std::literals;

struct Node
{
    virtual ~Node() = default;
    virtual std::string StrAltText() = 0;
    virtual std::string StrDebug() = 0;
};

struct SoftBreak : public Node {
    std::string StrAltText() { return " "s; }
    std::string StrDebug() { return " "s; }
};

struct HtmlBlock : public Node {
    std::string content;
    std::string StrAltText() { return content; }
    std::string StrDebug() { return std::format("HtmlBlock = {}", content); }
};

Массив таких объектов превращался бы в строку как-то так:

std::string GetAltText(const std::vector<Node *>& nodes) {
    std::string result;
    for (const auto& node : nodes)
        result += node->StrAltText();
    return result;
}

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

Однако остаётся вторая проблема: код сериализации размазан по множеству классов. Можно собрать обработку в одном месте, узнавая тип объекта в массиве с помощью dynamic_cast. Это медленно.

Поэтому классическим решением будет хранить идентификатор объекта в базовом классе, чтобы быстро определять его тип:

enum NodeType
{
    kSoftBreak,
    kHtmlBlock
};

struct Node
{
    const NodeType type;
    Node(NodeType t) : type(t) {}
    virtual ~Node() = default;
};

struct SoftBreak : public Node {
    SoftBreak() : Node(kSoftBreak) {}
};

struct HtmlBlock : public Node {
    std::string content;
    HtmlBlock() : Node(kHtmlBlock) {}
};

Теперь обработку, хоть и не очень изящно, можно собрать в одном месте:

std::string GetAltText(const std::vector<std::unique_ptr<Node>>& nodes)
{
    std::string result;
    for (const auto& node : nodes)
        switch(node->type)
        {
            case kSoftBreak:
            {
                // Здесь даже не требуется получать сам объект.
                // SoftBreak &n = *static_cast<SoftBreak *>(node);
                result += ' ';
                break;
            }
            case kHtmlBlock:
            {
                const HtmlBlock &n = static_cast<const HtmlBlock &>(*node);
                result += n.content;
                break;
            }
    }
    return result;
}

Если хочется большего изящества, можно воспользоваться типом данных std::variant, что и сделал Claude Opus. Тип std::variant — это такой умный вариант union, безопасный с точки зрения работы типов. Вот только тут есть нюанс. Жирный такой нюанс.

Но начнём мы с того, то ИИ вообще создал странную избыточную сущность. Он завёл перечисление NodeType, которое бывает нужно для схемы, которую мы рассмотрели выше:

enum class NodeType {
  ....
  kHtmlBlock,
  kBlockQuote,
  kList,
  kListItem,
  kText,
  kSoftBreak,
  ....
};

И вставил бессмысленный числовой идентификатор kType в структуры:

struct SoftBreak {
  static constexpr NodeType kType = NodeType::kSoftBreak;
};

struct HtmlBlock {
  static constexpr NodeType kType = NodeType::kHtmlBlock;
  std::pmr::string content;
  int block_type = 0;  // CommonMark HTML block type (1-7)
};

Это перечисление и идентификатор в структурах не нужны. kType нигде не используется, благо объявлен он как static и не занимает память в каждом объекте.

Само перечисление используется в единственном месте — в функции NodeTypeToString:

// Convert NodeType to string for debugging
inline std::string_view NodeTypeToString(NodeType type) {
  switch (type) {
    case NodeType::kDocument:
      return "Document";
    case NodeType::kParagraph:
      return "Paragraph";
    case NodeType::kHeading:
      return "Heading";
    case NodeType::kThematicBreak:
      return "ThematicBreak";
   ....
    case NodeType::kHtmlInline:
      return "HtmlInline";
  }
  return "Unknown";
}

Вот только непонятно, зачем и как этой функцией пользоваться на практике. Зачем она? Чем она поможет? Кажется, никому и ничем. В общем, всё это можно удалить и сократить размер кода проекта ещё строк на 100.

Ну лишний код, и ладно — не привыкать. Дальше нас ждёт самое толстое. В прямом смысле слова.

Особенность переменной типа std::variant в том, что она должна иметь достаточный размер для размещения внутри себя наибольшей альтернативы. В нашем случае вариант должен вмещать в себя следующие классы:

using InlineNode = std::variant<Text, SoftBreak, HardBreak, Code, Emphasis,
                                Strong, Link, Image, HtmlInline>;

Размер этих классов весьма разный, начиная с самых маленьких, таких как SoftBreak:

struct SoftBreak {
  static constexpr NodeType kType = NodeType::kSoftBreak;
};

Размер этого класса 1 байт. Хотя класс пустой, он всё равно должен занимать хотя бы один байт.

Самый большой класс — Image:

struct Image {
  static constexpr NodeType kType = NodeType::kImage;
  std::pmr::string destination;
  std::pmr::string title;
  std::pmr::string alt_text;
};

Его размер в 64-битной программе может составлять 120 байтов. Дело в том, что std::pmr::string, например, в реализации STL занимает 40 байтов. Вы уже поняли, что это означает?

Вариант ещё дополнительно хранит информацию, какой тип он содержит в текущий момент. Итого размер одного объекта типа InlineNode составляет 128 байтов! Можете здесь сами посмотреть.

Итого, программа работает с массивами, где размер каждого элемента 128 байтов. Офигительная "эффективность" расхода памяти.

Столько памяти нужно даже для того, чтобы хранить пустые классы, обозначающие разделители и превращающиеся обычно в пробелы:

} else if constexpr (std::is_same_v<T, SoftBreak>) {
  result += ' ';

Чтобы знать, где напечатать пробел, требуется хранить 128 байтов.

Перерасход памяти сам по себе, может, не такая уж большая проблема, но здесь ещё возникает и проблема производительности. При работе с таким растянутым массивом менее эффективно будет использоваться и кэш процессора.

Можно возразить, что вариант с умными указателями добавляет уровень косвенности при доступе к объекту, что тоже замедлит работу. И ещё нужно самому тип объекта проверять (см. выше пример со switch).

Не принимается. Косвенный доступ по указателю не существенный, учитывая, что потом всё равно будет куча обращений к памяти для чтения строк и их конкатенации. Перерасход памяти выглядит куда более существенной проблемой. Что по поводу проверки типа объекта, так она всё равно есть, просто скрыта внутри std::variant.

Ещё представьте, как болезненно происходит расширение вектора, если очередной элемент уже не влезает в зарезервированный буфер. Например, здесь:

std::pmr::vector<InlineNode> link_content;
for (size_t i = opener->pos + 1; i < result.size(); ++i) {
  link_content.push_back(std::move(result[i]));
}

При расширении вектора происходит перемещение объектов на новые места. Брр... Версия с умными указателями на базовый класс std::vector<std::unique_ptr<Node>> куда легковеснее.

Итог: перед нами вновь говнокод, поданный в красивой обёртке.

Я не говорю, что умные указатели или std::any здесь является лучшим решением. Можно что-то и другое придумать. Если задача — писать эффективный код, то это хорошее место, чтобы подумать, как хранить объекты и обходить их для сериализации.

Что ещё?

Не знаю. Я и так уже вышел за лимит времени, который собирался посвятить просмотру кода и статье.

Это ещё раз говорит о росте ценности ревьюеров. Нагенерировать код всё проще. Вопрос: где взять силы на его просмотр и вынесение вердикта, получилось ли то, что нужно? Кто возьмёт ответственность за результат? Мы всё-таки в мире C++, суть использования которого — эффективность. Если кому-то скорость вообще не нужна, что он тут забыл?

Внимательный разбор завершаю, но напоследок пройдусь по некоторым предупреждениям PVS-Studio.

Склероз

Предупреждение: V560 [CWE-571] A part of conditional expression is always true: !input.empty(). markus.h 2228

explicit LineBuffer(std::string_view input) {
  if (input.empty()) [[unlikely]] {
    return;
  }
  ....
  // input не модифицируется
  ....
  if (input.size() > line_start ||
      (!input.empty() && (input.back() == '\n' || input.back() == '\r'))) {
    line_offsets_.push_back({line_start, input.size() - line_start});
  }
  ....
}

ИИ педантично вставляет [[unlikely]], чтобы подсказать компилятору, что это маловероятное событие. Но при этом через некоторое время забывает, что далее строка всегда непустая, и влепляет бессмысленную повторную проверку !input.empty().

С кем поведёшься, от того и наберёшься

Предупреждение: V1048 [CWE-1164] The 'prev_line_had_content' variable was assigned the same value. markus.h 4090

void ExtractLinkReferences(Document& doc) {
  ....
  bool prev_line_had_content = false;
  ....
  if (prev_line_had_content) {
    prev_line_had_content = true;  // This line also has content
    Advance();
    continue;
  }
  ....
}

Переменной prev_line_had_content присваивается значение true, хотя из проверки и без того следует, что там лежит это значение. Анализатор прав — перед нами бессмысленная строчка кода. Но предупреждение неинтересное: я бы не стал его рассматривать, если бы не комментарий.

Слышали байки, что программисты иногда пишут верные, но бесполезные комментарии? Видимо, у ИИ были "хорошие" учителя. Это как раз тот случай, когда комментарий фактически пересказывает выполняемое действие, не добавляя ничего нового.

См. также "Вредный совет N53 — Отвечай в комментарии на вопрос "что?" в книге "60 антипаттернов для С++ программиста".

Создание временных прокси объектов

Предупреждение: V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2597

std::pmr::vector<InlineNodeId> ParseInlines() {
  std::pmr::vector<InlineNode> result;
  ....
  result.push_back(Text(text_.substr(text_start, pos_ - text_start)));
  ....
}

В начале создаётся временный объект типа Text, затем он помещается в контейнер. Первая мысль: использовать emplace_back:

result.emplace_back(Text(text_.substr(text_start, pos_ - text_start)));

Но изменение на emplace_back в этом случае даст примерно 0% ускорения, так ещё и нагрузит дополнительно компилятор — ему придётся делать инстанцирование шаблона функции emplace_back. Внутри него уже будет идеально передаваться созданный объект, который будет перемещён конструктором перемещения.

Здесь придётся написать более хитрый код, чтобы действительно получилось размещение сразу в контейнере. Это контейнер вариантов, значит, мы должны подсказать ему, какую именно альтернативу собираемся создавать, и передать её std::string_view.

Вот как это будет выглядеть:

result.emplace_back(std::in_place_type<Text>, text.substr(....));

В таком случае мы идеально передадим подсказку о конструируемом типе и аргументы конструктора этого типа. Нас интересует 5-я перегрузка конструктора std::variant.

При таком изменении мы избавимся от одного вызова конструктора перемещения.

  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2610
  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2632
  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2650
  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2733
  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2751
  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2754
  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2855
  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2931
  • V823 Decreased performance. Object may be created in-place in the 'quote_lines' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 5239
  • V823 Decreased performance. Object may be created in-place in the 'quote_lines' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 5241
  • V823 Decreased performance. Object may be created in-place in the 'item_lines' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 5641
  • V823 Decreased performance. Object may be created in-place in the 'item_lines' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 5643
  • V823 Decreased performance. Object may be created in-place in the 'para_lines' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 6045
  • V823 Decreased performance. Object may be created in-place in the 'para_lines' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 6047
  • V823 Decreased performance. Object may be created in-place in the 'delimiter_stack' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2729
  • V823 Decreased performance. Object may be created in-place in the 'delimiter_stack' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2746
  • V823 Decreased performance. Object may be created in-place in the 'line_offsets_' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2193
  • V823 Decreased performance. Object may be created in-place in the 'line_offsets_' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2196
  • V823 Decreased performance. Object may be created in-place in the 'line_offsets_' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2208
  • V823 Decreased performance. Object may be created in-place in the 'line_offsets_' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2218
  • V823 Decreased performance. Object may be created in-place in the 'line_offsets_' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2221
  • V823 Decreased performance. Object may be created in-place in the 'line_offsets_' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2229
  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2637
  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2947
  • V823 Decreased performance. Object may be created in-place in the 'result' container. Consider replacing methods: 'push_back' -> 'emplace_back'. markus.h 2958

Копия вместо перемещения

Предупреждение: V820 The 'content' variable is not used after copying. Copying can be replaced with move/swap for optimization. markus.h 4670

struct Heading {
  static constexpr NodeType kType = NodeType::kHeading;
  int level = 1;  // 1-6
  std::pmr::vector<InlineNodeId> children;
  std::pmr::string raw_content;  // Temporary storage for inline parsing
};

std::optional<Heading> TryParseAtxHeading() {
  ....
  std::pmr::string content(trimmed.substr(i));
  ....
  Heading heading;
  heading.level = level;
  heading.raw_content = content;    // <=
  return heading;
}

Строка в переменной content копируется, хотя её можно переместить, так как дальше она не нужна. Можно написать:

heading.raw_content = std::move(content);

А можно обойтись вообще без временного объекта heading и написать что-то типа:

return Heading { .level = level,
                 .raw_content = std::move(content)
               };
  • V820 The 'info_string' variable is not used after copying. Copying can be replaced with move/swap for optimization. markus.h 4761
  • V820 The 'content' variable is not used after copying. Copying can be replaced with move/swap for optimization. markus.h 4762
  • V820 The 'content' variable is not used after copying. Copying can be replaced with move/swap for optimization. markus.h 5101
  • V820 The 'content' variable is not used after copying. Copying can be replaced with move/swap for optimization. markus.h 5821

Закат солнца вручную

Предупреждение: V837 The 'insert' function does not guarantee that arguments will not be copied or moved if there is no insertion. Consider using the 'try_emplace' function. markus.h 5274

using LinkRefMap =
    std::pmr::unordered_map<std::pmr::string,
                            std::pair<std::pmr::string, std::pmr::string>>;

std::optional<BlockQuote> TryParseBlockQuote() {
  .... 
  LinkRefMap* parent_link_refs_ = nullptr;
  ....
  for (const auto& [label, dest_title] : nested_doc.link_references) {
    if (parent_link_refs_->find(label) == parent_link_refs_->end()) {
      parent_link_refs_->insert({label, dest_title});
    }
  }
  ....
}

PVS-Studio предупреждает о неэффективности, если в контейнере уже есть элемент с таким же ключом. В нашем случае защита от этого сделана вручную, но она неэффективна. В коде произойдут два поиска:

  • Сначала ищем элемент по ключу для проверки условия.
  • При вставке производим эту же операцию снова, пытаясь найти нужную позицию вставки в хэш-таблицу.

Вызов try_emplace в этой ситуации сделает ровно один поиск и вставит элемент, если его там не было:

for (const auto& [label, dest_title] : nested_doc.link_references) {
    parent_link_refs_->try_emplace(label, dest_title);
}

Заключение

Выводы всё те же, что были в предыдущей статье "Давайте заглянем в этот самый вайб-код". Независимо от того, как вы создаёте программный код, для обеспечения качества и надёжности разрабатываемого ПО следует использовать комплекс мер.

Подписаться на рассылку
Хотите раз в месяц получать от нас подборку вышедших в этот период самых интересных статей и новостей? Подписывайтесь!
Популярные статьи по теме

Комментарии (0)

Следующие комментарии next comments
close comment form