3.4. Прочие "подводные камни"
В этом разделе собрана небольшая коллекция не связанных между собой "подводных камней", с которыми пришлось столкнуться автору книги.
3.4.1. Порядок вычисления операндов
Эта проблема связана с тем, что у человека есть определенные интуитивные представления о порядке выполнения действий программой, однако компилятор не всегда им соответствует. Рассмотрим следующий код (листинг 3.47, пример OpOrder на компакт-диске).
Листинг 3.47. "Неправильный" порядок вычисления операндов
var
X: Integer;
function GetValueAndModifyX: Integer;
begin
X := 1;
Result := 2;
end;
procedure TForm1.Button1Click(Sender: TObject);
var
A1, A2: Integer;
begin
X := 2;
A1 := X + GetValueAndModifyX;
X := 2;
А2 := GetValueAndModifyX + X;
Label1.Caption := IntToStr(A1);
Label2.Caption := IntToStr(A2);
end;
Суть этого примера заключается в том, что функция GetValueAndModifyX имеет побочный эффект — изменяет значение глобальной переменной X. И эту же переменную мы используем при вычислении выражения, в которое входит также вызов GetValueAndModifyX. При вычислении A1 в выражении сначала упоминается X, а потом GetValueAndModifyX, при вычислении А2 — наоборот. Логично было бы предположить, что A1 получит значение 4, А2 — 3, т.к. вычисление первого операнда должно выполняться раньше второго. В действительности же обе переменные получат значение 3, поскольку компилятор сам выбирает порядок вычисления операндов независимо от того, в каком порядке они упоминаются в выражении. То же самое касается любых коммутативных операций: умножения, арифметических and, or и xor. Посмотрим, что будет для некоммутативных операций, например, для деления (листинг 3.48).
Листинг 3.48. "Неправильный" порядок вычисления операндов при делении
procedure TForm1.Button2Click(Sender: TObject);
var
A1, A2: Extended;
begin
X := 2;
A1 := X / GetValueAndModifyX;
X := 2;
A2 := GetValueAndModifyX / X;
Label1.Caption := FloatToStr(A1);
Label2.Caption := FloatToStr(A2);
end;
В результате выполнения этого кода A1 получает значение 0.5, A2 — 2, т.е. и здесь сначала вычисляется функция, а потом берется значение переменной X.
Если бы функция GetValueAndModifyX не имела побочных эффектов (т.е. только возвращала бы результат и больше ничего не меняла), порядок вычисления аргументов был бы нам безразличен. Вообще, функции, имеющие побочные эффекты, считаются потенциальным источником ошибок, поэтому их написание нежелательно. Но в некоторых случаях (например, в функции Random) обойтись без побочных эффектом невозможно.
Примечание
Побочные эффекты в функциях настолько небезопасны, что в некоторых языках они полностью запрещены. Например, в Аде изменять значения глобальных переменных могут только процедуры, но не функции.
Ради интереса посмотрим, что будет, если вторым аргументом тоже будет функция, зависящая от X, (листинг 3.49).
Листинг 3.49. Сложение двух операндов с побочными эффектами
function GetX: Integer;
begin
Result := X;
end;
procedure TForm1.Button3Click(Sender: TObject);
var
A1, A2: Integer;
begin
X:= 2;
A1 := GetX + GetValueAndModifyX;
X := 2;
A2 := GetValueAndModifyX + GetX;
Label1.Caption := IntToStr(A1);
Label2.Caption := IntToStr(A2);
end;
Здесь A1 получит значение 4, A2 — 3, т.e. интуитивно ожидаемые. Тем не менее полагаться на интуицию все же не стоит: в более сложных случаях она может подвести. Дело в том, что стандарт языка Паскаль разрешает разработчикам конкретной реализации языка самим выбирать порядок вычисления операндов [5]. Поэтому, даже если вам удалось добиться желаемого порядка вычисления, в следующих версиях Delphi (или при переносе на другую платформу) программа может начать работать неправильно. Таким образом, разработчик не имеет права делать какие-то предположения о том, в каком порядке будут вычисляться операнды, а когда изменение этого порядка может повлиять на результат, код должен быть написан таким образом, чтобы исключить эту возможность. В частности, пример со сложением должен быть переписан так (листинг 3.50).
Листинг 3.50. Явное управление порядком вычисления операндов
procedure TForm1.Button1Click(Sender: TObject);
var
A1, A2: Integer;
begin
X := 2;
A1 := X;
Inc(A1, GetValueAndModifyX);
X := 2;
A2 := GetValueAndModifyX;
Inc(A2, X);
Label1.Caption := IntToStr(A1);
Label2.Caption := IntToStr(A2);
end;
Такой код, несмотря на побочные эффекты функции GetValueAndModifyX, даст ожидаемые значения при любом порядке вычисления операндов, т.к. здесь вычисление операндов разнесено по разным операторам, а порядок выполнения операторов четко определен.
Примечание
Другие компиляторы могут использовать иной порядок вычисления операндов. Так, FreePascal вычисляет их в том порядке, в каком они встречаются в выражении, т.е. в первом примере А1 получит значение 4, А2 — 3.
3.4.2. Зацикливание обработчика TUpDown.OnClick при открытии диалогового окна в обработчике
Для демонстрации этого "подводного камня" нам потребуется проект, на форме которого находится компонент TUpDown со следующим обработчиком события OnClick (листинг 3.51, пример UpDownDlg на компакт-диске).
Листинг 3.51. Обработчик события OnClick компонента UpDown1
procedure TForm1.UpDown1Click(Sender: TObject; Button: TUDBtnType);
begin
Application.MessageBox('Text', 'Caption', MB_OK);
end;
Теперь, если запустить программу и нажать на верхнюю кнопку UpDown1, откроется окно с сообщением (при нажатии на нижнюю кнопку окно не будет открываться потому, что по умолчанию у компонент TUpDown свойства Position и Min равны нулю, поэтому нажатие на нижнюю кнопку не приводит к изменению значения Position, и событие OnClick не возникает; если изменить значение свойства Min или Position, то тот же эффект будет наблюдаться и при нажатии на нижнюю кнопку). Если закрыть это окно, то щелчок мышью в любом месте формы снова приведет к срабатыванию события OnClick и открытию окна, и так до бесконечности: любой щелчок по форме в любом ее месте будет снова и снова приводить к появлению сообщения. Эффект наблюдается и в том случае, когда вместо стандартного сообщения в обработчике показывается любая другая модальная форма. Кроме того, тот же эффект будет, и если использовать события OnChanging или OnChangingEx вместо OnClick, но мы далее для определенности будем говорить только об OnClick.
Если этот код пройти по шагам в отладчике, то никакого зацикливания не возникает: OnClick вызывается один раз, любое последующее нажатие кнопки мыши на форме не приводит ни к каким необычным результатам.
Причина этой проблемы в том, как VCL обрабатывает сообщения, которые система помещает в очередь. При нажатии на кнопку компонента TUpDown в очередь сообщений помещаются два сообщения: WM_LBUTTONDOWN и WM_NOTIFY. Компонент TUpDown по умолчанию имеет стиль csCaptureMouse — это означает, что при обработке WM_LBUTTONDOWN VCL захватывает мышь в монопольное пользование для данного компонента.
Примечание
Монопольное использование мыши означает, что любые сообщения, связанные с мышью, будут поступать захватившему мышь окну даже если ее курсор в это время находится за пределами данного компонента. Примером захвата мыши может служить любая кнопка: щелкните мышью над любой кнопкой на экране и, не отпуская клавиши мыши, начните перемещать курсор. Когда курсор будет выходить за пределы кнопки, она будет отжиматься, находить на нее — снова нажиматься. Теперь отведите курсор за пределы кнопки, отпустите клавишу мыши и снова подведите его к кнопке. Кнопка не нажмется. Это происходит потому, что пока клавиша мыши удерживается нажатой, мышь захвачена кнопкой, и сообщение об отпускании клавиши мыши передаётся кнопке, независимо от того, над каким окном находится курсор. Это позволяет кнопке правильно реагировать на отпускание пользователем мыши, в том числе и за ее пределами.
Затем начинает обрабатываться событие WM_NOTIFY, которое уведомляет программу о том, что пользователь нажал на кнопку компонента TUpDown. Именно при обработке этого сообщения VCL вызывает событие TUpDown.OnClick, в котором открывается модальное окно. Всё это происходит очень быстро, поэтому кнопку мыши пользователь отпускает тогда, когда модальное окно уже оказалось на экране. В результате сообщение WM_LBUTTONUP либо попадает в очередь открывшегося диалогового окна, если мышь находилась над ним, либо вообще никуда не попадает, если мышь была вне модального окна. На время существования модального окна система "забывает" о том, что мышь захвачена для монопольного использования, но "вспоминает" об этом, как только модальное окно закрывается. Монопольное использование мыши компонентом TUpDown должно отменяться при обработке сообщения WM_LBUTTONUP, но оно, как было сказано ранее, в очередь не попадает, поэтому после закрытия окна мышь остается захваченной данным компонентом. Поэтому любое нажатие кнопки мыши воспринимается системой как относящееся к UpDown1, и снова приводит к помещению в очередь сообщений WM_LBUTTONDOWN и WM_NOTIFY, которые обрабатываются описанным образом. Так получается порочный круг, из которого при нормальной работе программы нет выхода. Этот круг может быть разорван, например, отладчиком, который отменяет монопольное использование мыши компонентами программы, чтобы иметь возможность работать.
В этой проблеме виновата VCL, которая зачем-то назначает компоненту TUpDown стиль csCaptureMouse. Данный компонент реализуется не средствами VCL, — это стандартное окно системного класса UPDOWN_CLASS, а компонент TUpDown — это только оболочка для него. Поэтому все необходимые перехваты мыши выполняются самой системой. VCL нет нужды в это вмешиваться. Чтобы избавиться от проблемы, нужно убрать csCaptureMouse из списка стилей компонента. Делается это так:
UpDown1.ControlStyle := UpDown1.ControlStyle - [csCaptureMouse];
Этот код достаточно выполнить один раз (например, в обработчике события OnCreate формы), и проблемы с зацикливанием исчезнут (в примере UpDownDlg эта строка закомментирована).
Отметим, что в Windows предусмотрено специальное сообщение — WM_CANCELMODE, — посылаемое при открытии диалогового окна тому окну, которое захватило мышь, чтобы оно ее освободило. Один из способов решения проблемы — добавление в UpDown1 обработчика этого сообщения (для этого можно написать наследника TUpDown или же воспользоваться свойством WindowProc — см. разд. 1.1.8), который отменит захват мыши. Отсутствие этого обработчика — тоже явная ошибка VCL.
3.4.3. Access violation при закрытии формы с перекрытым методом WndProc
Чтобы увидеть этот "подводный камень", создадим проект, содержащий две формы: главную Form1 и вспомогательную Form2. В Form1 добавим код, который по нажатию кнопки открывает Form2.
Во второй форме напишем обработчик события OnClose таким образом, чтобы он устанавливал по закрытию действие caFree. Добавим поле строкового типа, перекроем конструктор и метод WndProc так, чтобы окончательный код выглядел следующим образом (листинг 3.52, пример CloseAV на компакт- диске).
Листинг 3.52. Код класса TForm2
type
TForm2 = class(TForm)
procedure FormClose(Sender: TObject; var Action: TCloseAction);
private
S: string;
protected
procedure WndProc(var Message: TMessage); override;
public
constructor Create(AOwner: TComponent); override;
end;
....
constructor TForm2.Create(AOwner: TComponent);
begin
S := 'abc';
inherited;
end;
procedure TForm2.WndProc(var Message: TMessage);
begin
inherited;
S[2] := 'x'; { * }
end;
procedure TForm2.FormClose(Sender: TObject; var Action: TCloseAction);
begin
Action := caFree;
end;
Обратите внимание, что в конструкторе сначала присваивается значение полю S, и лишь потом вызывается унаследованный конструктор. Это сделано потому, что по умолчанию S содержит пустую строку, т.е. nil, а уже при вызове унаследованного конструктора окно получит сообщения, для обработки которых будет вызван метод WndProc. Если в этот момент S будет по-прежнему nil, попытка обратиться ко второму символу строки вызовет Access violation. Поэтому еще до начала работы унаследованного конструктора поле S должно получить подходящее значение.
Запустим программу и попытаемся закрыть второе окно. Возникнет исключение Access Violation: Write of address 00000001. Проблема будет в строке, отмеченной {*}. При этом любые другие манипуляции с окном никаких исключений вызывать не будут.
При Action = caFree после завершения работы метода FormClose VCL вызывает метод TCustomForm.Release. Проблема именно в нем: если попытаться закрыть Form2 с помощью Release, возникнет то же самое исключение. В справке Release позиционируется как безопасный способ удаления формы из ее собственного метода. К сожалению, в действительности это не так: реализация этого удаления оставляет желать лучшего и может приводить к попыткам работать с объектом тогда, когда его уже не существует.
При вызове Release в очередь помещается сообщение CM_RELEASE, адресатом которого является сама удаляемая форма. В очередном цикле петли сообщений CM_RELEASE извлекается из очереди и передается на обработку. Так как сообщение адресовано форме, она же его и обрабатывает. Рассмотрим более подробно, как это происходит. (Детально механизм обработки сообщений в VCL описан в разд. 1.1.8; мы здесь рассмотрим только ту часть, которая относится к обработке CM_RELEASE.)
Система передает управление оконной процедуре. Для каждого экземпляра визуального компонента VCL создает свою оконную процедуру с помощью MakeObjectInstance. Эта процедура вызывает метод объекта MainWndProc, передающий управление тому методу, на который указывает свойство WindowProc. По умолчанию это WndProc. WndProc не обрабатывает CM_RELEASE самостоятельно, а передает его методу Dispatch. Dispatch пытается найти для этого сообщения специальный обработчик (метод с директивой message) и, т.к. в TCustomForm такой обработчик описан (он называется CMRelease), передаёт управление ему.
И здесь начинается самое интересное. CMRealease просто вызывает Free, удаляя тем самым объект, т.е. объект удаляется из метода самого объекта, что делать запрещено. Таким образом, после выполнения Free управление вновь получает CMRealease. Из него управление возвращается в Dispatch, оттуда — в WndProc, затем — в MainWndProc, далее — в оконную процедуру, и только после этого управление получает код, который никак не связан с конкретным экземпляром компонента. Мы видим, что после обработки CM_RELEASE и удаления объекта его методы продолжают работать. Методы уже не существующего объекта!
В принципе, методы несуществующего объекта могут вполне нормально завершить свою работу, если не будут обращаться к его полям или иным образом использовать указатель Self, который к этому моменту будет уже недействительным. Но стоило нам только вставить в один из этих методов код, задействующий поле объекта, как возникла ошибка.
В данном примере получается следующее: сначала CM_RELEASE передаётся стандартному обработчику, который вызывает деструктор. При работе деструктора финализируются все поля объекта, для которых это требуется. В нашем случае это означает, что в поле S заносится nil (освобождения памяти при этом не происходит, потому что S до этого ссылалась на литерал, хранящийся в кодовом сегменте, а не в динамической памяти). После этого начинает работать наш код, который пытается изменить второй символ в строке. Программа пытается обратиться к ячейке с адресом nil + 1, т.е. 00000001, что и приводит к ошибке Access violation.
Обращение в аналогичной ситуации к нефинализируемым полям (целым, вещественным, логическим и т. п.) обычно не приводит к исключению. Это связано с тем, что менеджер памяти Delphi обычно не сразу отдаёт системе ту память, которую освобождает объект, поэтому программа, с точки зрения системы, имеет полное право ею пользоваться. Поля объекта не очищаются, и его образ продолжает храниться в памяти, просто менеджер памяти помечает эту область как неиспользуемую и может в любой момент выделить ее для хранения другого объекта. Это создает иллюзию того, что объект продолжает существовать и позволяет работать с уже несуществующим объектом. Но это все равно некорректно, потому что любое перераспределение памяти в данной ситуации может привести к непонятной ошибке.
Посмотрим. что будет, если строку S[1] := 'x' заменить на S := IntToStr(Msg.Msg). Как мы уже выяснили, после уничтожения объекта в той области памяти, где хранилось значение S, будет nil. Указатель на вновь созданную строку будет помещен в эту область памяти. Но к ней уже не будет применяться финализация, т.к. менеджер памяти будет считать эту область памяти финализированной. Произойдет утечка памяти.
Отметим, что для вновь созданной строки память может быть выделена таким образом, что она наложится на те ячейки, в которых хранились значения полей уничтоженной формы, в том числе значение S. В этом случае попытка обратиться к такому полю приведет к непредсказуемым результатам.
Аналогичная проблема может появляться не только при перекрытии WndProc, а вообще при любом способе внедрения своего кода в цепочку обработки так, чтобы он выполнялся после CMRelease.
Совершенно непонятно, почему разработчики VCL реализовали такой заведомо некорректный механизм работы Release. Чтобы избежать всех описанных проблем, достаточно было бы просто посылать CM_RELEASE не самой форме, а окну, создаваемому объектом Application, а указатель на освобождаемую форму передавать через параметры этого сообщения. Тогда деструктор формы вызывался бы из метода объекта Application, и никаких проблем не было бы.
Эта проблема обнаружена во всех версиях Delphi с 3-й по 2007-ю (в других версиях не проверялась). Самый простой способ ее преодоления — отмена опасных действий, если получено сообщение CM_RELEASE. Например, в описанном случае безопасным будет следующий код (листинг 3.53).
Листинг 3.53. Безопасный вариант метода WndProc
procedure TForm2.WndProc(var Message: TMessage);
begin
inherited;
if Msg.Msg <> CM_RELEASE then s[2] := 'x';
end;
Другой способ заключается в том. чтобы перенести обработку CM_RELEASE в объект Application с помощью его события OnMessage. Проблема заключается лишь в том, что адрес удаляемой формы будет неизвестен, но его легко найти по дескриптору окна. Например, в данном случае можно положить на первую форму TApplicationEvents и в его обработчике OnMessage написать следующий код (листинг 3.54; в примере CloseAV этот код закомментирован).
Листинг 3.54. Обработка сообщения CM_RELEASE объектом Application
procedure TForm1.ApplicationEvents1Message(var Msg: tagMSG; var Handled: Boolean);
var
I: Integer;
begin
if Msg.Message = CM_RELEASE then
for I := 0 to Screen.FormCount - 1 do
if Screen. Forms[I].Handle = Msg.hwnd then
begin
Screen.Forms[I].Free;
Handled := True;
Exit;
end;
end;
Событие OnMessage позволяет перехватить сообщения до того, как они будут диспетчеризованы окну-адресату, соответственно, форма будет уничтожена раньше, чем начнет обрабатывать CM_RELEASE.
3.4.4. Подмена имени оконного класса, возвращаемого функцией GetClassInfo
Создадим новый проект в Delphi, поместим на форму кнопку и метку и создадим следующий обработчик нажатия кнопки (листинг 3.55, пример ClassName на компакт-диске).
Листинг 3.55. Подмена имени оконного класса
procedure TForm1.Button1Click(Sender: TObject);
var
CI: TWndClass;
S: string;
procedure DoGetClassInfo;
begin
GetClassInfo(hInstance, PChar('TForm' + IntToStr(1)), CI);
end;
begin
DoGetClassInfo;
S := 'abcde' + IntToStr(2);
Label1.Caption := CI.lpszClassName;
end;
Что будет выведено на экран в результате выполнения этого кода? Так как класс называется "TForm1", логично предположить, что именно это и будет выведено. На самом деле мы увидим abcde2 — ту строку, которая присвоена переменной S.
Разберемся, как значение переменной S оказывается в поле CI.lpszClassName. Согласно MSDN поле lpszClassName имеет тип LPCTSTR(PChar), и в него функция GetClassInfo заносит указатель на строку, содержащую имя оконного класса. Но нигде не сказано, в какой области памяти должна располагаться эта строка.
Функция GetClassInfo поступает очень просто, но не совсем корректно: один из ее аргументов — указатель на строку с именем класса. Именно его функция и помещает в lpszClassName.
В приведенном примере в качестве аргумента GetClassInfo передаётся выражение типа string, приведенное к PChar, которое не может быть вычислено на этапе компиляции, поэтому компилятор генерирует код, вычисляющий данное выражение. Этот код размещает вычисленное выражение в динамической памяти, и в GetClassInfo передаётся указатель на эту строку.
Все строковые выражения, вычисленные подобным образом, должны удаляться из памяти, чтобы не было утечек. Компилятор помещает код, освобождающий эту память, в эпилог той функции, в которой встретилось выражение. В данном случае — в эпилог локальной процедуры DoGetClassInfo.
Освободившуюся память менеджер памяти не сразу возвращает системе придерживает, чтобы иметь возможность быстрее выделить память при следующем запросе. Таким образом, после завершения работы DoGetClassInfo память, в которой хранится вычисленное имя оконного класса (и на которую указывает CI.lpszClassName), по-прежнему принадлежит процессу, но менеджер памяти полагает ее свободной и считает себя вправе использовать ее по своему усмотрению.
Когда присваивается значение переменной S, для размещения новой строки менеджер памяти выделяет ту самую область, в которой ранее хранилось имя класса. Так как CI.lpszClassName по-прежнему содержит этот адрес, обращение к этому полю возвращает новую строку, которая присвоена переменной S.
Примечание
В Delphi до 7-й версии включительно описанный эффект наблюдается при любой длине строки, присваиваемой переменной S, в более новых версиях Delphi — только в том случае, если длина этой строки находится в пределах от 5 до 11 символов. Это связано с тем, что новый менеджер памяти, появившийся в этих версиях Delphi, с целью уменьшения фрагментации разбивает кучу на несколько областей, в каждой из которых выделяет блоки памяти, укладывающиеся в соответствующий данной области диапазон размеров блоков. Если строка, присваиваемая переменной S, слишком сильно отличается по размеру от 'TForm1' для этой строки выделяется память в другой области, и подмены не происходит.
Если в данном примере не выносить вызов функции GetClassInfo в отдельную процедуру DoGetClassInfo, а вызывать ее напрямую из Button1Click, описанного эффекта не будет, потому что в этом случае освобождение памяти, занятой для вычисленного имени класса, будет производиться в эпилоге Button1Click, и на момент присваивания значения переменной S эта память будет считаться занятой, поэтому для S менеджер памяти выделит другую область.
Принципиально и то, что в обоих случаях (в функции GetClassInfo и при присваивании значения переменной S) используются не строковые литералы, а выражения, вычисляемые только на этапе выполнения программы. Строковые литералы размещаются компилятором в сегменте кода, и указатели, переданные в GetClassInfo и присвоенные переменной S, будут указывать не на динамическую память, а на эти литералы, и подмены не произойдет.
Избежать проблемы можно двумя способами. Во-первых, не следует передавать значение поля lpszClassName за пределы той функции, в которой была вызвана GetClassName. Во-вторых, имя оконного класса должно быть известно программе до вызова GetClassName. Лучше использовать ту строку, в которой хранится это имя, чем поле lpszClassName.
3.4.5. Ошибка EReadError при использовании вещественных свойств
Если в секции published компонента имеются свойства вещественного типа (Single, Double или Extended), то попытка присвоить в режиме проектирования формы этим свойствам некоторые вполне корректные значения приводит к ошибке EReadError при чтении ресурсов формы (т.е. при создании формы). Для типов Double и Extended ошибка возникает, если значение свойства X лежит в одном из указанных диапазонов:
-1e15 < х <= MinInt - 1
или
MaxInt + 1 <= X < 1e15
Не совсем понятно, при чем здесь значения MaxInt и MinInt, если речь идет о вещественных числах, но проблема существует. Типу Single не хватает точности, чтобы передавать значения MaxInt и MinInt без искажений. Тем не менее, с поправкой на уменьшение точности границ диапазонов, эта же ошибка возникает и для свойств типа Single. Ошибка возникает только в случае текстовой формы dfm-файла (все версии Delphi, начиная с пятой, по умолчанию используют эту форму). При бинарной форме dfm-файла ошибки не происходит.
Ошибка обнаружена в Delphi 5 и 6, причем в Delphi 5 попытка ввести значение из указанного диапазона также может привести к ошибке и в режиме проектирования, при переключении между текстом модуля и формой. В Delphi 6 были замечены ошибки только при запуске программы, в режиме проектирования они не возникали. В Delphi 7 эта проблема уже решена, указанные значения свойств не приводят к ошибкам. В более ранних версиях Delphi проблема, естественно, также отсутствует, потому что в них dfm-файл всегда представляется в бинарной форме.
Для решения проблемы могут быть рекомендованы два способа.
1. Обновить Delphi до седьмой (или более поздней) версии.
2. Выбрать бинарную форму dfm-файла. Для этого нужно щёлкнуть правой кнопкой мыши на форме и в открывшемся меню убрать галочки с пункта Text DFM.
Можно также отказаться от присвоения проблемных значений свойствам в режиме проектирования и присваивать их во время выполнения программы.
3.4.6. Ошибка List index out of bounds при корректном значении индекса
Windows позволяет с каждой строкой списка элементов управления ListBoх и ComboBox связать либо число, либо указатель (точнее — некоторую четырехбайтную величину, которую программа может трактовать как число, как указатель или как что-либо еще). В VCL эта возможность обеспечивает привязку к строкам списка объектов (четырёхбайтная величина по умолчанию трактуется как TObject). Доступ к этим объектам осуществляется через свойства TComboBox.Items.Objects[Index] и TListBox.Items.Objects[Index].
Иногда все-таки требуется привязывать к строкам не объекты, а числа. Для этого можно воспользоваться явным приведением типов, например:
ComboBox1.Items.Objects[I] := TObject(17);
I := Integer(ComboBox1.Items.Objects[I]);
Если таким образом связать со строкой значение -1, то при попытке получить его во всех версиях Delphi до 7-й включительно возникнет исключение EStringListError с комментарием "List index out of bounds". Рассмотрим следующий код (листинг 3.56. пример ListIndex на компакт-диске).
Листинг 3.56. Пример кода, вызывающего исключение EStringListError
procedure TForm1.Button1Click(Sender: TObject);
var
I: Integer;
begin
ComboBox1.Items.Clear;
ComboBox1.Items.AddObject('Text', TObject(-1));
I := Integer(ComboBox1.Items.Objects[0]); { * }
Label1.Caption := IntToStr(I);
end;
Исключение возникнет при попытке выполнить строку, отмеченную звездочкой, хотя очевидно, что индекс в данном случае корректен. Чтобы понять причину ошибки, необходимо рассмотреть, как осуществляется чтение значения, привязанного к строке, на уровне Windows API. Рассмотрим это на примере TComboBox. Для получения значения необходимо послать окну ComboBox сообщение CB_GETITEMDATA. Результатом обработки этого сообщения будет значение, связанное с указанной строкой, или CB_ERR, если при обработке сообщения возникнет ошибка. При этом документация не уточняет, какие именно ошибки могут в принципе возникнуть и как узнать, какая из них произошла.
Метод TComboBoxStrings.GetObject, через который читается значение свойства Objects, в Delphi 7 и более ранних версиях интерпретирует получение CB_ERR однозначно: генерирует исключение EStringListError с комментарием "List index out of bounds".
Проблема заключается в том, что константа CB_ERR имеет численное значение -1. Поэтому и в случае ошибки, и в случае, когда строке сопоставлено значение -1, системный обработчик сообщения CB_GETITEMDATA вернет одинаковый результат. И метод TComboBoxStrings.GetObject интерпретирует его как ошибку. (А что ему еще остается делать?)
Аналогичная проблема по тем же причинам возникает и для ListBox (аналогичная по смыслу константа LB_ERR также имеет значение -1). Это прямое следствие документированных особенностей работы Windows и модели работы VCL встречается во всех версиях Windows. Та же проблема возникает при попытке указать значение 4 294 967 295, т.к. на двоичном уровне это число записывается той же комбинацией битов, что и -1.
При использовании свойства Objects по прямому назначению, т.е. для хранения объектов, эта проблема не может возникнуть, потому что $FFFFFFFF — это адрес самого старшего байта в четырехгигабайтном виртуальном адресном пространстве программы. Эта область адресного пространства зарезервирована системой, и менеджер памяти Delphi не может выделить память для объекта в этой области. Рекомендуемые способы решения проблемы:
1. Пересмотреть алгоритм и отказаться от связывания значения -1 со строками.
2. Напрямую посылать CB_GETITEMDATA окну ComboBox, а попадание индекса в диапазон контролировать самостоятельно другими методами. Приведенный в листинге 3.57 код иллюстрирует последний совет.
Листинг 3.57. Получение связанного с элементом значения вручную
procedure TForm1.Button2Click(Sender: TObject);
var
I: Integer;
begin
ComboBox1.Items.Clear;
ComboBox1.Items.AddObject('Text', TObject(-1));
I := SendMessage(ComboBox1.Handle, CB_GETITEMDATA, 0, 0);
Label1.Caption := IntToStr(I);
end;
Как уже было отмечено ранее, в BDS 2006 и более поздних версиях исключение не возникает. Это связано с новой реализацией метода TCustomComboBoxStrings.GetObject, который отвечает за получение значения свойства Items.Object (листинг 3.58).
Листинг 3.58. Получение значения свойства Items.Object в BDS 2006 и выше
function TCustomComboBoxStrings.GetObject(Index: Integer): TObject;
begin
Result := TObject(SendMessage(ComboBox.Handle, CB_GETITEMDATA, Index, 0));
// Do additional checking on Count and Index here is so in the event
// the object being retrieved is the integer -1 the call will succeed
if (Longint(Result) = CB_ERR) and ((Count = 0) or
(Index < 0) or (Index > Count)) then
Error(SListIndexError, Index);
end;
Решение спорное, т.к. проверка корректности системой дополняется собственной проверкой индекса, и не совсем понятно, что делать в том случае, если система фиксирует какую-либо ошибку, не связанную с индексом. Но здесь Windows ставит разработчика в такие условия, что любое решение будет спорным, так что упреком по отношению к разработчикам VCL такая оценка их решения не является.
В таких элементах управления, как TListView и TTreeView, тоже существует возможность связывания 4-байтного значения с элементом (см. свойства TTreeNode.Data, TListItem.Data), но сообщения TVM_GETITEM и LVM_GETITEM, через которые можно получить значения этих свойств, устроены иначе, поэтому связывание с элементом значения -1 (а также любого другого 4-байтного значения) не приводит к аналогичным проблемам.
3.4.7. Неправильное поведение свойства Anchors
Свойство Anchors, появившееся в Delphi 5, является очень удобным средством управления положением и размерами визуальных компонентов при изменении размера родителя. Однако в тех случаях, когда начальные размеры формы по каким-то причинам не совпадают с установленными при проектировании, задание значения свойства Anchors не приносит желаемого эффекта: первоначальное расположение визуальных компонентов на форме соответствует размерам, установленным при проектировании, а не тем, которые реально получила форма. Примеры такого некорректного поведения демонстрирует программа WrongAnchors на компакт-диске.
Программа WrongAnchors — это MDI-приложение, в котором открываются две дочерние формы разных классов: ChildForm1 (класс TChildForm1) и ChildForm2 (класс TChildForm2). Во время проектирования эти две формы выглядят совершенно одинаково, но при запуске программы только вторая форма сохраняет заданные при проектировании размеры, а первая становится больше. При этом панель, лежащая на ней, не адаптирует свои размеры к изменившемуся размеру формы, хотя свойство Anchors обязывает ее к этому (это легко видеть, изменяя размеры формы после ее создания). Самый простой способ борьбы с этой неприятностью — заставить дочернюю MDI-форму иметь такой же начальный размер, какой задан при проектировании.
Дочерняя MDI-форма приобретает отличный от заданного размер потому, что метод CreateParams для ширины и высоты окна устанавливает не те значения, которые хранятся в свойствах Width и Height, а значение CW_USERDEFAULT. Это значение говорит системе, что она должна выбрать размеры окна на свое усмотрение. Чтобы этого не происходило, нужно вновь вернуть установленные при проектировании значения ширины и высоты в перекрытом методе CreateParams. Именно этим класс TChildForm2 отличается от TChildForm1 (листинг 3.59).
Листинг 3.59. Установка значений ширины и высоты, заданных при проектировании
procedure TChildForm2.CreateParams(var Params: TCreateParams);
begin
inherited CreateParams(Params);
Params.Width := Width;
Params.Height : = Height;
end;
Значение CW_USERDEFAULT присваивается ширине и высоте окна не только в том случае, если это дочерняя MDI-форма, но и когда значение свойства Position формы равно poDefault или poDefaultSizeOnly. Но в этом случае перекрывать CreateParams нет нужды, достаточно просто изменить значение свойства Position на другое. Просто необходимо помнить, что если свойство Position формы имеет одно из этих значений, свойства Anchors лежащих на форме компонентов должны иметь значения по умолчанию.
Другой случай, когда окно может при создании иметь размеры, отличные от заданных при проектировании, — это когда свойство WindowState равно wsMaximized. При этом окно растягивается на весь экран. В примере WrongAnchors в главном меню есть пункты Развернутое окно 1 и Развернутое окно 2, которые открывают диалоговые окна, развернутые на весь экран. Но в первом из этих окон панель опять не адаптируется к новым размеру окна, в то время как во втором — адаптируется, хотя значения свойства Anchors у обеих панелей одинаковые. Это происходит потому, что в первом случае значение wsMaximized присваивается свойству WindowState во время проектирования, и поэтому окно сразу создается развернутым. А во втором случае значение wsMaximized присваивается свойству WindowState только при обработке события OnShow формы, т.е. тогда, когда форма уже создана с заданными при проектировании размерами, но еще не видна на экране. При этом свойство Anchors работает так, как требуется. Это и есть решение проблемы — значение свойству WindowState нужно присваивать не во время проектирования, а в обработчике события OnShow.
Но самое интересное происходит, если свойство WindowState во время проектирования получило значение wsMaximized, а свойство Position — значение poDefault или poDefaultSizeOnly. Тогда размеры и положения визуальных компонентов на форме будут адаптированы к размеру, который не совпадает ни с размером развернутой формы, ни с размером, заданным во время проектирования. Если такой форме отменить развертывание на весь экран, то визуальные компоненты получат размеры и положения, установленные в режиме проектирования.
Нельзя сказать, что разработчики Delphi не знакомы с этой проблемой, они даже что-то делают, чтобы ее решить. Начиная с BDS 2006 можно устанавливать значение свойства WindowState в режиме проектирования, и визуальные компоненты на такой форме будут вести себя интуитивно ожидаемым образом, т.е. адаптироваться к размеру формы, растянутой на весь экран. Правда, с двумя существенными оговорками. Во-первых, свойство Position формы не должно быть равно poDefault или poDefaultSizeOnly. Во-вторых, это относится только к главной форме приложения, для всех остальных форм проблема сохраняется. Поэтому пример WrongAnchors будет работать одинаково и в новых версиях Delphi, и в старых — там на весь экран разворачиваются не главные формы.
3.4.8. Ошибка при сравнении указателей на метод
Процедурные типы в Delphi делятся на обычные (унаследованные от Turbo Pascal) и указатели на методы. Первые — что указатели на простые процедуры и функции, вторые — на методы объектов. Чтобы вызвать метод объекта недостаточно знать, где его код располагается в памяти, нужно еще иметь ссылку на конкретный экземпляр класса, к которому относится данный метод (т.е. необходимо значение указателя Self, который будет передан в данный метод). Поэтому указатели на методы называются указателями лишь условно: на самом деле это не один указатель, а два (на код и на объект). Размер переменных такого типа равен 8 байтам, в чем нетрудно убедиться с помощью функции SizeOf.
Очевидно, что два указателя на метод равны тогда и только тогда, когда указывают на один и тот же метод одного и того же объекта, т.е. входящие в них указатели попарно равны. Однако компилятор сравнивает указатели на методы неправильно, и пример MethodPtrCmp на компакт-диске демонстрирует это. На форме этого примера расположены две кнопки класса TButton. Обработчик нажатия на первую из них выглядит так, как в листинге 3.60.
Листинг 3.60. Пример неправильного сравнения указателей на метод
procedure TForm1.ButtonlClick(Sender: TObject);
var
P1, P2: procedure of object;
begin
P1 := Button1.Update;
P2 := Button2.Update;
// Здесь компилятор сравнивает указатели на методы неверно,
// давая ошибочный результат "равно"
if @Р1 = @Р2 then Label1.Caption := 'Равно'
else Label1.Caption := 'Не равно';
end;
Здесь мы получаем указатели на один и тот же метод разных объектов (для примера взяты класс TButton и метод Update, но подошел бы любой класс и любой метод). Сравнение указателей в этом примере дает ошибочный результат Равно, хотя эти указатели не равны между собой. Просмотр кода, который генерирует компилятор, показывает, что здесь сравниваются только указатели на код метода, а указатели на объекты игнорируются. Так как у нас метод один и тот же, различаются только объекты, то и получается ошибочный результат.
Сравнить указатели на методы правильно можно с помощью типа TMethod из модуля SysUtils, объявленного следующим образом:
TMethod = record
Code, Data: Pointer;
end;
Так можно получать доступ к отдельным указателям, входящим в указатель на метод. Сравнение указателей на метод с помощью этого типа иллюстрирует листинг 3.61.
Листинг 3.61. Правильный способ сравнения указателей на метод
procedure TForm1.Button2Click(Sender: TObject);
var
P1, P2: procedure of object;
begin
P1 := Button1.Update;
P2 := Button2.Update;
// Правильный способ сравнения указателей на методы
if (TMethod(P1).Data = TMethod(P2).Data) and
(TMethod(P1).Code = TMethod(P2).Code) then
Label1.Caption := 'Равно'
else Label1.Caption := 'He равно';
end;
Здесь мы явным образом заставляем компилятор сравнивать оба указателя, поэтому получаем правильный результат Не равно.
3.4.9. Возможность получения адреса свойства
Пусть у нас есть класс, описанный следующим образом (листинг 3.62).
Листинг 3.62. Класс со свойствами, читаемыми из переменной и из функции
TSomeClass = class private
FProp1: Integer;
function GetProp2: Integer;
public
property Prop1: Integer read FProp1;
property Prop2: Integer read GetProp2;
end;
В этом классе два свойства Prop1 и Prop2, значение одного из которых определяется полем FProp1, а другого — функцией GetProp2. Оба свойства предназначены только для чтения, но для того эффекта, о котором здесь пойдет речь, это не принципиально: свойства, значения которых можно менять, ведут себя в этом отношении точно так же.
Пусть X — это переменная типа TSomeClass. Легко убедиться, что компилятор допускает получение адреса свойства Prop1, т.е. конструкция вида @X.Prop1 считается допустимой. Результатом выполнении этого оператора станет указатель на поле FProp1. А вот конструкцию @X.Prop2 компилятор не допускает, выдаёт ошибку Variable required.
Ошибкой компилятора здесь является то, что он допускает получение адреса в первом случае, т.е. для свойства, значение которой берется из переменной. Это грубейшее нарушение принципа инкапсуляции, лежащего в основе объектно-ориентированного программирования, причем сразу по двум причинам. Во-первых, пользователь класса не должен видеть его внутреннюю реализацию, а здесь пользователь может определить, как читается свойство, по возможности применения оператора @ к нему. Во-вторых, пользователь класса должен взаимодействовать с ним строго через предоставленный интерфейс, а у нас получается, что, узнав адрес поля FProp1, пользователь сможет менять его значение в обход предусмотренных для этого в классе механизмов.
К счастью, ситуации, в которых эта недоработка компилятора могла бы принести пользу, крайне редки. Но если вы все-таки столкнулись с такой ситуацией, настоятельно рекомендуем не поддаваться соблазну и искать другие способы решения проблемы. Если класс, к полю которого вы хотите получить доступ таким образом, написан вами, то это веский повод пересмотреть внешний интерфейс класса, т.к. при его проектировании скорее всего, были допущены серьезные ошибки. Если это «чужой» класс, подумайте о том, что в следующей версии этого класса автор может изменить реализация свойства, и тогда ваш код откажется компилироваться.
3.4.10. Невозможность использования некоторых свойств оконного компонента в деструкторе
Проблема, о которой пойдет речь в этом разделе, гораздо шире, чем это явствует из заголовка. Однако наиболее ярко она проявится именно в этом случае. Поэтому мы начнем именно с этой ситуации, а потом рассмотрим проблему более широко.
Проблему демонстрирует пример ParentWnd на компакт-диске. В нем создан компонент TWrongCombo, наследник TComboBox. Листинг 5.67 содержит код компонента.
Листинг 3.63. Компонент TWrongCombo
type
TWrongCombo = class(TComboBox)
public
destructor Destroy; override;
procedure AddItem(const Title: string);
end;
destructor TWrongCombo.Destroy;
var
I: Integer;
begin
for I := 0 to Items.Count - 1 do
if Assigned(Items.Objects[I]) then
Dispose(PDateTime(Items.Objects(I]));
inherited;
end;
procedure TWrongCombo.AddItem(const Title: string);
var
P: PDateTime;
begin
New(P);
P^ := Now;
Items.AddObject(Title, TObject(P));
end;
Класс TWrongCombo с каждым элементом, добавленным с помощью метода AddItem, связывает значение типа TDateTime, хранящее время добавления элемента. В разд. 3.4.6 мы уже познакомились с возможностью связывания данных с элементом списка с помощью свойства Items.Objects. Но так мы можем связать с элементом только 4-байтное значение, а тип TDateTime занимает 8 байтов. Поэтому значение TDateTime мы будем хранить в динамической памяти, а с элементом свяжем указатель на него.
Раз мы выделили динамическую память, ее нужно освободить при удалении компонента. Наиболее подходящим местом для этого кажется деструктор, и именно в нем помещен код освобождения выделенной памяти.
Теперь попробуем воспользоваться компонентом. На главной форме программы ParentWnd находится кнопка Wrong Combo, при нажатии на которую создается компонент типа TWrongCombo (листинг 3.64).
Листинг 3.64. Реакция на кнопку Wrong Combo
procedure TForm1.BtnWrongComboClick(Sender: TObject);
begin
if FWrongCombo = nil then
begin
FWrongCombo := TWrongCombo.Create(Self);
FWrongCombo.Left := 10;
FWrongCombo.Top := 10;
FWrongCombo.Parent := Self;
FWrongCombo.AddItem('One');
FWrongCombo.AddItem('Two');
FWrongCombo.AddItem('Three');
end;
end;
Теперь, если нажать эту кнопку и затем попытаться закрыть форму, в деструкторе TWrongCombo возникнет исключение EInvalidOperation с сообщением "Control has no parent window". Если откомпилировать программу с включенной опцией Use Debug DCUs, видно, что исключение возникает в методе TWinControl.CreateWnd. Одно только это способно обескуражить — действительно, зачем метод создания окна вызывается при его удалении?
Причина заключается в том, что к моменту вызова деструктора окно компонента уже удалено, свойство Handle имеет нулевое значение, и свойство Parent тоже имеет значение nil. Обращение к свойству Items.Count приводит к отправке окну сообщения CB_GETCOUNT. Отправка осуществляется с помощью функции SendMessage, одним из параметров которой является дескриптор окна, в качестве которого, естественно, передается свойство Handle. А это свойство, напомним, к этому моменту равно нулю. В разд. 1.1.7 обсуждалось, что обращение к этому свойству в тот момент, когда оно равно нулю, приводит к попытке создания окна (см. листинг 1.8). Именно поэтому вызывается метод CreateWnd. И он возбуждает исключение, потому что окно, которое создает компонент TWrongCombo, имеет стиль WS_CHILD, т.е. не может не иметь родителя. А родитель компоненту не назначен, поэтому и возникает исключение с таким странным, на первый взгляд, сообщением.
Отсюда следует важный вывод, что никакое обращение в деструкторе компонента к тем свойствам и методам, которые требуют наличия окна, невозможно. Окно уже удалено, а попытка создания нового окна приведет к исключению. Поэтому, например, в нашем коде требуется искать другое место, чтобы корректно освободить занятую память.
Поиск этого места оказывается не такой простой задачей, как хотелось бы, потому что разработчики VCL весьма странным образом реализовали удаление дочерних оконных компонентов в деструкторе класса TWinControl: сначала вызывается системная функция DestroyWindow, которая удаляет и само окно, и, разумеется, все дочерние окна, а потом только дочерние компоненты начинают уведомляться о том, что их удаляют, т.е. к этому моменту они уже не имеют возможности как-то задействовать свои окна. Соответственно, в нашем случае деструктор формы уничтожает окна всех дочерних компонентов до того, как будут вызваны деструкторы этих компонентов.
Положение спасает то, что об уведомлении окон заботится система 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 класса TRightCombo
procedure 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 остается не освобождённой. В результате имеем утечку памяти вместо исключения. Кроме того, имеем утечку и других ресурсов, т.к. код, ответственный за удаление окна фрейма, на этот момент уже отработал и не будет запущен еще раз, чтобы удалить вновь созданное окно.
Решением проблемы может стать уже опробованный способ: нужно обрабатывать сообщение WM_DESTROY, посылаемое фрейму, выполнять в нем все те же проверки, что и в листинге 3.65, и при необходимости освобождать память, которую нельзя освободить в деструкторе.
Примечание
Если бы мы попытались использовать наследника от класса, например, TPanel вместо TFrame, ошибка при завершении работы программы возникла бы, компоненту TPanel не назначается родитель по умолчанию, и попытка создания его окна в деструкторе закончилась бы неудачей. Назначение родителя по умолчанию приводит еще к одному интересному эффекту: мы можем добавлять в ComboBox1 элементы до того, как будет назначено свойство Parent фрейма. Ошибки не возникнет, потому что окно фрейма будет создано успешно, а при последующем назначении свойства Parent фрейма в компоненте ComboBox1 сработает механизм переноса элементов при создании нового окна, и пользователь увидит добавленные элементы.
Главным выводом этого раздела должно стать то, что последовательность удаления визуальных компонентов в VCL очень плохо продумана (видимо, это одно из самых неудачных мест в VCL), поэтому нужно соблюдать особую осторожность в тех случаях, когда освобождение ресурсов удаляемого оконного компонента может быть связано с обращением к его окну. Деструктор для этих целей, как мы убедились, не подходит, удаление следует выполнять в обработчике WM_DESTROY, проверяя, действительно ли удаляется сам компонент, а не только его окно.