メソッド呼び出しへの割り込み方法と差異

最近、id:ladybug:20101223 へのアクセス数がウナギらしいので、メソッド呼び出しへ割り込む手法とそれぞれの違いについて、自分の記憶の整理の意味で簡単にまとめてみようかと思った。

ついでに、前述の記事のサンプルコードは ContextBoundObject に対する機能を提供していなかったので、ContextBoundObject に対して Install() を呼び出さなくてよいようにする AutoSqueezeAttribute を追加してアップロードしなおしてあるので、興味がある人は再ダウンロードしてください。
とりあえず手法は、

  • 透過プロキシ (MarshalByRefObject)
  • 透過プロキシ (ContextBoundObject)
  • 自己インストゥルメント
  • 呼出インストゥルメント
  • 派生クラス
  • プロファイル

あたりを比較してみる。これら以外のアプローチもあるし、シリアライズ等の比較すべき項目ももっとあると思うのだけど、まぁまぁとりあえずこれぐらいでいいだろう的な…。

親子クラスの制約

割り込みの対象となるクラスに制約があることを示す。透過プロキシでは MarshalByRefObject の派生クラスでなければならないし、派生クラスを生成することによる割り込みでは sealed であってはならないという制約がある。

Generics の利用

Microsoft .NET Framework の実装では、ContextBoundObject は Generics をサポートしていない。Generics を含むメソッドの呼び出しに割り込めないとか、型パラメータを指定できないとか、そんなレベルではなく、Generics を含む ContextBoundObject 派生クラスをドメインにロードすることができない。

型の互換性

ソースコード上で互換性があるように見えるか、実行時の代入互換性があるか、型の比較処理に互換性があるかといった問題。この対応が悪いと利用するのに大きなコストがかかってしまう。
派生クラスを使ったアプローチでは、インスタンスの型が変化してしまうため、実行時に型情報を取得したり、型情報を扱った比較処理などを行うと問題が発生する可能性がある。

インスタンス生成の互換性

これもソースコード上での互換性の問題だが、透過プロキシによるアプローチや派生クラスによるアプローチでは、割り込みが有効な場合と無効な場合でインスタンス生成処理に変更が必要になる。型の互換性ほど大きな問題にしないのは、ファクトリメソッドなどで比較的隠ぺいしやすい問題といえるため、別枠としてみた。

外部からのメソッド呼び出しへの割り込み

派生クラスによるアプローチでは、非 virtual なメソッドに対して割り込みができないため△とした。

内部からのメソッド呼び出しへの割り込み

これは、

public int Add(int delta)
{
   this.value += delta;
   return this.value;
}

public int Increment()
{
    return this.Add(1);
}

このような2つのメソッドに対して、外部から Add() と Increment() の呼び出しには割り込めるが、Increment() の内部から呼び出された Add() に割り込めるかどうかといった問題。派生クラスによるアプローチが△となっているのは、外部からの呼び出しと同様に virtual かつ private ではないことが必要なためとなっている。

公開フィールドの読み書きに割り込み

プロパティやイベントはメタデータに set/get/add/remove という特別なメソッドを持たせることで実現されているため、メソッドとして割り込むことで対応できることがほとんどだが、公開されたフィールドについてのみ別枠として書き出した。インストゥルメンテーションによる対応では、メソッドよりも若干難易度が高いため、△としておいた。

static メソッドへの割り込み

インスタンスに細工するアプローチが多いため、static メソッドに対して無効なアプローチが多い。

引数や戻値の書き換え

割り込んだメソッドに与えられた引数を書き換えて実際のメソッドの挙動を変更したり、割り込んだメソッドの結果を書き換えて呼び出し元を騙すようなことができるかどうかを示している。インストゥルメンテーションによるアプローチでは、書き換えそのものは可能であっても書き換えを実行する実装ポイントをどのようにして提供するかが課題となるため、△とした。

インスタンス漏洩の対策

static メソッドの時にも書いたように、インスタンスに細工するアプローチが多いが、透過プロキシのような実際のインスタンスを隠ぺいしている実装では、隠ぺいしている実際のインスタンスが外部に漏れないような対策が必要になる。典型的な例は、StringBuilder のような

public T Append(int value)
{
    this.Buffer.Append(value)
    return this;
}

といった自身を返すメソッドが該当する。ref や out パラメータだったり、自身を含む配列や辞書などのコレクションだったり、どんなことが原因で漏えいするかわからないため、きちんと対策するのは結構面倒だったりする。

実装への影響

これは、感覚的なものでしかないのだが、どのようなアプローチをとっても本来のメソッドの動作とは異なる処理をすることになる。各アプローチがアプリケーションの挙動をどれぐらい変えてしまうかといった影響度合いを主観的に評価してみた。

透過プロキシによるアプローチ

透過プロキシは .NET Remoting のために CLR が持っている機能なので手軽に利用できる。反面、Remoting では外部からのアクセスのみをハンドリングすればよいため、外部からの呼び出しのみが対象となってしまう。また、.NET 4 からはドキュメント上で使用を控えるべき古い技術であることが記述されるようになってしまった。
親クラスが MarshalByRefObject または ContextBoundObject でなければならないという制約があり、表を見てもらえるとわかるように、前者はインスタンス生成、後者は Generics に問題を持っている。

インストゥルメントによるアプローチ

いわゆる IL の書き換え。呼び出される対象メソッドを持つ側を書き換える方法と、呼び出している側のコードを書き換える方法の2通りがある。
あるメソッドに対する割り込みを行う場合、前者は呼び出されるメソッド1つを書き換えるだけで済むが、後者は呼び出しているすべてのコードを書き換える必要がある。それだけ考えると前者のほうが楽そうに思えるが、実際の実装では後者がとられることが多い。これは、コンパイラがメソッド呼び出しを生成するときに、非 virtual メソッドに対しても callvirt を使うなどが便利だったりするかららしい。*1

派生クラスによるアプローチ

ほぼインストゥルメントと同様だが、動的または事前に派生クラスを生成しておいて、入れ替えちゃうという方法。まっとうな範囲でやろうと努力するため、virutal なメソッドに対してしか力を発揮できなかったり、GetType() されると困ったりするなどの違いが発生する。

プロファイラによるアプローチ

プロファイル手段によってはインストゥルメンテーション経由になっちゃうものもある。メソッド呼び出しの履歴をとるとか、メソッド呼び出しの発生を検出するとか、そういった要求に対しては一番力を発揮するアプローチでもあるが、引数や結果の書き換えができなかったり、リアルタイム性が低かったりすることがあるのも、このアプローチの特徴。

*1:そういえば、.NET 1.1 な頃にはここで Visual Studio 付属の某インストゥルメンテーションプロセスに不具合があったことをレポートしたこともあったね