はじめに
メタなプログラミングがホイホイ気軽にできてしまうのがSmalltalk。アプリを作っているのか、言語を作っているのか、しばしばわからなくなります。必要なのはオブジェクトにメッセージを送るという原則だけ、シンプルですね。
ということで軽い気持ちでインターセプターを作ってみることにしましょう。
インターセプターとは、ここでは、「オブジェクトに送られたメッセージを横取りするもの」とします。悪い奴ではないので、横取りした後で、オリジナルのオブジェクトにメッセージを送り直します。
メッセージを横取りして何をするかですが、今回は単純に、送られたメッセージを逐一トランスクリプトに表示させることにします。
これだけでもデバッグなどの目的で、なかなか役に立つでしょう。
なお、処理系はSqueak4.4-jaを使います。
言語の基本的な部分しか使っていないのでPharoなど他の処理系でも動作すると思います。
クラスの定義
ProtoObject
を継承します。オリジナルのオブジェクトを参照するためのインスタンス変数としてoriginalObject
を持ちます。
ProtoObject subclass: #AdvMessageInterceptor
instanceVariableNames: 'originalObject'
classVariableNames: ''
poolDictionaries: ''
category: 'AdvMessageInterceptor'
ProtoObject
はSmalltalkのクラス階層のトップに位置するクラスで、ほとんど何のメソッドも実装されていないという特徴を持ちます。表だってのトップであるObject
も、実はProtoObject
を継承しています。
以下をワークスペースで"print it"(Alt or Cmd+p)してみると確認できます。
Object superclass. "=> ProtoObject"
ProtoObject superclass. "=> nil"
ほぼメソッドが実装されていないクラスを継承する理由ですが、doesNotUnderstand:aMessage
というコールバックを使いたいからです。
これは、あるオブジェクトがメッセージを受け取ったけれども、理解できないという時に、VM側から送られる特殊メッセージになります。
(早い話がRubyのmethod_missing
です。動的のOO言語であれば、最近はよく見られる機構でしょう。)
つまり、AdvMessageInterceptor
はProtoObject
を継承することで、ほとんどのメッセージ送信を理解できず、結果としてVMからdoesNotUnderstand:aMessage
が送られるということになります。引数は処理できなかったメッセージオブジェクトです。
メソッドの定義
doesNotUnderstand:
は以下のように実装できるでしょう。
doesNotUnderstand: aMessage
aMessage transcriptOn: originalObject.
^aMessage sendTo: originalObject
引数のメッセージオブジェクトを、transcriptOn:
で、トランスクリプトに出力しています。その後のsendTo:
で、元のオブジェクトにメッセージを送り直しています。
基本はこれだけなのですが、もう少し詳しく見ましょう。transcriptOn:
はいわゆるエクステンション(拡張メソッド)として、Message
クラスのほうに追加しました。
transcriptOn: receiver
Transcript cr;
show: ('a {1}({2}) >> {3}'
format: {receiver class name. receiver identityHash. self selector})
Smalltalkはコア部分のクラスライブラリであろうが、分け隔て無くどこでも拡張するという文化を持ちます。クラスは常にオープンです。
Transcript
にshow:
を送ってメッセージの情報を文字列として書き出しています。フォーマット文字列の生成もSmalltalkではメッセージ送信です。'a{1}({2}) >> {3}'といった文字列にformat: <オブジェクトの配列>
を送ると各オブジェクトが文字列化して埋め込まれます。
さて、後はサポート部分です。AdvMessageInterceptor
に、元のオブジェクトを設定するためのメソッドxxxOriginalObject:
を定義します。
xxxOriginalObject: anObject
originalObject := anObject
これは単なるセッターです。xxxで始まる変な名前にしているのは、「理解できる」メソッドがこれ以上増えないようにするための工夫です。よく使われるような、普通の名前のメソッド(例えばname
)をAdvMessageInterceptor
に定義してしまうと、name
がインターセプターに送られたときに、普通にAdvMessageInterceptor
のname
メソッドが起動されてしまうので、doesNotUnderstand:
のフックでインターセプトするという戦略が役に立たなくなってしまうのです。そのためAdvMessageInterceptor
のメソッド定義は、必要最小限にしておき、内部的に使うようなものは変な名前にしておくほうがよいのです。
インスタンス生成用のメソッドとしてon:
を作りましょう。これはクラスメソッドです(他の言語で言うところのstaticメソッド)
on: anOriginalObject
^self new xxxOriginalObject: anOriginalObject
説明の必要はないでしょう。インスタンスを作ってオリジナルのインターセプトされるオブジェクトをセットしています。結果、オリジナルのオブジェクトを保持したインターセプターが返されます。
もう動きます。ワークスペースで"print it"(Alt or Cmd+p)して試してみましょう。トランスクリプトに出力されるので、あらかじめトランスクリプトを開いておくのをお忘れなく。
(AdvMessageInterceptor on: 'Hello'), 'World'. "=> 'HelloWorld'"
'HelloWorld'という文字列が返ってきます。同時に,
メッセージが送られた旨がトランスクリプトにも表示されます。
ただ、毎回AdvMessageInterceptor on: 'Hello'
などとするのはいかにも野暮ったいですね。ここは一つ、Object
にintercepted
というショートカット用のメソッドを定義することにしましょう。
intercepted
^AdMessageInterceptor on: self
これで'Hello' intercepted, 'World'
と簡潔に書けるようになります。また、インターセプターがインターセプターをインターセプトするとややこしいことになるので、intercepted
をAdvMessageInterceptor
にも定義しておくことにしましょう。
intercepted
^self
これは必須ではないですが、予防のためです。誤って既にオブジェクトをインターセプト中のインターセプターに、intercepted
を送っても何も起こりません。
動作確認
では、定番の3+4でやってみることにします。
3 intercepted + 4 intercepted. "=>7"
3+4もSmalltalkではメッセージ送信で処理されます。3に+
メッセージが引数4で送られると、引数の4側にいろいろと内部でメッセージが送られているということが確認できます。
「こんなことをしているから遅いのだ」という感じもあるかもしれませんが、Smalltalkは実際のところ動的言語の中では結構速いので問題ありません。
そして混沌へ
仕上げとして、もっと複雑なオブジェクトをインターセプトしてみます。「クラスもオブジェクト」なのがSmalltalkですから、いままで定義してきたAdvMessageInterceptor
クラス をインターセプトすることにしましょう。
こんな感じでしょうか。ワークスペースで"do it"します(Alt or Cmd+d)。
Smalltalk at: #AdvMessageInterceptor put: AdvMessageInterceptor intercepted
ここでのSmalltalk
は、システム辞書と呼ばれるグローバルな名前空間を提供するオブジェクトです。Smalltalk内のクラスオブジェクトは、グローバルなクラス名をキーにして、この辞書に登録されています。
上記を実行すると、#AdvMessageInterceptorというキーで登録されていた、AdvMessageInterceptor
クラスがインターセプターに包み込まれ、登録し直されることになります。(若干危険なことをするので、"do it"前にイメージを保存しておいたほうが良いかもしれません。)
さて、この状態でAdvMessageInterceptor
クラスをブラウズしてみましょう。
ブラウザ上でAdvMessageInterceptor
クラスのメソッドを選択する、コメントを表示させるなど、いろいろと操作してみてください。そのたびにトランスクリプトにメッセージが次々と出力されるはずです。上は?ボタンを押してコメントを表示させてみたところです。ブラウザがhasComment
メッセージを送って、AdvMessageInterceptor
クラスにコメントがあるかを調べているのがわかりますね。(コメント書いていないので、すみませんという感じですが)
このように、クラスブラウザも、クラスオブジェクトに様々なメッセージを送ることでツールとしての機能を実現しているということがわかります。どこでもメッセージ送信ですね。クラスオブジェクト自身に対するインターセプターというのは、デバッグ用途でブラウザの動きを知りたいといった時には、非常に役立つのではないでしょうか。
とはいえ、そろそろ怖くなってきたので元に戻したいという気持ちも出てきました。戻し方は、インターセプターのoriginalObject
に入っているAdvMessageInterceptor
クラスオブジェクトを、#AdvMessageInterceptorというキーで、Smalltalk
システム辞書に登録しなおせば良いはずです。
Smalltalk at: #AdvMessageInterceptor put: (Smalltalk at: #AdvMessageInterceptor) xxxOriginalObject
ところがxxxOriginalObject
アクセッサを定義していなかったのでした! これは失敗ですね。最小限のメソッド定義にとらわれすぎ、xxxOriginalObject:
というセッターのみに節約してしまいました。
さて、困りものですが、なんとか回復することにします。
Object>>becomeForward:
をAdvMessageInterceptor
に移植してきましょう。
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
を定義します。
quitIntercepting
self becomeForward: originalObject
これで回復できますね。ワークスペースで以下を"do it"します。
(Smalltalk at: #AdvMessageInterceptor) quitIntercepting
もちろん、以下のようにしても良いです。
AdvMessageInterceptor quitIntercepting
一見「AdvMessageInterceptor
クラス」にクラスメッセージを送っているようですが、実際は 「AdvMessageInterceptor
クラスをインターセプトしているAdvMessageInterceptor
インスタンス」 にメッセージを送っているというところに注意してください。
これが送られると、「AdvMessageInterceptorクラスをインターセプトしているAdvMessageInterceptorインスタンス」はただの「AdvMessageInterceptorクラス」になり、平和が戻ります。もうクラスブラウザをいろいろ操作してもトランスクリプトには何もでません。
システムの深い部分をいじりすぎてピンチに陥り脱出する、というのもSmalltalkならではのスリリングで楽しい体験ですので、皆様、ぜひお試し下さい。
ソース一式はGistに載せておきました。
パッケージはSmalltalkHubにあります。
実は全く別のアプローチ「メッセージをとにかく理解する」というインターセプターの実装も用意しているのですが、長くなりましたのでこれにて終了です。またの機会ということでお楽しみに。