суббота, 23 июля 2011 г.

Хак №10: получение параметров published методов

Это перевод Hack #10: Getting the parameters of published methods. Автор: Hallvard Vassbotn.

Этот хак не очень полезен на практике, он был сделан под вдохновением от комментария к посту о published методах. Тогда я начал расследование, как это можно сделать. Вспомните, что компилятор сейчас не кодирует сигнатуру метода в RTTI для published методов - сохраняются только адрес и имя метода.

Так что с первого взгляда кажется, что получить эту информацию - невозможно. Но давайте сделаем шаг назад и подумаем о том, как IDE обрабатывает события и published методы в design-time. Если у вас уже объявлено несколько обработчиков событий (реализованных в нескольких published методах формы), то инспектор объектов отфильтрует их и покажет только те методы (в выпадающем списке), сигнатура которых совместима с событием. Как IDE узнаёт, какие методы нужно показывать в списке, а какие - нет?

Ну, поскольку каждый компонент из design-time скомпилирован в пакет и зарегистрирован в IDE, то у IDE есть полный доступ к RTTI компонента (прим.пер.: а доступа к исходному коду компонента может и не быть). Как мы (вероятно) увидим в следующей статье, каждое published свойство-событие (OnClick, OnSelected и т.п.) описывается компилятором с помощью RTTI, которая включает в себя информацию о параметрах события. Из этой информации IDE извлекает число и типы параметров, так что присваиваемый published метод должен иметь это же число параметров тех же типов. IDE также использует эту RTTI информацию для построения правильной сигнатуры метода, когда вы создаёте новый обработчик события.

Но IDE всё ещё не имеет доступа к любой RTTI параметров published методов формы. На самом деле, IDE вообще не имеет доступа к скомпилированному представлению формы. Хотя у неё есть козырь: у IDE есть полный доступ к исходному коду формы. IDE "просто" анализирует код формы-источника и находит методы, которые имеют правильные количество и типы параметров. Этот анализ не является совершенным, и он не всегда может верно вычислить объявления псевдонимов типов, поэтому обычно типы параметров должны использовать дословные копии типов, используемых в объявлении типа события.

Это не слишком нам поможет - ведь у нас нет доступа к исходному коду формы в run-time. Но как отметил вездесущий Аноним в своем комментарии к статье о published методах, существуют декомпиляторы кода Delphi, которые способны определять типы параметров published методов в объявлении формы. Как они делают это? Ну, есть две подсказки - форма обычно содержит перечень published полей; это - ссылки на компоненты, которые потоковая система создаёт автоматически, когда она загружает .DFM. Эти поля имеют RTTI, которая включает тип класса компонента. Кроме того, у формы есть свойство-массив Components, содержащий ссылки на все компоненты и элементы управления, принадлежащие форме. При использовании любого из них мы получаем доступ ко всем компонентам, связанных с формой.

Эти компоненты обычно имеют одно или несколько свойств-событий, назначенных на методы формы. Если эти события были назначены во время разработки, то методы, на которые они указывают, будут объявлены как published. Все свойства-события компонентов, которые могут быть назначены во время разработки, также должны быть published. Компилятор предоставляет RTTI для таких событий - в том числе информацию о параметрах событий - и, следовательно, параметрах назначенного совместимого published метода, присвоенного событию.

Всё будет немного сложнее для статических декомпиляторов Delphi, но основная цепь информации, которую нужно будет размотать, будет той же самой. Написание декомпилятора выходит за рамки этой статьи (это оставлено ​​в качестве упражнения для читателя :) ), но давайте попробуем написать простой код, который может выяснять параметры всех published методов, которые были назначены на published события компонента.

Базовый алгоритм будет выглядеть примерно так:
  • Мы принимаем параметрами Instance и TStrings
  • Перечисляем все published методы объекта
  • Для каждого published метода:
    • Для экземпляра Instance:
      • Перечисляем все published события
      • Получаем значение каждого свойства-события
      • Если адрес published метода равен значению Code события, у нас есть связь
      • Возвращаем RTTI по параметрам типа события – эта же сигнатура используется и для published метода
    • Повторяем указанные выше шаги для каждого owned-компонента Instance (если Instance - это компонент)
Звучит достаточно прямолинейно. Давайте попробуем превратить это в код:
procedure GetPublishedMethodsWithParameters(Instance: TObject; List: TStrings);
var
  i: integer;
  Method: PPublishedMethod;
  AClass: TClass;
  Count: integer;
begin
  List.BeginUpdate;
  try
    List.Clear;
    AClass := Instance.ClassType;
    while Assigned(AClass) do
    begin
      Count := GetPublishedMethodCount(AClass);
      if Count > 0 then
      begin
        List.Add(Format('Published methods in %s', [AClass.ClassName]));
        Method := GetFirstPublishedMethod(AClass);
        for i := 0 to Count - 1 do
        begin
          List.Add(PublishedMethodToString(Instance, Method));
          Method := GetNextPublishedMethod(AClass, Method);
        end;
      end;  
      AClass := AClass.ClassParent;
    end;
  finally
    List.EndUpdate;
  end;
end;
GetPublishedMethodsWithParameters - это высокоуровневый метод, который использует подпрограммы из прошлой статьи, чтобы перечислить все published методы экземпляра объекта (Instance). Он добавляет строковое представление каждого метода в список TStrings. Сама конвертация published метода в строку осуществляется функцией PublishedMethodToString:
function PublishedMethodToString(Instance: TObject; Method: PPublishedMethod): string;
var
  MethodSignature: TMethodSignature;
begin
  if FindPublishedMethodSignature(Instance, Method.Address, MethodSignature) then
    Result := MethodSignatureToString(Method.Name, MethodSignature)
  else 
    Result := Format('procedure %s(???);', [Method.Name]);  
end;
Эта функция сначала пытается получить сигнатуру метода, используя FindPublishedMethodSignature, и если это ей удаётся, то она использует найденную сигнатуру метода для построения строкового представления, используя MethodSignatureToString. Скоро мы посмотрим на эти подпрограммы, но пока посмотрим на определение записи для сигнатур методов:
  PMethodParam = ^TMethodParam;
  TMethodParam = record
    Flags: TParamFlags;
    ParamName: PShortString;
    TypeName: PShortString;
  end;
  TMethodParamList = array of TMethodParam;
  PMethodSignature = ^TMethodSignature;
  TMethodSignature = record
    MethodKind: TMethodKind;
    ParamCount: Byte;
    ParamList: TMethodParamList;
    ResultType: PShortString;
  end;
Эти определения - мои собственные структуры данных, которыми проще получать доступ к RTTI типов событий без необходимости мучаться с переменной длиной записей. Мои записи дублируют информацию из raw структур RTTI в удобной форме. Сами структуры RTTI, сгенерированные компилятором, доступны через модуль TypInfo. Вот соответствующие объявления из модуля TypInfo:
type
  TMethodKind = (mkProcedure, mkFunction, mkConstructor, 
    mkDestructor, mkClassProcedure, mkClassFunction,
    { Obsolete }
    mkSafeProcedure, mkSafeFunction);
  TParamFlag = (pfVar, pfConst, pfArray, pfAddress, pfReference, pfOut);
  TParamFlags = set of TParamFlag;
  TTypeData = packed record
    case TTypeKind of
     /// ...
     tkMethod: (
        MethodKind: TMethodKind;
        ParamCount: Byte;
        ParamList: array[0..1023] of Char
       {ParamList: array[1..ParamCount] of
          record
            Flags: TParamFlags;
            ParamName: ShortString;
            TypeName: ShortString;
          end;
        ResultType: ShortString});
     end;
Ok. Запись TTypeData кодирует тип события (свойство типа указатель на метод) следующим образом. Поле MethodKind указывает, что это за вид метода – AFAICT, сейчас используются только два значения: mkProcedure и mkFunction, соответствуя объявлениям procedure … of object и function … of object соответственно. Затем идёт байт, содержащий число параметров метода, ограничивая, таким образом, число параметров в типе события до 255 :-) Затем следует упакованный массив из упакованных записей с информацией о каждом параметре; вид параметра (var, const, out, array of), имя параметра и его тип. За всеми параметрами следует строка с именем типа, который возвращает метод, если MethodKind был mkFunction.

Поскольку ParamName, TypeName и ResultType кодируются как упакованные ShortString, с которыми очень неудобно работать, выше я объявил записи TMethodParam и TMethodSignature. Вот функция GetMethodSignature, которая конвертирует PPropInfo события в более-простую-в-использовании TMethodSignature:
function PackedShortString(Value: PShortstring; var NextField{: Pointer}): PShortString; overload;
begin
  Result := Value;
  PShortString(NextField) := Value;
  Inc(PChar(NextField), SizeOf(Result^[0]) + Length(Result^));
end;  

function PackedShortString(var NextField{: Pointer}): PShortString; overload;
begin
  Result := PShortString(NextField);
  Inc(PChar(NextField), SizeOf(Result^[0]) + Length(Result^));
end;  

function GetMethodSignature(Event: PPropInfo): TMethodSignature;        
type
  PParamListRecord = ^TParamListRecord;
  TParamListRecord = packed record 
    Flags: TParamFlags;
    ParamName: {packed} ShortString; // на самом деле: string[Length(ParamName)]
    TypeName:  {packed} ShortString; // на самом деле: string[Length(TypeName)]
  end;
var
  EventData: PTypeData;
  i: integer;
  MethodParam: PMethodParam;
  ParamListRecord: PParamListRecord;
begin
  Assert(Assigned(Event) and Assigned(Event.PropType));
  Assert(Event.PropType^.Kind = tkMethod);
  EventData := GetTypeData(Event.PropType^);
  Result.MethodKind := EventData.MethodKind;
  Result.ParamCount := EventData.ParamCount;
  SetLength(Result.ParamList, Result.ParamCount);
  ParamListRecord := @EventData.ParamList;
  for i := 0 to Result.ParamCount - 1 do
  begin
    MethodParam := @Result.ParamList[i];
    MethodParam.Flags     := ParamListRecord.Flags;
    MethodParam.ParamName := PackedShortString(@ParamListRecord.ParamName, ParamListRecord);
    MethodParam.TypeName  := PackedShortString(ParamListRecord);
  end;  
  Result.ResultType := PackedShortString(ParamListRecord);
end;
Она использует парочку перегруженных (overload) вспомогательных функций, чтобы получить упакованные ShortString и перейти к следующей записи. Мне также пришлось переобъявить запись TParamListRecord, т.к. вариант в TypInfo закомментирован. Мы, вероятно, разберём структуры PPropInfo позже – а в этом контексте достаточно сказать, что мы можем получить из сигнатуры метода типа события интересную информацию и вернуть её в удобном и полезном формате.

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

Смотря на высокоуровневый алгоритм, который мы определили выше, нам надо пройтись по всем published событиям. Вот код, который это делает:
function FindEventProperty(Instance: TObject; Code: Pointer): PPropInfo;
var
  Count: integer;
  PropList: PPropList;
  i: integer;
  Method: TMethod;
begin
  Assert(Assigned(Instance));
  Count := GetPropList(Instance, PropList);
  if Count > 0 then
    try
      for i := 0 to Count - 1 do
      begin
        Result := PropList^[i];
        if Result.PropType^.Kind = tkMethod then
        begin
          Method := GetMethodProp(Instance, Result);
          if Method.Code = Code then
            Exit;
        end;  
      end;  
    finally
      FreeMem(PropList);
    end;
  Result := nil;
end;
Этот код получает список всех published свойств, отфильтровывая только свойства-события (tkMethod), получает текущее значение свойства и проверяет, не указывает ли оно на заданный адрес. Если да, то мы возвращаем PPropInfo метода-события, иначе мы возвращаем nil. Этот код проверяет только один экземпляр, но нам надо проверить все owned-компоненты (если экземпляр класса является наследником TComponent) – так что давайте сделаем подпрограмму для рекурсивного вызова:
function FindEventFor(Instance: TObject; Code: Pointer): PPropInfo;
var
  i: integer;
  Component: TComponent;
begin
  Result := FindEventProperty(Instance, Code);
  if Assigned(Result) then 
    Exit;

  if Instance is TComponent then
  begin
    Component := TComponent(Instance);
    for i := 0 to Component.ComponentCount - 1 do
    begin
      Result := FindEventFor(Component.Components[i], Code);
      if Assigned(Result) then 
        Exit;
    end;  
  end;
  Result := nil;
  // TODO: проверить published поля
end;
Эта функция пытается найти свойство-событие, которому присвоен заданный адрес. Она ищет свойство в самом объекте, а также во всех принадлежащих ему компонентах (только для объектов-компонентов).

Здесь мы используем массив Components, который имеют все компоненты, чтобы проверить, имеет ли какой-то подкомпонент интересующее нас свойство-событие. Как указываем комментарий, мы могли бы также (или вместо) проверять объекты, на которые указывают published поля. Поскольку RTL не содержит аналогичных легко-используемых функций доступа для перечисления всех published полей, и мы пока не зашли так далеко в нашей серии по раскопке внутренностей компилятора Delphi, то я пока опускаю эту возможность. Кроме того, published поля и массив Components (в основном) дублируют друг друга.

Теперь у нас достаточно служебного кода, чтобы собрать всё вместе. Вот функция FindPublishedMethodSignature, которую вызывает функция PublishedMethodToString выше:
function FindPublishedMethodSignature(Instance: TObject; Code: Pointer; var MethodSignature: TMethodSignature): boolean; 
var
  Event: PPropInfo;
begin
  Assert(Assigned(Code));
  Event := FindEventFor(Instance, Code);
  Result := Assigned(Event);
  if Result then
    MethodSignature := GetMethodSignature(Event);
end;
Функция сначала использует рекурсивную FindEventFor для поиска PPropInfo события, которая описывает метод. И если она её находит, то она конвертирует тяжело-используемую PPropInfo в легко-используемую TMethodSignature. Наконец, нам осталось написать только код, который конвертирует запись TMethodSignature в строковое представление метода:
function MethodKindString(MethodKind: TMethodKind): string;
begin
  case MethodKind of
    mkSafeProcedure, 
    mkProcedure     : Result := 'procedure';
    mkSafeFunction,
    mkFunction      : Result := 'function';
    mkConstructor   : Result := 'constructor';
    mkDestructor    : Result := 'destructor';
    mkClassProcedure: Result := 'class procedure'; 
    mkClassFunction : Result := 'class function';
  end;  
end;  
 
function MethodParamString(const MethodParam: TMethodParam; ExcoticFlags: boolean = False): string;
begin
       if pfVar       in MethodParam.Flags then Result := 'var '
  else if pfConst     in MethodParam.Flags then Result := 'const '
  else if pfOut       in MethodParam.Flags then Result := 'out '
  else                                          Result := '';
  if ExcoticFlags then
  begin
    if pfAddress   in MethodParam.Flags then Result := '{addr} ' + Result;
    if pfReference in MethodParam.Flags then Result := '{ref} ' + Result;
  end;  
  Result := Result + MethodParam.ParamName^ + ': ';
  if pfArray in MethodParam.Flags then 
    Result := Result + 'array of ';
  Result := Result + MethodParam.TypeName^;
end;  
 
function MethodParametesString(const MethodSignature: TMethodSignature): string;
var
  i: integer;
  MethodParam: PMethodParam;
begin
  Result := '';
  for i := 0 to MethodSignature.ParamCount - 1 do
  begin
    MethodParam := @MethodSignature.ParamList[i];
    Result := Result + MethodParamString(MethodParam^);
    if i < MethodSignature.ParamCount-1 then
      Result := Result + '; ';
  end;  
end;  
 
function MethodSignatureToString(const Name: string; const MethodSignature: TMethodSignature): string;
begin
  Result := Format('%s %s(%s)', [MethodKindString(MethodSignature.MethodKind), Name, MethodParametesString(MethodSignature)]); 
  if Length(MethodSignature.ResultType^) > 0 then
    Result := Result + ': ' + MethodSignature.ResultType^;
  Result := Result + ';';     
end;
Фух! Эта статья получилась ужасно длинной и с кучей кода! Но теперь у нас есть серьёзный (и довольно бесполезный) код для выдирания параметров published методов. Заметьте, что он работает только по экземпляру объекта, который должен иметь published свойство, указывающее на published метод. Хорошие новости: это всегда так для самых интересных published методов – вроде обработчиков событий на TForm. Плохие новости: это не будет работать для любых published методов, вызываемых вручную в run-time (поскольку метод не будет присваиваться событию).

Если вы всё ещё с нами, то сейчас вы можете написать код для проверки работы:
type
  {$M+}
  TMyClass = class;

  TOnFour = function (A: array of byte; const B: array of byte; var C: array of byte; out D: array of byte): TComponent of object; 
  TOnFive = procedure (Component1: TComponent; var Component2: TComponent; out Component3: TComponent; const Component4: TComponent) of object;
  TOnSix = function (const A: string; var Two: integer; out Three: TMyClass; Four: PInteger; Five: array of Byte; Six: integer): string of object; 

  TMyClass = class
  private
    FOnFour: TOnFour;
    FOnFive: TOnFive;
    FOnSix: TOnSix;
  published
    function FourthPublished(A: array of byte; const B: array of byte; var C: array of byte; out D: array of byte): TComponent; 
    procedure FifthPublished(Component1: TComponent; var Component2: TComponent; out Component3: TComponent; const Component4: TComponent); 
    function SixthPublished(const A: string; var Two: integer; out Three: TMyClass; Four: PInteger; Five: array of Byte; Six: integer): string; 
    property OnFour: TOnFour read FOnFour write FOnFour;
    property OnFive: TOnFive read FOnFive write FOnFive;
    property OnSix: TOnSix read FOnSix write FOnSix;
  end;
 
function TMyClass.FourthPublished;
begin 
  Result := nil;
end;

procedure TMyClass.FifthPublished;
begin
end;

function TMyClass.SixthPublished;
begin 
end;

procedure DumpPublishedMethodsParameters(Instance: TObject);
var
  i : integer;
  List: TStringList;
begin
  List := TStringList.Create;
  try
    GetPublishedMethodsWithParameters(Instance, List);
    for i := 0 to List.Count - 1 do
      WriteLn(List[i]);
  finally
    List.Free;
  end;
end;
 
procedure Test;
var
  MyClass: TMyClass;
begin
  MyClass := TMyClass.Create;
  MyClass.OnFour := MyClass.FourthPublished;
  MyClass.OnFive := MyClass.FifthPublished;
  MyClass.OnSix := MyClass.SixthPublished;
  DumpPublishedMethodsParameters(MyClass);
end;

begin
  Test;
  ReadLn;
end.
После запуска мы получаем:
Published methods in TMyClass
function FourthPublished(A: array of Byte; const B: array of Byte; var C: array of Byte; out D: array of Byte): TComponent;
procedure FifthPublished(Component1: TComponent; var Component2: TComponent; out Component3: TComponent; const Component4: TComponent);
function SixthPublished(const A: String; var Two: Integer; out Three: TMyClass; Four: PInteger; Five: array of Byte; Six: Integer): String;
По-моему, это очень похоже! Тестовый код выше немного надуман - например, экземпляр объекта не будет назначать свои свойства-события на свои же собственные методы. Более реалистичный тест будет с формой с многочисленными published событиями, подключенными в design-time. Я загрузил проект \Demos\RichEdit\RichEdit.dpr, поставляемый с Delphi 7 (в Delphi 2006 путь будет \Demos\DelphiWin32\VCLWin32\RichEdit\RichEdit.bdsproj). На главной форме из модуля remain.pas я добавил мой модуль HVPublishedMethodParams в предложение uses и изменил обработчик Help | About:
procedure TMainForm.HelpAbout(Sender: TObject);
begin
  GetPublishedMethodsWithParameters(Self, Editor.Lines);
{  with TAboutBox.Create(Self) do
  try
    ShowModal;
  finally
    Free;
  end;}
end;
Этот код сдампит все published методы формы в редактор – пытаясь сопоставить их с событиями с RTTI информацией, чтобы узнать сигнатуру методов. Когда я запускаю приложение и выбираю Help | About, редактор заполняется таким текстом:
Published methods in TMainForm
procedure SelectionChange(Sender: TObject);
procedure FormCreate(Sender: TObject);
procedure ShowHint(???);
procedure FileNew(Sender: TObject);
procedure FileOpen(Sender: TObject);
procedure FileSave(Sender: TObject);
procedure FileSaveAs(Sender: TObject);
procedure FilePrint(Sender: TObject);
procedure FileExit(Sender: TObject);
procedure EditUndo(Sender: TObject);
procedure EditCut(Sender: TObject);
procedure EditCopy(Sender: TObject);
procedure EditPaste(Sender: TObject);
procedure HelpAbout(Sender: TObject);
procedure SelectFont(Sender: TObject);
procedure RulerResize(Sender: TObject);
procedure FormResize(Sender: TObject);
procedure FormPaint(Sender: TObject);
procedure BoldButtonClick(Sender: TObject);
procedure ItalicButtonClick(Sender: TObject);
procedure FontSizeChange(Sender: TObject);
procedure AlignButtonClick(Sender: TObject);
procedure FontNameChange(Sender: TObject);
procedure UnderlineButtonClick(Sender: TObject);
procedure BulletsButtonClick(Sender: TObject);
procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
procedure RulerItemMouseDown(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X: Integer; Y: Integer);
procedure RulerItemMouseMove(Sender: TObject; Shift: TShiftState; X: Integer; Y: Integer);
procedure FirstIndMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X: Integer; Y: Integer);
procedure LeftIndMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X: Integer; Y: Integer);
procedure RightIndMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X: Integer; Y: Integer);
procedure FormShow(Sender: TObject);
procedure RichEditChange(Sender: TObject);
procedure SwitchLanguage(Sender: TObject);
procedure ActionList2Update(Action: TBasicAction; var Handled: Boolean);
Это довольно хорошая дословная копия published методов в разделе interface модуля формы. Только метод ShowHint не имеет параметров. Это потому, что этот published метод не присвоен событию в design-time. Вместо этого, он назначается и используется во время выполнения:
    procedure ShowHint(Sender: TObject);
///…
procedure TMainForm.FormCreate(Sender: TObject);
begin
  Application.OnHint := ShowHint;
//…
end;
Объект TApplication не публикует какие-либо свои свойства, так что нет и простого способа получения параметров ShowHint. А между тем, метод ShowHint, объявленный как published - это ошибка дизайна. По всей логике, этот метод должен быть private.

На этом мы завершаем этот интригующий, но AFAICS, бесполезный хак. Теперь вы должны иметь лучшее понимание того, как published методы и published события связаны друг с другом в run-time, и как декомпиляторы Delphi могут совершать некоторые свои волшебные трюки. Мы также показали, как много информации о вашей программе хранится в EXE-файле - вам лучше убедиться, что в именах ваших published методов, событий или их параметров и типов не содержится конфиденциальная информация :-)

Надеюсь, вы наслаждались процессом!

2 комментария:

  1. респект) и благодарности за статью)
    на самом деле не так уж бесполезно.
    мне кажется любой синтаксический анализатор командной строки консольного приложения в лучшей своей версии должен использовать похожий подход.

    ОтветитьУдалить
  2. кстати в модуле rtti.pas объявлен такой класс как TRttiMethod, у которого есть
    function GetParameters: TArray; virtual; abstract;
    я не проверял что из себя представляет, это какое-то нововведение? я в 2010.

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.