C++11 — еще больше пуль для ног

Submitted by 0xd34df00d on Sat, 12/24/2011 - 16:43

В одном пописываемом мной C++-проектике после перехода на C++11 начались проблемы на некоторых компиляторах, связанные с implicit move ctor, и поэтому я решил поковыряться в move semantics. В процессе нашел такой забавный баг особенность дизайна языка.

Рассмотрим относительно minimal reproducing example, демонстрирующий проблему:

  1. #include <string>
  2. #include <iostream>
  3.  
  4. struct A
  5. {
  6. std::string s;
  7.  
  8. A () : s ("Hi!") { }
  9. A (A&& a) : s (std::move (a.s)) { }
  10. A (const A& a) : s (a.s) { }
  11. };
  12.  
  13. struct B
  14. {
  15. const A& a;
  16.  
  17. B (const A& obj) : a (obj) { }
  18. ~B () { std::cout << a.s << std::endl; }
  19. };
  20.  
  21. A f1 ()
  22. {
  23. A a;
  24. B b (a);
  25.  
  26. // volatile bool t = false;
  27. // if (t)
  28. // return A ();
  29.  
  30. return a;
  31. }
  32.  
  33. int main ()
  34. {
  35. A a1 (f1 ());
  36. }

Для пущей связи с реальной жизнью можно считать, что 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. Интересно, почему.