Smalltalk
SmalltalkDay 14

メタプログラミング:インターセプターを作ってみる

More than 3 years have passed since last update.


はじめに

メタなプログラミングがホイホイ気軽にできてしまうのがSmalltalk。アプリを作っているのか、言語を作っているのか、しばしばわからなくなります。必要なのはオブジェクトにメッセージを送るという原則だけ、シンプルですね。

ということで軽い気持ちでインターセプターを作ってみることにしましょう。

インターセプターとは、ここでは、「オブジェクトに送られたメッセージを横取りするもの」とします。悪い奴ではないので、横取りした後で、オリジナルのオブジェクトにメッセージを送り直します。

メッセージを横取りして何をするかですが、今回は単純に、送られたメッセージを逐一トランスクリプトに表示させることにします。

これだけでもデバッグなどの目的で、なかなか役に立つでしょう。

なお、処理系はSqueak4.4-jaを使います。

言語の基本的な部分しか使っていないのでPharoなど他の処理系でも動作すると思います。


クラスの定義

ProtoObjectを継承します。オリジナルのオブジェクトを参照するためのインスタンス変数としてoriginalObjectを持ちます。


AdvMessageInterceptor

ProtoObject subclass: #AdvMessageInterceptor

instanceVariableNames: 'originalObject'
classVariableNames: ''
poolDictionaries: ''
category: 'AdvMessageInterceptor'

ProtoObjectはSmalltalkのクラス階層のトップに位置するクラスで、ほとんど何のメソッドも実装されていないという特徴を持ちます。表だってのトップであるObjectも、実はProtoObjectを継承しています。

以下をワークスペースで"print it"(Alt or Cmd+p)してみると確認できます。


doIt

Object superclass. "=> ProtoObject"

ProtoObject superclass. "=> nil"

ほぼメソッドが実装されていないクラスを継承する理由ですが、doesNotUnderstand:aMessageというコールバックを使いたいからです。

これは、あるオブジェクトがメッセージを受け取ったけれども、理解できないという時に、VM側から送られる特殊メッセージになります。

(早い話がRubyのmethod_missingです。動的のOO言語であれば、最近はよく見られる機構でしょう。)

つまり、AdvMessageInterceptorProtoObjectを継承することで、ほとんどのメッセージ送信を理解できず、結果としてVMからdoesNotUnderstand:aMessageが送られるということになります。引数は処理できなかったメッセージオブジェクトです。


メソッドの定義

doesNotUnderstand:は以下のように実装できるでしょう。


AdvMessageInterceptor>>doesNotUnderstand

doesNotUnderstand: aMessage 

aMessage transcriptOn: originalObject.
^aMessage sendTo: originalObject

引数のメッセージオブジェクトを、transcriptOn:で、トランスクリプトに出力しています。その後のsendTo:で、元のオブジェクトにメッセージを送り直しています。

基本はこれだけなのですが、もう少し詳しく見ましょう。transcriptOn:はいわゆるエクステンション(拡張メソッド)として、Messageクラスのほうに追加しました。


Message>>transcriptOn

transcriptOn: receiver

Transcript cr;
show: ('a {1}({2}) >> {3}'
format: {receiver class name. receiver identityHash. self selector})


Smalltalkはコア部分のクラスライブラリであろうが、分け隔て無くどこでも拡張するという文化を持ちます。クラスは常にオープンです。

Transcriptshow:を送ってメッセージの情報を文字列として書き出しています。フォーマット文字列の生成もSmalltalkではメッセージ送信です。'a{1}({2}) >> {3}'といった文字列にformat: <オブジェクトの配列>を送ると各オブジェクトが文字列化して埋め込まれます。

さて、後はサポート部分です。AdvMessageInterceptorに、元のオブジェクトを設定するためのメソッドxxxOriginalObject:を定義します。


AdvMessageInterceptor>>xxxOriginalObject

xxxOriginalObject: anObject

originalObject := anObject

これは単なるセッターです。xxxで始まる変な名前にしているのは、「理解できる」メソッドがこれ以上増えないようにするための工夫です。よく使われるような、普通の名前のメソッド(例えばname)をAdvMessageInterceptorに定義してしまうと、nameがインターセプターに送られたときに、普通にAdvMessageInterceptornameメソッドが起動されてしまうので、doesNotUnderstand:のフックでインターセプトするという戦略が役に立たなくなってしまうのです。そのためAdvMessageInterceptorのメソッド定義は、必要最小限にしておき、内部的に使うようなものは変な名前にしておくほうがよいのです。

インスタンス生成用のメソッドとしてon:を作りましょう。これはクラスメソッドです(他の言語で言うところのstaticメソッド)


AdvMessageInterceptor_class>>on

on: anOriginalObject

^self new xxxOriginalObject: anOriginalObject


説明の必要はないでしょう。インスタンスを作ってオリジナルのインターセプトされるオブジェクトをセットしています。結果、オリジナルのオブジェクトを保持したインターセプターが返されます。

もう動きます。ワークスペースで"print it"(Alt or Cmd+p)して試してみましょう。トランスクリプトに出力されるので、あらかじめトランスクリプトを開いておくのをお忘れなく。


doIt

(AdvMessageInterceptor on: 'Hello'), 'World'. "=> 'HelloWorld'"


'HelloWorld'という文字列が返ってきます。同時に,メッセージが送られた旨がトランスクリプトにも表示されます。

HelloWorldIntercepted-001.png

ただ、毎回AdvMessageInterceptor on: 'Hello'などとするのはいかにも野暮ったいですね。ここは一つ、Objectinterceptedというショートカット用のメソッドを定義することにしましょう。


Object>>intercepted

intercepted

^AdMessageInterceptor on: self

これで'Hello' intercepted, 'World'と簡潔に書けるようになります。また、インターセプターがインターセプターをインターセプトするとややこしいことになるので、interceptedAdvMessageInterceptorにも定義しておくことにしましょう。


AdvMessageInterceptor>>intercepted

intercepted

^self

これは必須ではないですが、予防のためです。誤って既にオブジェクトをインターセプト中のインターセプターに、interceptedを送っても何も起こりません。


動作確認

では、定番の3+4でやってみることにします。


doIt

3 intercepted + 4 intercepted. "=>7"


3+4Intercepted-002.png

3+4もSmalltalkではメッセージ送信で処理されます。3に+メッセージが引数4で送られると、引数の4側にいろいろと内部でメッセージが送られているということが確認できます。

「こんなことをしているから遅いのだ」という感じもあるかもしれませんが、Smalltalkは実際のところ動的言語の中では結構速いので問題ありません。


そして混沌へ

仕上げとして、もっと複雑なオブジェクトをインターセプトしてみます。「クラスもオブジェクト」なのがSmalltalkですから、いままで定義してきたAdvMessageInterceptor クラス をインターセプトすることにしましょう。

こんな感じでしょうか。ワークスペースで"do it"します(Alt or Cmd+d)。


doIt

Smalltalk at: #AdvMessageInterceptor put: AdvMessageInterceptor intercepted


ここでのSmalltalkは、システム辞書と呼ばれるグローバルな名前空間を提供するオブジェクトです。Smalltalk内のクラスオブジェクトは、グローバルなクラス名をキーにして、この辞書に登録されています。

上記を実行すると、#AdvMessageInterceptorというキーで登録されていた、AdvMessageInterceptorクラスがインターセプターに包み込まれ、登録し直されることになります。(若干危険なことをするので、"do it"前にイメージを保存しておいたほうが良いかもしれません。)

さて、この状態でAdvMessageInterceptorクラスをブラウズしてみましょう。

commentIntercepted-003.png

ブラウザ上でAdvMessageInterceptorクラスのメソッドを選択する、コメントを表示させるなど、いろいろと操作してみてください。そのたびにトランスクリプトにメッセージが次々と出力されるはずです。上は?ボタンを押してコメントを表示させてみたところです。ブラウザがhasCommentメッセージを送って、AdvMessageInterceptorクラスにコメントがあるかを調べているのがわかりますね。(コメント書いていないので、すみませんという感じですが)

このように、クラスブラウザも、クラスオブジェクトに様々なメッセージを送ることでツールとしての機能を実現しているということがわかります。どこでもメッセージ送信ですね。クラスオブジェクト自身に対するインターセプターというのは、デバッグ用途でブラウザの動きを知りたいといった時には、非常に役立つのではないでしょうか。

とはいえ、そろそろ怖くなってきたので元に戻したいという気持ちも出てきました。戻し方は、インターセプターのoriginalObjectに入っているAdvMessageInterceptorクラスオブジェクトを、#AdvMessageInterceptorというキーで、Smalltalkシステム辞書に登録しなおせば良いはずです。


doIt

Smalltalk at: #AdvMessageInterceptor put: (Smalltalk at: #AdvMessageInterceptor) xxxOriginalObject


ところがxxxOriginalObjectアクセッサを定義していなかったのでした! これは失敗ですね。最小限のメソッド定義にとらわれすぎ、xxxOriginalObject:というセッターのみに節約してしまいました。

さて、困りものですが、なんとか回復することにします。

Object>>becomeForward:AdvMessageInterceptorに移植してきましょう。


AdvMessageInterceptor>>becomeForward

becomeForward: otherObject 

"Primitive. All variables in the entire system that used to point
to the receiver now point to the argument.
Fails if either argument is a SmallInteger."

(Array with: self)
elementsForwardIdentityTo:
(Array with: otherObject)

becomeForward:は、引数のオブジェクトに、レシーバ自身が成るというものです。selfであったオブジェクトはどこかに葬り去られます。Smalltalkの重要な黒魔術の一つであり、通常は、あるオブジェクトへの参照を保ちつつそのオブジェクトをこっそりと変身・進化させる場合に用いることが多いです。

残念ながらProtoObjectでなくObjectに定義されていたので、メソッドのコピペとなりました。Smalltalker的にはメソッドのコピペは、かなりの屈辱なのですが、緊急事態なのでまあ良しとしましょう。

becomeForward:を使うquitInterceptingを定義します。


AdvMessageInterceptor>>quitIntercepting

quitIntercepting

self becomeForward: originalObject

これで回復できますね。ワークスペースで以下を"do it"します。


doIt

(Smalltalk at: #AdvMessageInterceptor) quitIntercepting


もちろん、以下のようにしても良いです。


doIt

AdvMessageInterceptor quitIntercepting


一見「AdvMessageInterceptorクラス」にクラスメッセージを送っているようですが、実際は AdvMessageInterceptorクラスをインターセプトしているAdvMessageInterceptorインスタンス」 にメッセージを送っているというところに注意してください。

これが送られると、「AdvMessageInterceptorクラスをインターセプトしているAdvMessageInterceptorインスタンス」はただの「AdvMessageInterceptorクラス」になり、平和が戻ります。もうクラスブラウザをいろいろ操作してもトランスクリプトには何もでません。

システムの深い部分をいじりすぎてピンチに陥り脱出する、というのもSmalltalkならではのスリリングで楽しい体験ですので、皆様、ぜひお試し下さい。

ソース一式はGistに載せておきました。

パッケージはSmalltalkHubにあります。

実は全く別のアプローチ「メッセージをとにかく理解する」というインターセプターの実装も用意しているのですが、長くなりましたのでこれにて終了です。またの機会ということでお楽しみに。