пятница, 8 мая 2026 г.

[prog.c++] Хочется странного, но теперь уже от std::map

В std::map начиная с C++17 есть отличный метод try_emplace. Он особенно хорош, когда конструирование mapped_type очень дешевое. Например, когда в качестве ключа у нас int, а в качестве значения -- структура с несколькими int-ами внутри. Тогда получается эффективно: попробовали вставить, если ключа в map еще нет, то из параметров сконструировали mapped_type и добавили в map новое значение. А если же в map ключ уже есть, то передача в try_emplace нескольких int-ов как параметров для конструктора mapped_type -- это копейки, на которые во многих случаях можно просто не обращать внимания.

Но вот когда у нас в качестве mapped_type какой-то "тяжелый" объект, вот тогда ситуация грустнее. Например, mapped_type -- это std::unique_ptr с указателем на класс с кучей собственных контейнеров внутри.

Если мы напишем что-то вроде:

auto [it, inserted] = my_map.try_emplace(key, std::make_unique<heavy_object>(...));

то это явная пессимизация -- ведь нам придется создавать heavy_object при каждом обращении к try_emplace, даже если ключ в map уже есть.

Я вижу два стандартных пути выхода из этой ситуации.

Во-первых, мы можем пойти классическим способом, через find с последующим emplace, если find завершился неудачно:

auto it = my_map.find(key);
if(it == my_map.end()) {
  it = my_map.emplace(key, std::make_unique<heavy_object>(...)).first;
}
// Теперь it указывает на объект внутри std::map.

Но здесь плохо то, что для вставки объекта поиск по std::map нужно будет делать дважды.

Во-вторых, в try_emplace можно передать пустой unique_ptr, а сам объект создать уже после вставки. Т.е. что-то вроде:

auto [it, inserted] = my_map.try_emplace(key, std::unique_ptr<heavy_object>{});
if(inserted) {
  // Была вставка. Теперь у нас в my_map лежит нулевой указатель, нужно
  // это исправить.
  it->second = std::make_unique<heavy_object>(...);
}

Но здесь плохо то, что нам нужно позаботиться об exception safety, ведь вызов make_unique может бросать исключение. И самое худшее, что можно сделать, это написать что-то вроде:

auto [it, inserted] = my_map.try_emplace(key, std::unique_ptr<heavy_object>{});
if(inserted) {
  // Была вставка. Теперь у нас в my_map лежит нулевой указатель, нужно
  // это исправить.
  try {
    it->second = std::make_unique<heavy_object>(...);
  }
  catch(...) {
    // Удаляем только что вставленный пустой указатель.
    my_map.erase(it);
    throw;
  }
}

Гораздо лучше было бы иметь что-то вроде scope(failure) из D. Что-то вроде:

auto [it, inserted] = my_map.try_emplace(key, std::unique_ptr<heavy_object>{});
if(inserted) {
  // Была вставка. Теперь у нас в my_map лежит нулевой указатель, нужно
  // это исправить.
  // Защищаемся от исключений.
  auto guard = at_failure(
    // Эта лямбда будет вызвана если выход из скоупа произойдет
    // из-за исключения.
    [&it, &my_map]() {
      my_map.erase(it);
    });
  it->second = std::make_unique<heavy_object>(...);
}

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

auto [it, inserted] = my_map.try_emplace(key, std::unique_ptr<heavy_object>{});
if(inserted) {
  // Была вставка. Теперь у нас в my_map лежит нулевой указатель, нужно
  // это исправить.
  // Защищаемся от исключений.
  do_with_rollback_on_exception(
    // Что хотим сделать.
    [&it, ...]() {
      it->second = std::make_unique<heavy_object>(...);
    },
    // Эта лямбда будет вызвана если первая лямбда бросит исключение.
    [&it, &my_map]() {
      my_map.erase(it);
    });
}

Но все эти приседания были бы не нужны, если бы был вариант try_emplace, который бы принимал не аргументы для конструктора mapped_type, а фабрику, которая может породить экземпляр mapped_type для вставки:

auto [it, inserted] = my_map.try_emplace_with_factory(key,
  // Эта лямбда будет вызвана, если объекта в map нет.
  [...]() {
    return std::make_unique<heavy_object>(...);
  });

К сожалению, такого варианта try_emplace_with_factory в std::map нет.

PS. Вышесказанное относится и к std::unordered_map.


Upd. В обсужении на LinkedIn посоветовали обходной маневр вида:

#include <map>
#include <string>

class simple_data {
    std::string _data;
public:
    simple_data(const char * s) : _data{ s }
    {}
};

class data_holder {
    std::string _data;
public:
    template<typename... Args>
    data_holder(Args && ...args) : _data{ std::forward<Args>(args)... }
    {}
};

template<typename F>
struct deferred {
    F _f;

    template<typename T>
    operator T() { return _f(); }
};

int main()
{
    std::map<int, simple_data> m1;
    m1.try_emplace(0, deferred{ []{ return simple_data{ "Hello, world" }; } });

    std::map<int, data_holder> m2;
//    m2.try_emplace(0, deferred{ []{ return std::string{ "Hello, world" }; } });
    m2.try_emplace(0"Hello, world");
}

Но не для всех случаев он будет работать. В частности для data_holder-а не сработает, т.к. у data_holder-а есть шаблонный конструктор (цынк).

понедельник, 4 мая 2026 г.

[prog] Любопытное из книги "C++ Ultra-Low Latency: Multithreading and Low-Level Optimizations"

Попалась в руки книга "C++ Ultra-Low Latency: Multithreading and Low-Level Optimizations". Начал ее листать, т.к. темой низкоуровневых оптимизаций на C++ никогда не занимался. Мне всегда было интересно писать корректно работающий код, который был бы понятным и сопровождабельным, который бы было просто использовать правильно, но сложно неправильно, но в плане скорости работы кода никогда не упарывался. В общем, как однажды сказали про мой код: "получение гарантий корректности времени компиляции при этом не используя Haskell" 🙂

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

Про саму книгу ничего не скажу, только начал с ней знакомиться, а начало тупо пропустил, т.к. там много говорится о специфике HFT, а к HFT вообще не имею никакого отношения. Перешел сразу к главам, где про конкретные приемы рассказывается. И вот в разделе про Branch Prediction наткнулся на вещи, которые мне прям как бальзам на душу, а именно:

Дело в том, что для меня всегда наиболее естественно было писать в стиле:

if(some_condition) {
  ... // тут много строчек кода с выполнением основной логики.
  ...
  ...
}
else {
  return some_error_code;
}

Т.е. большинство действий сосредотачивается именно в ветке then.

При этом регулярно встречал рекомендации, что в if-ах в then должны быть наиболее короткие блоки кода. Мол так код воспринимается лучше: когда в then короткий блок, то мы еще помним контекст когда доходим до else. А вот если в then длинный блок, то когда мы доберемся до else, то на фоне действий из then уже не будем понимать где находимся. Такие рекомендации декларируют в качестве "хорошего" стиля вот такой:

if(!some_condition) {
  return some_error_code;
}
else {
  ... // тут много строчек кода с выполнением основной логики.
  ...
  ...
}

Или даже вот такой:

if(!some_condition) {
  return some_error_code;
}

... // тут много строчек кода с выполнением основной логики.
...
...

Но оба эти стиля мне не нравятся на каком-то интуитивном уровне. Особенно последний (про этот стиль я уже высказывался: например, в контексте языка Go). Хотя, если мы в проекте придерживаемся принципов defensive programming, то начало метода/функции из if-ов для проверки входных параметров/состояния объекта, т.е. что-то вроде:

int f(int a, int b, int c) {
  if(a < 0) return invalid_parameter_a;
  if(b < 10 || b > 100) return invalid_parameter_b;
  if(c > 1000) return invalid_parameter_c;

  ... // Далее основная логика.
}

то такие короткие if-ы -- это нормально. Но когда проверки входных данных завершены и идет основной код метода/функции, то if-ы с короткими then или if-ы, в которых только return, на мой взгляд, ухудшают код (хуже только циклы, внутри которых короткие if-ы с continue).

И вот листая книгу "C++ Ultra-Low Latency: Multithreading and Low-Level Optimizations" вдруг натыкаюсь на подтверждение того, что привычный для меня способ написания if-ов имеет под собой обоснование еще и с точки зрения эвристик компилятора по обеспечению branch predictions.