Лучшая форма для конструкторов? Передать по значению или ссылке? - PullRequest
26 голосов
/ 01 декабря 2010

Мне интересна лучшая форма для моих конструкторов. Вот пример кода:

class Y { ... }

class X
{
public:
  X(const Y& y) : m_y(y) {} // (a)
  X(Y y) : m_y(y) {} // (b)
  X(Y&& y) : m_y(std::forward<Y>(y)) {} // (c)

  Y m_y;
}

Y f() { return ... }

int main()
{
  Y y = f();
  X x1(y); // (1)
  X x2(f()); // (2)
}

Насколько я понимаю, это лучшее, что может сделать компилятор в каждой ситуации.

(1a) y копируется в x1.m_y (1 копия)

(1b) y копируется в аргумент конструктора X, а затем копируется в x1.m_y (2 копии)

(1c) y перемещено в x1.m_y (1 ход)

(2a) результат f () копируется в x2.m_y (1 копия)

(2b) f () встроен в аргумент конструктора, а затем скопирован в x2.m_y (1 копия)

(2c) f () создается в стеке, а затем перемещается в x2.m_y (1 ход)

Теперь несколько вопросов:

  1. В обоих случаях передача по константу не хуже, а иногда и лучше, чем передача по значению. Это, кажется, идет вразрез с обсуждением «Хотите скорость? Проходите по значению». . Для C ++ (не C ++ 0x) я должен придерживаться передачи по константной ссылке для этих конструкторов, или я должен пройти по значению? И для C ++ 0x, должен ли я передавать по rvalue ссылку на pass по значению?

  2. Для (2) я бы предпочел, чтобы временное было построено непосредственно в x.m_y. Я думаю, что даже версия rvalue требует перемещения, которое, если объект не выделяет динамическую память, является такой же работой, как и копия. Есть ли способ закодировать это так, чтобы компилятору было позволено избегать этих копий и перемещений?

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

1 Ответ

40 голосов
/ 01 декабря 2010

Я собрал несколько примеров. Я использовал GCC 4.4.4 во всем этом.

Простой кейс, без -std=c++0x

Сначала я собрал очень простой пример с двумя классами, которые принимают std::string каждый.

#include <string>
#include <iostream>

struct A /* construct by reference */
  {
    std::string s_;

    A (std::string const &s) : s_ (s)
      {
        std::cout << "A::<constructor>" << std::endl;
      }
    A (A const &a) : s_ (a.s_)
      {
        std::cout << "A::<copy constructor>" << std::endl;
      }
    ~A ()
      {
        std::cout << "A::<destructor>" << std::endl;
      }
  };

struct B /* construct by value */
  {
    std::string s_;

    B (std::string s) : s_ (s)
      {
        std::cout << "B::<constructor>" << std::endl;
      }
    B (B const &b) : s_ (b.s_)
      {
        std::cout << "B::<copy constructor>" << std::endl;
      }
    ~B ()
      {
        std::cout << "B::<destructor>" << std::endl;
      }
  };

static A f () { return A ("string"); }
static A f2 () { A a ("string"); a.s_ = "abc"; return a; }
static B g () { return B ("string"); }
static B g2 () { B b ("string"); b.s_ = "abc"; return b; }

int main ()
  {
    A a (f ());
    A a2 (f2 ());
    B b (g ());
    B b2 (g2 ());

    return 0;
  }

Вывод этой программы на stdout выглядит следующим образом:

A::<constructor>
A::<constructor>
B::<constructor>
B::<constructor>
B::<destructor>
B::<destructor>
A::<destructor>
A::<destructor>

Заключение

GCC удалось оптимизировать каждый временный A или B прочь. Это согласуется с C ++ FAQ . По сути, GCC может (и хочет) генерировать код, который создает a, a2, b, b2 вместо , даже если вызывается функция, которая явно возвращает значение. Тем самым GCC может избежать многих временных, чье существование можно было бы «вывести», посмотрев на код.

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

Реалистичный чехол, без -std=c++0x

#include <string>
#include <iostream>

struct S
  {
    std::string s_;

    S (std::string const &s) : s_ (s)
      {
        std::cout << "  S::<constructor>" << std::endl;
      }
    S (S const &s) : s_ (s.s_)
      {
        std::cout << "  S::<copy constructor>" << std::endl;
      }
    ~S ()
      {
        std::cout << "  S::<destructor>" << std::endl;
      }
  };

struct A /* construct by reference */
  {
    S s_;

    A (S const &s) : s_ (s) /* expecting one copy here */
      {
        std::cout << "A::<constructor>" << std::endl;
      }
    A (A const &a) : s_ (a.s_)
      {
        std::cout << "A::<copy constructor>" << std::endl;
      }
    ~A ()
      {
        std::cout << "A::<destructor>" << std::endl;
      }
  };

struct B /* construct by value */
  {
    S s_;

    B (S s) : s_ (s) /* expecting two copies here */
      {
        std::cout << "B::<constructor>" << std::endl;
      }
    B (B const &b) : s_ (b.s_)
      {
        std::cout << "B::<copy constructor>" << std::endl;
      }
    ~B ()
      {
        std::cout << "B::<destructor>" << std::endl;
      }
  };

/* expecting a total of one copy of S here */
static A f () { S s ("string"); return A (s); }

/* expecting a total of one copy of S here */
static A f2 () { S s ("string"); s.s_ = "abc"; A a (s); a.s_.s_ = "a"; return a; }

/* expecting a total of two copies of S here */
static B g () { S s ("string"); return B (s); }

/* expecting a total of two copies of S here */
static B g2 () { S s ("string"); s.s_ = "abc"; B b (s); b.s_.s_ = "b"; return b; }

int main ()
  {
    A a (f ());
    std::cout << "" << std::endl;
    A a2 (f2 ());
    std::cout << "" << std::endl;
    B b (g ());
    std::cout << "" << std::endl;
    B b2 (g2 ());
    std::cout << "" << std::endl;

    return 0;
  }

А вывод, к сожалению, соответствует ожиданиям:

  S::<constructor>
  S::<copy constructor>
A::<constructor>
  S::<destructor>

  S::<constructor>
  S::<copy constructor>
A::<constructor>
  S::<destructor>

  S::<constructor>
  S::<copy constructor>
  S::<copy constructor>
B::<constructor>
  S::<destructor>
  S::<destructor>

  S::<constructor>
  S::<copy constructor>
  S::<copy constructor>
B::<constructor>
  S::<destructor>
  S::<destructor>

B::<destructor>
  S::<destructor>
B::<destructor>
  S::<destructor>
A::<destructor>
  S::<destructor>
A::<destructor>
  S::<destructor>

Заключение

GCC был не в состоянии оптимизировать временный S, созданный конструктором B. Использование конструктора копирования по умолчанию S не изменило это. Изменение f, g на

static A f () { return A (S ("string")); } // still one copy
static B g () { return B (S ("string")); } // reduced to one copy!

имел указанный эффект. Похоже, что GCC хочет создать аргумент для конструктора B на месте, но не решается создать член B на месте. Обратите внимание, что временные A или B по-прежнему не создаются. Это означает, что a, a2, b, b2 все еще строится на месте . Круто.

Давайте теперь рассмотрим, как семантика нового перемещения может повлиять на второй пример.

Реалистичный кейс, с -std=c++0x

Попробуйте добавить следующий конструктор к S

    S (S &&s) : s_ ()
      {
        std::swap (s_, s.s_);
        std::cout << "  S::<move constructor>" << std::endl;
      }

И изменение конструктора B на

    B (S &&s) : s_ (std::move (s)) /* how many copies?? */
      {
        std::cout << "B::<constructor>" << std::endl;
      }

Мы получаем этот вывод

  S::<constructor>
  S::<copy constructor>
A::<constructor>
  S::<destructor>

  S::<constructor>
  S::<copy constructor>
A::<constructor>
  S::<destructor>

  S::<constructor>
  S::<move constructor>
B::<constructor>
  S::<destructor>

  S::<constructor>
  S::<move constructor>
B::<constructor>
  S::<destructor>

B::<destructor>
  S::<destructor>
B::<destructor>
  S::<destructor>
A::<destructor>
  S::<destructor>
A::<destructor>
  S::<destructor>

Итак, мы смогли заменить четыре копии на два хода , используя pass by rvalue.

Но мы фактически создали сломанную программу.

Напомним g, g2

static B g ()  { S s ("string"); return B (s); }
static B g2 () { S s ("string"); s.s_ = "abc"; B b (s); /* s is zombie now */ b.s_.s_ = "b"; return b; }

Помеченное местоположение показывает проблему. Движение было сделано на объекте, который не является временным. Это потому, что ссылки на rvalue ведут себя как ссылки на lvalue, за исключением того, что они могут также привязываться к временным файлам. Поэтому мы не должны забывать перегружать конструктор B тем, который принимает постоянную ссылку на lvalue.

    B (S const &s) : s_ (s)
      {
        std::cout << "B::<constructor2>" << std::endl;
      }

Затем вы заметите, что оба g, g2 вызывают вызов "constructor2", поскольку символ s в любом случае лучше подходит для константной ссылки, чем для rvalue-ссылки. Мы можем убедить компилятор сделать шаг в g одним из двух способов:

static B g ()  { return B (S ("string")); }
static B g ()  { S s ("string"); return B (std::move (s)); }

Выводы

Делать возврат по значению. Код будет более читабельным, чем код «заполнить ссылку, которую я вам дам» и быстрее и , может быть, даже более безопасным для исключения. Рассмотрите возможность изменения f на static void f (A &result) { A tmp; /* ... */ result = tmp; } /* or */ static void f (A &result) { /* ... */ result = A (S ("string")); } Это будет соответствовать строгой гарантии , только если это предусмотрено назначением A. Копия в result не может быть пропущена, а также не может быть tmp вместо result, так как result не создается. Таким образом, это медленнее, чем раньше, где копирование не было необходимо. Компиляторы C ++ 0x и операторы присваивания перемещения позволили бы сократить накладные расходы, но это все же медленнее, чем возврат по значению. Возврат по стоимости обеспечивает более надежную гарантию. Объект построен на месте. Если одна из этих частей выйдет из строя, а другие уже построены, обычная размотка будет очищена и, пока конструктор S выполняет основную гарантию в отношении своих собственных членов и надежную гарантию в отношении глобальных элементов, весь процесс возврата по стоимости фактически обеспечивает надежную гарантию. Всегда передавайте по значению, если вы все равно собираетесь копировать (в стек)

Как обсуждено в Хотите скорость? Передать по значению. . Компилятор может генерировать код, который создает, если возможно, аргумент вызывающей стороны, исключая копию, которую он не может сделать, когда вы берете ссылку, а затем копируете вручную. Основной пример: НЕ написать это (взято из цитируемой статьи)

T& T::operator=(T const& x) // x is a reference to the source
{ 
    T tmp(x);          // copy construction of tmp does the hard work
    swap(*this, tmp);  // trade our resources for tmp's
    return *this;      // our (old) resources get destroyed with tmp 
}

но всегда предпочитаю это

T& T::operator=(T x)    // x is a copy of the source; hard work already done
{
    swap(*this, x);  // trade our resources for x's
    return *this;    // our (old) resources get destroyed with x
}

Если вы хотите скопировать в расположение не в стеке фрейма, передайте константную ссылку перед C ++ 0x и дополнительно передайте rvalue ссылочную запись C ++ 0x

Мы уже видели это. Передача по ссылке приводит к меньшему количеству копий, когда создание на месте невозможно, чем передача по значению И семантика ходов C ++ 0x может заменить многие копии меньшими и более дешевыми ходами. Но имейте в виду, что движение сделает зомби из объекта, который был перемещен. Перемещение не копирование. Просто предоставление конструктора, который принимает ссылки на rvalue, может привести к поломке, как показано выше.

Если вы хотите скопировать в расположение кадра, отличное от стека, и иметь swap, рассмотрите возможность передачи по значению в любом случае (до C ++ 0x)

Если у вас есть дешевая конструкция по умолчанию, которая в сочетании с swap может быть более эффективной, чем копирование вещей вокруг. Рассмотрим конструктор S как

    S (std::string s) : s_ (/* is this cheap for your std::string? */)
      {
        s_.swap (s); /* then this may be faster than copying */
        std::cout << "  S::<constructor>" << std::endl;
      }
...