C++11 — еще больше пуль для ног
В одном пописываемом мной C++-проектике после перехода на C++11 начались проблемы на некоторых компиляторах, связанные с implicit move ctor, и поэтому я решил поковыряться в move semantics. В процессе нашел такой забавный баг особенность дизайна языка.
Рассмотрим относительно minimal reproducing example, демонстрирующий проблему:
#include <string> #include <iostream> struct A { std::string s; A () : s ("Hi!") { } A (A&& a) : s (std::move (a.s)) { } A (const A& a) : s (a.s) { } }; struct B { const A& a; B (const A& obj) : a (obj) { } ~B () { std::cout << a.s << std::endl; } }; A f1 () { A a; B b (a); // volatile bool t = false; // if (t) // return A (); return a; } int main () { A a1 (f1 ()); }
Для пущей связи с реальной жизнью можно считать, что A — некий ресурс, B — что-то вроде RAII, связанное с A.
Если строки в f1 () закомментированы (как в коде выше), то мы получим относительно предполагаемое поведение: программа при запуске выведет на экран Hi!. А если их раскомментировать, то начинается интересное — на экран будет выведена пустая строка.
Разгадка довольно проста, хоть и неочевидна сходу. Когда из функции может вернуться только один объект, пусть и с некоторыми преобразованиями, то можно применить RVO, сконструировав объект сразу в том месте, где он будет использован. То есть, компилятор видит, что из функции всегда возвращается вон тот объект a, и создает его прямо «в объекте» a1.
Если добавить еще один return с еще одним объектом, то компилятор больше не может применить RVO. Действительно, какой из двух объектов конструировать на месте a1? А так как для A определен move constructor, то компилятор и выполняет этот самый move, после чего объект a остается хоть и в валидном состоянии, но в непонятно каком. В данном случае вызывается move ctor для std::string, который убирает из исходного объекта строку, заменяя ее пустой — это эффективнее и быстрее тупой копии.
Лично мне кажется, что было бы разумно рассматривать move ctor как этакий полудеструктор, и не разбивать move-out и dtor. То есть, move ctor станет отложенным и выполнится не в момент return'а, а перед вызовом деструктора a. В однопоточной модели машины, которую вроде и использует стандарт C++, это едва ли бы привело к каким проблемам, а выглядело бы логичнее. В частности, в приведенном выше коде все работало бы по-старому — деструктор b видел бы еще старое состояние a, которое и должно быть.
Впрочем, люди с #c++ на фриноде говорят, что так о move ctor'ах думать нельзя. Я так и не понял, почему.
Короче, теперь C++ стал еще более бесконечно сложным. Теперь даже от return можно ожидать чего угодно. И теперь нужно быть аккуратнее, потому что добавление всяких разных строк-операторов-вызовов может сломать тонкие комбинации оптимизаций где угодно и привести ко всяким забавным эффектам.
ИМХО explicit return-move было бы круче. Что-то типа return std::move (a);, да.
P. S. Похоже, что всякие условия с волатильными переменными не нужны. По крайней мере, все из проверенных версий gcc не делают RVO даже в случае второго return сразу после первого, который уже явный unreachable code. Интересно, почему.