четверг, 21 марта 2013 г.

C++11: ссылки на rvalue и семантика переноса

Это небольшой пример для иллюстрации возможностей семантики переноса (move semantics) в C++11. Для компиляции программы я использовал компилятор gcc версии 4.7.2. В программе создается вектор, содержащий элементы некоторого класса A, который дальше заполняется большим количеством объектов этого класса. Внутри класс A содержит член data типа указателя на int, который инициализируется массивом в конструкторе класса с помощью оператора new[] и освобождается в деструкторе с помощью оператора delete[]. Вот исходный код программы:
#include <iostream>
#include <vector>

#ifdef __GXX_EXPERIMENTAL_CXX0X__
    #define NOEXCEPT      noexcept
    #define STDMOVE( x )  std::move( ( x ) )
#else
    #define NOEXCEPT
    #define STDMOVE( x )  ( x )
#endif

#ifdef DEBUG
    #define VSIZE 1
    #define DPRINT( x ) std::cout << ( x ) << std::endl;
#else
    #define VSIZE 10000
    #define DPRINT( x )
#endif

namespace
{
    const size_t  d_size( 1024 );
    const size_t  v_size( VSIZE );
}

class  A
{
    public:
        A() : data( new int[ d_size ] )
        {
            DPRINT( "Constructor" )
        }

        ~A() NOEXCEPT
        {
            DPRINT( "Destructor" )
            delete[] data;
        }

        A( const A &  a ) : data( new int[ d_size ] )
        {
            DPRINT( "Const reference" )

            /* May not assign 'a->data' to 'this->data' as 'a' may go out of
             * scope and be destructed, therefore 'a.data' will be deleted */
            /* this->data = a.data; */

            /* May not assign 'a.data' as it is const reference  */
            /* a.data = NULL; */

            /* May only copy data! */
            for ( int  i( 0 ); i < d_size; ++)
            {
                this->data[ i ] = a.data[ i ];
            }
        }

#ifdef __GXX_EXPERIMENTAL_CXX0X__
        A( A &&  a ) noexcept
        {
            DPRINT( "Rvalue reference" )

            /* May assign 'a.data' to 'this->data' because 'a' is
             * rvalue reference */
            this->data = a.data;

            /* 'a.data' must be uninitialized to avoid double deletion! */
            a.data = nullptr;
        }
#endif

    private:
        int *  data;
};


int  main( void )
{
    std::vector< A >  v( v_size );

    for ( int  i( 0 ); i < v_size; ++)
    {
        A  a;
        v.push_back( STDMOVE( a ) );
    }
}
В самом начале находятся определения макросов NOEXCEPT и STDMOVE: они нужны для того, чтобы программу можно было скомпилировать как с поддержкой расширений C++11, так и без нее. В случае компиляции без поддержки C++11 эти макросы не расширяются, а при поддержке - превращаются соответственно в noexcept и std::move. Далее идут определения макросов VSIZE и DPRINT: если собирать программу с флагом -DDEBUG, то DPRINT позволит выводить важные сообщения на экран терминала, а VSIZE, который мы будем использовать для определения размера вектора v, будет равен 1 - это позволит не захламлять экран большим числом сообщений, но при этом даст возможность убедиться, что вызываются правильные конструкторы.

Внутри класса A определены последовательно: конструктор A(), деструктор ~A(), копирующий конструктор, конструктор с семантикой переноса (завернутый в макрос __GXX_EXPERIMENTAL_CXX0X__) и член data. В функции main() создается вектор v, дальше в цикле локальный объект a типа A добавляется в конец вектора v.

Проверим, что все работает правильно. Для этого сначала соберем нашу программу без поддержки C++11 и семантики переноса, но с флагом -DDEBUG:
g++ -g -O2 -DDEBUG -o test main.cpp
Запустим собранную программу:
$ ./test 
Constructor
Const reference
Destructor
Constructor
Const reference
Const reference
Destructor
Destructor
Destructor
Destructor
Отлично, строки Const reference свидетельствуют о том, что были вызваны конструкторы копирования. Теперь соберем программу с поддержкой C++11:
g++ -g -O2 -std=c++11 -DDEBUG -o test main.cpp
Запускаем:
$ ./test 
Constructor
Constructor
Rvalue reference
Rvalue reference
Destructor
Destructor
Destructor
Destructor
Теперь были вызваны конструкторы с семантикой переноса! Кстати, обратим внимание на очевидные различия при инициализации вектора без поддержки и с поддержкой C++11. Теперь сравним производительность: компилируем оба варианта без флага -DDEBUG (теперь в вектор будет записано 10000 элементов типа A) и смотрим, насколько конструктор с семантикой переноса быстрее конструктора копирования.
  1. g++ -g -O2 -o test main.cpp
    $ time ./test 
    
    real 0m0.070s
    user 0m0.036s
    sys 0m0.030s
    
  2. g++ -g -O2 -std=c++11 -o test main.cpp
    $ time ./test 
    
    real 0m0.047s
    user 0m0.007s
    sys 0m0.038s
    
Можно запустить несколько раз для большей статистики - разница окажется более чем в полтора раза! Главное различие в семантике конструкторов: конструктор копирования обязан выделить память под data, а затем скопировать туда элементы data из копируемого объекта; в то же время конструктор с семантикой переноса ничего этого делать не обязан: вместо этого он может просто присвоить своему члену data значение data "копируемого" объекта, а это простой указатель! При этом, однако, он обязан присвоить data исходного объекта нулевое значение, иначе оператор delete[] в деструкторе класса A попытается дважды удалить выделенную память. Различия между конструкторами подчеркнуты в комментариях внутри исходного кода.

Теперь о некоторых второстепенных деталях. Во-первых, если в push_back() передавать не именованный объект класса A, а созданный на месте объект A(), то можно обойтись без std::move(): стандарт не позволяет рассматривать именованные объекты как ссылки на rvalue, в то время как неименованные таковыми являются. Задачей std::move() как раз и является превращение именованного объекта в ссылку на rvalue. Во-вторых, если вы уберете слово noexcept из деструктора или конструктора с семантикой переноса, то вы получите вызов конструктора копирования в дебрях алгоритма копирования элементов в std::vector. Попробуйте убрать это слово хотя бы из одной из этих функций, скомпилируйте программу с флагом -DDEBUG, и вы увидите всё сами.

Очень подробно вопросы, связанные с семантикой переноса, рассматриваются в этой статье. В частности, там показано, почему так важны std::move() и noexcept.