Mini Squeak 2.2 (Webブラウザで動作する、古く、最小構成に近いSmalltalk)の古典的 MVC でタイマーを実装する
前回のエントリー『「使わないと損をする Model-View-Controller」のサンプルコードを SqueakJS のデモ画面(Mini Squeak 2.2)で動かす 』では個人的にも実に久しぶりに古典的MVC に触れて楽しかったので、今回は引き続き Mini Sqeauk 2.2 環境を使って @miwa719 さんにご提案いただいたお題にチャレンジしてみました。
Smalltalk知らなすぎるのでSqueakでタイマー作ってみようかな
— miwa (@miwa719) 2019年4月16日
欲しい機能はこの画像みたいなやつなんだけど、まずは空っぽのウィンドウを開くところからかな(超初心者!) pic.twitter.com/HDkntO1Tfh
容量を小さくすることを優先して、必要最低限を超えてクラスやメソッドがそぎ落とされてしまった Mini Squeak 2.2.の環境では何かと制約が多いため、なるべくシンプルな実装を目指し、古典的MVC に用意されたプラガブルVCオブジェクト(今のOSで言うウィジェット)をポトペタ感覚で組み合わせてそれっぽく仕上げてみました。
仕様も単純にしています。
- min、sec ボタンを押すと、それぞれ分と秒の数値がインクリメントされる。
- clear で 00 : 00 にリセット
- start でカウントダウンを開始して、00 : 00 になるとビープを決められた回数鳴らす
- カウントダウン中(あるいはビープを鳴らしている最中)に clear を押すと止められる
- カウントダウン中(あるいはその終了後)、下の枠内に開始時刻と終了時刻、カウントダウンした時間を表示する
以下は、実装時の試行錯誤の過程をさわりだけですが再現してみたものです。最終的に書き上がったコードは最後のリンク先に置いてありますので、前回のエントリー同様、ダウンロード後、ファイルをドラッグ&ドロップで Webブラウザのストアにコピーして、Mini Squeak 2.2環境内のファイラ(デスクトップクリック → open... → file list)から fileIn 後、システムブラウザでコードを見たり、動かして遊んでみたりしてください。
※SqueakJS は Android や iOS デバイスでも動作しますが、外部からのコードの読み込みはちょっと難しいかもしれません(今のところ方法を思いつきません…)。実際に試してはいないのですが、コードを直接タイプして入力すればもしかしたら動くかもしれません。どうぞあしからず。
▼Mac版 Chrome に奪われた簡易補完キーアクションのキーアサインを変更する
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:
特に画面に変化はないのですが、このあとたとえば 100 fac
までタイプして cmd + 3 を押すと factorial
と補完され、さらに押すと画面が反転して元の fac
に戻るようなら設定の変更は成功です。
なお 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秒ほど経ってから画面の反転とビープ音が鳴るはずです。
▼まずは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
としますが、気に入らなければ好きな名前に変えてもらって構いません。
クラスカテゴリーが追加されると、下のコードペインにクラス定義のためのテンプレートが現れるので次のように修正して Timer
クラスを定義します。
Model subclass: #Timer instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' category: 'MiniSqueak22-Timer'
修正が終わったら(あるいはここからコピペして置き換えるのでもOKです)、右クリックメニュー → accept して決定します。
上段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) すると空のウインドウを表示できます。
試しにいくつかサブビュー(ウィジェット)を追加してみましょう。上で定義した 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 すると今度は内部にいくつかのウィジェットが割り振られたウインドウが開きます。
どうやらサブビューは window:
で適当な相対的なサイズを指定し、addSubView:below:
や addSubVew:toRightOf:
で追加しつつ配置してやるとうまいことやってくれるようです(←面倒くさがってまじめにコードを見ていない^^;)。ただデフォルトのウインドウのサイズがでかくて間延びしてしまうのでこれをなんとか対処してみましょう。
システムブラウザの instance ボタンをクリックしてインスタンスメソッド定義モードに戻し、no message
をクリックして選択後、下のコードペインに次のメソッドを追加します。
initialExtent ^200 @ 200
ひとつのシステムブラウザでインスタンスサイド(instanceボタンをクリック)とクラスサイド(class ボタンをクリック)を行き来するのは面倒なので、もう一つシステムブラウザを開き(デスクトップクリック → open... → browser)こちらをクラスサイドブラウズ用に使うことにします。システムブラウザは何個でも開くことができるので、インスタンスサイド、クラスサイドの切り替えに限らず、必要ならどんどん開くとよいと思います。
Timer
のクラスメソッドの open
を呼び出し、Time open
を選択して do it すると、今度は小さなウインドウで表示されます。
▼ min と sec ボタンが機能するようにモデルを整える
現時点では、タイマーの min ボタン(あるいは sec ボタン)を押すと、当然エラーになります(スタックトレースを表示したノーティファイアが表示される)。これは、モデルであるタイマー(Timer
)にはサブビュー作成時に指定した incMin
、incSec
などというメソッドはまだ定義されていないからです。
そこで次にモデルの Timer
を UI のお試しができる程度に incMin
、incSec
を含めて少し手を入れてみましょう。
まず、今の open
の実装では disp
サブビューのモデルは決め打ち('00 : 00' asDisplayText
)なので、これをTimer
に生成させ、その管理下に置く必要がありそうです。
モデルとしての Timer
の実装をどうするかはいろいろ考えられそうですが、ここではシンプルに秒換算整数値の counter
を持ち、incMin
はそれに 60 を、incSec
はそれに 1 を足すことにします(将来実装するカウントダウン機構ではこの counter
を1秒経過するごとに減らしてゆく予定です)。また Timer
は、秒換算値の counter
を分と秒に分けて 00 : 00
形式で表示するDisplayText
オブジェクトを生成・保持して disp
サブビューのモデルとして提供し、適宜内容を更新して(それを伝播することで)表示を更新させます。
はじめに、Timer
に counter
と dispDispText
(disp
サブビューのモデルの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 キー を押す
と SequenceableCollection
が選択されるので第3ペインの as yet unclassified をクリックして選択後、下のコードペインに次のコードを入力して accept します。
last: n ^ self copyFrom: self size - n + 1 to: self size
改めて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)
以下、同様に続けてください。
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 Smalltalk は new
してインスタンスを生成する際に initialize
を自動で呼ぶ仕様にはまだなっていなかったので、new
を再定義してそれをやらせる必要があります。
new ^super new initialize; yourself
open
も disp
のモデルを決め打ちしているこの行
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 するとデバッガーを起動できます。
デバッガーの最上段ペインのスタックトレースから StandardSystemView
あるいは StandardSystemController
がレシーバーのコンテキスト(スタックフレーム)を探してクリックして選択してから、最下段左側のペインの model
をクリックして選択後、右クリック → inspect でインスペクターを開きます。
インスペクターの counter
をクリックすると、(min や sec ボタンどちらかを1回でもクリックしていれば)確かに値は初期化時の 0
のままではないはずです。さらに念のため、dispDispText
をクリックして選択してから 右クリック → inpect → 現れたインスペクターの text
をクリックしてその値を確認してみても、disp
サブビューのモデルである dispDispText
はきちんと更新されている('00 : 00'
のままではない)ことが確認できます。
では、どうして disp
サブビューが更新されないのか気になるので、デバッガーでトレースして探ってみましょう。古典的な Smalltalk でブレークポイントは、任意のメソッドのコード内の任意のレシーバーに対し halt
というメッセージを送る記述を挿入することで設置できます。ここでは dispChanged
メソッド内で dispDispText
のテキストを更新した後にモデルの変更を通知する changed
を送る直前に設置してみます(次図の反転部分)。
accept してコンパイルしてから、Timer ウインドウの min もしくは sec ボタンをクリックするとノーティファイアが現れるので、右クリック → debug でデバッガーを起動します。
デバッガーの最上段のペイン(スタックトレース)で 右クリック → step (t) するか、同ペイン内にマウスカーソルを置いた状態でT
キーをタイプすると、デバッガー中央のペインにブレークポイントを設置したメソッドのソースと次にコールされるメソッドが(Smalltalk風に言うと「次に送られるメッセージが」)反転表示された状態で呼び出せます。
うっかりこのまま連続して step (t) してしまうと折角止めたコンテキスト(スタックフレーム)を抜けてしまうので、次に、最上段のペインで 右クリック → send (e) して changed
がコールされた直後のコンテクストにステップインします。
この調子で、changed: self
を send (e)、dependents
を step (t) 、do: [:aDependent | aDependent update: aParameter
を send (e)、size
と at: index
は step (t)、value: (self at: index)
を send (e) 、update: aParameter
を send (e) と順に処理を辿っていくと、果たしてついに DisplayTextView
が通知を受け取って何をしているかが明らかになります。
なんと、self を返している、つまり、何もしていないのです。w これでは画面は更新されないはずです。おそらく、DisplayTextView のユースケースではモデルが更新されることが想定されていないのでしょう。
しかたがないので、今回のタイマー用に、DisplayTextView
を継承した DisplayTextViewForTimer
なるクラスを作ってちゃんと画面更新処理をする update:
を再定義して、これを使うことにします。^^;
DisplayTextView subclass: #DisplayTextViewForTimer instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' category: 'MiniSqueak22-Timer'
update: param self model: model; displayView. super update: param
もちろん 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 でメソッドの変更履歴を呼び出し、
ひとつ前のバージョンをクリックして選択して 右クリック → fileIn selections で元に戻せます。
デバッガーは同じく最上段ペインの右クリック → proceed で閉じてしまってください。
改めて、min もしくは sec ボタンをクリックすると、めでたく disp
サブビューが更新されるはずです。
▼終わりに
実装の途中ですが、思いのほか長くなってしまったので、ここらへんで終わりにします。
古いうえ制約の多い Mini Squeak 2.2 を使ってではありましたが、Smalltalk環境でのプログラミングのおもしろさの一端を垣間見ていただけたかと思います。今でこそ当たり前ですが、簡易とは言えメソッド名の補完やコンパイル時のスペル修正機能、メソッド単位のインクリメンタルなコンパイルやバージョン管理機能は遥か昔の1980年代にすでに存在した技術であったことを驚きをもって実感してもらえたなら幸いです。
試行錯誤を経て完成させた版のタイマーのコードは下のリンク先に置いておきますので、Mini Squeak 2.2 環境に fileIn した後、システムブラウザで読んだり、実際に動かしてみたり、さらに手を加えてしていろいろ試して遊んでみてください。
[追記: 2019-05-02] 仕様変更(スタートしたら start ボタンは stop ボタンに & カウントダウン中の min、sec ボタンで押下で延長を可能に)
@miwa719 さんに開発の経緯のツイートまとめを教えてもらいました!
タイマーはマルチスレッド処理を書かなきゃならないのでちょっと難しい。だからうまく動くと「やったー!」て嬉しい気持ちになります。
— miwa (@miwa719) 2019年5月1日
これはiOSアプリ開発をしてみたくてキッチンタイマーを作ったときのツイートのまとめ(とても長いのでやったことを1つの画像にまとめた)https://t.co/8ghvu5eOP5 pic.twitter.com/ecfOMgYNyG
start ボタンはカウントダウンをスタートすると stop ボタンに変わることが分かったので、そのように変更してみました。MiniSqueak22-Timer.st に続いて Timer-toggleStateTweak.cs というパッチを用意しましたのでこれを fileIn するとコードが変更されます。なお念のため .cs は C# …ではなく^^;「change set(変更の集合)」の略で、Smalltalk において古くから用いられている拡張子です。
さらに悪ノリして、カウントダウン中でも min や sec ボタンを押せるようにして、押した分だけ時間を延長することができるようにもしてみました。複数スレッドからの counter
の変更には排他制御を使っています。Timer-toggleStateTweak.cs の後にさらに fileIn すると仕様が更新されます。