четверг, 22 марта 2012 г.

Неявный вызов конструктора копирования (С++)


- Какая разница между пуганой вороной и письменным столом?
"Вот это совсем другой разговор! - подумала Алиса.- Загадки-то я люблю! Поиграем!"
- Кажется, сейчас отгадаю,- прибавила она вслух.
- Ты думаешь, что могла бы отыскать отгадку? - удивленно спросил Заяц...
                 Льюис Керролл "Алиса в стране чудес"

Чем отличается выражение
1)     Entity cowedCrow( 1);
от выражения
2)     Entity desk = 1;
кроме названия объектов, конечно же?
На удивление многие программисты дают неверный ответ. Менее опытные ошибочно полагают, что во втором случае сначала вызывается конструктор без аргументов, а затем оператор присваивания. Более опытные наивно полагают, что данные выражения эквивалентны (по сути, не отличаются). И, наконец, опытные убеждены, что знают правильный ответ, и готовы прекратить чтение статьи.
Что же, попробуем доставить немного удовольствия опытным программистам, а заодно и развеять заблуждения менее опытных.

Рассмотрим возможное определение класса Entity:
using namespace std; // Стыдимся, но для примера сойдет

class Entity {
public:
       Entity( int number) {
             cout << "ctor: " << number << endl;
       }
       Entity( const Entity &) {
             cout << "copy ctor" << endl;
       }
private:
       Entity & operator= ( const Entity &);
};

Отметим, что при такой реализации выражения 1) и 2) успешно компилируются, несмотря на то, что Entity не имеет конструктора по умолчанию и его оператор присваивания недоступен. Это происходит потому, что оба выражения, согласно стандарту С++, описывают инициализацию (создание) объекта. Таким образом, в выражении 2) оператор присваивания не вызывается, поскольку объект desk еще не создан.
Выполнение выражений 1) и 2) приводит к выводу на консоль:
ctor: 1
ctor: 1
Это именно тот результат, который ожидали получить более опытные программисты. Но значит ли это, что выражения 1) и 2) эквивалентны? Изменим интерфейс класса Entity следующим образом:
class Entity {
public:
// Как и раньше
private:
       Entity( const Entity &) { // Теперь недоступен вне класса
             // Как и раньше
       }
       // Как и раньше
};

Мы закрыли доступ к конструктору копирования, что привило к тому, что компилятор gcc 4 выдал ошибку в выражении 2), ссылаясь на недоступный конструктор копирования. Компилятор VC10 при этом откомпилировал этот же код без ошибок. Для того, чтобы понять, какой компилятор прав, вновь обратимся к стандарту С++.
Выражение 1) Entity cowedCrow( 1); описывает вид инициализации объекта, называемый прямой инициализацией (direct initialization) и подразумевает явный вызов конструктора.
Выражение 2) Entity desk = 1; описывает вид инициализации объекта, называемый инициализацией копированием (copy initialization) и подразумевает неявный вызов конструктора копирования.
Таким образом, компилятор gcc 4 поступает в соответствии со стандартом. Поведение VC10 объясняется тем, что в Visual Studio 2010 по умолчанию включены расширения языка (Project->Properties->C/C++->Language->Disable Language Extensions = No). Если эти расширения отключить, то результат будет аналогичен gcc 4.
Итак, мы выяснили, что выражение 2) подразумевает вызов конструктора копирования, так почему же результат выполнения выражений 1) и 2) в примере выше одинаковый (вызова конструктора копирования в выражении 2) не было)? Дело в том, что по стандарту компилятору разрешено производить преобразования с целью исключения создания временных объектов и обращения к конструктору копирования и генерировать тот же самый код, что и при прямой инициализации. И хотя большинство компиляторов выполняют такое преобразование, стандарт этого не требует. А это значит, что могут найтись компиляторы, которые при выполнении выражения 2) вызовут конструктор копирования.
Для того, чтобы окончательно убедить вас в том, что результат выполнения выражения 2) – результат преобразований компилятора, рассмотрим результаты выполнения следующих выражений (компилятор VC10, конструктор копирования объявлен в секции public):
3) Entity desk2 = Entity( 2);
4) Entity cowedCrow2( Entity( 3));

На консоль будет выведено (ни в одном случае конструктор копирования не вызывается):
ctor: 2
ctor: 3

Отметим также и тот факт, что хотя VC10 с включенными расширениями языка и закрытым конструктором копирования без проблем компилирует выражение Entity desk = 1, он не может сделать того же для выражения Entity desk2 = Entity( 2).
Возвращаясь к рассматриваемому преобразованию, заметим, что компилятор применяет его после того, как проверит семантику программы. Вот почему компилятор выдавал ошибку на выражении Entity desk = 1; при недоступном конструкторе копирования. Логика рассуждений компилятора в данном случае примерно такая:
1)    создать из 1 временный объект класса Entity: Entity(1);
2)    создать объект desk, копированием Entity(1) в desk;
3) проанализировать возможность преобразования и выполнить его: исключить создание временного Entity(1) и, таким образом, создать из 1 объект desk напрямую.
До шага 3) компилятор не доходит, поскольку не может выполнить шаг 2) при недоступном конструкторе копирования.
Итак, одно из отличий выражений 1) и 2) в том, что выражение 2) может не откомпилироваться в случае, когда конструктор класса Entity недоступен, а при доступном конструкторе копирования может привести к его вызову. Есть ли какие-нибудь другие отличия? Как оказывается – да! Обратите внимание на слово «неявный» в названии статьи. Давайте посмотрим, что оно означает. Изменим интерфейс класса Entity следующим образом:
class Entity {
public:
       explicit Entity( int number) { // Теперь разрешены только явные вызовы
             // Как и раньше
       }
       Entity( const Entity &) {
             // Как и раньше
       }
private:
       // Как и раньше
};

Выражение Entity desk = 1; больше не компилируется, поскольку для выполнения шага 1) (см. выше) компилятору требуется неявно вызвать конструктор Entity( int) для создания временного объекта Entity. При этом проблем с вызовом Entity desk2 = Entity( 2) не возникает, поскольку на внутренние преобразования компилятора не влияет тот факт, что конструктор Entity объявлен явным.
В принципе, мы можем пойти еще дальше и объявить конструктор копирования явным:
class Entity {
public:
       explicit Entity( int number) {
             // Как и раньше
       }
       explicit Entity( const Entity &) {
             // Как и раньше
       }
private:
       // Как и раньше
};

Такая конструкция обеспечивает полный запрет неявной инициализации (инициализации копированием): ни Entity desk = 1; ни Entity desk2 = Entity( 2) больше не компилируются (даже в VC10 с включенными расширениями языка). Доступной при этом остается только прямая инициализация. Зачем может потребоваться специально запрещать синтаксис инициализации копированием – отдельный вопрос, однако суть в том, что раз язык позволяет, то всегда существует вероятность того, что подобная конструкция языка будет где-нибудь использована.
Таким образом, следующее отличие выражений 1) и 2) в том, что синтаксис выражения 2) можно запретить (сделав его ошибочным).
Анализируя отличия прямой инициализации и инициализации копированием можно сделать следующий вывод: за исключением тривиальных случаев (например, встроенные типы языка) следует отдавать предпочтение синтаксису прямой инициализации. Это правило особенно важно в шаблонных выражениях. Однако и при инициализации конкретных классов оно не утрачивает актуальности: часто существует вероятность того, что в процессе разработки/сопровождения программы интерфейс класса изменится (например, конструктор класса станет явным или конструктор копирования станет недоступным) и инициализация копированием перестанет работать.

4 комментария:

  1. "Однако и при инициализации конкретных классов оно не утрачивает актуальности: часто существует вероятность того, что в процессе разработки/сопровождения программы интерфейс класса изменится (например, конструктор класса станет явным или конструктор копирования станет недоступным) и инициализация копированием перестанет работать."

    Конструкторы не отосятся к интерфейсам классов, потому что они не описывают поведение.

    Кстати именно из таких соображений я не рекомендую в C# использовать ограничения типов в шаблоне интерфейсов вроде new(). Например, public ISomething: where T: new().

    ОтветитьУдалить
    Ответы
    1. Черт, шаблон интерфейса парсер съел. Ну я думаю, все понятно.

      Удалить
    2. Я придерживаюсь общепринятой терминологии С++, когда понятие "интерфейс класса" употребляется и для обозначения "внешней части" класса (т.е. части, которая необходима клиенту для того, чтобы откомпилировать программу). Возможно, лучше назвать это сигнатурой класса, но так не принято выражаться, и придется давать разъяснения (как и в случае использования термина "внешняя часть"). Термин "объявление класса" в С++ означает несколько иное. "Определение класса" может содержать детали реализации класса - тоже не годится. Кстати, Скотт Майерс в своей знаменитой книге (55 советов...) тоже местами использует подобную терминологию.
      Мурад, смирись, наконец, с тем, что одни и те же термины могут означать разные вещи - это присуще большинству языков.

      Удалить
  2. Явный конструктор копирования (например, в примере выше: explicit Entity( const Entity &)) имеет следующий "побочный" эффект: нельзя создать функцию, возвращающую Entity по значению или сгенерировать исключение типа Entity.

    ОтветитьУдалить