Уже год в свободное от работы время я пилю что-то вроде смеси Maven и Spring для С++. Важной её частью является самописная система умных указателей. Зачем мне всё это — отдельная тема. В данной статье я хочу коротко рассказать о том, как одна, казалось бы, полезная фича С++ заставила меня усомниться в здравом смысле Стандарта.
Редактировано:
Приношу свои извинения хабрасообществу и Стандарту. Буквально на следующий день после отправки статьи осознал грубую ошибку в своих размышлениях. Лучше читать сразу конец статьи… и, да, к copy elision, выходит, статья относиться лишь косвенно.
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) удивились не меньше моего. Между тем, данная оптимизация может породить поведение, странное с точки зрения программиста, и вызвать ошибки при написании С++ приложений.
Надеюсь, данная статья кому-нибудь пригодится и сэкономит чьё-нибудь время.
П.С.: Пишите по поводу замеченных оплошностей — буду править.
Редактировано:
Комментирующие правы, я вообще неправильно понял проблему. Спасибо всем, особенно Monnoroch за выявление логических ошибок в статье.
Написав следующий тестовый код, я получил корректный output:
///////////////////////////////////////////////////////////////////////////////
#include <iostream>
///////////////////////////////////////////////////////////////////////////////
template<typename T_Type>
class TestTemplateClass {
private:
typedef TestTemplateClass<T_Type> OwnType;
T_Type _state;
public:
TestTemplateClass() : _state() {
std::cout << "Default constructor" << std::endl;
}
TestTemplateClass(int inState) : _state(inState) {
std::cout << "State constructor" << std::endl;
}
TestTemplateClass(const OwnType &inValue) {
std::cout << "Copy constructor" << std::endl;
}
template<typename T_OtherType>
TestTemplateClass(const TestTemplateClass<T_OtherType> &inValue) {
std::cout << "Template-copy constructor" << std::endl;
}
template<typename T_OtherType>
void operator = (const TestTemplateClass<T_OtherType> &inValue) {
std::cout << "Operator" << std::endl;
}
~TestTemplateClass() {
std::cout << "Destructor" << std::endl;
}
};
///////////////////////////////////////////////////////////////////////////////
TestTemplateClass<int> createFunction() {
return TestTemplateClass<int>();
}
///////////////////////////////////////////////////////////////////////////////
int main() {
TestTemplateClass<int> theReference = createFunction();
std::cout << "Finished" << std::endl;
return 0;
}
///////////////////////////////////////////////////////////////////////////////
Output:
Default constructor
Copy constructor
Destructor
Copy constructor
Destructor
Finished
Destructor
То есть действительно, проблема была не в copy elision и не нужно никаких хаков.
Действительная же ошибка оказалась банальной. Сейчас стыдно за то, что я взялся писать статью, не проверив всё как следует.
Дело в том, что умные указатели принимают три шаблонных аргумента:
template<typename T_Type, typename T_Holding, typename T_Access>
class DReference {
. . .
- T_Type — тип объекта, который управляется системой умных указателей.
- T_Holding — стратегия владения памятью.
- T_Access — стратегия доступа к памяти.
Такой вариант реализации умных указателей позволяет сделать гибкой настройку их поведения, но при этом делает громоздким их использование (особенно с учётом того, что стратегии также шаблонные классы).
Пример объявления strong-указателя:
DReference<MyType, DReferenceStrongHolding<MyType>, DReferenceCachingAccess< MyType > > theReference;
Чтобы избежать загромождения кода, я хотел использовать фичу стандарта С++11 — template-alias. Но, как оказалось, g++ 4.6.1 их не поддерживает. Конечно, когда пишешь свой домашний pet-project возиться с настройкой среды лень, поэтому я решил сделать очередной workaround и избавиться от аргумента с помощью наследования:
template<typename T_Type>
class DStrongReference : public DReference< T_Type, DReferenceStrongHolding<MyType>, DReferenceCachingAccess< MyType > > {
. . .
При этом нужно было определить кучу конструкторов для DStrongReference, вызывающих из себя соответствующие конструкторы базового класса DReference — ведь конструкторы не наследуются. И, конечно, я пропустил конструктор копирования… В общем, единственный совет, который могу дать после всех этих приключений — нужно быть очень внимательным при использовании шаблонов, чтобы не попасть в такую глупую ситуацию, в которую попал я.
P.S.: Вот тест, который использует наследование для замены template-alias (спасибо ToSHiC за дельный совет передавать this в output):
///////////////////////////////////////////////////////////////////////////////
#include <iostream>
///////////////////////////////////////////////////////////////////////////////
template<typename T_Type, typename T_Strategy>
class TestTemplateClass {
private:
typedef TestTemplateClass<T_Type, T_Strategy> OwnType;
T_Type _state;
T_Strategy _strategy;
public:
TestTemplateClass() : _state(), _strategy() {
std::cout << "Default constructor: " << this << std::endl;
}
TestTemplateClass(int inState) : _state(inState), _strategy() {
std::cout << "State constructor: " << this << std::endl;
}
TestTemplateClass(const OwnType &inValue)
: _state(), _strategy()
{
std::cout << "Copy constructor: " << this << " from " <<
&inValue << std::endl;
}
template<typename T_OtherType, typename T_OtherStrategy>
TestTemplateClass(
const TestTemplateClass<T_OtherType, T_OtherStrategy> &inValue)
: _state(), _strategy()
{
std::cout << "Template-copy constructor: " << this << std::endl;
}
void operator = (const OwnType &inValue) {
std::cout << "Assigning: " << this << " from " << inValue << std::endl;
}
template<typename T_OtherType, typename T_OtherStrategy>
void operator = (
const TestTemplateClass<T_OtherType, T_OtherStrategy> &inValue)
{
std::cout << "Assigning: " << this << " from "
<< &inValue << std::endl;
}
~TestTemplateClass() {
std::cout << "Destructor: " << this << std::endl;
}
};
///////////////////////////////////////////////////////////////////////////////
template<typename T_Type>
class TestTemplateClassIntStrategy : public TestTemplateClass<T_Type, int> {
private:
//- Types
typedef TestTemplateClassIntStrategy<T_Type> OwnType;
typedef TestTemplateClass<T_Type, int> ParentType;
public:
TestTemplateClassIntStrategy() : ParentType() { }
TestTemplateClassIntStrategy(int inState) : ParentType(inState) { }
TestTemplateClassIntStrategy(const OwnType &inValue)
: ParentType(inValue) { }
template<typename T_OtherType, typename T_OtherStrategy>
TestTemplateClassIntStrategy(
const TestTemplateClass<T_OtherType, T_OtherStrategy> &inValue)
: ParentType(inValue) { }
//- Operators
void operator = (const OwnType &inValue) {
ParentType::operator =(inValue);
}
template<typename T_OtherType, typename T_OtherStrategy>
void operator = (
const TestTemplateClass<T_OtherType, T_OtherStrategy> &inValue)
{
ParentType::operator =(inValue);
}
};
///////////////////////////////////////////////////////////////////////////////
TestTemplateClassIntStrategy<int> createFunction() {
return TestTemplateClassIntStrategy<int>();
}
int main() {
TestTemplateClassIntStrategy<int> theReference = createFunction();
std::cout << "Finished" << std::endl;
return 0;
}
Output:
Default constructor: 0x28fed8
Copy constructor: 0x28ff08 from 0x28fed8
Destructor: 0x28fed8
Copy constructor: 0x28ff00 from 0x28ff08
Destructor: 0x28ff08
Finished
Destructor: 0x28ff00
. . .
int main() {
TestTemplateClassIntStrategy<int> theReference;
theReference = createFunction();
std::cout << "Finished" << std::endl;
return 0;
}
Default constructor: 0x28ff00
Default constructor: 0x28fed8
Copy constructor: 0x28ff08 from 0x28fed8
Destructor: 0x28fed8
Assigning: 0x28ff00 from 0x28ff08
Destructor: 0x28ff08
Finished
Destructor: 0x28ff00
Важный минус этого способа: если таким образом определить strong-pointer и weak-pointer, они будут совершенно разных типов (не связанных даже с одним template-классом) и не выйдет их присваивать один другому напрямую в момент инициализации.
<Редактировано №2>
Снова поторопился что-то утверждать. Ночью понял. Выйдет ведь… У этих классов один общий шаблонный предок.
То есть если в наследнике (который имитирует template-alias) будет описан шаблонный конструктор от произвольного DReference, в следующем коде всё будет нормально:
DStrongReference<Type> theStrongReference;
// В следующей строке будет вызван шаблонный конструктор от базового шаблонного класса. Вот этот:
//
// template<typename Type, typename Owning, typename Holding>
// DWeakReference::DWeakReference(const DReference<Type, Owning, Holding> &ref) : Parent(ref) { }
//
// Этот конструктор будет вызван за счёт того, что DStrongReference наследует DReference.
//
DWeakReference<Type> theWeakReference = theStrongReference;
Тестовый код для двух классов, организованных таким образом:
//============================================================================
// Name : demiurg_application_example.cpp
// Author :
// Version :
// Copyright : Your copyright notice
// Description : Hello World in C++, Ansi-style
//============================================================================
///////////////////////////////////////////////////////////////////////////////
#include <iostream>
///////////////////////////////////////////////////////////////////////////////
template<typename T_Type, typename T_Strategy>
class TestTemplateClass {
private:
typedef TestTemplateClass<T_Type, T_Strategy> OwnType;
T_Type _state;
T_Strategy _strategy;
public:
TestTemplateClass() : _state(), _strategy() {
std::cout << "Default constructor: " << this << std::endl;
}
TestTemplateClass(int inState) : _state(inState), _strategy() {
std::cout << "State constructor: " << this << std::endl;
}
TestTemplateClass(const OwnType &inValue)
: _state(), _strategy()
{
std::cout << "Copy constructor: " << this << " from " <<
&inValue << std::endl;
}
template<typename T_OtherType, typename T_OtherStrategy>
TestTemplateClass(
const TestTemplateClass<T_OtherType, T_OtherStrategy> &inValue)
: _state(), _strategy()
{
std::cout << "Template-copy constructor: " << this << std::endl;
}
void operator = (const OwnType &inValue) {
std::cout << "Assigning: " << this << " from " << &inValue << std::endl;
}
template<typename T_OtherType, typename T_OtherStrategy>
void operator = (
const TestTemplateClass<T_OtherType, T_OtherStrategy> &inValue)
{
std::cout << "Assigning: " << this << " from "
<< &inValue << std::endl;
}
~TestTemplateClass() {
std::cout << "Destructor: " << this << std::endl;
}
};
///////////////////////////////////////////////////////////////////////////////
//- Integer strategy
template<typename T_Type>
class TestTemplateClassIntStrategy : public TestTemplateClass<T_Type, int> {
private:
//- Types
typedef TestTemplateClassIntStrategy<T_Type> OwnType;
typedef TestTemplateClass<T_Type, int> ParentType;
public:
TestTemplateClassIntStrategy() : ParentType() { }
TestTemplateClassIntStrategy(int inState) : ParentType(inState) { }
TestTemplateClassIntStrategy(const OwnType &inValue)
: ParentType(inValue) { }
template<typename T_OtherType, typename T_OtherStrategy>
TestTemplateClassIntStrategy(
const TestTemplateClass<T_OtherType, T_OtherStrategy> &inValue)
: ParentType(inValue) { }
//- Operators
void operator = (const OwnType &inValue) {
ParentType::operator =(inValue);
}
template<typename T_OtherType, typename T_OtherStrategy>
void operator = (
const TestTemplateClass<T_OtherType, T_OtherStrategy> &inValue)
{
ParentType::operator =(inValue);
}
};
//- Boolean strategy
template<typename T_Type>
class TestTemplateClassBoolStrategy : public TestTemplateClass<T_Type, bool> {
private:
//- Types
typedef TestTemplateClassBoolStrategy<T_Type> OwnType;
typedef TestTemplateClass<T_Type, bool> ParentType;
public:
TestTemplateClassBoolStrategy() : ParentType() { }
TestTemplateClassBoolStrategy(int inState) : ParentType(inState) { }
TestTemplateClassBoolStrategy(const OwnType &inValue)
: ParentType(inValue) { }
template<typename T_OtherType, typename T_OtherStrategy>
TestTemplateClassBoolStrategy(
const TestTemplateClass<T_OtherType, T_OtherStrategy> &inValue)
: ParentType(inValue) { }
//- Operators
void operator = (const OwnType &inValue) {
ParentType::operator =(inValue);
}
template<typename T_OtherType, typename T_OtherStrategy>
void operator = (
const TestTemplateClass<T_OtherType, T_OtherStrategy> &inValue)
{
ParentType::operator =(inValue);
}
};
///////////////////////////////////////////////////////////////////////////////
TestTemplateClassBoolStrategy<int> createFunction() {
return TestTemplateClassBoolStrategy<int>();
}
int main() {
TestTemplateClassIntStrategy<int> theReference;
theReference = createFunction();
std::cout << "Finished" << std::endl;
return 0;
}
Output:
Default constructor: 0x28fed8
Copy constructor: 0x28ff08 from 0x28fed8
Destructor: 0x28fed8
Copy constructor: 0x28ff00 from 0x28ff08
Destructor: 0x28ff08
Finished
Destructor: 0x28ff00
В общем, всё работает
</Редактировано №2>
Спасибо 1eqinfinity, Torvald3d за указание орфографических ошибок.
Автор: semenyakinVS