пятница, 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-а есть шаблонный конструктор (цынк).

2 комментария:

Stanislav Mischenko комментирует...

Вместо find можно воспользоваться lower_bound. Тогда мы получим позицию для вставки нового элемента, если его там нет. И, исходя из документации, если элемент будет вставлен непосредственно перед элементом найденым lower_bound, то время такой вставки константно.

Пример:
auto it = map.lower_bound(key);
if (it->first != key) {
map.insert(it, {key, make_unique(args)});
}

eao197 комментирует...

Да, спасибо, упустил этот вариант из виду. Но он тоже не сказать, что удобный :(