четверг, 9 сентября 2010 г.

Хак №7: Interface в Object

Это перевод Hack #7: Interface to Object. Автор: Hallvard Vassbotn.

Хак, который делает "невозможное".

Поддерживается в .NET, но не в Win32

Люди, которые приходят к Delphi из Java или даже .NET, могут быть удивлены, что у нас нет способа получить ссылку на объект из интерфейсной ссылки в native Delphi (или в большинстве других native-языков). Компилятор не позволит вам выполнить безопасное, проверяемое в run-time преобразование as от интерфейсной ссылки на объектную:
procedure Foo(const I: IInterface);
var
  O: TObject;
begin
  O := I as TObject; // Не скомпилируется в D7
end;
Хотя компилятор позволит вам сделать жёсткое приведение типов, но это будет просто двоичной пере-интерпретацией и просто не будет работать. Если вы попробуете вызвать виртуальный метод или получить доступ к полю - может произойти что угодно. Если вам "повезёт" - у вас выскочит access violation:
procedure Foo(const I: IInterface);
var
  O: TObject;
begin
  O := TObject(I); // Компилируется успешно, но не работает, как надо в D7
  WriteLn(O.ClassName); // Вылетает с AV в D7
end;
В королевстве .NET (и я думаю, что в лагере Java ситуация аналогична) run-time и большинство языков (включая Delphi 8 .NET) позволяют вам преобразовать интерфейс обратно в объект, который реализует этот интерфейс. У Delphi 8 есть дополнительная возможность, которая позволяет вам делать операции вида I is TMyObject и приводить интерфейс к объекту жёстким преобразованием типов, но при этом компилятор Delphi 8 не позволяет вам использовать преобразования as:
procedure Foo(const I: IInterface);
var
  O: TObject;
begin
  O := I as TObject; // Не компилируется в D8
  O := TObject(I); // Компилируется и работает в D8
  if Assigned(O) then
    WriteLn(O.ClassName); // Работает в D8
  if I is TMyObject then // Компилируется и работает в D8
    WriteLn('TMyObject');
end;
Если жёсткое приведение неуспешно, то его результат будет nil (без возбуждения исключения). Надеюсь, что преобразования as будут доступны в будущих версиях компилятора.

Чистое решение

Когда речь заходит о хорошем дизайне, то преобразование интерфейса к объекту весьма спорно. Пуристы ООП скажут вам, что это нарушает слабосвязанность и независимость кода от реализации, что и предлагает нам использование интерфейсов. И в большинстве своём я с ними согласен. Необходимость преобразования интерфейса в объект с хорошей вероятностью говорит об изъяне в дизайне. Если вы знаете, что за объект реализует интерфейс, то в чём смысл использования интерфейса?

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

VCL в Delphi и класс TComponent используют такую гибридную модель, чтобы позволить компонентам реализовывать интерфейсы, и чтобы позволить интерфейсным свойствам подцепляться для использования в инспекторе объектов. Чтобы разобраться с проблемами учёта ссылок, методы _AddRef и _Release попросту ничего не делают в TComponent. Чтобы работать с опубликованными интерфейсными свойствами, система сериализации компонентов нуждается в получении реализующего интерфейс компонента, чтобы получить имя компонента, проверить его свойства по-умолчанию, обрабатывать уведомления и т.п. Вместо того, чтобы использовать грязные хаки, типа обсуждаемого ниже, TComponent использует чистое и правильное решение: он реализует интерфейс IInterfaceComponentReference с единственным методом GetComponent, который и возвращает компонент, реализующий интерфейс. См. исходный код и комментарии в Classes.pas.

Так что в общем случае чистое решение будет примерно таким:
type
  IInterfaceObjectReference = interface
  ['{41D23E9C-0974-4ED4-BBE8-4375D65E1129}']
    function GetImplementator: TObject;
  end;

  TMyObject = class(TInterfacedObject, IInterfaceObjectReference)
    function GetImplementator: TObject;
  end;

function TMyObject.GetImplementator: TObject;
begin
  Result := Self;
end;

Несите сюда хак

Теперь, когда мы рассмотрели вводную информацию и документированное решение, давайте приступим к хаку. Этот хак может быть полезным в тех случаях, когда вы не можете изменить код объекта, реализующего интерфейс, но вам очень нужно получить на него ссылку по интерфейсу. Я применил этот хак один раз, когда мне нужно было получить объект-реализатор в инфраструктуре MIDAS.

Некоторые люди утверждают, что это невозможно. По-своему, они правы. Это невозможно в общем случае: к примеру, интерфейс ведь может быть реализован не Delphi-объектом, а, скажем, C++, VB или даже просто на C. Но если мы знаем, что этот интерфейс реализуется Delphi-объектом в нашем же процессе (прим.пер.: важно, чтобы это была та же версия Delphi, что и собирает наш код), то это вполне возможно.

Если вы подумаете об этом минутку, то сообразите, что компилятору как-то нужно конвертировать интерфейсную ссылку, которой владеет вызывающий, в объектную ссылку, которую методы ожидают в неявном параметре Self, каждый раз когда делается вызов метода через интерфейс. Как он это делает? Настало время запустить наш старый-добрый отладчик и закопаться в ассемблерный код.

Как реализуются интерфейсы в Delphi?

Внимательно изучая как раскладываются поля в объектах, реализующих интерфейсы, вы обнаружите, что каждый интерфейс занимает 4 байта в экземпляре. Каждый такой интерфейсный слот является указателем на IMT (Interface Method Table) - просто упрощённый вариант VMT (Virtual Method Table), но без этих служебных записей по отрицательным смещениям (это виртуальные методы TObject, ClassName, DMT и т.п.). Интерфейсная ссылка на самом деле указывает на этот слот с IMT в объектном экземпляре. Поэтому, чтобы привести интерфейсную ссылку к объектной - нам надо просто вычесть из неё определённое смещение:
ObjectRef := TObject(Cardinal(InterfaceRef) - IMTOffset);

Изучение пациента

Суть хака заключается в поиске этого самого смещения для вычитания. IMT содержит указатель для каждого метода, определённого в интерфейсе. Этот указатель, однако, не указывает на реализацию самого метода: если бы это было так, то при вызове метода он получил бы мусор вместо Self (там сидела бы интерфейсная ссылка вместо объектной).

Поэтому все указатели в любых IMT всегда указывают не небольшие куски кода, называемых заглушками (thunks), которые создаются компилятором автоматически (под капотом языка). Эти заглушки-переходники ответственны за конвертацию интерфейсной ссылки в объектную (Self) и передаче управления настоящему методу, написанному программистом (прим.пер.: см. также). Код заглушек может быть разный, в зависимости от таких факторов:
  • Смещение слота IMT в экземпляре объекта: ShortInt или LongInt.
  • Соглашение вызова метода: register или стековое (stdcall, cdecl, safecall, pascal).
  • Результат метода: нет/простой или запись/управляемый.
  • Тип метода-реализатора: обычный, виртуальный или динамический.
  • Прим.пер.: потенциально - версии Delphi, конечно же.
Заметьте, что message-методы не поддерживаются как реализаторы методов интерфейса.

Давайте посмотрим на простой пример:
program TestIMT;

{$O-}
{$APPTYPE CONSOLE}

uses
  SysUtils;
type
  IMyInterface = interface
    procedure Foo;
    procedure Bar; stdcall;
  end;

  TMyClass = class(TInterfacedObject, IMyInterface)
    procedure Foo;
    procedure Bar; stdcall;
  end;

procedure TMyClass.Foo;
begin
  WriteLn(ClassName, '.Foo');
end;

procedure TMyClass.Bar;
begin
  WriteLn(ClassName, '.Bar');
end;

var
  MyClass: TMyClass;
  MyInterface: IMyInterface;
begin
  MyClass := TMyClass.Create;
  MyInterface := MyClass;
  MyInterface.Foo;
  MyInterface.Bar;
end.
Мы определили простой интерфейс с двумя методами и класс, реализующий его. Затем мы создали объект, преобразовали его в интерфейс и вызвали оба метода через интерфейсную ссылку. Внутри методов мы вызываем ClassName, что приведёт к вылету программы, если Self не будет корректным.

Расположите курсор на вызове MyInterface.Foo и нажмите F4 для запуска программы до курсора. Когда сработает одноразовая точка останова, откройте окно CPU-отладчика (Ctrl+Alt+C или View | Debug | CPU). Там будут такие инструкции:
mov eax, [MyInterface]
mov edx, [eax]
call dword ptr [edx + $0C]
Этот код загружает интерфейсную ссылку в EAX, затем загружает в EDX указатель на IMT, после чего вызывает четвёртый метод интерфейса ($0C / SizeOf(Pointer) + 1 = 12 / 4 + 1 = 4). Но разве Foo не первый метод в IMyInterface? Ну, да, но все Win32-интерфейсы неявно наследуются от IInterface, у которого есть три метода _AddRef, _Release и QueryInterface, так что Foo - это четвёртый метод интерфейса. Этот способ вызова интерфейсных методов идентичен генерируемому коду, когда вызывается обычный виртуальный метод по объектной ссылке. В обоих случаях целевой адрес берётся из таблицы указателей (VMT или IMT), с фиксированным смещением каждого метода.

Но когда целью виртуального вызова является сам виртуальный метод, вещи работают немного иначе в стране интерфейсов. Выполните инструкцию CALL с заходом (F7). Вы обнаружите, что вместо входа в реализацию метода, вас перенесли в какой-то странно выглядящий код. Когда мы вызываем метод Foo (который использует соглашение по-умолчанию register), эта заглушка-переходник выглядит так:
add eax, -$0C
jmp TMyClass.Foo
Для метода register Self передаётся в регистре EAX. Как мы видели ранее, EAX теперь содержит ссылку на интерфейс, а не ссылку на объект, которую ожидает увидеть там метод Foo, поэтому компилятор и создаёт эту заглушку, чтобы подправить EAX, вычитанием из него смещения, о котором мы говорили раньше. В нашем конкретном случае это смещение оказалось равным $0C байт или 12. Это означает, что у нас есть 12 байт данных между началом объекта и слотом IMT для этого интерфейса: 4 байта для VMT, 4 байта для поля FRefCount и 4 байта для слота IInterface (оба последних унаследованы от TInterfacedObject). (Прим.пер.: иными словами, совпадение этого смещения с числом $0C в предыдущем фрагменте кода - чисто случайно). После того как Self был исправлен, заглушка переходит напрямую на начало настоящей реализации метода.
Те же шаги для метода типа stdcall (Bar) дают нам:
mov eax,[MyInterface]
push eax
mov eax, [eax]
call dword ptr [eax+$10]
Это, фактически, такой же код, как и выше, но теперь Self заталкивается на стек (из-за stdcall), и мы вызываем на этот раз пятый метод. Вход в вызов CALL даст нам такой код:
add dword ptr[esp+$04], -$0C
jmp TMyClass.Bar
Поскольку Self у нас теперь на стеке, то заглушка подправляет значение на стеке, а не в регистре. В остальном она такая же.
В настоящий момент все возможные варианты заглушек используют одну из этих двух форм, подправляя значение в регистре или в стеке. Смещение IMT, которое нас интересует, закодировано в самой инструкции как один байт (как в нашем примере) или как 4 байта. Код вызова реализации метода становится более сложным в случае вызова виртуального или динамического метода, но это не имеет значения для нас.
Итак, я просуммировал то, что мы узнали, на этом рисунке:


Реализация хака

Теперь, когда мы знаем, как выглядит "волшебная" заглушка, мы готовы написать хак, который по указателю на интерфейс извлечёт смещение IMT слота и использует его для вычисления ссылки на объект. Одним потенциальным усложнением является то, что различные соглашения вызова приводят к немного разному машинному коду исправления Self. Мы могли бы сделать наш хак пригодным к обработке всех ситуаций, но существует более простое решение. Вспомните, что все интерфейсы наследуются от IInterface. Первый метод IInterface (а, следовательно, и любого интерфейса в Delphi вообще) - это QueryInterface:
function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
Тут важно заметить, что этот метод имеет соглашение вызова stdcall, что означает, что Self передаётся на стеке. Поэтому заглушка для QueryInterface выглядит так:
add dword ptr[esp+$04], -$0C
jmp TInterfacedObject.QueryInterface
Теперь у нас достаточно деталей для написания кода:
function GetImplementingObject(const I: IInterface): TObject; 
const   
  AddByte = $04244483;  
  AddLong = $04244481;  

type   
  PAdjustSelfThunk = ^TAdjustSelfThunk;   
  TAdjustSelfThunk = packed record     
    case AddInstruction: longint of       
      AddByte : (AdjustmentByte: shortint);       
      AddLong : (AdjustmentLong: longint);   
    end;   
  PInterfaceMT = ^TInterfaceMT;   
  TInterfaceMT = packed record     
    QueryInterfaceThunk: PAdjustSelfThunk;   
  end;   
  TInterfaceRef = ^PInterfaceMT; 

var   
  QueryInterfaceThunk: PAdjustSelfThunk; 
begin   
  Result := Pointer(I);   
  if Assigned(Result) then     
  try       
    QueryInterfaceThunk := TInterfaceRef(I)^.QueryInterfaceThunk;       
    case QueryInterfaceThunk.AddInstruction of         
      AddByte: Inc(PChar(Result), QueryInterfaceThunk.AdjustmentByte);         
      AddLong: Inc(PChar(Result), QueryInterfaceThunk.AdjustmentLong);         
    else     
      Result := nil;       
    end;     
  except       
    Result := nil;     
  end; 
end;
Некоторые люди считают, что низко-уровневые хаки должны быть написаны на ассемблере: это делает их практически нечитаемыми для непосвящённых, и выглядит это как какая-то магия. Я предпочитаю делать код настолько простым (в восприятии), насколько это возможно (но не проще). Для начала я попытался описать в коде слой памяти интерфейс -> IMT -> заглушка (см. рисунок выше), используя записи и типизированные указатели.

Интерфейсная ссылка реализуется как указатель на указатель IMT (TInterfaceRef = ^PInterfaceIMT). Любая IMT начинается с указателя на заглушку для метода QueryInterface (запись TInterfaceMT). Из-за того, что QueryInterface использует stdcall, заглушка всегда начинается с исправления параметра Self на стеке. Для этого используются две возможные инструкции (опкоды): одна для короткого смещения (до 128 байт), а вторая - для больших смещений (TAdjustSelfThunk). Наконец, я определил два возможных опкода, используя две константы (AddByte и AddLong).

Такие определения делают понимание исходного дела намного проще. Мы также защищаем код секцией try/except, чтобы вернуть nil для неверной ссылки на интерфейс или интерфейса, реализованный не в Delphi.

Предупреждение: впереди драконы

Этот хак очень сильно зависит от деталей реализации интерфейсов в компиляторе, поэтому он имеет высокие шансы сломаться в будущем. Я проверял этот код только в Delphi 6 и 7. Borland может легко изменить реализацию интерфейсов в любое время. Кроме того, этот хак может не работать для C++ Builder-а и наверняка не будет работать для любого другого компилятора. Во всех этих случаях он должен вернуть nil.

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

Замечу, что способность приводить интерфейсную ссылку к объектной реализована Borland в .NET. Возможно, они реализуют эту же функциональность и в native Delphi. Они могли бы сделать это, используя технику этого хака (прим.пер.: это реализовано Embarcadero в Delphi 2010). Но поскольку они будут контролировать и хак и реализацию интерфейсов, то их вариант будет намного более безопасным.

Комментариев нет:

Отправить комментарий

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

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

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

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

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

Примечание. Отправлять комментарии могут только участники этого блога.