вторник, ноября 07, 2006

Конструирование объектов в Дельфи и С++

Статья Exceptional Safety на блоге The Oracle at Delphi послужила предметом разговора за чашкой чая у нас с мужем, где мы обсуждали насколько же по-разному конструируются объекты в Дельфи и С++.

В С++ порядок конструирования объектов зачастую является источником недопонимания. При вызове конструктора класса сначала конструируется объект базового класса, потом его наследника и так далее. Непонятки начинаются при попытке вызова из конструктора базового класса виртуальной функции.


class CBase
{
public:
CBase()
{
cout<<"CBase::CBase"<<endl;
print();
}
virtual void print()
{
cout<<"print CBase::print"<<endl;
}
};

class CDerived : public CBase
{
public:
CDerived()
{
cout<<"CDerived::CDerived"<<endl;
}
virtual void print()
{
cout<<"print CDerived::print"<<endl;
}
};

int main()
{
cout<<"Creating CDerived"<<endl;
CDerived d;
}


Вывод получается такой.

Creating CDerived
CBase::CBase
print CBase::print
CDerived::CDerived


Поскольку vtable CDerived еще не заполнена на момент вызова функции print, будет вызвана функция CBase::print. В Дельфи таблица виртуальных функций, VMT, заполняется перед началом отработки конструктора и таких проблем не возникает.

type
TBase = class
protected
procedure Print; virtual;
public
constructor Create;
end;

TDerived = class(TBase)
protected
procedure Print; override;
public
constructor Create;
end;

{ TBase }

constructor TBase.Create;
begin
inherited Create;
ShowMessage('TBase.Create');
Print;
end;

procedure TBase.Print;
begin
ShowMessage('TBase.Print');
end;

{ TDerived }

constructor TDerived.Create;
begin
inherited Create;
ShowMessage('TDerived.Create');
end;

procedure TDerived.Print;
begin
ShowMessage('TDerived.Print');
end;


При попытке создать экземляр класса TDerived будут выданы следующие соообщения

TBase.Create
TDerived.Print
TDerived.Create


Причем не только VMT заполняется, вообще создается полный экземпляр класса, заполненный нулями.

В Дельфи все не так как в С++ и с порядком вызова конструктора. Вообще конструкторов может быть несколько и называться они могут по-разному. Но вызывает программист конструктор базового класса явно и тогда, когда ему удобно. Если считает, что это не нужно, то может вообще конструктор базового класса не вызывать.

Такие разные подходы ведут к интересным последствиям в случае выброса исключения из конструктора. В С++ выброс исключения в конструкторе часто рекомендуют для индикации ошибки при конструировании объекта, потому что конструктор не возвращает значений, а писать ошибку куда-либо еще неудобно. Но после выбрасывания исключения из конструктора деструктор вызван не будет. И надо как-то подчищать то, что конструктор успел сделать. Существуют разные способы это сделать, все несколько неочевидные.

В Дельфи в такой ситуации идет неявный вызов деструктора. Который уберет то, что было проинициализировано, но не тронет то, до чего дело не дошло. Не тронет он потому, что изначально все ссылки проинициализированы nil'ами, нулями. Те поля, для которых память уже выделена, будут иметь значения, отличные от nil. Для освобождения ресурсов в Дельфи есть деструктор по умолчанию Destroy и метод ему в помощь под названием Free. Destroy никогда не надо вызывать явно, он считается служебным, а Free отрабатывает следующим образом.
if Self <> nil then Self.Destroy;

Политика Дельфи мне нравится все-таки больше. Создание экземпляров класса более очевидно, а неявный вызов деструктора при вызове исключения из конструктора позволяет не задумываться над корректным освобождением ресурсов.

Ссылки по теме:
Thinking in C++, vol.2. 1.Exception Handling, Cleaning up.
[17] Exceptions and error handling, C++ FAQ Lite

26 коммент.:

CyberZX комментирует...

С++ по-моему всё же более логичен. Главное понять суть конструктора. Конструктор не имеет полиморфного действия. Поэтому конструрирование объекта полностью детерминированно. Конструктор в С++ нужен лишь для задания начального состояния объекта.
Я думаю, что для этого не нужен полиморфизм.
Для сложных схем инициализации объектов, когда базовый класс предоставляет абстрактный защищенный интерфейс для дочерних классов, надо использовать функции, которые будут вызываться явно после создания объекта.

В С++ очень не хватает полиморфных фукнций которые действуют как конструкторы и деструкторы. То есть вызывают по цепочке функции базового класса. Можно конечно это руками делать, но так менее надежно получается.

Анонимный комментирует...

Ну вобщем-то в С++ деструкторы тоже вызываются. И про корректное освобождение ресурсов я как-то специально не думаю. Оно само получается.

Alena комментирует...

Ну вобщем-то в С++ деструкторы тоже вызываются. И про корректное освобождение ресурсов я как-то специально не думаю

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

Анонимный комментирует...

Смешная точка зрения. С чего бы это вызывать деструктор объекта который не был сконструирован и следовательно вообще никогда не существовал? Не говоря уж о том что такая похабная модель грубо нарушает инкапсуляцию базового класса

Анонимный комментирует...

Вызов в конструкторе виртуального метода, это явный антипаттерн. Многие утилиты верификации кода, отмечают такие места как череватые ошибками.

Достаточно рассмотреть пример чуть ближе к реальности:

void CDeriveAdapter::print() {
count<<pDelegatedTo->getSomeValue();
}

pDelegatedTo инициализируется в конструкторе CDeriveAdapter, и если метод print вызывается (или в один прекрасный момент стал вызываться) из конструктора родителя, мы поимеем кучу гемороя с неинициализированными указателями.

Анонимный комментирует...

Тут я говорю только про случай, когда из конструктора выбрасывается исключение. Тогда деструктор вызван не будет.
Какой именно деструктор не будет вызыван? Совершенно точно будут вызваны деструкторы всех объектов, который сконструированы на момент выброса исключения. И все очистится. Другое дело, что если конструктор занял какие-то системные ресурсы, например, открыл файл, то он не закроется. Но можно, например, системные ресурсы инициализировать в объекте, конструктор которого не кидает исключений. Не могу сказать, что это прям-таки супер ухищрение.

Ivan Sagalaev комментирует...

Ничего себе реакция :-). Я-то думал, что попытки считать Delphi "неправильным языком" по сравнению с C++ уже вышли из моды. Позволю себе несколько комментариев:

Смешная точка зрения. С чего бы это вызывать деструктор объекта который не был сконструирован и следовательно вообще никогда не существовал? Не говоря уж о том что такая похабная модель грубо нарушает инкапсуляцию базового класса

"Смешно" и "похабно" - это не те признаки, на основе которых принимаются технические решения. Существует проблема - по ходу конструктора заняты ресурсы, которые надо освободить. Ее можно решать по-разному. Одно из решений - использовать готовый деструктор, просто сделав его безопасным для частично сконструированного объекта. Это не смешно, это удобно.

Если же не использовать деструктор для этого, то что, писать отдельный какой-то код, который будет делать то же, что деструктор?

Кроме того, я не понял, где тут нарушение инкапсуляции. Конструируется объект TDerived, вызывается его собственный деструктор. Этот деструктор в базовый класс сам не полезет, вызовет унаследованный.

Вызов в конструкторе виртуального метода, это явный антипаттерн. Многие утилиты верификации кода, отмечают такие места как череватые ошибками.

Это антипаттерн в C++, как раз из-за того, о чем написала Алена в статье. В Delphi это активно используется на практике.

Пример с вызовом виртуального метода, дергающего неинициализированное поле тоже подходит только для C++. В Delphi, поскольку наследник знает, что из базового уонструктора будет вызван его виртуальный метод, просто проинициализирует поле до вызова базового конструктора. Хотя по правде так просто не надо писать... Виртуальные методы, вызываемые из конструктора, обычно делают полиморфную инициализацию, то есть доназначают свои поля, а не используют их.

Какой именно деструктор не будет вызыван? Совершенно точно будут вызваны деструкторы всех объектов, который сконструированы на момент выброса исключения. И все очистится.

Серьезно?

CBase::CBase() {
mField = new SomeClass();
throw Exception();
}

Вот здесь будет вызван delete mField?

Вы говорите только об объектах в стеке, а объекты в куче, открытые файлы (как вы заметили), занятые локи, начатые транзакции и еще какие угодно варианты ресурсов автоматически не освобождаются.

Анонимный комментирует...

Об объектах на стеке, а также о деструкторах базовых классов. Согласитесь, это уже немало. Объекты на куче можно освобождать с помощью простейших умных указателей. То же самое для системных ресурсов. Конечно, это некоторый геморрой, но голову ломать над этим не надо.
Опять-таки, всю "указательную" и "системную" логику можно вынести в базовый класс.

Анонимный комментирует...

Может и не в тему, однако мне показалось интересным.
Про виртуальный конструктор.

http://kalinin.ru/programming/cpp/12_08_00.shtml

Alena комментирует...

Объекты на куче можно освобождать с помощью простейших умных указателей. То же самое для системных ресурсов. Конечно, это некоторый геморрой, но голову ломать над этим не надо.
Опять-таки, всю "указательную" и "системную" логику можно вынести в базовый класс.


Конечно, это все делается. Но в Дельфи над этим всем не надо задумываться вообще.

Анонимный комментирует...

>>Кроме того, я не понял, где тут нарушение инкапсуляции.

Базовый принцип ООП - OCP открытый для расширения и закрытый для модификации. У тебя нет доступа к состоянию базового класса и возможность не вызывать его конструктор это грубый дефект языка.
Не надо говорить что он нулями заполняется, это НЕ ИНИЦИАЛИЗАЦИЯ, заполнение нулями еще не делает валидним состояние объекта.

>>В Delphi это активно используется на практике
Это еще не значит что это хорошее решение и все остальные должны начать его использовать. В конструкторе базового класса я имею право вызвать свой виртуальный метод так? Чтобы все хорошо работало я должен сначала инициализировать Dervied чтобы он обрел состояние и можно было бы дергать этот метод безопасно. А чтобы можно было говорить о состоянии Derived нужно инициализировать Base. Все - циклический граф.

Повторюсь -- заполнение 0 это удобно но еще не есть инициализация и не делет состояние объекта валидным.

Ivan Sagalaev комментирует...

Базовый принцип ООП - OCP открытый для расширения и закрытый для модификации.

Это, возможно, базовый принцип ООП в C++, но не ООП вообще. Рекомендую прочитать вот эту статью Пола Грэхема про разные признаки того, что считать объектно-ориентированным.

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

Это не грубый дефект. Это возможность, которая может быть использовано как во вред, так и на пользу. Такие вещи есть в любом языке, потому что не существует теоретических абстракций, работающих абсолютно всегда, поэтому есть средства их обойти.

Следуя Вашей логике friend'ов в C++ тоже следует считать грубым дефектом языка, потому что они нарушают скрытие данных :-)

>>В Delphi это активно используется на практике
Это еще не значит что это хорошее решение и все остальные должны начать его использовать.


Я не призывал использовать это везде. Я говорил, что в Delphi это имеет смысл.

Повторюсь -- заполнение 0 это удобно но еще не есть инициализация и не делет состояние объекта валидным.

Я такого не говорил и не очень вижу, откуда это можно заключить. Вы спорите с собственной интерпретацией, а не со мной :-).

lrrr комментирует...

А по-моему разница небольшая -- в C++ грабли в основном в конструкторе сосредоточены (надо чтоб он корректно все обратно освобождал), в дельфи -- в деструкторе - деструктор должен догадываться как-то что успело проинициализироваться, а что нет.
А заполнение нулями смахивает на хак.
Про объекты в хипе ("где тут будет вызван delete?") -- вообще не из этой оперы, тут надо просто смартпоинтеры юзать.

>>Базовый принцип ООП - OCP открытый для расширения и закрытый для модификации.
Ну это как бы вряд ли можно назвать основным принципом ООП, тесные логические связи между наследником и базовым классом есть.
Другое дело -- надо помнить о LSP, и о модульности на уровне процедур надо заботиться, это ни в какие ворота:
"В Delphi, поскольку наследник знает, что из базового конструктора будет вызван его виртуальный метод, просто проинициализирует поле "/* вызова базового конструктора."

Ivan Sagalaev комментирует...

это ни в какие ворота

Заметьте, я сразу написал, что так вообще не надо программировать :-). Но иногда вам просто достается базовый класс со странным поведением от другого программиста.

Анонимный комментирует...

В C++ что бы не задумываться над удалением ресурсов используют концепцию RAII. Для памяти это уже упомянутые смарт-поинтеры, для файлов можно использовать fstream-ы и т.п.

Имхо просто разные языки приводят к различиям в методах написания корректного кода. И вопрос о том - какой подход удобнее больше зависит от человека и от того, к чему он привык. (Кроме того - конструкторы не есть весь язык и С++ по сравнению с делфи имеет тут ряд преимуществ, но это уже более флейм, чем реальный разговор)

Анонимный комментирует...

>>Пример с вызовом виртуального
>>метода, дергающего
>>неинициализированное поле тоже
>>подходит только для C++. В Delphi,
>>поскольку наследник знает, что из
>>базового уонструктора будет вызван
>>его виртуальный метод, просто >>проинициализирует поле до вызова
>>базового конструктора.

Угу, и сразу после того как появились наследники базового класса, этот класс становится священной коровой, и трогать его нельзя, ибо хрен знает где, что сломается. Правильно анонимус говорит про нарушение инкапсуляции.

Кстати проблема актуальна для Java, где не final методы фактически являются виртуальными, доступны из конструктора базового класса, и где нет возможности указать вызов базового констуктора в лбом месте.

Анонимный комментирует...

Alena: "Но в Дельфи над этим всем не надо задумываться вообще."
А как же указатели, которые те же программисты на Delphi используют сплошь и рядом? Они тоже автоматически освободятся? А системные объекты Windows? Delphi просто закрывает самые крупные дырки, напрочь забывая об остальном!
kmmbvnr: "этот класс становится священной коровой"
К сожалению для инкапсуляции в С++ применили именно это рассуждение. Я бы предпочел какой-нибудь вариант типа *_cast для работы с private. По той же причине я использую goto для выхода из двойного цикла - так удобнее и часто ,как не странно, понятней! А если кто-то пишет из-за этих возможностей плохие проги - ЭТО ЕГО ПРОБЛЕМА! Пусть пишет на бейсике :)))

Анонимный комментирует...

зачем бросать исключения в конструкторах? - может лучше создать метод bool init(),
использовать явную, либо отложенную инициализацию...
так намного понятней, особенно если нет под рукой исходного кода, а писать try-catch напоминает паранойю...

Alena комментирует...

зачем бросать исключения в конструкторах? - может лучше создать метод bool init(),
использовать явную, либо отложенную инициализацию...
так намного понятней, особенно если нет под рукой исходного кода, а писать try-catch напоминает паранойю...


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

Анонимный комментирует...

все равно ситуация сложная, если в конструкторе моего n-ого класса-наследника возникла исключительная ситуация, то кто освободит ресурсы аллоцированные в конструкторах 1..n-1 классов?

Alena комментирует...

все равно ситуация сложная, если в конструкторе моего n-ого класса-наследника возникла исключительная ситуация, то кто освободит ресурсы аллоцированные в конструкторах 1..n-1 классов?

угу, это забота программиста

Анонимный комментирует...

Пишу и на дельфи и на С++, но ниразу не задумывался об этом. Каким-то образом обхожу подобные ошибки автоматически...
Кстати, описанное здесь уже было на чьем-то англоязычном блоге, что, на мой взгляд, доказывает: мысли спецов идут похожими путями.

Анонимный комментирует...


>
>>все равно ситуация сложная, если в конструкторе моего n-ого класса-наследника возникла исключительная ситуация, то кто освободит ресурсы аллоцированные в конструкторах 1..n-1 классов?
>
>угу, это забота программиста
>


С ума сошли, что ли? Деструкторы базовых классов будут вызваны, конечно же.

Иначе бы техника выбрасывания исключений из конструктора была бесполезной.

Насчет кучи - попросту нигде не следует использовать голых указателей, например таких конструкций:

Object *p = new Object;

Их следует заменять, например, на

std::auto_ptr< Object> p (new Object);

и проблем не будет.

А вместо динамических массивов использовать std::vector.

Alena комментирует...

С ума сошли, что ли?
В этом блоге приветствуется спокойная вежливая дискуссия.

Деструкторы базовых классов будут вызваны, конечно же.

Угу, да. Деструкторы базовых классов должны быть вызваны. Я набрела на рассуждения о том, насколько это правильно, на жалобы на компиляторы, которые так не делают, но вообще должны.

Цей блог вмер комментирует...

Почти год назад я писал на Хабре (http://habrahabr.ru/blogs/cpp/64369/) про одну извращённую технику, позволяющую делать виртуальный вызов в конструкторе :) Автор, правда, не я. Как говорится, «я просто разместил объяву». Но вдруг кому пригодится…

Константин комментирует...

Если бы в C++ у конструктора было бы полиморфное поведение, то пришлось бы конструировать все поля всей иерархии до того как выполнять конструктор самого базового класса. Мне кажется, что это несколько запутано.