Message методы, или обработка сообщений классами в Delphi

Наверняка каждый из нас хотя бы раз в своей практике, но встречался с кодом вида:

TForm1 = class(TForm)
private
 procedure MyCoolHandler(var Message: TMessage); message WM_USER;
public
 {some code here}
end;

procedure TForm1.MyCoolHandler(var Message: TMessage);
begin
 Message.Result := 32767;
 Caption := 'wow, this works!';
end; 

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

Однако, как это работает? Почему это работает? И как следует правильно это использовать? Какие могут возникнуть проблемы, подводные камни? Давайте посмотрим.

Предпосылки

Логика возникновения подобного трюка лично мне совершенно ясна - разработчики Delphi хотели ясный, понятный и читаемый код при разработке визуальных компонент. Как всем известно, при создании окна необходимо задать оконную функцию. То есть некую callback функцию, которая принимает по ссылке сообщение и возвращает результат. Во всех примерах приложений на чистом WinAPI эта оконная функия состоит из case с перечислением всех сообщений, которое окно хочет обрабатывать и вызова DefWindowProc для остальных сообщений. Для обработки небольшого числа сообщений этот case не страшен. Однако если окно обрабатывает большое количество сообщений и обработчики сообщений содержат ветвления, секции исключений и прочие запутывающие вещи, то отладка, поиск обработки нужного сообщения и чтение подобного превращается в сущий кошмар (А ведь если взглянуть на количество контролов и классов в Delphi можно прийти в ужас от подобной перспективы).

Разработчики RTL пошли хитрым путем. Вместо огорода из case они придумали хитрый, изящный и весьма интересный трюк - message методы.

Как это работает

Message методы - часть языка Delphi, эта функциональность реализована уже в TObject, так что создавая любой класс вы уже имеете возможность обрабатывать сообщения (об этом чуть позднее). При добавлении message метода (сигнатура процедуры при этом должна быть определенного вида: procedure Name(var Message: MessageRecord); message [число]) в класс этот метод располагается в vmt по адресу, кратному числовому значению сообщения, стоящего после ключевого слова message. Message методы - динамические, ведь обработка сообщения должна быть не только у базового класса, но и у его потомков. Чтобы "послать сообщение", классу нужно сформировать струкутру Message (не обязательно TMessage, об этом чуть позже) и вызвать метод Dispatch нужного класса. Этот метод производит поиск в vmt адрес метода по смещению Msg структуры Message у класса и, если его нет непосредственно у класса, среди message методов его родителей. Если адрес метода не найден (то есть обработка такого сообщения не присутствует ни у одного класса в иерархии), то производится вызов метода DefaultHandler. Таким образом, схема обработки сообщений в VCL приобретает более удобную для восприятия и модификации форму. Вместо:

function WindowProc(hwnd: HWND; uMsg: Cardinal; wParam: WPARAM; lParam: LPARAM): Integer; stdcall;
begin
 case uMsg of
  WM_NULL:
  begin
  {код обработки WM_NULL}
  Result := 0;
  end;
  ...{несколько километров кода с перечислением всех сообщений}
  WM_USER:
  begin
  {код обработки WM_USER}
  Result := 0;
  end
  else
  Result := DefWindowProc(hwnd, uMsg, wParam, lParam);
 end;
end; 

получаем (приближенно):

TWinControl = class(TControl)
private
 procedure WMNull(var Message: TMessage); message WM_NULL;
 …{несколько километров кода с перечислением message методов всех сообщений}
 procedure WMUser(var Message: TMessage); message WM_USER;
protected
 procedure DefaultHandler(var Message); override;
 procedure WndProc(var Message: TMessage); override;
end;

{ TWinControl }

procedure TWinControl.WMNull(var Message: TMessage);
begin
 {код обработки WM_NULL}
 Message.Result := 0;
end;
…{несколько километров кода методов всех сообщений}
procedure TWinControl.WMUser(var Message: TMessage);
begin
 {код обработки WM_USER}
 Message.Result := 1;
end;

procedure TWinControl.WndProc(var Message: TMessage);
begin
 Dispatch(Message);
end;

procedure TWinControl.DefaultHandler(var Message);
begin
 with TMessage(Message) do
  Result := DefWindowProc(Handle, Msg, WParam, LParam)
end; 

Если изобразить вышесказанное в виде схемы, то получим для классической обработки:

 

После выборки сообщения из очереди, оно направляетя в оконную процедуру (1), где пробегается через case (2) и направляется в блок обработки сообщения (3), затем результат возвращается в качестве результата оконной функции (4).

Типичная схема обработки сообщений в VCL будет выглядеть приблизительно так:

 

После выборки сообщения (1), оно направляется в оконную процедуру WndProc (2). В ней происходит вызов метода Dispatch (3), который ищет, есть ли у TWinControl обработчик данного сообщения (4). Пусть в обработчике будет произведен вызов inherited. Метод динамический, поэтому будет производиться поиск среди message методов родителя (то есть TControl). Для удобства понимания (хотя на деле конечно не так, убедитесь в этом, открыв окно CPU) пройдем такой же путь - через Dispatch. Структура, содержащая сообщение направляется в родительский класс (4.1, 4.2), в нем идет поиск обработчика. Если обработчик не найден, вызывается метод DefaultHandler. Поскольку DefaultHandler у TWinControl переопределен, то произойдет его вызов (4.3), в котором будет вызов обработчика сообщений по умолчанию, то есть для Windows это DefWindowProc. Далее будет воврат в обработчик (4) и в оконную процедуру вернется уже структура с измененным значением поля Result, которое в итоге отдается как результат обработки сообщения.

Исходя из вышеописанного алгоритма, можно сделать несколько выводов:

Во-первых, методы динамические. При этом не обязательно писать override или называть метод тем же именем и даже принимать структуру того же типа, размера и с тем же выравниванием полей, вызов inherited внутри message метода приведет к вызову message метода родителя с передачей туда структуры Message, либо, если у родительских классов нет обработки этого сообщения, к вызову DefaultHandler. Это играет очень важную роль при работе с VCL.

Во-вторых, message методы не обязательно должны принимать именно тип TMessage, описанный в модуле Messages. Достаточно того, чтобы структура имела первое поле типа DWORD, чтобы можно было осуществить переход по адресу, равному числовому значению этого поля. На остальные параметры структуры не налагается ограничений. VCL использует TMessage, поскольку все сообщения Windows, а так же пользовательские сообщения CN_BASE + xxx имеют одну (или сходную по размерам) структуру. Однако, структуру обязательно нужно передавать по ссылке.

Надо заметить, что на диапазон обрабатываемых сообщений налагается ограничение, а именно от 1 до 49151. Почему 49151? Потому что данный прием был введен прежде всего для обработки сообщений Windows, а в Windows номера сообщений от 1 до WM_USER-1 зарезервированы системой, от WM_USER до $7FFF - для пользовательских сообщений и от WM_APP до $C000-1 (49151) - для сообщений на уровне приложения. От $C000 до $FFFF идет диапазон строковых пользовательских сообщений уровня приложения, создаваемых через RegisterWindowMessage, результат вызова фунции невозможно предсказать на этапе компиляции, поэтому логику обработки подобных сообщений лучше делать в оконной процедуре. По поводу оконной процедуры также стоит отметить, что вначале сообщение идет в статический метод MainWndProc, а в нем уже идет безопасный вызов WndProc. Для того, чтобы Windows могла принять MainWndProc как оконную функцию, VCL использует функцию Classes.MakeObjectInstance. Функция возвращает адрес на процедуру, которую можно отдать Windows как оконную, и которая перенаправит все вызовы оконной функции в метод класса.

Inherited

Как уже было сказано, message методы динамические. А это значит, что каждый новый потомок может переопределять реакцию на сообщение. Как и любое перекрытие, его нужно делать правильно. Вызов inherited не в том месте или его отсутствие может повлиять на логику работы контрола, поэтому нужно четко понимать, для чего нужен или не нужен вызов родительской обработки сообщения. Если вызова родительской обработки не производится, то следите за возвращаемым результатом сообщения.

Подводные камни

Не всегда в VCL можно решить задачу обработки сообщения исключительно переопределением message метода. Иногда это не приводит ни к какому результату, потому, что помимо обработки сообщения в message методе идет ее обработка и в WndProc. Яркий тому пример. Автору вопроса необходимо было запретить рисование системной стрелки в выпадающем меню. Но обработка WM_DRAWITEM не приводила ни к чему, потому что в WndProc состояние Canvas пункта меню возвращалось в исходное. Поэтому иногда все-таки приходится лезть в WndProc, хоть это и нехорошо :).

Message методы также являются одной из причин, по которой классический сабклассинг (то есть переопределение оконной процедуры окна через SetWindowLong) в Delphi крайне не рекомендуется. Одной из причин, как известно, является метод Perform у TControl, перенаправляющий сообщения напрямую в оконную процедуру. Если внутри кода класса будет вызов Perform, то будет вызван метод WndProc класса, а не фактическая оконная процедура окна (которая, как известно получается из приведения WndProc к виду, которому требует Windows через MakeObjectInstance). Однако и message методы тут не самые лучшие помощники - метод Dispatch направляет сообщение сразу в обработчик, а не в оконную процедуру. Поэтому проводя сабклассинг нужно быть готовым к подобным фокусам.

Если читатель знаком с WTL или MFC в си, то он явно заметил, что подобный механизм есть и там - через карты сообщений. Там имеется похожая проблема - если сообщение пришло через непосредственный вызов SendMessage, то эти сообщения обходят фильтры сообщений.

Собственная выгода

Итак, стало понятно как это работает в VCL. Однако использование message методов не ограничивается только лишь оконными (то есть обладающими хендлом) компонентами, и класс TControl тому подтверждение - все неоконные контролы способны обрабатывать сообщения. И оконный компонент ответственнен за перенаправление сообщений подчиненным неоконным контролам. Именно message методы сделали это возможным! Более того, их использование также не ограничивается только обработкой сообщений Windows - вы можете с таким же успехом обрабатывать свои сообщения, вооружившись знанием о том, как они работают. К примеру, у вас есть задача обмена некими данными между классами, однако заранее нельзя предугадать какими именно и как обрабатывать результат. Можно нагородить лес из кучи методов, предоставляющих возможность обмена разнотипными данными, а можно воспользоваться механизмом передачи сообщений. Например, задав такую структуру:

type
 TDataMessage = record
  Msg: DWORD;
  Data: Integer;
  DataSize: Integer;
  Result: Integer;
  Tag: Integer;
 end; 

и 2 сообщения - передача строки и передача числа:

const
 MSG_STRING = 1;
 MSG_INT = 2; 

можно обмениваться строковыми и целочисленными данными между классами через message методы:

type
 TClass1 = class
 private
  FSomeString: WideString;
  procedure OnStringGet(var Msg: TDataMessage); message MSG_STRING;
  procedure OnIntegerReceived(var Msg: TDataMessage); message MSG_INT;
 end;

procedure TClass1.OnIntegerReceived(var Msg: TDataMessage);
begin
 Msg.Result := Msg.Data * 2;
end;

procedure TClass1.OnStringGet (var Msg: TDataMessage);
begin
 FSomeString := 'test string example';
 Msg.Result := Integer(PWideChar(FSomeString));
end;

...
var
 Msg: TDataMessage;
begin
 with Msg do
 begin
  Msg := MSG_STRING;
  Data := 1321564;
 end;
 Class1.Dispatch(Msg);
Class2.SomeIntValue := Msg.Result;

FillChar(Msg, SizeOf(Msg), 0);
Msg.Msg := MSG_STRING;
Class1.Dispatch(Msg);
Class2.SomeStr := Copy(PWideChar(Msg.Result), 1, 10);

... 

Опять же, взяв на вооружение механизмы в VCL, можно писать приложения на чистом винапи, не городя при этом многокилометровых case в оконных процедурах ;) Или можно писать свой набор неоконных компонент и при этом спокойно обрабатывать оконные сообщения. Это раскрывает широкие возможности для разгула фантазии:)

Несколько слов о передаче строк и потоках

Если вы решились использовать подобный прием в своих проектах, то должны четко понимать что и как следует передавать. Рекоммендуется в структурах не использовать слишком много полей, желательно использовать только целые поля, чтобы передавать адреса передаваемых данных. Если вы передаете строку string, то помните про счетчик ссылок (очень хорошая статья об этом вот тут), если передаете PChar, то позаботьтесь о корректном выделении и освобождении памяти. Я бы порекомендовал WideString, поскольку в них нет счетчика ссылок.

При использовании message методов в отдельном потоке не забывайте про синхронизацию. Dispatch - это не SendMessage, при передаче сообщения классу в потоке с другим контекстом остановки потока не будет!

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

Надеюсь, статья не показалась вам скучной и непонятной, и дала вам пищу для размышлений.