Как сделать функцию, которая может возвращать статические или автоматические объекты хранения без копирования в C++?

Предположим, я хочу написать что-то такое, что:

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

Что лучше написать эту функцию, с требованиями, которые:

  • при вызове не требуется никаких конструкторов копирования, только перемещение, чтобы предотвратить копирование всех данных дорогого объекта
  • Я не хочу использовать new сырые указатели. Если требуются указатели, они должны быть умными и автоматически удалять

Вариант использования следующий:

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

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

Пока что единственной чистой реализацией, которой я управлял, является использование shared_ptr как показано ниже, но это похоже на излишество. В частности, потому что это делает распределение кучи, где он чувствует, что он действительно не требуется. Есть ли лучший подход?

#include <cassert>
#include <iostream>
#include <memory>

struct C {
    int i;
    static int count;
    C(int i) : i(i) {
        std::cout << "constr" << std::endl;
        count++;
    }
    C(const C& c) : C(c.i) {
        std::cout << "copy" << std::endl;
    }
    ~C() {
        std::cout << "destr" << std::endl;
        count--;
    }
};
int C::count = 0;

std::shared_ptr<C> func_reg_maybe_static(int i) {
    static auto static_obj = std::make_shared<C>(0);
    if (i == 0) {
        return static_obj;
    } else {
        return std::make_shared<C>(i);
    }
}

int main() {
    assert(C::count == 0);

    {
        auto c(func_reg_maybe_static(0));
        assert(c->i == 0);
        assert(C::count == 1);
    }
    assert(C::count == 1);

    {
        auto c(func_reg_maybe_static(0));
        assert(c->i == 0);
        assert(C::count == 1);
    }
    assert(C::count == 1);

    {
        auto c(func_reg_maybe_static(1));
        assert(c->i == 1);
        assert(C::count == 2);
    }
    assert(C::count == 1);

    {
        auto c(func_reg_maybe_static(2));
        assert(c->i == 2);
        assert(C::count == 2);
    }
    assert(C::count == 1);
}

Который я компилирую с GCC 6.4.0:

g++ -std=c++17 -Wall -Wextra -pedantic-errors -o main.out func_ret_maybe_static.cpp

и это производит ожидаемый вывод (гарантированный копией elision, я полагаю):

constr
constr
destr
constr
destr
destr

Я добавил статический счетчик ссылок C::count просто для проверки того, что объекты действительно удаляются, как и ожидалось.

Если бы у меня не было статического случая, я бы просто сделал прямо:

C func_reg_maybe_static(int i) {
    return C(i);
}

и копирование семантики elision/move сделает все эффективным.

Однако, если я попробую что-то аналогичное, как в:

C func_reg_maybe_static(int i) {
    static C c(0);
    return c;
}

затем C++ ловко прекращает просто перемещать C и начинает копировать его, чтобы избежать повреждения static.

Ответы

Ответ 1

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

C& func_reg(const int i)
{
    static std::unordered_map<int, C> cache;
    auto it = cache.find(i);
    if (it == cache.end())
      it = cache.emplace(i, C(i));

    return it->second;
}

Не всегда нужен кеш? Отлично! Добавь это:

C func_reg_nocache(const int i)
{
    return C(i);
}

Если я неправильно понял, и вам действительно нужна эта вещь, когда передача is_static == true дает вам совершенно другой объект (is_static == true с 0 а не с i), тогда просто создайте новую функцию для этого; это делает что-то особенное.

C& func_reg()
{
    static C obj(0);
    return obj;
}

C func_reg(const int i)
{
    return C(i);
}

Ответ 2

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

C& func_reg_maybe_static(int i, void* buf)
{
    static C c(0);
    if(i == 0)
        return c;
    else
        return *new (buf) C(i);
}

using uninit_C = std::aligned_storage<sizeof(C), alignof(C)>;
uninit_C buf;
auto& c = func_reg_maybe_static(i, &buf);

Это требует ручного вызова деструктора далее по линии, если и только если деструктор имеет побочные эффекты. Еще один дубль будет

C& func_reg_maybe_static(int i, C& buf)
{
    static C c(0);
    if(i == 0)
        return c;
    else
    {
        buf.~C();
        return *new (&buf) C(i); // note below
    }
}

C buf;
auto& c = func_reg_maybe_static(i, buf);

Эта версия, очевидно, требует, чтобы C был конструируемым по умолчанию. Размещение new совершенно законно, но использование buf после вызова функции может или не может быть законным в зависимости от содержимого C Деструктор будет вызван при выходе из области видимости.

Ответ 3

То, что вы хотите, по сути, вариант:

std::variant<C, C*> func_reg_maybe_static(int i)
{
  static C static_obj{ 0 };
  if (i == 0) {
    return &static_obj;
  } else {
    return std::variant<C, C*>{ std::inplace_type_t<C>{}, i };
  }
}

Это имеет то преимущество, что не требует каких-либо дополнительных выделений памяти (по сравнению с подходом shared_ptr). Но это несколько неудобно использовать на стороне вызывающего абонента. Мы могли бы обойти это, написав класс-оболочку:

template <class C> class value_or_ptr
{
  std::variant<C, C*> object_;
public:
  explicit template <class... Params> value_or_ptr(Params&&... parameters)
    : object_(std::inplace_type_t<C>{}, std::forward<Params>(parameters)...)
  {}
  explicit value_or_ptr(C* object)
    : object_(object)
  {}
  // other constructors...

  C& operator*()
  {
    return object_.index() == 0 ? std::get<0>(object_) : *std::get<1>(object_);
  }

  // other accessors...
}

Если у вас нет компилятора С++ 17, то же самое можно сделать с объединениями, но реализация, конечно, будет более сложной. Исходная функция тогда становится:

value_or_ptr<C> func_reg_maybe_static(int i)
{
  static C static_obj{ 0 };
  if (i == 0) {
    return value_or_ptr{ &static_obj };
  } else {
    return value_or_ptr{ i };
  }
}

Контраст с перегрузками

Мы могли бы достичь аналогичных результатов с перегрузками функций:

C& func_reg()
{
  static C obj{ 0 };
  return obj;
}

C func_reg(const int i)
{
  return C{ i };
}

Преимущество этого в том, что он проще, но может потребовать, чтобы вызывающий сделал копию. Также требуется, чтобы вызывающая сторона знала, будет ли возвращен предварительно вычисленный объект (что нежелательно). При использовании варианта подхода вызывающая сторона может обрабатывать результаты единообразно. Вызывающий всегда возвращает объект с семантикой значения и ему не нужно знать, получит ли он предварительно вычисленный объект.