Сообщений 2    Оценка 235        Оценить  
Система Orphus

Обработчики событий в Delphi

Автор: Александр Просторов
Источник: RSDN Magazine #4-2004
Опубликовано: 08.02.2005
Исправлено: 10.12.2016
Версия текста: 1.0
Процедурные типы данных
Указатели на методы
Как работают обработчики событий
Как присваивать обработчики событий
Операция @

ПРЕДУПРЕЖДЕНИЕ

Примеры кода, приведенные в этой статье, проверены на Delphi 6 (Build 6.163) и могут потребовать незначительных изменений при использовании других версий Delphi.

Как правило, обучение Delphi начинается с книг, которые учат бросать компоненты на формы, настраивать их свойства через Object Inspector, и с его же помощью создавать в программном коде обработчики событий. Потом программист учится работать с компонентами в программном коде: изменять их свойства во время выполнения программы (например – при нажатии менять надпись на кнопке со "Старт" на "Стоп" и обратно) и создавать новые компоненты непосредственно в рантайме. При этом довольно часто недостаточно полно описывается работа в этом режиме с обработчиками событий; в большинстве книг язык Object Pascal не рассматривается достаточно глубоко для понимания механизма, и программист, видя в обработчиках нечто магическое, не знает, как же работать с ними в своем коде.

Прежде всего, следует понимать, что обработчики событий – такие же свойства компонента, как Caption или Left. В них нет ничего волшебного, ничего особенного; как и другим свойствам, им просто нужно присвоить выражение соответствующего типа.

Процедурные типы данных

Реализация обработчиков событий опирается на возможность Object Pascal, называемую процедурными типами (procedural types). Процедурные типы позволяют описать переменные (а также параметры процедур и функций, свойства и т. п.), значением которых является процедура (в дальнейшем под словом "процедура" может пониматься также функция). В нужное время эта процедура может быть вызвана – причем именно та процедура, которая является значением переменной в текущий момент; если затем изменить значение переменной, в следующий раз вызванной окажется уже другая процедура. Рассмотрим пример:

      type
  TCalcFunction = function(const A, B : integer) : integer;

function Add(const A, B : integer) : integer;
begin
  Result := A + B;
end;

function Sub(const A, B : integer) : integer;
begin
  Result := A - B;
end;

function Mul(const A, B : integer) : integer;
begin
  Result := A * B;
end;

procedure Example;
var CalcFunction : TCalcFunction;
begin
  CalcFunction := Add;
  ShowMessageFmt('CalcFunction(2, 3) = %d', [CalcFunction (2, 3)]);
  CalcFunction := Sub;
  ShowMessageFmt('CalcFunction(2, 3) = %d', [CalcFunction (2, 3)]);
  CalcFunction := Mul;
  ShowMessageFmt('CalcFunction(2, 3) = %d', [CalcFunction (2, 3)]);
end;

Прежде всего, в нем описывается тип TCalcFunction. Значением переменной этого типа может быть функция, получающая два константных параметра типа integer и возвращающая результат также типа integer. Не углубляясь в детали, можно сформулировать простое и достаточно точное правило: процедура будет корректным значением для переменной процедурного типа, если ее декларация (за возможным исключением имен параметров) совпадает с декларацией в процедурном типе; в противном случае компилятор выдаст сообщение об ошибке "несовместимые типы данных".

Затем в процедуре Example с помощью одной и той же переменной – CalcFunction – вызываются три разных процедуры, выдающие результат соответственно 5, -1 и 6.

Физически переменные процедурного типа являются указателями на процедуру/функцию. После операции присваивания значением переменной CalcFunction является адрес соответствующей процедуры. При использовании переменной в выражении (в процедуре ShowMessageFmt) вызывается функция, адрес которой сохранен в переменной. Как и для других указателей, корректным значением для переменной процедурного типа будет nil; разумеется, попытка вызова функции при таком значении переменной приведет к ошибке. Для проверки значения переменной можно использовать обычные методы: выражение CalcFunction = nil или вызов функции Assigned (CalcFunction).

Выполнив приведение типов, переменной процедурного типа можно присвоить указатель на процедуру другого типа. За корректность такой операции, как и во всех остальных случаях, отвечает программист. Чтобы поступать так, необходимо детальное знание реализации вызова процедур и передачи параметров в Object Pascal.

Указатели на методы

Ключевой момент, часто вызывающий непонимание – обычные процедуры существенно отличаются от методов объектов. Если в предыдущем примере функция Add будет определена в классе – указатель на нее не удастся присвоить переменной CalcFunction ни непосредственно, ни даже с помощью приведения типов.

Отличие методов объектов в том, что кроме обычных параметров они получают еще один, скрытый параметр – указатель на сам объект. Этот указатель доступен в теле метода как Self, а также неявно используется во всех случаях, когда метод обращается к полям, свойствам или методам объекта. При вызове метода объекта всегда используются два указателя: например, оператор Form1.Close означает вызов метода Close (указатель) объекта Form1 (указатель). Указатели на методы объектов – объектные процедурные типы или method pointers – хранят в себе оба этих адреса, поэтому физически они реализуются двумя "обычными" указателями и занимают восемь байт в памяти. Таким образом, их не удастся присвоить переменной CalcFunction (которая реализована одним "обычным" указателем и занимает четыре байта) ни непосредственно, ни с помощью приведения типов. Указатели на методы классов (class methods) реализованы аналогично, но вместо указателя на объект содержат указатель на класс.

При работе с объектами предыдущий пример может быть переписан следующим образом:

      type
  TCalcFunction = function (const A, B : integer) : integer ofobject;

function TTestForm.Add (const A, B : integer) : integer;
begin
  Result := A + B;
end;

function TTestForm.Sub (const A, B : integer) : integer;
begin
  Result := A - B;
end;

function TTestForm.Mul (const A, B : integer) : integer;
begin
  Result := A * B;
end;

procedure Example;
var CalcFunction : TCalcFunction;
begin
  CalcFunction := TestForm.Add;
  ShowMessageFmt ('CalcFunction (2, 3) = %d', [CalcFunction (2, 3)]);
  CalcFunction := TestForm.Sub;
  ShowMessageFmt ('CalcFunction (2, 3) = %d', [CalcFunction (2, 3)]);
  CalcFunction := TestForm.Mul;
  ShowMessageFmt ('CalcFunction (2, 3) = %d', [CalcFunction (2, 3)]);
end;

В декларацию типа TCalcFunction добавляются ключевые слова of object – это указывает, что значением будет не просто процедура, но метод класса. Кроме того, при присвоении значения переменной CalcFunction нужно указать объект, метод которого присваивается – именно этот объект будет значением Self при вызове метода. Здесь присутствует синтаксическая неоднозначность: в случае, если процедуру Example сделать методом класса TTestForm, явное указание объекта станет необязательным; прежний синтаксис (CalcFunction := Add) приведет к присвоению значения Self.Add. Это обычное для объектов соглашение скрадывает внешнее различие между procedural types и method pointers и часто приводит к непониманию.

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

При использовании объектных процедурных типов сравнение с nil становится некорректным – вместо него необходимо использовать функцию Assigned. Присвоение переменной значения nil остается корректным и обнуляет оба указателя: на объект и на процедуру. Процедура Assigned проверяет на равенство nil только указатель на процедуру; таким образом, можно вызвать метод объекта, передав ему в качестве Self значение nil.

Как работают обработчики событий

Ответ на этот вопрос – "очень просто". Рассмотрим, например, как реализован обработчик события TButton.OnClick (опустив несущественные подробности, но собрав вместе весь код, который имеет к этому отношение):

      type
  TNotifyEvent = procedure(Sender : TObject) ofobject;

  TControl = class(TComponent)
  private
    FOnClick : TNotifyEvent;
  protectedprocedure Click; dynamic;
    property OnClick : TNotifyEvent read FOnClick write FOnClick;   
  end;

procedure TControl.Click;
beginif Assigned(FOnClick) then FOnClick(Self);
end;

Класс TButton наследуется от TControl. и публикует свойство OnClick. Object Inspector, видя свойство процедурного типа, размещает его на странице обработчиков событий. При нажатии на кнопку класс TButton вызывает метод Click, и тот вызывает соответствующий обработчик события, если последний присвоен.

Как присваивать обработчики событий

Рассмотрим несколько примеров. Для начала предположим, что мы хотим создать на форме группу из 25 (5x5) кнопок, присвоив каждой один и тот же обработчик события OnClick. Это делается так:

      type
  TTestForm = class(TForm)
    procedure FormCreate(Sender:TObject);
  privateprocedure OwnButtonClick(Sender:TObject);
  end;

procedure TTestForm.FormCreate (Sender : TObject);
var i, j : integer;
beginfor i := 0 to 4 dofor j := 0 to 4 dowith TButton.Create (Self) dobegin
        Caption := Format ('Button %d %d', [i, j]);
        Top     := 100 + 30 * i;
        Left    := 100 + 90 * j;
        Width   := 80;
        Height  := 25;
        OnClick := OwnButtonClick;
        Parent  := Self;
      end;
end;

procedure TTestForm.OwnButtonClick (Sender : TObject);
begin
  ShowMessageFmt ('Нажата кнопка %s', [TButton (Sender).Caption]); 
end;

Здесь следует обратить внимание, что мы присваиваем обработчику события метод объекта TestForm, и именно эта форма будет Self-ом в теле обработчика события. Для того чтобы добраться до свойств нажатой кнопки, используется параметр Sender. Сам метод OwnButtonClick в нашем случае добавлен "руками" в определение класса TTestForm, но с тем же успехом можно воспользоваться обработчиком события, созданным для одного из компонентов на форме.

Процедура, присваиваемая обработчику события, может быть определена в любом классе, не обязательно в форме. Допустим, например, мы делаем класс, который поддерживает список форм в порядке перехода пользователя между ними – например, для реализации функциональности наподобие кнопки "Назад".

      type
  TActiveFormWatcher = classprivate
    FForms : TComponentList;
  protectedprocedure ActiveFormChanged (Sender : TObject);
  publicconstructor Create;
    { ... прочие декларации класса ... }end;

constructor TActiveFormWatcher.Create;
begin
  FForms := TComponentList.Create (false);
  Screen.OnActiveFormChange := ActiveFormChanged;
end;

procedure TActiveFormWatcher.ActiveFormChanged (Sender : TObject);
begin
  FForms.Add (Screen.ActiveForm);
end;

Здесь обработчику события Screen.OnActiveFormChange присваивается метод класса TActiveFormWatcher. Аналогичной техникой можно воспользоваться везде; даже если вы пишете обычную процедуру, вы можете описать небольшой служебный класс и присвоить его метод обработчику создаваемого компонента.

Недостатком такого подхода (равно как и создания класса-синглтона) является то, что необходимо заботиться о явном создании и уничтожении объекта. В общем случае следует идти именно этим путем; тем не менее, для полноты картины опишем пару хакерских приемов, которые можно использовать в этом случае.

Чтобы работать с классом, не создавая объектов этого класса, можно воспользоваться методами класса (class methods). Например, присвоить обработчик OnClick произвольной кнопке можно следующим образом:

      type
  TButtonClicker = classclassprocedure ButtonClick (Sender : TObject);
  end;

classprocedure TButtonClicker.ButtonClick (Sender : TObject);
begin
  ShowMessageFmt ('Нажата кнопка %s', [TButton (Sender).Caption]);
  ShowMessageFmt ('Self.Name = %s', [Self.ClassName]);
end;

procedure AssignButtonClick (const Button : TButton);
begin
  Button.OnClick := TButtonClicker.ButtonClick;
end;

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

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

      procedure ButtonClick (Self : TButton; Sender : TButton);
begin
  ShowMessageFmt ('Нажата кнопка %s', [Sender.Caption]);
  ShowMessageFmt ('Self = %s', [Self.Name]);
end;

procedure AssignButtonClick (const Button : TButton);
var Method : TMethod;
begin
  Method.Code := @ButtonClick;
  Method.Data := Button;
  Button.OnClick := TNotifyEvent (Method);
end;

В этом случае в процедуре, предназначенной быть обработчиком события, необходимо явно описать еще один, дополнительный параметр, который и будет получать значение Self. Правильный метод его описания зависит от соглашения о связях (calling convention), используемого в процедурном типе и методе. Для модели register (которая используется для всех стандартных обработчиков и принята по умолчанию для всех процедур в Delphi) этот параметр должен идти первым, а за ним – все остальные, так, как они описаны в типе обработчика события. Для приведения типов используется специальный тип TMethod, описанный в модуле System; он представляет собой внутреннюю реализацию объектного процедурного типа.

В пользу такого подхода можно найти два аргумента. Во-первых, в этом случае мы легко можем передать в обработчик события любой параметр Self (так, в примере, мы передаем словно бы собственный метод кнопки). Во-вторых, зная действительные типы параметров, мы можем использовать их при декларации процедуры и избежать лишних приведений типов: так, в ButtonClick параметры Self и Sender определены как TButton, хотя, вообще говоря, корректней бы было описать их как TObject.

Наконец, существует еще один, наиболее некорректный прием. Вовсе не обязательно создавать объект для того, чтобы можно было воспользоваться его методом. Классическим примером здесь будет процедура TObject.Free, которая хорошо и правильно работает с указателем на объект, имеющим значение nil. Можно воспользоваться этим подходом и присвоить обработчику указатель на метод не созданного объекта, например, так:

      type
  TButtonClicker = classprocedure ButtonClick (Sender : TObject);
  end;

procedure TButtonClicker.ButtonClick (Sender : TObject);
begin
  ShowMessageFmt ('Нажата кнопка %s', [TButton (Sender).Caption]);
end;

procedure AssignButtonClick (const Button : TButton);
begin
  Button.OnClick := TButtonClicker (nil).ButtonClick;
end;

В данном случае класс TButtonClicker используется только для того, чтобы процедура ButtonClick была совместима с типом свойства TButton.OnClick. Она не использует Self, поэтому вместо ссылки на объект можно указать значение nil; разумеется, в этом случае любая попытка, прямая или косвенная, использовать Self приведет к ошибке "access violation".

В пользу этого подхода, пожалуй, можно сказать только то, что он наиболее "прост" – не требует знания методов классов или приведения типов через TMethod. В то же время он наиболее опасен и не имеет никаких преимуществ перед другими методами; поэтому правильным будет не использовать его.

Операция @

Особым моментом, о котором необходимо упомянуть, является операция @ (взятие адреса). Вообще говоря, ее следовало бы использовать при всех операциях с процедурными типами. Так, процедуру Example из самого первого примера было бы в некотором смысле правильнее записать так:

      procedure Example;
var CalcFunction : TCalcFunction;
begin
  CalcFunction := @Add;
  ShowMessageFmt('CalcFunction (2, 3) = %d', [CalcFunction (2, 3)]);
  CalcFunction := @Sub;
  ShowMessageFmt('CalcFunction (2, 3) = %d', [CalcFunction (2, 3)]);
  CalcFunction := @Mul;
  ShowMessageFmt('CalcFunction (2, 3) = %d', [CalcFunction (2, 3)]);
end;

Обратите внимание на использование операции @ в присваиваниях. Результат в этом случае будет абсолютно таким же: компилятор отслеживает операции с процедурными типами и при необходимости добавляет неявную операцию взятия адреса. В то же время в ряде случаев такое рассуждение невозможно: так, при использовании TMethod нам пришлось написать @ButtonClick из-за того, что поле TMethod.Code определено как pointer, а не как процедурный тип. Аналогично, в процедуре Example мы могли бы написать:

      if CalcFunction = Add then{ ... }

и тем поставить компилятор перед выбором: то ли мы хотели сравнить указатели, то ли - вызвать две функции и сравнить их результаты. В случае если бы TCalcFunction определял функцию без параметров, оба этих толкования были бы синтаксически верны. Во избежание конфликтов в таких случаях компилятор всегда использует вызов процедуры, а для сравнения указателей необходимо использовать операцию @ – то есть писать

      if @CalcFunction = @Add then{ ... }

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

Благодарю Максима Гумерова, также известного как Slicer [Mirkwood], за ценные замечания и комментарии


Эта статья опубликована в журнале RSDN Magazine #4-2004. Информацию о журнале можно найти здесь
    Сообщений 2    Оценка 235        Оценить