Пропуск конструктора — довольно приятная оптимизация в плане быстродействия. Но так ли она безопасна? Давайте разбираться. Для начала немного информации, для тех, кто еще не курсе.

Copy elision (пропуск копии) — оптимизация, заключающаяся в том, что компилятор может избавиться от вызова «лишних» конструкторов копирования.

Например:

class M
{
public:
	M() { cout << "M()" << endl; }
	M(const M &obj) { cout << "M(const M &obj)" << endl; }
};

M func()
{
	M m1;
	return m1;
}

int _tmain(int argc, _TCHAR* argv[])
{
	M m2 = func();

	Sleep(-1);
	return 0;
}

Что делает этот код? Позволю себе предположить:

1) Создается временный объект m1 класса M в теле func();
2) Происходит вызов конструктора копирования для помещения копии m1 в возвращаемое значение, ведь функция должна предоставить копию временного объекта, который будет уничтожен, как только произойдет выход из тела функции;
3) Происходит вызов деструктора для временного объекта m1;
4) Происходит вызов конструктора копирования для m2, который производит конструирование m2 на основе объекта, который вернула func();
5) Происходит вызов деструктора временного объекта, который вернула func(), так как он больше не нужен.

На самом деле, результат работы этого кода будет иным! Запускаю программу в Visual Studio 2013 Release, вижу:

M()

Оптимизатор убрал вызов целых двух конструкторов копирования и двух деструкторов. Повторяю эксперимент, вполне возможно, что произошло элементарное отбрасывание, по аналогии с пустыми циклами и балластными переменными. Привожу код к виду, близкому к боевому. Для ясности — не определяю перемещающий оператор присваивания и перемещающий конструктор:

size_t I = 0;// Переменная для симуляции глобальных эффектов.

class M
{
private:
	int i;
public:
	M(int Value = 0) : i{ Value }
	{ 
		/* Конструктор с параметром по умолчанию */
		++I;
		cout << "M(" << Value << ")" << endl; 
	}
	M(const M &obj) : i{ obj.i }
	{ 
		/* Конструктор копирования */
		++I;
		cout << "M(const M &obj)" << endl; 
	}
	M &operator=(const M &obj)
	{
		/* Копирующий оператор присваивания */
               if (this != &obj)
               {
		      i = obj.i;
		      cout << "M &operator=(const M &obj)" << endl;
                }
              return *this;
	}
	~M()
	{
                ++I;
		cout << "~M()" << endl;
	}
};

M func(int Value)
{
	if (Value > 0)
	{
		M m1{ 100 };
		return m1;
	}
	else {
		M m1{ -100 };
		return m1;
	}
}

int _tmain(int argc, _TCHAR* argv[])
{
	M m2 = func(1);
	cout << endl;
	M m3 = func(-1);
	cout << endl;
        cout << I << endl;
	Sleep(-1);
	return 0;
}

Что должно произойти в результате выполнения этой программы? По логике произойти должно следующее:

1) Должен создаться временный объект класса M в одной из логических веток func() — что означает вызов конструктора с параметром для создаваемого временного объекта;
2) Должно произойти копирование временного объекта, который создается в теле функции, чтобы при выходе из функции существовала копия этого временного объекта;
3) Должен произойти вызов деструктора локального объекта m1;
4) Должен произойти вызов конструктора копирования для m2, который примет в качестве аргумента объект, который возвращает func() (копия локального объекта);
5) Должен произойти вызов деструктора временного объекта, который вернула функция для передачи в конструктор копирования m2;
...) Для m3 все аналогично.

Что мы ожидаем? Ожидаем мы пять вызовов ++I при выполнении M m2 = func(); И еще пять вызовов ++I при выполнении M m3 = func();

Результат работы программы:

M(100)
M(const M &obj)
~M()

M(-100)
M(const M &obj)
~M()

6


Что мы тут видим? А видим мы боль. Компилятор проигнорировал тот факт, что конструкторы и деструкторы M имеют глобальные побочные эффекты. Умный компилятор выбросил из нашей логической цепочки:

— создание копии m1 для передачи копии локального объекта как результата работы функции;
— вызов деструктора локального объекта m1 (что логично после выполнения первого пункта).

Деструктор локального объекта был вызван только после вызова конструктора копирования m2/m3.

В итоге — I изменилась не на 10, а на 6.

А теперь введем маленькую правку — сделаем так, чтобы func() возвращала не копию M, а ссылку &M. Логика подсказывает, что так делать нельзя — ссылка на локальный объект становится некорректной сразу же после выхода из функции. Это очевидно.

Но раз у нас есть «умный» компилятор, который откладывает вызов деструктора локального объекта до завершения конструктора копирования, то почему бы не рискнуть? Возможно, компилятор отложит вызов деструктора локального объекта, ссылка на который используется? Это было бы очень хорошо и правильно. Это имело бы смысл. Пробуем:

M &func(int Value)// Теперь возвращаем ссылку, а не копию.
{
	if (Value > 0)
	{
		M m1{ 100 };
		return m1;
	}
	else {
		M m1{ -100 };
		return m1;
	}
}

Результат работы программы:

M(100)
~M()
M(M &obj)

M(-100)
~M()
M(M &obj)

6


Что мы видим? Деструктор локального объекта m1 теперь вызывается сразу же после выхода из функции. «Умный» компилятор все делает наоборот. Ссылка становится битой. На основе ссылки на уничтоженный объект создается еще один объект. Программа не вызывает исключение только чудом. Если бы класс имел какие-то динамические данные или реализовывал бы семантику перемещения, то мы бы получили очень неприятные и непонятные стелс-ошибки. В большом проекте — это гарантия долгих и увлекательных приключений.

Такой вот Copy elision…

Компилятор игнорирует глобальные побочные эффекты конструкторов. Да, это поведение определено в Стандарте — применение copy elision при наличии глобальных побочных эффектов в конструкторах/конструкторах копирования. В результате чего — программа функционирует абсолютно неправильно, а потенциальное количество проблем в крупном проекте увеличивается до неприличия.

На самом деле, есть еще несколько интересных моментов, которые вытекают из такого поведения. Тем, кому интересно — рекомендую поиграться с разными настройками компиляции и перемещающими конструкторами.

После таких вещей я вообще перестаю понимать, что происходит в коде, который я пишу. Главное правило, какое можно вынести из этих примеров звучит так:

— Никогда, абсолютно никогда не допускайте глобальных побочных эффектов в конструкторах.

Учитывая тот факт, что логика конструирования может меняться в каждом конкретном участке кода, трудно даже представить, к каким проблемам может привести корректный на первый взгляд код благодаря «пропуску копии».

PS. Прошу высказывать всю критику относительно статьи. Это моя первая статья, я понимаю, что излагаю не совсем гладко и прозрачно, но я хотел помочь тем, кто может столкнуться с таким поведением компилятора. Сам довольно долго разбирался с этим.

Комментарии (39)