воскресенье, 7 августа 2011 г.

Хак №14: изменение класса объекта в run-time

Это перевод Hack#14: Changing the class of an object at run-time. Автор: Hallvard Vassbotn.

Иногда (вроде, когда вам по той или иной причине нужно сохранить обратную совместимость с бинарным dcu) вам может быть необходимым использовать хак или два. Один такой хак заключается в изменении класса объекта в run-time. К примеру, это может понадобится для изменения виртуального, динамического или message-метода.

Vista и мерцание ProgressBar
Вот один из примеров. По какой-то причине (наиболее вероятно - исправление бага) Microsoft изменила поведение элемента управления ProgressBar в Windows Vista, так что он теперь отправляет сообщение WM_ERASEBKGND каждый раз, когда изменяется прогресс и элементу нужно себя перерисовать. Компонент TProgressBar, который является обёрткой к родному контролу, никак не обрабатывает WM_ERASEBKGND - ни явным переопределением message-метода, ни неявно в общем методе WndProc (фактически, он даже не замещает WndProc). Итоговой результат: каждый раз, когда меняется свойство Position, вы можете увидеть весьма заметное мерцание, потому что компонент сперва полностью очищает фон и лишь затем рисует поверх полосу прогресса в нужном стиле. Jordan Russell сообщил о этой проблеме в Quality Central.

Поскольку Delphi 2007 был выпуском Delphi с добавленной поддержкой Windows Vista, ему нужно было исправлять проблемы вроде этой. В обычных условиях вы бы исправили эту проблему простым добавлением обработчика WM_ERASEBKGND, который опускает логику по умолчанию (заливка области фоновым цветом):
type
  TProgressBar = class(TWinControl)
  private
    procedure WMEraseBkgnd(var Message: TWmEraseBkgnd); message WM_ERASEBKGND;
  end;

procedure TProgressBar.WMEraseBkgnd(var Message: TWmEraseBkgnd);
begin
  DefaultHandler(Message);
end;
Но, конечно, в этом случае проблема в том, что Delphi 2007 решено было сделать "non-breaking release" - т.е. все модуля должны были сохранить двоичную совместимость с предыдущей версией Delphi. Иными словами, интерфейсные секции модулей не должны были меняться. Но существует как минимум три разных способа решения этой проблемы. В этой статье мы рассмотрим простейшее и, вероятно, наименее надёжное решение: изменение класса всех экземпляров TProgressBar в run-time.

Изменение класса
С первого взгляда, это может показаться вам невозможным - изменить класс уже существующего (созданного) экземпляра класса (объекта). Класс экземпляра определяется двумя вещами: типом класса T, который использовался при объявлении переменной для экземпляра для хранения объектной ссылки, и типом класса D, который использовался при объявлении типа в design-time. Тип D должен быть совместим с T - т.е. быть его наследником:
type
  T = class
  end;

  D = class(T)
  end;

procedure Foo;
var
  Ref: T;
begin
  Ref := D.Create;
end;
Хотя вы определённо не можете изменить тип переменной для хранения объектной ссылки (T), вы можете изменить в run-time тип экземпляра объекта. Почему это возможно? Ну, тип экземпляра объекта хранится в неявном поле экземпляра объекта в памяти - первые 4 байта экземпляра всегда содержат ссылку на TClass (реализованную как указатель на VMT класса) класса, который был использован при создании объекта. Это зарезервированное поле инициализируется в классовом методе InitInstance, определённым в TObject:
class function TObject.InitInstance(Instance: Pointer): TObject;
begin
  FillChar(Instance^, InstanceSize, 0);
  PInteger(Instance)^ := Integer(Self);
Этот метод выполняет и другие задачи (вроде инициализации всех полей таблицы интерфейсных методов), но нам интересно только то, что до вызова параметр Instance содержит мусор (вы можете увидеть, что NewInstance вызывает GetMem, а затем InitInstance). Этот блок памяти сначала очищается нулями вызовом FillChar (именно это гарантирует нам нули по умолчанию во всех полях объекта) и затем перезаписывает первые 4 байта ссылкой на TClass (которую можно взять из неявного параметра Self у не статических классовых методов).

Фух! Теперь, когда мы знаем, что де-факто класс экземпляра объекта в run-time явно хранится в поле с известным смещением (0), то изменить класс становится ужасно легко - мы можем просто перезаписать ссылку на TClass на другую. Но нам надо быть осторожными, чтобы программа не вылетела (ведь скомпилированный код делает предположения о смещениях полей, индексах виртуальных методов и т.п. - на основании исходного класса объекта) - мы должны перезаписывать класс только на тот, который унаследован от текущего. А поскольку экземпляр уже был создан (с фиксированным размером), мы не можем добавлять никаких дополнительных полей.

Давайте посмотрим, как мы можем использовать эту технику в нашем случае с TProgressBar. Для начала давайте объявим и реализуем наследника TProgressBar, который исправляет указанную проблему:
type
  TProgressBarVistaFix = class(TProgressBar)
  private
    procedure WMEraseBkgnd(var Message: TWmEraseBkgnd); message WM_ERASEBKGND;
  end;

procedure TProgressBarVistaFix.WMEraseBkgnd(var Message: TWmEraseBkgnd);
begin
  DefaultHandler(Message);
end;
Код практически идентичен чистому "interface-breaking" решению выше - мы только изменили имя класса и наследовали его от TProgressBar. Теперь нам надо написать код, который берёт существующий экземпляр TProgressBar и изменяет его класс на TProgressBarVistaFix:
procedure PatchProgressBar(ProgressBar: TProgressBar);
type
  PClass = ^TClass;
begin
  if ProgressBar.ClassType = TProgressBar then
    PClass(ProgressBar)^ := TProgressBarVistaFix;
end;
Заметьте, что код сначала проверяет, является ли класс экземпляра в действительности именно TProgressBar, а не чем-то ещё или унаследованным классом - иначе подобная замена привела бы к большому хаосу, поскольку TProgressBarVistaFix унаследован именно от TProgressBar и ни от кого иного.

Одна из проблем с этой техникой хака - вам нужно явно патчить каждый создаваемый экземпляр. К примеру, вы могли бы делать это в FormCreate всех форм, содержащих хотя бы один progress bar. Если же мы являемся CodeGear (или ищем приключений на пятую точку) - мы могли бы применить этот хак в одном-единственном месте: конструкторе TProgressBar:
constructor TProgressBar.Create(AOwner: TComponent);
begin
  PatchProgressBar(Self);
  // ...
end;
Этот хак является полезным, если вам нужно быстро изменить класс экземпляра в run-time без изменения интерфейса. У него, однако, есть несколько недостатков:
  • Он требует явного исправления каждого экземпляра индивидуально.
  • Он изменяет класс экземпляра. К примеру, в нашем случае ClassName будет возвращать 'TProgressBarVistaFix'. Один из обходных путей - переместить TProgressBarVistaFix в отдельный модуль и назвать его TProgressBar. Но RTTI и классовая ссылка всё равно будут другими (это может иметь значение для кода, который явно проверяет равенство классов).
  • Хак не исправляет проблему для наследников класса. Но в случае с TProgressBar у нас их просто нет (по умолчанию).
  • Чтобы гарантировать изменение всех экземпляров - вам нужно вмешаться в код конструктора и менять класс в конструкторе.
  • Компилятор создаёт новую VMT для хак-класса, что немного увеличивает размер.

Преимуществами же являются:
  • Это очень простой хак, в том смысле, что он не требует записи в защищённые области (что потребовало бы использование VirtualProtect или WriteProcessMemory).
  • Его очень просто расширить на добавление замещения новых методов в классе (любого числа). Он также может быть использован для подъёма видимости свойств, так что для них будет генерироваться RTTI. Просто обновите хак-класс новыми определениями - и пусть компилятор делает всю работу по генерации новых VMT и RTTI для вас.
  • Он гибок, в том смысле, что позволяет изменить лишь один выбранный экземпляр, вместо всех подряд.
Как этот хак, так и другие варианты хаков, которые мы рассмотрим в будущих статьях, всегда имеют определённые недостатки, которые сложно преодолеть из-за самой сути VMT и компилятора. Хаки хорошо работают для листовых классов, не имеющих наследников, но если вы используете их для замещения методов в не листовом классе, а виртуальный метод также замещается в классе-наследнике, и наследник вызывает унаследованный метод, то всё будет работать не так, как если бы метод менялся чистым способом. Что, ясно как в тумане? Ничего, мы подробнее рассмотрим этот вопрос в другой раз.

Вывод
Представленный здесь хак по изменению класса объектной ссылки в run-time может быть полезен в некоторых частных случаях. Используйте его только для листовых классов и заменяйте класс на совместимый, не добавляющий новых возможностей (новые поля, методы). Для большинства других случае другие техники хака будут более полезны и/или надёжны. Мы посмотрим на некоторые из них в будущих постах. Оставайтесь с нами! ;)

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

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

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

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

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

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

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