Использование ссылок rvalue в функциональном параметре перегруженной функции создает слишком много комбинаций

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

class MyClass {
public:
   void f(const MyBigType& a, int id);
   void f(const MyBigType& a, string name);
   void f(const MyBigType& a, int b, int c, int d);
   // ...
};

Эта функция делает копию a (MyBigType), поэтому я хочу добавить оптимизацию, предоставив версию f, которая перемещает a вместо копирования.

Моя проблема в том, что теперь число перегрузок f будет дублироваться:

class MyClass {
public:
   void f(const MyBigType& a, int id);
   void f(const MyBigType& a, string name);
   void f(const MyBigType& a, int b, int c, int d);
   // ...
   void f(MyBigType&& a, int id);
   void f(MyBigType&& a, string name);
   void f(MyBigType&& a, int b, int c, int d);
   // ...
};

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

Кто-нибудь справился с этой проблемой? Есть ли хорошее решение/шаблон для решения этой проблемы?

Спасибо!

Ответы

Ответ 1

Herb Sutter говорит о чем-то подобном в о разговоре cppcon

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

class MyClass {
  public:
    template <typename T>
    typename std::enable_if<std::is_convertible<T, MyBigType>::value, void>::type
    f(T&& a, int id);
};

Единственный параметр шаблона будет соответствовать фактическому типу параметра, возвращаемый тип enable_if запрещает несовместимые типы. Я возьму это отдельно по частям

std::is_convertible<T, MyBigType>::value

Это выражение времени компиляции будет оцениваться до true, если T может быть неявно преобразовано в MyBigType. Например, если MyBigType были std::string, а T были char*, выражение было бы истинным, но если T было int, оно было бы ложным.

typename std::enable_if<..., void>::type // where the ... is the above

это выражение приведет к void в случае, когда выражение is_convertible истинно. Когда он будет ложным, выражение будет искажено, поэтому шаблон будет выброшен.

Внутри тела функции вам нужно будет использовать совершенную пересылку, если вы планируете назначить копию или переместить присвоение, тело будет чем-то вроде

{
    this->a_ = std::forward<T>(a);
}

Здесь образ живого примера coliru с using MyBigType = std::string. Как говорит Герб, эта функция не может быть виртуальной и должна быть реализована в заголовке. Сообщения об ошибках, которые вы получаете от вызова с неправильным типом, будут довольно грубыми по сравнению с не templated перегрузками.


Благодаря Barry комментарий к этому предложению, чтобы уменьшить повторение, вероятно, неплохо создать псевдоним шаблона для механизма SFINAE. Если вы объявите в своем классе

template <typename T>
using EnableIfIsMyBigType = typename std::enable_if<std::is_convertible<T, MyBigType>::value, void>::type;

то вы можете уменьшить объявления до

template <typename T>
EnableIfIsMyBigType<T>
f(T&& a, int id);

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

template <typename T, typename R>
using EnableIfIsMyBigType = typename std::enable_if<std::is_convertible<T, MyBigType>::value,R>::type;

Затем объявите с указанным типом возвращаемого значения

template <typename T>
EnableIfIsMyBigType<T, void> // void is the return type
f(T&& a, int id);


Параметр чуть медленнее, чтобы принять аргумент по значению. Если вы делаете

class MyClass {
  public:
    void f(MyBigType a, int id) {
        this->a_ = std::move(a); // move assignment
    } 
};

В случае, когда f передается lvalue, он копирует конструкцию a из своего аргумента, а затем переводит ее в this->a_. В случае, когда f передается rvalue, он переносит конструкцию a из аргумента и затем перемещает назначение. Настоящим примером такого поведения является здесь. Обратите внимание, что я использую -fno-elide-constructors, без этого флага, случаи raleue возвращают конструкцию перемещения и имеет место только назначение переадресации.

Если объект дорог для перемещения (например, std::array), этот подход будет заметно медленнее, чем супер-оптимизированная первая версия. Кроме того, рассмотрите эту часть бесед Herb, которые Chris Drew ссылаются на чтобы понять, когда это может быть медленнее, чем использование ссылок. Если у вас есть копия Эффективный современный С++ от Scott Meyers, он обсуждает взлеты и падения в пункте 41.

Ответ 2

Вы можете сделать что-то вроде следующего.

class MyClass {
public:
   void f(MyBigType a, int id) { this->a = std::move(a); /*...*/ }
   void f(MyBigType a, string name);
   void f(MyBigType a, int b, int c, int d);
   // ...
};

У вас просто есть дополнительный move (который может быть оптимизирован).

Ответ 3

Моя первая мысль заключается в том, что вы должны изменить параметры для передачи по значению. Это покрывает существующую необходимость копирования, за исключением того, что копия происходит в точке вызова, а не явно в функции. Он также позволяет создавать параметры путем построения перемещения в перемещаемом контексте (либо неназванных временных, либо с помощью std::move).

Ответ 4

Зачем вам это делать

Эти дополнительные перегрузки имеют смысл только в том случае, если изменение параметров функции при реализации функции действительно дает вам значительную прирост производительности (или какую-то гарантию). Это вряд ли имеет место, если не считать конструкторов или операторов присваивания. Поэтому я бы посоветовал вам переосмыслить, действительно ли нужно делать эти перегрузки.

Если реализации почти идентичны...

Из моего опыта эта модификация просто передает параметр другой функции, завернутой в std::move(), а остальная часть функции идентична версии const &. В этом случае вы можете превратить свою функцию в шаблон такого типа:

template <typename T> void f(T && a, int id);

Тогда в реализации функции вы просто замените операцию std::move(a) на std::forward<T>(a), и она должна работать. Если хотите, вы можете ограничить тип параметра T std::enable_if.

В случае const ref: не создавайте временное, просто чтобы его изменить

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

void f( MyBigData a, int id );

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

Значительно разные реализации

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

Ответ 5

Вы можете ввести изменяемый объект:

#include <memory>
#include <type_traits>

// Mutable
// =======

template <typename T>
class Mutable
{
    public:
    Mutable(const T& value) : m_ptr(new(m_storage) T(value)) {}
    Mutable(T& value) : m_ptr(&value) {}
    Mutable(T&& value) : m_ptr(new(m_storage) T(std::move(value))) {}
    ~Mutable() {
        auto storage = reinterpret_cast<T*>(m_storage);
        if(m_ptr == storage)
            m_ptr->~T();
    }

    Mutable(const Mutable&) = delete;
    Mutable& operator = (const Mutable&) = delete;

    const T* operator -> () const { return m_ptr; }
    T* operator -> () { return m_ptr; }
    const T& operator * () const { return *m_ptr; }
    T& operator * () { return *m_ptr; }

    private:
    T* m_ptr;
    char m_storage[sizeof(T)];
 };


// Usage
// =====

#include <iostream>
struct X
{
    int value = 0;

    X() { std::cout << "default\n"; }
    X(const X&) { std::cout << "copy\n"; }
    X(X&&) { std::cout << "move\n"; }
    X& operator = (const X&) { std::cout << "assign copy\n"; return *this; }
    X& operator = (X&&) { std::cout << "assign move\n"; return *this; }
    ~X() { std::cout << "destruct " << value << "\n"; }
};

X make_x() { return X(); }

void fn(Mutable<X>&& x) {
    x->value = 1;
}

int main()
{
    const X x0;
    std::cout << "0:\n";
    fn(x0);
    std::cout << "1:\n";
    X x1;
    fn(x1);
    std::cout << "2:\n";
    fn(make_x());
    std::cout << "End\n";
}

Ответ 6

Это критическая часть вопроса:

Эта функция создает копию (MyBigType),

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

  • 1) для назначения объекту, существовавшему до f?
  • 2) или вместо этого хранится в локальной переменной:

то есть:

void f(??? a, int id) {
    this->x = ??? a ???;
    ...
}

или

void f(??? a, int id) {
    MyBigType a_copy = ??? a ???;
    ...
}

Иногда, первая версия (назначение) может быть выполнена без каких-либо копий или перемещений. Если this->x уже длинный string, а если a короткий, то он может эффективно повторно использовать существующую емкость. Никакой копировальной конструкции и никаких ходов. Короче говоря, иногда назначение может быть быстрее, потому что мы можем пропустить копию.


В любом случае, здесь идет:

template<typename T>
void f(T&& a, int id) {
   this->x = std::forward<T>(a);  // is assigning
   MyBigType local = std::forward<T>(a); // if move/copy constructing
}

Ответ 7

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