А. Григорьев - О чём не пишут в книгах по Delphi
Положение спасает то, что об уведомлении окон заботится система Windows: всем окнам, которые удаляются в результате вызова функции DestroyWindow, отправляется сообщение WM_DESTROY, причем в момент получения этого сообщения ни окно, ни его родитель еще не уничтожены. Это позволяет компоненту как-то реагировать на свое удаление до того, как окно будет уничтожено.
Казалось бы, выход найден: нужно освобождать память в обработчике сообщения WM_DESTROY. Но и тут не все так просто. Дело в том, что окно может уничтожаться не только при удалении компонента, но и при изменении некоторых свойств (например, Parent). При этом окно удаляется, а вместо него создается новое, и при удалении старого окна компонент тоже получает сообщение WM_DESTROY. Что же касается компонента TComboBox, он обеспечивает, что при удалении и последующем создании окна все элементы, в том числе связанные с ними значения, восстанавливаются. Таким образом, если мы в наследнике TComboBox в обработчике сообщения WM_DESTROY всегда будем освобождать выделенную память, после повторного создания окна получим "битые" ссылки в свойстве Items.Objects, чего, естественно, хотелось бы избежать. Требуется научиться отличать полное удаление компонента от удаления окна с целью повторного создания.
Вообще говоря, механизм для этого предусмотрен в VCL — это флаг csDestroying в свойстве ComponentState. Выполнение деструктора TWinControl.Destroy начинается с вызова метода Destroying, добавляющего этот флаг во всех компонентах, которыми владеет данный компонент. Однако по наличию этого флага у компонента мы не можем в обработчике WM_DESTROY узнать, удаляется весь компонент, или только окно для создания заново. Рассмотрим, например, ситуацию, когда на форму во время проектирования разработчик положил панель, а на эту панель — любой оконный компонент, например, кнопку. Владельцем кнопки в этом случае все равно является форма, а панель — только родителем. Если теперь удалить панель, не удаляя форму, метод Destroying панели не затронет кнопку, и на момент получения кнопкой сообщения WM_DESTROY флаг csDestroying у нее еще не будет установлен, несмотря на то, что кнопка удаляется.
Тем не менее флаг csDestroying все же может помочь нам. Компонент удаляется в одном из трех случаев:
1. Удаляется непосредственно данный компонент.
2. Удаляется владелец компонента.
3. Удаляется родитель компонента.
В первом случае удаление начинается не с удаления окна, а с вызова деструктора компонента, и окно компонент удаляет уже сам, когда флаг csDestroying установлен деструктором. Во втором случае деструктор владельца, прежде чем удалить окно, заботится о том, чтобы компонент получил флаг csDestroying, поэтому даже если владелец является одновременно и родителем, флаг у компонента в момент удаления окна уже будет. И, наконец, остается третья ситуация, в которой флага csDestroying у компонента может и не быть. Но в любом случае удаление цепочки компонентов начинается с вызова деструктора "главного" из них. По линии владельца флаг csDestroying передается, по линии родителя — нет, но самый верхний из цепочки родителей обязательно имеет такой флаг. Соответственно, чтобы определить, удаляется ли окно из-за уничтожения визуального компонента, нужно искать флаг csDestroying не только у самого компонента, но и у всей цепочки его родителей. Если флаг нигде не найден, значит, удаляется только окно, но не сам компонент.
На главном окне примера ParentWnd есть также кнопка Right Combo, которая создает на форме визуальный компонент типа TRightCombo. Это правильный вариант класса TWrongCombo, в котором деструктор не переопределяется, а обработчик сообщения WM_DESTROY реализован в соответствии с тем, что написано ранее (листинг 3.65).
Листинг 3.65. Обработчик сообщения WM_DESTROY класса TRightComboprocedure TRightCombo.WMDestroy(var Msg: TMessage);
var
I: integer;
FinalDestruction: Boolean;
P: TControl;
begin
FinalDestruction := False;
P := Self;
while Assigned(P) do
begin
if csDestroying in F.ComponentState then
begin
FinalDestruction := True;
Break;
end;
P := P.Parent;
end;
if FinalDestruction then
for I := 0 to Items.Count - 1 do
Dispose(PDateTime(Items.Objects[I]));
inherited;
end;
Такой компонент корректно освобождает память при его удалении, но не освобождает ее тогда, когда окно создается заново.
ПримечаниеЕсть еще одна очень распространенная причина получения ошибки "Control has no parent window" при разработке собственных компонентов — попытка обращения к свойствам, требующим наличия окна, до назначения свойства Parent. Например, такая ошибка появилась бы, если бы мы в наших наследниках TComboBox попытались при создании добавить элементы, вызвав в конструкторе метод AddItem. Свойство Items.Objects в случае TComboBox работает через оконные сообщения CB_GETITEMDATA и CB_SETITEMDATA, при попытке отправить которые будет использовано свойство Handle. Это также приведет к попытке создания окна, которая завершится исключением из-за отсутствия родителя. Другими словами, ошибку мы получим не при удалении компонента, а при его создании. Бороться с этой проблемой можно, выполняя начальную инициализацию тогда, когда родитель уже назначен, например, в перекрытом методе SetParent после того, как отработает унаследованный SetParent. Необходимо только помнить, что SetParent может быть вызван не только при создании компонента и при необходимости позаботиться о том, чтобы инициализация выполнялась только при первом вызове SetParent с аргументом, отличным от nil.
Чтобы убедиться, насколько некорректно реализовано удаление компонентов в VCL, рассмотрим еще один пример (на компакт-диске он называется FrameDel). В этом примере на форму помещается фрейм с одним компонентом типа TComboBox. Код фрейма показан в листинге 3.66.
Листинг 3.66. Код фреймаtype
TFrame1 = class(TFrame)
ComboBox1: TComboBox;
private
{ Private declarations }
public
destructor Destroy; override;
procedure AddComboItem;
end;
destructor TFrame1.Destroy;
var
I: Integer;
begin
for I := 0 to ComboBox1.Items.Count - 1 do
if Assigned(ComboBox1.Items.Objects[I]) then
Dispose(PDateTime(ComboBox1.Items.Objects[I]));
inherited;
end;
procedure TFrame1.AddComboItem;
var
P: PDateTime;
begin
New(P);
P^:= Now;
ComboBox1.Items.AddObject('Item ' + TimeToStr(P^), TObject(P));
end;
На форму в обработчике события OnShow помещается такой фрейм и вызывается его метод AddComboItem, чтобы в компоненте ComboBox1 появился один элемент в списке. Если закрыть такую форму, никаких исключений не возникает, все выглядит нормально. Но при трассировке можно заметить, что цикл внутри деструктора не выполняется ни разу, потому что ComboBox1.Items.Count возвращает 0. Это происходит потому, что на момент вызова этого деструктора и окно фрейма, и окно ComboBox1 уже не существуют, в чем легко убедиться, проверив в деструкторе значение поля ComboBox1.FHandle (до обращения к свойству ComboBox1.Items.Count оно равно нулю). А при обращении к этому свойству происходит попытка создать окно. Так как свойство TComboBox1.Parent в этот момент еще не обнулено, предпринимается попытка создать заново и фрейм тоже, и эта попытка становится успешной. К этому моменту свойство Parent фрейма уже обнулено, но метод TCustomFrame.CreateParams реализован таким образом, что родителем всех фреймов, для которых родитель не задан явно, становится невидимое окно приложения, которое на этот момент еще не разрушено. Таким образом, окно фрейма и окно компонента TComboBox1 успешно создаются заново, и им можно посылать сообщения.
Ранее мы говорили, что код компонента TComboBox обеспечивает перенос элементов при удалении и последующем создании окна. Но в данном случае этот код даже не догадывается, что после удаления окно может быть создано ещё раз, и потому механизм переноса не задействуется. Вновь созданное окно компонента ComboBox1 не получает в свой список ни одного элемента, что и приводит к тому, что свойство Items.Count равно нулю. Но динамическая память, выделенная в методе AddComboItem остается не освобождённой. В результате имеем утечку памяти вместо исключения. Кроме того, имеем утечку и других ресурсов, т.к. код, ответственный за удаление окна фрейма, на этот момент уже отработал и не будет запущен еще раз, чтобы удалить вновь созданное окно.