Опасный copy elision

от автора

Уже год в свободное от работы время я пилю что-то вроде смеси Maven и Spring для С++. Важной её частью является самописная система умных указателей. Зачем мне всё это — отдельная тема. В данной статье я хочу коротко рассказать о том, как одна, казалось бы, полезная фича С++ заставила меня усомниться в здравом смысле Стандарта.

1. Проблема

Умные указатели для проекта были сделаны ещё летом прошлого года.

Избранный код указателей и пояснения

template<typename T_Type, typename T_Holding, typename T_Access> class DReference {  . . . 	IDSharedMemoryHolder *_holder;  	void retain(IDSharedMemoryHolder *inHolder) { . . . } 	void release() { . . . }   . . .  	~DReference() { release(); }  	template<typename T_OtherType, 			typename T_OtherHolding, 			typename T_OtherAccess> 	DReference( 			const DReference<T_OtherType, T_OtherHolding, T_OtherAccess> & 					inLReference) : _holder(NULL), _holding(), _access() 	{ 		retain(inLReference._holder); 	}  . . .   } 

У нас есть структуры-стратегии, которые реализуют логику хранения объекта и логику доступа к объекту. Мы передаём их типы в качестве шаблонных аргументов класса умного указателя. IDSharedMemoryHolder — интерфейс доступа к память объекта. Через вызов функции retain() умный указатель начинает владеть объектом (для strong reference-а ++ref_count). По вызову release() указатель освобождает объект (для strong reference-а —ref_count и удаление объекта если ref_count == 0).

Я намеренно опустил здесь вещи, связанные с разыменованием и с retain по вызовам операторов. Описываемая проблема этих моментов не касается.

Работа умных указателей проверялась рядом простых тестов: «создали объект, связанный с указателем — присвоили указателю указатель — глянули, чтобы reatin/release прошли правильно». Тесты (что сейчас кажется очень странным) проходили. Код на умные указатели я перевёл в начале января и… да, тогда всё тоже работало.

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

Поясню на конкретном примере:

DStrongReference<DPlugIn> DPlugInManager::createPlugIn( 		const DPlugInDescriptor &inDescriptor) { . . .  	DStrongReference<DPlugIn> thePlugInReference = 			internalCreatePlugIn(inDescriptor); . . .  	return thePlugInReference; }  ...  DStrongReference<DPlugIn> DPlugInManager::internalCreatePlugIn( 		const DPlugInDescriptor &inDescriptor) { 	for (IDPlugInStorage *thePlugInStorage : _storages) { 		if (thePlugInStorage->getPlugInStatus(inDescriptor)) 			return thePlugInStorage->createPlugIn(inDescriptor); 	} 	return DStrongReference<DPlugIn>(); }  ...  class DPlugInStorageImpl : public IDPlugInStorage { public: 	virtual ~DPlugInStorageImpl() { }  	virtual DStrongReference<DPlugIn> createPlugIn( 			const DPlugInDescriptor &inDescriptor); }; 

При вызове метода DPlugInStorageImpl::createPlugIn(…) создавался объект, возвращаемый через DStrongReference, после чего этот умный указатель возвращался через метод DPlugInManager::internalCreatePlugIn(…) в контекст вызова — метод DPlugInManager::createPlugIn(…).

Так вот, когда умный указатель возвращался в метод DPlugInManager::createPlugIn(…), thePlugInReference указывал на удалённый объект. Очевидно, дело было в неправильном количестве retain/release-вызовов. Потратив кучу нервов с дебаггером в Eclipse (к слову — он ужасен), я плюнул, и решил проблему по-простому — использовал лог. Поставил на вызовах методов retain и release вывод, запустил программу… Что я ожидал увидеть? Вот такое что-то (псевдокод):

DPlugInStorageImpl::createPlugIn(…) => RETAIN
DPlugInManager::internalCreatePlugIn(…), return createPlugIn => RETAIN
DPlugInStorageImpl::createPlugIn(…), ~DStrongReference() => RELEASE
DPlugInManager::createPlugIn(…), thePlugInReference = internalCreatePlugIn(…) => RETAIN
DPlugInManager::internalCreatePlugIn(…),~DStrongReference() => RELEASE

Итого: ref_count = 1 для thePlugInReference. Всё должно было быть чётко.

То, что я увидел на самом деле, заставило меня сделать вот так (0_0) и потратить следующие полтора часа пять минут на всякие clean-up, перекомпиляции, перепроверки настроек оптимизации, попытки флашить stdout и проч.

DPlugInStorageImpl::createPlugIn(…) => RETAIN
DPlugInManager::internalCreatePlugIn(…),~DStrongReference() => RELEASE

Отчаявшись решить проблему в боевом коде и уже подозревая что-то крайне неладное, я создал маленький тестовый проект.

2. Тест

Тестовый код:

#include <iostream> #include <stdio.h>  class TestClass { private: 	int _state;  public: 	TestClass(int inState) : _state(inState) { std::cout << "State based: " << inState << std::endl; }  	TestClass() : _state(1) { std::cout << "Default" << std::endl; }  	TestClass(const TestClass &inObject0) : _state(2) { std::cout << "Const Copy" << std::endl; } 	TestClass(TestClass &inObject0) : _state(3) { std::cout << "Copy" << std::endl; }  	TestClass(const TestClass &&inObject0) : _state(4) { std::cout << "Const Move" << std::endl; } 	TestClass(TestClass &&inObject0) : _state(5) { std::cout << "Move" << std::endl; }  	~TestClass() { std::cout << "Destroy" << std::endl; }  	void call() { std::cout << "Call " << _state << std::endl; } };  /////////////////////////////////////////////////////////////////////////////// int main() { 	TestClass theTestObject = TestClass(); 	theTestObject.call(); 	fflush(stdout); 	return 0; } 

Ожидаемый результат:

Default
Const Copy
Call 1
Destroy

Реальный результат:

Default
Call 1
Destroy

То есть копи-конструктор не вызывался. И только тогда я сделал то, что нужно было сделать сразу. Загуглил и узнал про copy_elision.

3. Страшная правда

В двух словах — любой компилятор С++ может без предупреждения и каких-либо флагов игнорировать вызов copy-конструктора и вместо этого, например, напрямую копировать полное состояние объекта. При этом выполнить какую-либо логику в процессе такого копирования без хаков просто так нельзя. Вот тут в разделе Notes прямо сказано: "Элизия копирования — единственный разрешённый вид оптимизации, который может иметь наблюдаемые побочные эффекты", «Copy elision is the only allowed form of optimization that can change the observable side-effects».

Оптимизация — это, конечно, отлично… Но что если мне нужно выполнять какую-нибудь логику в конструкторе копирования. Например, для умных указателей? И что мне до сих пор непонятно, почему нельзя было разрешить выполнять подобную оптимизацию при -o1 в случае, если в теле copy-конструктора нет никакой логики?.. До сих пор сие мне не ясно.

4. Решение

Я нашёл два способа заставить компилятор выполнять логику в момент конструирования объектов класса:

1) Через флаги компиляции. Плохой способ. Компиляторозависимый. Например, для g++ нужно ставить флаг -fno-elide-constructors, и это либо будет влиять на весь проект (что ужасно), либо придётся использовать в соответствующих местах push/pop настройки флагов компилятора, что загромождает код и делает менее читабельным (особенно с учётом того, что такое придётся делать под каждый компилятор).

2) Через ключевое слово explicit. Это тоже плохой способ, но, на мой взгляд, это лучше, чем использовать флаги компиляции.
Спецификатор explicit нужен, чтобы запретить неявное создание экземпляров класса через синтаксис приведения типов. То есть, для того, чтобы вместо MyInt theMyInt = 1 нужно было обязательно писать MyInt theMyInt = MyInt(1).
В случае, если выставить это слово перед copy-конструктором, мы получим достаточно забавный запрет неявного приведения типа — запрет приведения к своему типу.

Таком образом, например, следующий

код

#include <iostream> #include <stdio.h>  class TestClass { private: 	int _state;  public: 	TestClass(int inState) : _state(inState) { std::cout << "State based: " << inState << std::endl; }  	TestClass() : _state(1) { std::cout << "Default" << std::endl; } 	explicit TestClass(const TestClass &inObject0) : _state(2) { std::cout << "Const Copy" << std::endl; } }  	~TestClass() { std::cout << "Destroy" << std::endl; }  	void call() { std::cout << "Call " << _state << std::endl; } };  /////////////////////////////////////////////////////////////////////////////// int main() { 	TestClass theTestObject = TestClass(); 	theTestObject.call(); 	fflush(stdout); 	return 0; } 

у меня (g++ 4.6.1) вызвал ошибку:

error: no matching function for call to ‘TestClass::TestClass(TestClass)’

Что ещё забавнее, из-за особенностей синтаксиса С++ вот так: TestClass theTestObject(TestClass()) записать тоже не выйдет, ведь это будет считаться объявлением указателя на функцию и вызовет ошибку:

error: request for member ‘call’ in ‘theTestObject’, which is of non-class type ‘TestClass(TestClass (*)())’

Таким образом, мы вместо того, чтобы заставить компилятор выполнять конструктор копирования, запретили вызывать этот конструктор.

К счастью для меня, такое решение подошло. Дело в том, что запретив конструктор копирования я вынудил компилятор использовать спецификацию шаблонного конструктора с теми же шаблонными аргументами, что и у текущего класса. То есть это не было «приведение объекта к своему типу», а это было «приведение к типу, у которого те же шаблонные аргументы», что порождает ещё один метод, но заменяет конструктор копирования.

Вот что вышло

template<typename T_Type, typename T_Holding, typename T_Access> class DReference {  . . . 	IDSharedMemoryHolder *_holder;  	void retain(IDSharedMemoryHolder *inHolder) { . . . } 	void release() { . . . }   . . .  	~DReference() { release(); }  	//NB: Workaround for Copy elision 	explicit DReference( 			const OwnType &inLReference) 					: _holder(NULL), _holding(), _access() 	{ 		// Call for some magic cases 		retain(inLReference._holder); 	}  	template<typename T_OtherType, 			typename T_OtherHolding, 			typename T_OtherAccess> 	DReference( 			const DReference<T_OtherType, T_OtherHolding, T_OtherAccess> & 					inLReference) : _holder(NULL), _holding(), _access() 	{ 		retain(inLReference._holder); 	}  . . .   } 

Для тестового примера аналог этого костыля выглядел бы вот так:

Тестовый код и короткое пояснение

#include <iostream> #include <stdio.h>  class TestClass { private: 	int _state;  public: 	TestClass(int inState) : _state(inState) { std::cout << "State based: " << inState << std::endl; }  	TestClass() : _state(1) { std::cout << "Default" << std::endl; } 	explicit TestClass(const TestClass &inObject0) : _state(2) { std::cout << "Const Copy" << std::endl; }  	template<typename T> 	TestClass(const T &inObject0) : _state(13) { std::cout << "Template Copy" << std::endl; }  	~TestClass() { std::cout << "Destroy" << std::endl; }  	void call() { std::cout << "Call " << _state << std::endl; } };  /////////////////////////////////////////////////////////////////////////////// int main() { 	TestClass theTestObject = TestClass(); 	theTestObject.call(); 	fflush(stdout); 	return 0; } 

Та же фишка. Спецификация шаблона, заменяющая конструктор копирования… Тут видно, что это плохое решение, ведь мы не к месту использовали шаблоны. Если кто знает как лучше — отпишитесь.

Вместо заключения

Когда я рассказал о copy elision нескольким знакомым, которые года три в С++ и около-С++ разработке, они тоже сделали вот так (0_0) удивились не меньше моего. Между тем, данная оптимизация может породить поведение, странное с точки зрения программиста, и вызвать ошибки при написании С++ приложений.

Надеюсь, данная статья кому-нибудь пригодиться и сэкономит чьё-нибудь время.

П.С.: Пишите по поводу замеченных оплошностей — буду править.

ссылка на оригинал статьи http://habrahabr.ru/post/252885/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *