Антон Григорьев дата публикации 13-09-2005 08:07 КАТЕГОРИЯ | | БИБЛИОТЕКА.VCL.AV при закрытии формы с перекрытым методом WndProc | ПРОДУКТ | | Delphi | ПЛАТФОРМА | | Windows |
Создадим проект, содержащий две формы: главную (Form1) и неглавную (Form2). В Form1 добавим код, который показывает вторую форму (например, по нажатию кнопки или по OnShow).
Во второй форме напишем обработчик OnClose таким образом, чтобы он устанавливал по закрытию действие caFree. Добавим поле строкового типа, перекроем конструктор и метод WndProc так, чтобы окончательный код выглядел следующим образом:
unit Unit2;
interface
uses
Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,
StdCtrls;
type
TForm2 = class(TForm)
procedure FormClose(Sender: TObject; var Action: TCloseAction);
protected
S:string;
procedure WndProc(var Msg:TMessage);override;
public
constructor Create(AOwner:TComponent);override;
end;
var
Form2: TForm2;
implementation
{$R *.DFM}
constructor TForm2.Create(AOwner:TComponent);
begin
S:='abc';
inherited
end;
procedure TForm2.WndProc(var Msg:TMessage);
begin
inherited;
S[2]:='x' {*}
end;
procedure TForm2.FormClose(Sender: TObject; var Action: TCloseAction);
begin
Action:=caFree
end;
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 рассмотрен в статье "Основы работы с Windows API в VCL-приложениях". Мы здесь рассмотрим только ту часть, которая относится к обработке CM_Release.)
Система передаёт управление оконной функции. Для каждого экземпляра визуального компонента VCL создаёт свою оконную процедуру с помощью MakeObjectInstance. Эта процедура вызывает метод объекта InitWndProc, который передаёт управление тому методу, на который указывает свойство WindowProc. По умолчанию это WndProc. WndProc не обрабатывает CM_Release самостоятельно, а передаёт его методу Dispatch. Dispatch пытается найти для этого сообщения специальный обработчик (метод с директивой message) и, так как в TCustomForm такой обработчик описан (он называется CMRelease), передаёт управление ему.
И здесь начинается самое интересное. CMRelease просто вызывает Free, удаляя тем самым объект!
Таким образом, после выполнения Free управление вновь получает CMRelease. Из него управление возвращается в Dispatch, оттуда - в WndProc, затем - в InitWndProc, далее - в оконную процедуру, и только после этого управление получает код, который никак не связан с конкретным экземпляром компонента. Мы видим, что после обработки 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.
Отметим ещё одну потенциальную опасность: в деструкторе TWinControl вызывается FreeObjectInstance и уничтожается связанная компонентом оконная процедура. В большинстве случаев это не приводит к удалению процедуры из памяти, просто эта память помечается как неиспользуемая. Но в редких случаях память всё же будет возвращаться системе, и после вызова обработчика CM_Release управление будет передано по адресу, уже не принадлежащему адресному пространству программы. Последствия - всё то же Access Violation. Особенно плохо то, что для этого не надо перекрывать код WndProc или иным образом вмешиваться в обработку CM_Release - при неблагоприятном стечении обстоятельств всё произойдёт само.
Совершенно непонятно, почему разработчики VCL реализовали такой заведомо некорректный механизм работы Release. Чтобы избежать всех описанных проблем, достаточно было бы просто посылать CM_Release не самой форме, а окну, создаваемому объектом Application, а указатель на освобождаемую форму передавать через параметры этого сообщения. Тогда деструктор формы будет вызван из метода объекта Application, и никаких проблем не произойдёт.
Программа проверялась на Delphi 3-7. Проблема присутствует во всех версиях.
Самым простым способом борьбы с проблемой является отмена опасных действий, если получено сообщение CM_Release. Например, в описанном случае безопасным будет следующий код:
procedure TForm2.WndProc(var Msg:TMessage);
begin
inherited;
if Msg.Msg<>CM_Release then
S[2]:='x'
end;
Другой способ заключается в том, чтобы перенести обработку CM_Release в объект Application с помощью его события OnMessage. Проблема заключается лишь в том, что адрес удаляемой формы будет неизвестен, но его легко найти по дескриптору окна. Например, в данном случае можно положить на первую форму TApplicationEvents и в его обработчике OnMessage написать следующий код:
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.
Прочитать статью:
"Основы работы с Windows API в VCL-приложениях"
Скачать пример:
StoneTest_88.zip
Достойный камушек, высшей пробы. Образцовое исследование.
[TForm] [TApplication] [Интерфейс. Компоненты и формы] [Компонентные сообщения CM_]
Обсуждение материала [ 04-10-2005 14:00 ] 11 сообщений |