суббота, 8 декабря 2012 г.

Презентация по C++11 часть 1: Rvalue-ссылки

    Придет день и компиляторы будут полностью поддерживать С++11,
Но радоваться этому будут уже другие, более совершенные существа...

    В этой статье приведены материалы первой части моей презентации о С++11, а именно, о Rvalue-ссылках. Однако кроме вопросов, которые мы рассмотрели на митинге (Move Semantics), здесь также приведены дополнительные материалы о Forwarding Problem и Perfect Forwarding.
    Вы сможете сами листать слайды презентации и смотреть мои комментарии к ним. Неочевидные моменты/конструкции я постарался объяснить отдельно. Также здесь я привожу ссылки по теме, которые обещал или которые могут быть вам интересны.
    Если у вас возникнут вопросы по теме, буду рад ответить на них в комментариях к статье. Надеюсь, что приведенная информация окажется полезной для вас.

1. Ссылки по теме.
1.1 Домашний сайт комитета по стандартизации С++: http://isocpp.org. Здесь же, в частности, приведены инструкции по внесению своих предложений в следующий стандарт языка: http://isocpp.org/std/submit-a-proposal.
1.2 Поддержка С++11 компиляторами.
1.2.1 Общая таблица (содержит не полный перечень нововведений): http://wiki.apache.org/stdcxx/C++0xCompilerSupport. Эта таблица периодически обновляется.
1.2.2 MSVC: http://blogs.msdn.com/b/vcblog/archive/2011/09/12/10209291.aspx. Кажется заброшенной: не содержит обновлений компилятора MSVC11 за ноябрь 2012.
1.2.3 Clang: http://clang.llvm.org/cxx_status.html.
1.2.4 GCC: http://gcc.gnu.org/projects/cxx0x.html.
1.2.5 Intel C++: http://software.intel.com/en-us/articles/c0x-features-supported-by-intel-c-compiler/.
1.2.6 IBM xlC++: https://www.ibm.com/developerworks/mydeveloperworks/blogs/5894415f-be62-4bc0-81c5-3956e82276f3/entry/xlc_compiler_s_c_11_support50?lang=en.
1.3 Черновики нового стандарта и другие документы: http://open-std.org/jtc1/sc22/wg21/docs/papers/.

2. Презентация по Rvalue-ссылкам.
2.1 На слайдах, которые содержат мои вопросы к вам или неоптимальный код, отображается задумчивый смайл. При щелчке мышью на слайде вы сможете увидеть мой ответ.
2.2 Некоторые слайды содержат мои комментарии. Чтобы их увидеть надо нажать кнопку "СС" в окне презентации или клавишу "C" на клавиатуре.
2.3 В комментариях к слайдам, на которых присутствует изображение открытой книги, содержится ссылка на пункт стандарта С++11.


3. Некоторые пояснения.
3.1 Особенности своптимизации типов (swap, идиома copy-and-swap) хорошо описаны в известной книге Скотта Майерса (Эффективное использование С++: 55 верных способов улучшить структуру и код ваших программ). Вот ссылка на соответствующее "правило" для ознакомления (правило 25). Про гарантии безопасности исключений можно прочитать в этом правиле 29. На мой взгляд это одна из лучших книг по С++, такую книгу удобно иметь под рукой в бумажном варианте.

3.2 Copy Elision. Скотт Майерс упоминает об этом трюке в конце правила 11. Я был немного некорректен, когда на митинге сказал, что Скотт Майерс отдает предпочтение ясности. Вот, что он пишет "Лично меня беспокоит, что такой подход приносит ясность в жертву изощренности, но, перемещая операцию копирования из тела функции в конструирование параметра, компилятор иногда может сгенерировать более эффективный код.". Под более эффективным кодом здесь понимается возможность исключения вызовов конструкторов копирования для временных объектов. В нашем примере, на слайде 10 (6), временный объект rhs может быть создан вызовом одного конструктора:

Foo foo;
foo = Foo(/*параметры*/);

Без оптимизации сначала будет создан временный объект Foo(/*параметры*/), затем объект rhs (Foo& Foo::operator= (Foo rhs)) будет создан копированием из этого временного объекта.
С оптимизацией объект rhs будет создан внутри operator= так, как если бы мы написали:

Foo rhs(/*параметры*/).

Большинство современных компиляторов легко осуществляют такую оптимизацию. Так GCC ее осуществляет даже при отключении оптимизации (-O0). Чтобы запретить GCC делать это надо задать специальную опцию (-fno-elide-constructors). Эта опция запретит оптимизацию Copy Elision, RVO/NRVO.
Стандарт разрешает компиляторам исключать вызовы конструкторов копирования объектов, если конечный результат от этого не изменится, даже если эти конструкторы содержат побочные эффекты.

3.3  На слайде 10 (6) не генерирующая исключений функция Foo::swap помечена динамической спецификацией исключений throw(). Динамические спецификации исключений запрещены в С++11 (компилятор может выдавать предупреждения) и вместо них следует использовать более "мощный" спецификатор noexcept (он может указывать, что функция либо не генерирует исключений, либо может генерировать любые исключения. Почему данный спецификатор является более мощным - тема отдельной статьи). Однако важно то, что согласно С++11 спецификация throw() без параметров рассматривается как совместимая со спецификацией noexcept/noexcept(true). Это оказывается важным в случае, например, когда оператор noexcept применяется к функции помеченной спецификацией throw().

3.4 Почему в записи Foo obj( (Foo()) ); нужны дополнительные внутренние скобки? Иначе эта запись будет воспринята как объявление функции (даже внутри main()). Эта особенность известна в кругах С++ под названием most vexing parse.

3.5 Рассмотрим типичные операции перемещения. Начнем с перемещающего конструктора.

Foo(Foo && other): 
    ptr(other.ptr) { // шаг 1: крадем "ссылку" на ресурс
    other.ptr = NULL;// шаг 2: обнуляем "ссылку" жертвы на ресурс
}
Два простых шага.
Шаг 1 в общем виде должен был бы быть записан так:

ptr(std::move(other.ptr))

Поскольку other - lvalue т.к. имеет имя и чтобы использовать перемещение из lvalue мы должны явно сказать компилятору об этом (с помощью std::move). Однако ptr здесь является обычным примитивным указателем, а для них копирование совпадает с перемещением (ptr является тривиально копируемым). При перемещении other.ptr все равно не обнулился бы автоматически. Поэтому в этом примере вызов std::move является лишним. Однако в случае с объектами классовых типов использование std::move может оказаться необходимым.
Шаг 2 необходим, поскольку иначе объект other, при разрушении, в своем деструкторе удалит ресурс на который теперь ссылается наш ptr. Однако вызов delete ptr; где ptr является нулевым указателем, безопасен по стандарту С++.
Перемещающий оператор присваивания:

Foo& operator= (Foo && rhs) {
    if(&rhs != this) {        // Этот шаг никто не отменял!
        delete ptr;           // И этот тоже
        ptr = rhs.ptr;        // Шаг 1: как и ранее
        rhs.ptr = NULL;       // Шаг 2: как и ранее
    }
    return *this;
}

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

3.6 Несколько слов о категории значения выражений. Почему, например, указатель this является prvalue, ведь, казалось бы, он имеет имя? Дело в том, что "this" это не имя идентификатора, а специальный токен для обозначения специального указателя. Это же относится к true/false, nullptr или именам енумераторов.

3.7 На слайде 17 (13), где рассказывается про правила привязки к ссылкам имеется, конечно, в виду, что не само выражение привязывается к ссылке, а результат выражения.
Насчет того, что теперь не рекомендуется возвращать из функций константные prvalue. Почему это было необходимо, опять же, хорошо описано в правиле 3 (const Rational). Однако в С++11 эта часть правила "устарела", поскольку возврат const Foo не позволит выполнить перемещение из этого временного объекта. Проблема, которую решал старый подход в С++11 решается (и решается лучше чем раньше) с помощью ref-квалификаторов. Но эта тема для отдельной статьи.

3.8 Пример использования const Type &&.

class Entity {
public:
    void Func(const Foo & obj); // (1)
private:
    void Func(const Foo &&);    // (2)
} entity;

Теперь Entity::Func может быть вызвана только для lvalue Foo.

Foo foo;
entity.Func(foo);   // OK
entity.Func(Foo()); // Ошибка

Почему? Ведь const Foo & является ссылкой, которая может быть инициализирована Foo любой категории значения? Все дело в последовательных этапах, которые выполняет компилятор при анализе выражения entity.Func(Foo()):
1) Этап выбора подходящих функций. Составляется список всех функций Func для entity. В этот список попадают перегрузки функции Func (1) и (2).
2) Этап разрешения перегрузки (overload resolution). На этом этапе должна остаться наиболее подходящая функция. Этой функцией является функция (2) поскольку она принимает rvalue-ссылку на Foo, а Foo() является временным объектом.
3) Проверяется доступ к выбранной функции (2). Доступ private и возникает ошибка компиляции, при этом функция (1) уже не рассматривается т.к. была исключена на этапе (2).
А нельзя ли параметр функции (2) пометить как Foo &&, вместо const Foo && ? Можно, но тогда функция Func(const Foo &) сможет быть вызвана и для константных временных объектов, поскольку константный объект не может быть привязан к Foo && даже если это временный объект.
Приведенное решение не идеальное: функция (2) может быть вызвана из функций-членов Entity и друзей Entity. Поэтому, чтобы этот вызов все-таки закончился ошибкой важно объявить, но не определять функцию (2). Тем не менее в указанных случаях (друзья, функции-члены) ошибка будет диагностирована только на этапе компоновки. Мы бы хотели диагностировать ошибку как можно раньше (например, в Visual Studio ошибку может обнаружить IntelliSense еще до начала компиляции, однако ошибки компоновки это средство не диагностирует). В С++11 появился механизм, позволяющий улучшить наше решение - удаленные функции. Используя их мы можем объявить функцию (2) в секции public следующим образом:

void Func(const Foo &&) = delete;    // (2)

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

3.9 Своптимизированный перемещающий оператор присваивания.

Foo& Foo::operator= (Foo && rhs) {
    Foo(std::move(rhs)).swap(*this);
    return *this;
}
Зачем нужен дополнительный временный объект Foo в который мы перемещаем rhs? Чтобы это понять рассмотрим следующий пример:

Foo foo1, foo2;
foo1 = std::move(foo2);

Таким образом, rvalue-ссылка rhs может ссылаться на lvalue объект (foo2). Если бы этого дополнительного временного объекта не было бы, то swap переместила бы данные foo1 в foo2 и эти данные были бы уничтожены не внутри operator=, а при разрушении foo2. В ряде случаев такое поведение опасно и не желательно. Дополнительный временный объект гарантирует, что наши старые данные будут разрушены внутри operator= и в foo2 не попадет состояние foo1.

3.10 Сам вызов std::move не приводит к перемещению объекта. Результат вызова std::move имеет категорию значения xvalue (безымянная rvalue-ссылка), а xvalue предпочитает привязку к rvalue-ссылкам. Все, что делает std::move - возврат rvalue-ссылки на переданный в нее аргумент.

3.11 Логика работы std::move_if_noexcept следующая: если объект типа переданного в нее аргумента может быть сознан с помощью конструктора копирования и при этом перемещающий конструктор этого объекта может генерировать исключения, то функция возвращает lvalue-ссылку на константу переданного в нее аргумента. В противном случае функция возвращает rvalue-ссылку на переданный в нее аргумент (т.е. ведет себя как std::move). 
Эта функции введена для поддержки гарантий безопасности исключений. Основная идея в том, что иногда мы можем предпочесть копирование объекта его перемещению, если перемещение объекта связано с риском возникновения исключения. Особенность функции в том, что не осуществляется проверок на возможность генерирования исключений в конструкторе копирования объекта. Например, если такая функция будет вызвана для объекта, тип которого является как копируемым, так и перемещаемым и если его конструктор копирования и конструктор перемещения могут генерировать исключения, то функция вернет const T&. Т.е. будет выбрано копирование несмотря на возможность генерирования исключений в конструкторе копирования. Однако, если тип не является копируемым, то в любом случае будет выбрано перемещение (возврат T&&) если оно доступно для типа, т.е. в этом случае функция std::move_if_noexcept приведет к перемещению объекта несмотря на то, что перемещающая операция может выбросить исключение.

3.12 Рассмотрим еще раз следующий код:
int i = 10;
double && d = i;

Здесь, начиная с версии rvalue-ссылок 2.1, произойдет следующее: i будет преобразовано во временный объект double(i) и ссылка d будет инициализирована этим временным объектом. По стандарту (12.2/5) время жизни этого временного объекта будет расширенно до времени жизни d (висячей ссылки не будет). Фактически компилятор сгенерирует подобный следующему код:


int i = 10;
double __temp = i;
double && d = static_cast<double &&>(__temp);


3.13 Частичная реализация шаблонной фабрики, которую я привожу при обсуждении Forwarding Problem взята из Boost.Factory. Там же расписаны все нюансы подобной реализации.
Я рассматриваю вариант с двумя аргументами, хотя в общем случае их может быть произвольное количество. Perfect Porwarding решает только 1-ую часть Forwarding Problem, 2-ую часть проблемы (передача произвольного количества аргументов) решает другое новшество С++11 - Variadic Templates.

3.14 Рассмотрим первую часть Forwarding Problem подробнее.

template<typename A1, typename A2>
T* Factory::operator() (const A1 & arg1, const A2 & arg2) const
    { return new T(arg1, arg2); }

Эта реализация приведет к ошибке, если, например, конструктор T имеет вид T(Type1 &, Type2 &), поскольку arg1 и arg2 - ссылки на константу и, поэтому не могут быть переданы в этот конструктор.

template<typename A1, typename A2>
T* Factory::operator() (A1 & arg1, A2 & arg2) const
    { return new T(arg1, arg2); }

Теперь указанная выше проблема отсутствует, однако следующий вызов закончится ошибкой:

Factory factory;
factory(10, 20);

Дело в том, что 10 и 20 это литералы (т.е. prvalue) причем по стандарту такие литералы трактуются как не константные. Если бы они трактовались как константные, то проблем бы не было, поскольку в примере выше константность может быть добавлена к A1 и A2 при выводе аргументов шаблона. Почему в С++11 не разрешили трактовать литералы как константные? Потому, что это подломило бы совместимость со старым кодом. Т.о. этот способ тоже не универсальный.

template<typename A1, typename A2>
T* Factory::operator() (const A1 & arg1, const A2 & arg2) const
    { return new T(const_cast<A1&>(arg1), const_cast<A2&>(arg2)); }

Это не безопасно. Например, если конструктор T имеет вид T(Type1 &, Type2 &):

const int i1 = 10, i2 = 20;
Factory factory;
factory(i1, i2);


Конструктор T принимает i1, i2 по не константной ссылке и изменяет их. Результат - неопределенное поведение. Поэтому такое решение - наихудшее.
И, наконец, решение с перегрузкой вполне работоспособное, но не рациональное, поскольку требует 2^n перегрузок, где n - количество аргументов функции.
Таким образом, ни одно из прежних решений не было идеальным: приходилось выбирать меньшее из зол, в зависимости от решаемой задачи.

3.15 std::forward позволяет восстановить категорию значения переданного в нее параметра. Это важно, поскольку, как мы помним, как только аргумент функции приобретает имя, то внутри этой функции он трактуется как lvalue. Также обратите внимание, что кроме аргумента эта функция также принимает его тип (в угловых скобках). Если тип аргумента забыть задать, то код не скомпилируется, поскольку именно он позволяет std::forward правильно восстанавливать тип переданного в нее аргумента.
Часто в примерах реализации функции std::forward можно увидеть использование вспомогательного класса identity. Однако этот класс не является стандартным (хотя такая реализация может работать корректно). Реализация std::forward которую вы видели на слайде, полностью соответствует стандарту.

3.16 std::enable_if является стандартным классом-характеристикой. Его использование позволяет сформулировать требования к обобщенным параметрам функции или класса. Если эти требования не выполняются - функция исключается из списка перегрузок (не может быть инстанцирована/вызвана), аналогично для классов. Рассмотрим его работу на примере:

template<typename T>
Entity(T && arg, typename std::enable_if<!std::is_integral<T>::value>::type* = 0);

В конструктор Entity мы добавили фиктивный параметр со значением по умолчанию. Этот же фиктивный параметр мы могли бы добавить в список параметров шаблона (т.е. template<typename T, typename std::enable_if<!std::is_integral<T>::value>::type * = 0>). Однако такой вариант скомпилируется только если ваш компилятор поддерживает значения по умолчанию для шаблонных аргументов функций (это еще одна новая фича С++11).
Трюк enable_if заключается в том, что тип std::enable_if<true>::type определен, в то время как std::enable_if<false> не содержит вложенного типа type. Таким образом, выражение
std::enable_if<false>::type является ошибочным, но это не приводит к ошибке компиляции, если есть другие перегрузки данной функции, согласно принципу SFINAE (Substitution Failure Is Not An Error).
Выражение std::is_integral<T>::value (еще один стандартный класс-характеристика) выводится в true, когда T является интегральным типом (таковыми являются все целочисленные встроенные типы), в противном случае, выводится в false.
Таким образом, приведенный конструктор будет использован только если T не является интегральным типом.

Комментариев нет:

Отправить комментарий