Mini Squeak 2.2 (Webブラウザで動作する、古く、最小構成に近いSmalltalk)の古典的 MVC でタイマーを実装する

前回のエントリー『「使わないと損をする Model-View-Controller」のサンプルコードを SqueakJS のデモ画面(Mini Squeak 2.2)で動かす 』では個人的にも実に久しぶりに古典的MVC に触れて楽しかったので、今回は引き続き Mini Sqeauk 2.2 環境を使って @miwa719 さんにご提案いただいたお題にチャレンジしてみました。

容量を小さくすることを優先して、必要最低限を超えてクラスやメソッドがそぎ落とされてしまった Mini Squeak 2.2.の環境では何かと制約が多いため、なるべくシンプルな実装を目指し、古典的MVC に用意されたプラガブルVCオブジェクト(今のOSで言うウィジェット)をポトペタ感覚で組み合わせてそれっぽく仕上げてみました。

f:id:sumim:20190430171608p:plain

仕様も単純にしています。

  1. min、sec ボタンを押すと、それぞれ分と秒の数値がインクリメントされる。
  2. clear で 00 : 00 にリセット
  3. start でカウントダウンを開始して、00 : 00 になるとビープを決められた回数鳴らす
  4. カウントダウン中(あるいはビープを鳴らしている最中)に clear を押すと止められる
  5. カウントダウン中(あるいはその終了後)、下の枠内に開始時刻と終了時刻、カウントダウンした時間を表示する

以下は、実装時の試行錯誤の過程をさわりだけですが再現してみたものです。最終的に書き上がったコードは最後のリンク先に置いてありますので、前回のエントリー同様、ダウンロード後、ファイルをドラッグ&ドロップで Webブラウザのストアにコピーして、Mini Squeak 2.2環境内のファイラ(デスクトップクリック → open... → file list)から fileIn 後、システムブラウザでコードを見たり、動かして遊んでみたりしてください。

※SqueakJS は AndroidiOSバイスでも動作しますが、外部からのコードの読み込みはちょっと難しいかもしれません(今のところ方法を思いつきません…)。実際に試してはいないのですが、コードを直接タイプして入力すればもしかしたら動くかもしれません。どうぞあしからず。

MacChrome に奪われた簡易補完キーアクションのキーアサインを変更する

SqueakJS を動かす Webブラウザには Chrome を推奨しているのですが、Mac版を試していてまず最初に困ったのがメソッド名(Smalltalkでは「メッセージセレクタ」あるいは短く「セレクタ」と呼ぶ)を簡易的に補完する機能(query symbol, cmd + q)が使えないことでした。

Smalltalk のメソッド名は他言語に比べると長いのが特徴です。たとえば addSubView: hoge toRightOf: fuga なんてのをいちいちタイプするのはおっくうなので addsu くらいまでタイプしたところで addSubView: addSubView: below: addSubView: toRightOf: などから必要なものを選ぶことができる程度の手抜きはしたいものです。これをサポートしてくれるのが前述の cmd + q で利用できる query symbol という簡易補完機能なのですが、よく知られている通り、Mac では Chrome に限らず、そして古くから、同キーコンビネーションは伝統的に Quit に割り当てられているため使えません。そこで、実質空いている cmd + 3 に振り替えます。

本来であれば ParagraphEditor class>>initializeCmdKeyShortcuts(クラスPharagraphEditorのクラスメソッドinitializeCmdKeyShortcutsを慣習的にこう表記する)を修正するのがスジですが、ここは簡単のため ParagaphEditor クラスのクラス変数 CmdActionsアサインされている辞書代わりの配列の内容を半ば強引に直接変更してしまいます。

ワークスペース(デスクトップクリック → open... → workspace で起動)などで次の式をタイプして入力するかコピペしてから念のため選択した後、右クリック → do it (d) します。

 (ParagraphEditor classPool at: #CmdActions) at: $3 asciiValue + 1 put: #querySymbol:

f:id:sumim:20190501143332p:plain

特に画面に変化はないのですが、このあとたとえば 100 fac までタイプして cmd + 3 を押すと factorial と補完され、さらに押すと画面が反転して元の fac に戻るようなら設定の変更は成功です。

f:id:sumim:20190501143730p:plain f:id:sumim:20190501143757p:plain

なお Win版 Chrome で動かす場合、この簡易補完機能は alt + q で使えるので上記変更は必要ありません。

余談ですが、簡易補完を使いたくなる長いメソッド名はたいてい複数の引数をとるので(そして、Smalltalk は引数をメソッド名中のコロンの後に挿入する変態文法であることを思い出してください)、引数の挿入場所まで一気に移動できる argument advance (Mac では cmd + shift + a、Win では alt + shift + a が使えないので代わりに ctrl + shift + a)を併用するとさらに便利です。

Mac版では他にも senders of it (cmd + n。Win版では alt + n なのでやはり問題なし) が使えなかったりしていろいろ不便ですが、query symbol などのキー操作でしか使えない機能と違いメニューに同等機能があるショートカットの場合、少々面倒でも右クリックメニューから実行できるのでこれ以上あまり深追いはしないことにします。^^;

▼タイマーの実験(簡単な並列処理)

タイマーの実装のキモは、カウントダウンをバックグラウンドで行なうマルチスレッド処理です。Smalltalk ではブロック(Ruby のブロック { } とは違い [ ] で括る)に fork というメッセージを送ることで別スレッドで処理を走らせることができます。なお、Smalltalk でスレッドは「プロセス」 と呼び、クラス名もそのまま Process なので他言語ユーザーは少々混乱するかもしれません。

残念ながら「チューチュー」と鳴らすことはできませんが、か細く「ピー」とビープを鳴らすことは Smalltalk beep という式でできるのでそのテストも兼ねて。

[ (Delay forMilliseconds: 3000) wait.
    Display flash: Display boundingBox.
    3 timesRepeat: [Smalltalk beep]
] fork.
3 + 4

この式をタイプするかコピペして改めて選択した後、右クリック → print it (p) すると、3 + 4 の結果の 7 を表示したあと 3秒ほど経ってから画面の反転とビープ音が鳴るはずです。

f:id:sumim:20190501145024p:plain f:id:sumim:20190501145038p:plain

▼まずはUIだけ部分的に作って試す

Smalltalk の古典的MVCフレームワークでは、StandardSystemView で外枠を作り、その中にプラガブルなビューを追加(add subview)してゆくことで UI を構築できます。モダンな GUI と違い、ウィジェットを自由な位置に配置するのではなく、ペイン(枠)として区画を割り当てるようにするのがポイントです。

Smalltalk環境でのコード片のお試しや動作テストは、通常はワークスペースでの do it や print it で試みるものなのですが、残念ながら Mini Squeak 2.2 ではシュリンクによる機能削減の煽りを受けてか、ワークスペースでのコード修正(テンポラリー変数の宣言のし忘れやタイプミスの指摘と自動修正)がうまく機能せず、普通の言語処理系のようにエラーだけ返してくるので Smalltalk環境のうまみがまったく失われてしまっています。

幸い、システムブラウザにはこれらのSmalltalk環境らしい機能がなんとか保たれているようなので、先に Timer クラスを作って、そのクラスメソッド open として試したいコードを記述しそれを更新してゆきながら試行錯誤を続けるのがよさそうだと判断しました。

まずシステムブラウザを起動し(デスクトップクリック → open... → browser)、左上のペインの右クリック → add item... でクラスカテゴリーを新しく追加します。ここでは MiniSqueak22-Timer としますが、気に入らなければ好きな名前に変えてもらって構いません。

f:id:sumim:20190430230957p:plain

クラスカテゴリーが追加されると、下のコードペインにクラス定義のためのテンプレートが現れるので次のように修正して Timer クラスを定義します。

Model subclass: #Timer
    instanceVariableNames: ''
    classVariableNames: ''
    poolDictionaries: ''
    category: 'MiniSqueak22-Timer'

修正が終わったら(あるいはここからコピペして置き換えるのでもOKです)、右クリックメニュー → accept して決定します。

f:id:sumim:20190430231434p:plain

上段2番目の枠に Timer が追加されれば成功です。すぐ下の ? を挟んで右側にある class ボタンをクリックしてクラスメソッド定義モード(クラスサイド)に切り替えてから、3番目の枠の no message をクリックすると下のコードペインに、こんどはメソッド定義のテンプレートが現れるので次のコードに置き換えてください。

open
    "Timer open"
    | timer topView |
    timer := Timer new.
    topView := StandardSystemView new model: timer.
    topView borderWidth: 2; label: 'Timer'.

    topView controller open

入力(もしくはコピペによる置き換え)が終わったら 右クリック → accept でコンパイルします(初回のコンパイル時にはイニシャルを尋ねられるので適当に答えてあげてください)。コンパイルが成功すると右上のペインに open が追加されます(Smalltalkではメソッド単位、つまりインクリメンタルなコンパイルが通常です)。コメントとして仕込んでおいた Timer open を選択して 右クリック → do it (d) すると空のウインドウを表示できます。

f:id:sumim:20190430233208p:plain

試しにいくつかサブビュー(ウィジェット)を追加してみましょう。上で定義した Timer class>>open メソッドを少々長くなりますが次のように変更して accept してください。

open
    "Timer open"
    | timer topView disp minBtn secBtn |
    timer := Timer new.
    topView := StandardSystemView new model: timer.
    topView borderWidth: 2; label: 'Timer'.

    disp := DisplayTextView new model: '00 : 00' asDisplayText; controller: NoController new.
    disp window: (0 @ 0 extent: 100 @ 40); centered.
    disp borderWidthLeft: 0 right: 0 top: 0 bottom: 2.
    topView addSubView: disp.

    minBtn := PluggableButtonView on: timer getState: nil action: #incMin.
    minBtn window: (0 @ 0 extent: 25 @ 25).
    minBtn borderWidthLeft: 0 right: 2 top: 0 bottom: 2; label: 'min'.
    topView addSubView: minBtn below: disp.

    secBtn := PluggableButtonView on: timer getState: nil action: #incSec.
    secBtn window: (0 @ 0 extent: 25 @ 25).
    secBtn borderWidthLeft: 0 right: 2 top: 0 bottom: 2; label: 'sec'.
    topView addSubView: secBtn toRightOf: minBtn.

    topView controller open

改めて Timer open を do it すると今度は内部にいくつかのウィジェットが割り振られたウインドウが開きます。

f:id:sumim:20190430234408p:plain

どうやらサブビューは window: で適当な相対的なサイズを指定し、addSubView:below:addSubVew:toRightOf: で追加しつつ配置してやるとうまいことやってくれるようです(←面倒くさがってまじめにコードを見ていない^^;)。ただデフォルトのウインドウのサイズがでかくて間延びしてしまうのでこれをなんとか対処してみましょう。

システムブラウザの instance ボタンをクリックしてインスタンスメソッド定義モードに戻し、no message をクリックして選択後、下のコードペインに次のメソッドを追加します。

initialExtent
    ^200 @ 200

ひとつのシステムブラウザでインスタンスサイド(instanceボタンをクリック)とクラスサイド(class ボタンをクリック)を行き来するのは面倒なので、もう一つシステムブラウザを開き(デスクトップクリック → open... → browser)こちらをクラスサイドブラウズ用に使うことにします。システムブラウザは何個でも開くことができるので、インスタンスサイド、クラスサイドの切り替えに限らず、必要ならどんどん開くとよいと思います。

Timer のクラスメソッドの open を呼び出し、Time open を選択して do it すると、今度は小さなウインドウで表示されます。

f:id:sumim:20190501002720p:plain

▼ min と sec ボタンが機能するようにモデルを整える

現時点では、タイマーの min ボタン(あるいは sec ボタン)を押すと、当然エラーになります(スタックトレースを表示したノーティファイアが表示される)。これは、モデルであるタイマー(Timer)にはサブビュー作成時に指定した incMinincSec などというメソッドはまだ定義されていないからです。

f:id:sumim:20190501003856p:plain

そこで次にモデルの Timer を UI のお試しができる程度に incMinincSec を含めて少し手を入れてみましょう。

まず、今の open の実装では disp サブビューのモデルは決め打ち('00 : 00' asDisplayText)なので、これをTimerに生成させ、その管理下に置く必要がありそうです。

モデルとしての Timer の実装をどうするかはいろいろ考えられそうですが、ここではシンプルに秒換算整数値の counter を持ち、incMin はそれに 60 を、incSec はそれに 1 を足すことにします(将来実装するカウントダウン機構ではこの counter を1秒経過するごとに減らしてゆく予定です)。また Timer は、秒換算値の counter を分と秒に分けて 00 : 00 形式で表示するDisplayText オブジェクトを生成・保持して disp サブビューのモデルとして提供し、適宜内容を更新して(それを伝播することで)表示を更新させます。

はじめに、TimercounterdispDispTextdisp サブビューのモデルのDisplayTextオブジェクトを保持する)の2つのインスタンス変数を追加します。インスタンスサイド用のシステムブラウザをクリックしてアクティブにした後、改めて instance ボタンをクリックする(あるいは、Timerをクリックしていったん選択を解除後、再びクリックして選択する)と Timer の定義式がコードペインに呼び出されるので、次のように変更して accept します。

Model subclass: #Timer
    instanceVariableNames: 'counter dispDispText '
    classVariableNames: ''
    poolDictionaries: ''
    category: 'MiniSqueak22-Timer'

引き続き、インスタンスサイド(instance ボタンをクリックした方のブラウザ)に次のメソッド群を追加します。

…が、その前に今の Squeak Smalltalk では馴染みの、しかし Mini Squeak 2.2 には存在しない SequenceableCollection>>last: というメソッドを Timer>>dispStringFrom: を書くときにいつものクセでつい使ってしまっている^^;ので、これをあらかじめ追加しておきます。

新しいシステムブラウザを開き(デスクトップクリック → open... → browser)、上段右端のペインの右クリック → find class... → seq などとタイプし return/enter キー を押す

f:id:sumim:20190501152407p:plain

SequenceableCollection が選択されるので第3ペインの as yet unclassified をクリックして選択後、下のコードペインに次のコードを入力して accept します。

last: n
    ^ self copyFrom: self size - n + 1 to: self size

f:id:sumim:20190501153351p:plain

改めてTimerインスタンスサイド用のシステムブラウザに戻り、上段の3番目のペインの as yet unclassified を選択して下のコードペインにメソッド定義のテンプレートを呼び出してから、下のコードをコピペ→accept を繰り返せば次々にメソッドを追加してゆくことができます。

最初は、直前で追加した last: を使って、counter から 00 : 00 形式の文字列を生成する dispStringFrom: です。

dispStringFrom: int
    ^('0', (int // 60) asString last: 2), ' : ', ('0', (int \\ 60) asString last: 2)

f:id:sumim:20190501152715p:plain

以下、同様に続けてください。

dispString
    ^self dispStringFrom: counter
dispText
    ^self dispString asText allBold; yourself
initialize
    counter := 0.
    dispDispText := self dispText asDisplayText.
dispDispText
    ^dispDispText
counterLimit
    ^100 * 60 - 1
incMin
    counter := counter + 60 min: self counterLimit.
    self changed
incSec
    counter := counter + 1 min: self counterLimit.
    self changed
dispChanged
    dispDispText text: self dispText; changed
changed
    self dispChanged.
    super changed

以上、インスタンスメソッド群の定義が済んだところで、クラスサイド(class ボタンをクリックしてある方)のシステムブラウザに移動して、クラスメソッドとして改めて new を再定義します。この当時の Squeak Smalltalknew してインスタンスを生成する際に initialize を自動で呼ぶ仕様にはまだなっていなかったので、new を再定義してそれをやらせる必要があります。

new
    ^super new initialize; yourself

opendisp のモデルを決め打ちしているこの行

disp := DisplayTextView new model: '00 : 00' asDisplayText; controller: NoController new.

を次のように変更して accept します。

disp := DisplayTextView new model: timer dispDispText; controller: NoController new.

改めて Timer open してウインドウを開き、min や sec をクリックしてみましょう…。

ところがしかしここで問題発生。^^;

エラーこそ出ないものの、disp サブビューが更新される気配がありません。これはいったいどうしたことでしょうか?

dispサブビューが更新されない理由を探り、DisplayTextViewForTimer を作ってこれを使う

Timer ウインドウがアクティブな状態で、Mac では cmd + .(ピリオド)、Win では alt + .(ピリオド)をタイプすると、アクティブレッドに割り込みをかけられます。割り込み時点のスタックトレースが表示されたノーティファイアが開くので、そこで右クリック → debug するとデバッガーを起動できます。

f:id:sumim:20190501015157p:plain

デバッガーの最上段ペインのスタックトレースから StandardSystemView あるいは StandardSystemController がレシーバーのコンテキスト(スタックフレーム)を探してクリックして選択してから、最下段左側のペインの model をクリックして選択後、右クリック → inspect でインスペクターを開きます。

f:id:sumim:20190501015646p:plain

f:id:sumim:20190501015740p:plain

インスペクターの counter をクリックすると、(min や sec ボタンどちらかを1回でもクリックしていれば)確かに値は初期化時の 0 のままではないはずです。さらに念のため、dispDispText をクリックして選択してから 右クリック → inpect → 現れたインスペクターの text をクリックしてその値を確認してみても、disp サブビューのモデルである dispDispText はきちんと更新されている('00 : 00' のままではない)ことが確認できます。

f:id:sumim:20190501020742p:plain

では、どうして disp サブビューが更新されないのか気になるので、デバッガーでトレースして探ってみましょう。古典的な Smalltalkブレークポイントは、任意のメソッドのコード内の任意のレシーバーに対し halt というメッセージを送る記述を挿入することで設置できます。ここでは dispChanged メソッド内で dispDispText のテキストを更新した後にモデルの変更を通知する changed を送る直前に設置してみます(次図の反転部分)。

f:id:sumim:20190501021849p:plain

accept してコンパイルしてから、Timer ウインドウの min もしくは sec ボタンをクリックするとノーティファイアが現れるので、右クリック → debug でデバッガーを起動します。

f:id:sumim:20190501022123p:plain

デバッガーの最上段のペイン(スタックトレース)で 右クリック → step (t) するか、同ペイン内にマウスカーソルを置いた状態でTキーをタイプすると、デバッガー中央のペインにブレークポイントを設置したメソッドのソースと次にコールされるメソッドが(Smalltalk風に言うと「次に送られるメッセージが」)反転表示された状態で呼び出せます。

f:id:sumim:20190501022914p:plain

うっかりこのまま連続して step (t) してしまうと折角止めたコンテキスト(スタックフレーム)を抜けてしまうので、次に、最上段のペインで 右クリック → send (e) して changed がコールされた直後のコンテクストにステップインします。

f:id:sumim:20190501023101p:plain

この調子で、changed: self を send (e)、dependents を step (t) 、do: [:aDependent | aDependent update: aParameter を send (e)、sizeat: index は step (t)、value: (self at: index) を send (e) 、update: aParameter を send (e) と順に処理を辿っていくと、果たしてついに DisplayTextView が通知を受け取って何をしているかが明らかになります。

f:id:sumim:20190501024157p:plain

なんと、self を返している、つまり、何もしていないのです。w これでは画面は更新されないはずです。おそらく、DisplayTextView のユースケースではモデルが更新されることが想定されていないのでしょう。

しかたがないので、今回のタイマー用に、DisplayTextView を継承した DisplayTextViewForTimer なるクラスを作ってちゃんと画面更新処理をする update: を再定義して、これを使うことにします。^^;

DisplayTextView subclass: #DisplayTextViewForTimer
    instanceVariableNames: ''
    classVariableNames: ''
    poolDictionaries: ''
    category: 'MiniSqueak22-Timer'
update: param
    self model: model; displayView.
    super update: param

f:id:sumim:20190501024952p:plain

もちろん Timer class>>open メソッド内の DisplayTextView を使用した行

disp := DisplayTextView new model: timer dispDispText; controller: NoController new.

の該当部分を DisplayTextViewForTimer に置き換えて

disp := DisplayTextViewForTimer new model: timer dispDispText; controller: NoController new.

accept してから、Timer open を do it して動作を確認します。

ブレークポイントを設置したままなので、相変わらずノーティファイアが表示されますが、右クリック → debug でデバッガーを起動して step (t) してブレークポイントを設置したメソッド(のコンテキスト)を呼び出した状態で最上段ペインの右クリック → versions でメソッドの変更履歴を呼び出し、

f:id:sumim:20190501042833p:plain

ひとつ前のバージョンをクリックして選択して 右クリック → fileIn selections で元に戻せます。

f:id:sumim:20190501042856p:plain

デバッガーは同じく最上段ペインの右クリック → proceed で閉じてしまってください。

改めて、min もしくは sec ボタンをクリックすると、めでたく disp サブビューが更新されるはずです。

f:id:sumim:20190501031217p:plain

▼終わりに

実装の途中ですが、思いのほか長くなってしまったので、ここらへんで終わりにします。

古いうえ制約の多い Mini Squeak 2.2 を使ってではありましたが、Smalltalk環境でのプログラミングのおもしろさの一端を垣間見ていただけたかと思います。今でこそ当たり前ですが、簡易とは言えメソッド名の補完やコンパイル時のスペル修正機能、メソッド単位のインクリメンタルなコンパイルやバージョン管理機能は遥か昔の1980年代にすでに存在した技術であったことを驚きをもって実感してもらえたなら幸いです。

試行錯誤を経て完成させた版のタイマーのコードは下のリンク先に置いておきますので、Mini Squeak 2.2 環境に fileIn した後、システムブラウザで読んだり、実際に動かしてみたり、さらに手を加えてしていろいろ試して遊んでみてください。

[追記: 2019-05-02] 仕様変更(スタートしたら start ボタンは stop ボタンに & カウントダウン中の min、sec ボタンで押下で延長を可能に)

@miwa719 さんに開発の経緯のツイートまとめを教えてもらいました!

start ボタンはカウントダウンをスタートすると stop ボタンに変わることが分かったので、そのように変更してみました。MiniSqueak22-Timer.st に続いて Timer-toggleStateTweak.cs というパッチを用意しましたのでこれを fileIn するとコードが変更されます。なお念のため .cs は C# …ではなく^^;「change set(変更の集合)」の略で、Smalltalk において古くから用いられている拡張子です。

f:id:sumim:20190502004944p:plain

さらに悪ノリして、カウントダウン中でも min や sec ボタンを押せるようにして、押した分だけ時間を延長することができるようにもしてみました。複数スレッドからの counter の変更には排他制御を使っています。Timer-toggleStateTweak.cs の後にさらに fileIn すると仕様が更新されます。