Swanman's Horizon

性欲をもてあましつつなんらかの話をするよ。

class helperで擬似的にインスタンス変数を追加する。

はじめに

class helperは任意のクラスにメソッドや定数、クラス変数を追加する機能を持ちますが、インスタンス変数を追加することはできません。しかしインスタンスの実態が単なるポインタであり、任意のフィールドへのアクセスが「インスタンスアドレス+フィールドオフセット」の逆参照をしているだけに過ぎないと考えると、インスタンスサイズさえ増やすことができれば、増やした部分にアクセスするclass helperを書くことでインスタンス変数を追加するのと同等の効果を得ることができます。
なお、この方法は「最後の手段」であり、dcuしかないから対象のコードに手が入れられないとかそういう場合を想定しています。

インスタンスサイズはどこで決まるか

TObject.Createのようにコンストラクタを実行することで、自身が記述した処理とは別に暗黙的にインスタンスの初期化が行われます。この初期化は大きく分けて2つの処理があり、ひとつがメモリの確保、もうひとつが確保したメモリの初期化です。この暗黙的な初期化がTObject.NewInstanceで、その中で実行されるメモリの確保が_GetMem関数、それに続いて行われるインスタンスの初期化がTObject.InitInstanceにあたります。
メモリの確保をする_GetMem関数はTObject.InstanceSizeを引数として実行されます。これで得られる値はクラス毎に異なり、これを書き換えれば任意のクラスのインスタンスサイズを増やすことができそうです。しかしTObject.InstanceSizeは単なるメソッドであり、好きな数値を代入することはできません。そのためクラス毎のインスタンスサイズ値が記録されているメモリアドレスを計算し、そこを書き換える必要があります。また、その場所は実行可能なメモリ領域であり、通常は書き込みが禁止されているため、メモリ保護属性を書き換えて書き込み可能にする必要があります。

コード例

説明だけグダグダ続けても分かりづらいので、試しにTButtonにフィールドを2つ追加するコードを書いてみます。

unit ExtraButtonFields;

interface

uses
  Winapi.Windows, Vcl.StdCtrls;

type
  TButtonHelper = class helper for TButton
  private
  const
    ExtraFields = SizeOf(UInt8) + SizeOf(Int64); // 追加フィールドがある場合はここに追加
    ExtraFieldSize = (ExtraFields + (SizeOf(Pointer) - 1)) and not (SizeOf(Pointer) - 1);
    FieldOffset1 = 0;
    FieldOffset2 = 1;
    function GetValue1: UInt8; inline;
    function GetValue2: Int64; inline;
    procedure SetValue1(const Value: UInt8); inline;
    procedure SetValue2(const Value: Int64); inline;
  public
    property Value1: UInt8 read GetValue1 write SetValue1;
    property Value2: Int64 read GetValue2 write SetValue2;
  end;

implementation

{ TButtonHelper }

function TButtonHelper.GetValue1: UInt8;
begin
  Result := PByte(PByte(Self) + InstanceSize - (ExtraFieldSize + hfFieldSize - FieldOffset1))^;
end;

function TButtonHelper.GetValue2: Int64;
begin
  Result := PInt64(PByte(Self) + InstanceSize - (ExtraFieldSize + hfFieldSize - FieldOffset2))^;
end;

procedure TButtonHelper.SetValue1(const Value: UInt8);
begin
  PByte(PByte(Self) + InstanceSize - (ExtraFieldSize + hfFieldSize - FieldOffset1))^ := Value;
end;

procedure TButtonHelper.SetValue2(const Value: Int64);
begin
  PInt64(PByte(Self) + InstanceSize - (ExtraFieldSize + hfFieldSize - FieldOffset2))^ := Value;
end;

procedure ResizeInstance(Cls: TClass; ExtraSize: Integer);
var
  p: PInteger;
  oldProtect: DWORD;
begin
  p := PInteger(PByte(Cls) + vmtInstanceSize);
  VirtualProtect(p, SizeOf(Integer), PAGE_READWRITE, oldProtect);
  p^ := p^ + ExtraSize;
  VirtualProtect(p, SizeOf(Integer), oldProtect, nil);
end;

initialization
  ResizeInstance(TButton, TButton.ExtraFieldSize);
end.

このコードをプロジェクトソースの一番最初でusesするとTButtonにValue1とValue2が追加されます。厳密にはTButtonを使用しているユニットより先にusesしてあれば一番じゃなくてもいいんですが、まあ一番最初に追加しておけば間違いは無いということで。

コードの解説

ResizeInstance手続きでインスタンスサイズの変更を行っています。インスタンスサイズの場所はインスタンスアドレスにvmtInstanceSize定数を足せば取れるんですが、上述のようにそのままでは書き込みができないのでVirtualProtect関数で一時的に書き込みできるように変更しています。
class helper側はメソッドが複数ありますが、肝はフィールドアドレスの計算ただひとつです。まずインスタンスアドレスにTObject.InstanceSizeを足すことで、インスタンスフィールドの終端を得ます。そこからExtraFieldSizeを引けば自身が追加した領域にアクセスできそうなものですが、実は似た仕組みをDelphi自身が使っていて、各インスタンスには末尾にHidden fieldと呼ばれる領域*1が存在します。なので、ここのサイズであるhfFieldSize定数*2も引く必要があります。これでようやく自身の追加した領域の先頭アドレスが得られたので、後は個々のフィールドのオフセットを足せば完了です。
このコードを改造してフィールドを追加する場合は、ExtraFieldsと各FieldOffset、そしてプロパティとそのアクセッサメソッドを追加すればOKです。ResizeInstance手続きは一応再利用可能にしたので、任意のクラスに適用できます。
注意点としては、インスタンスの拡張は指定したクラスのみに適用され、継承クラスには反映されないということです。例えば今回の例でいえばTButtonを継承したTButtonExというクラスがあっても、TButtonEx自体にResizeInstanceを適用しない限りサイズは拡張されません*3
ちなみにWindows APIを使っていることからも分かるように、このコードはWindows専用です。ただ、VirtualProtectの代わりにmprotectを使えばOSXiOS*4でも同じことができると思います。mprotectはページ境界アドレス*5しか指定できないのでちょっとした計算が必要です。Androidは全く触ってないので知らない。

余談

ちなみにフィールドアドレスの計算部分、括弧を使わずに以下のように素直に書いた方が分かりやすいと思うんですが、

  PInt64(PByte(Self) + InstanceSize - ExtraFieldSize - hfFieldSize + FieldOffset2)^ := Value;

この書き方だと、ExtraFieldSizeもhfFieldSizeもFieldOffset2も定数なのにもかかわらず、最適化がかからず全部律儀にそのまま機械語に落とすというアホみたいなコード生成をしているので、仕方なく変更しました。
ちなみにこうなる。

; PByte(Self) + InstanceSize - ExtraFieldSize - hfFieldSize + FieldOffset2
mov edx,[eax] ; PByte(Self)
add edx,-$34  ; 
mov edx,[edx] ; 
add edx,eax   ; + InstanceSize
sub edx,$0c   ; - ExtraFieldSize
sub edx,$04   ; - hdFieldSize
inc edx       ; + FieldOffset2

; PByte(Self) + InstanceSize - (ExtraFieldSize + hfFieldSize - FieldOffset2)
mov edx,[eax] ; PByte(Self)
add edx,-$34  ; 
mov edx,[edx] ; 
add edx,eax   ; + InstanceSize
sub edx,$0f   ; - (ExtraFieldSize + hfFieldSize - FieldOffset2)

1回の減算と複数回の減算ではフラグレジスタの結果が変わるので(使われてないけど)、ぎりっぎり分からなくも無いかなとさっきまでは思ってましたが、いざVC++で同じようなコードを書いてみたらあっさり最適化してくれたので、単純にDelphiコンパイラの実装が糞なだけのようです。本当にありがとうございました。

*1:現在はTMonitor専用

*2:Systemユニットで定義

*3:追加フィールドが同じであればclass helperの方は2つ書く必要はない

*4:iOSでmprotectが通るようになったかどうかは知らない

*5:メジャーなOSはほぼ全部4KB、つまり$1000の倍数