オブジェクト指向と await の相性は良いのか?の落とし穴を考える

で、めでたく await を使ったモーダルダイアログができたわけですが、これが花札ゲームのロジックにあてはめられるかを試してみます。本来ならば「あてはめられるかどうか?」を UML なりを使ってシーケンス的にチェックする訳ですが、どうやら async/await の組み合わせ…というか、WinRT の UI の限界(あるいは、従来からある Windows フォーム系のイベント指向からのパラダイムシフト)の違いが顕著に表れる例で、どうも UML のシーケンス部分と(あるいは関数呼び出しとしてのシーケンス)との相性が悪いような気がしています。まぁ、IO 系を中心としたドライバーの非同期の呼び出し(call した後に return を非同期で受けるようなコード)が慣れている場合はいいのですが、もともと UML 自体に非同期のロジックが不足していた歴史的経緯を思い出し、Java を発祥とするオブジェクト指向記述型の UML であったことをあわせると、UI とロジックの分離のところで、UI とロジックが密接になってしまうというジレンマに陥るのでは?ってことです。

■発端は、UI なしで考えることから。

花札ゲームの場合、ゲームロジックを共通化することを優先したので、データ構造とロジックから作りはじめています。なので、「UI は飾りであって、取り換え可能である」という論理のもとに UI とロジックを分離させて、UI をチープなものからリッチなものへ変化させる、あるいはアニメーション部分は UI の担当なので後から変更できるようにする、というのが最初の思惑です。

Game クラスがゲームのロジック全般をつかさどるところ、Player クラスがユーザーのクラスですね。Player 自体はコンピュータの AI と切り替えを可能にするので(CPU 対戦というやつ)、ユーザーに問い合わせをする UI を使いません。インターフェースだけを使って、プレイヤーの思考プログラムを組むというやり方は、もとはシミュレーションの「エージェント指向」、近いところでは「アスペクト指向」って方法です。最近では、その手の「○○指向」的な話が沈静化しているので(フレームワークの話とか、DI の話とか)どういう動向で作っているのかが不明なところのなのですが、「温故知新」ってことで掘り出してみれば、20年前のオブジェクト指向ロジックですね。

さて、Pleyer がユーザーの場合には、ユーザーの選択を確かめるために「問い合わせ」を行います。これが、モーダルダイアログで、シーケンスの中に UI を挿入してユーザーの選択を待つ、という仕掛けです。このモーダルダイアログってのはウィンドウシステムの鬼っ子なところがあって、ユーザーから見れば「どこからでもアプリの制御ができる」というモードレスな画面に対して、どちらかを選択しないといけないモードがある画面が突如として出てくる、というシーケンスです。まあ、今となっては当たり前なのですが、コマンドラインで順番にコマンドを流す、という「対話形式」の入力方式から、Excel のようにどこからで操作ができるというウィンドウへのジャンプは結構大きかったのですよ。が、ジャンプが大きかったものの、YES/NO だけを選択させる画面が出るというのはシーケンスを阻害する意味もあり、しかしロジック的にはどちらかを選択しないと次のロジックに進めないというジレンマもあり、ってことで、モーダルダイアログ(問い合わせダイアログ)ってのは、コード的にも貴重な「鬼っ子」であるのです。

そんな訳で、場に花札を出したときに、マッチする札が2枚ある場合には、どちらを選択するかユーザーに問い合わせをするのです。ええ、ユーザーインターフェース的には、札を出すときにマッチする札へドロップさせる、という方法もあるわけで、実は「この時点でロジックが UI を制限している」ってのを意識しないといけないのですが…従来の流れもあってあまり意識せずにつくってしまいます…というのが今回の落とし穴ですね。

画面から選択させるのであれば、モーダルダイアログを出して「どちらを選択しますか?」で OK と考えるわけえで、シーケンス的には、Player から UI に SelCard 呼び出しをして、UI に問い合わせダイアログを開くのが簡単な方法です。
ユーザーが選択したら SelCard 関数の戻り値が返ってくるので、めでたく SelCard の戻り値で Card が選択されるので処理を続けられるというフローになります。ええ、わかりやすいですよね。

■イベントドリブンってことで、最初の一発は UI から始まる

この設計で OK ってことで、Game クラスと Player クラスを組むわけです。最初はチープな画面を作ってテストをしたり、ロジック部分を NUnit を使ってテストしたりして、そこそこ強固なロジックができあがります。

さて、先のシーケンス図では、Player への呼び出しはどこから始まるのか?を明確にしていませんが、実際のところ「場に札を捨てる」という動作は、ユーザーが UI で「手札をクリックする」という動作からはじまります。ここで注意したいのは UI を持っている場合は、ユーザーのアクションからロジックがスタートするってところです。なので、VBの時代には、Click イベントのがしがしとロジックを書いてスパゲッティになってしまったり、逆にロジックを無理矢理分離させるためじ Click イベントにロジックのワンメソッドが書いてあったりするわけですが、UI とロジックの分離は実は、もうちょっと複雑な関係なってきています。

最初の左矢印の putBa が UI から呼び出されるところ、その後に SelCard のところで再び UI に戻ってきて問い合わせダイアログ。札を選択したら山から1枚撮ってくるので、再び SelCard を呼び出して UI に表示して…ってな感じで、putBa の呼び出しから、内部で再帰的に UI が呼び出されます。実は、これは「状態遷移」の問題で、シーケンス的には関数呼び出しなのか、状態遷移なのか(メッセージングなのか)がわかりづらいのですが、これをクラス化して、シーケンスで動かそうとするといきおい、最初のメソッドが内部のメソッドを呼び出して、さらに内部のメソッドが内部の内部のメソッドを呼び出す…これを何回か繰り返した後に戻り値で戻ってくるという「構造化プログラミング」になっています。関数呼び出し≒スタック積み上げの方式は、これはこれでよいのですが、あまり入れ子になってしまうと昔はスタックの問題があったので、これを一度 main ルーチンに戻してから次のステップへ移動させる、っていう「状態遷移」の方法を取ります。この場合、状態を持たなければボタンクリックなどの「イベント駆動」のことになって、ある状態を持って開始から終了まで遷移をすれば、yacc とかなんらかのステートマネージャーを用意することになります。

■ブロッキングを使うか、状態遷移を使うかの選択

ロジックのシーケンスを作るときに UI に問い合わせが発生した場合には、ブロッキング(モーダルダイアログ)にするか、ノンブロッキング(状態遷移)にするかが悩ましいところです…といいますか、花札の2枚選択のようなユーザーに問い合わせをしてしまうパターンの場合には、「モーダルダイアログ」で OK ですよね?「はい」「いいえ」だけとか、場から札を選択させるだけなので、状態遷移なんて考えたこともありませんでした…ってのが罠なんですよ~。

先に書いた通り、UI のイベント駆動の場合には、シーケンスのスタートは UI になります。なので、ロジックの間に再び UI が挟まるという形になります。この部分、MessageBox.Show とか、普通に使っていますが、内部的に、Application.DoEvent を使うとか、別の WinProc を使っているとか、実は問い合わせを出しているダイアログでは、ちょっと複雑なことになっています。といいますか、DoEvent の場合はメッセージループを元に戻すとか、MessageBox の場合は別スレッド(別ウィンドウ)にするとか、それぞれ UI の実装の仕方が違います。

■WinRT はひとつの UI スレッドしかない

問い合わせダイアログを出すときは、従来の Windows フォームの場合には MessageBox を使って別のウィンドウに出ますが、Windows ストア アプリの場合には MessageDialog の ShowAsync メソッドを使って一見、Windows フォームように使えますが…実は大幅に実装が異なります。

private async void Button_Click_DeleteItem(object sender, RoutedEventArgs e)
{
    // 問い合わせを開く
    var dlg = new MessageDialog("アイテムを削除しますか?");
    dlg.Commands.Add(new UICommand("はい"));		
    dlg.Commands.Add(new UICommand("いいえ"));			
    dlg.DefaultCommandIndex = 1; // 「いいえ」をデフォルトボタンにする
    var cmd = await dlg.ShowAsync();				
    // キャンセルの場合
    if (cmd == dlgCommands[1] )		//(*7)
    {
        return;
    }
    // アイテムを削除
    var item = (SampleDataItem)this.flipView.SelectedItem;
    SampleDataSource.DeleteItem(item);
    // メインページに戻る
    this.Frame.GoBack();
}

一見、ShowAsync メソッドで「待ち」をしているように見えるのですが、await がついて Sync がついているのでこれは非同期メソッドです。なので、実はメソッドを呼び出したときにはこの Button_Click_DeleteItem メソッドは一度終了していて、ShowAsync メソッド内のタスクが終了した後に(戻り値が確定したときに)再び Button_Click_DeleteItem が呼び出されて、次の if (cmd == dlgCommands[1] ) の行から実行されるように「状態遷移」が隠されているのですね。

なるほど。WinRT の場合には、UI をブロックさせないようにするために、時間がかかかるロジックは非同期メソッドが用意されている、かつ、それのコードを軽減させるために async/await が用意されている…っていう流れで、これらを UI のイベントから呼び出す場合には、async をメソッドに付ければよいのでめでたしめでたし、ってな訳なのですが、いえいえ、話をシーケンスに戻すと、かなり複雑な現象に陥ります。

■async メソッドを呼び出すメソッドも async を付ける

2枚から選択するときに SelCard メソッド内で問い合わせダイアログを出します。ここでは、コードを簡単にするために MessageDialog.ShowAsync を使うのですが…このメソッドは非同期メソッドなので await を使うわけですね。で、いきおい、SelCard メソッドに async を付けないといけないわけです。

public async Card SelCard(Card c1, Card c2)
{

    // 問い合わせを開く
    var dlg = new MessageDialog("札を選択してください);
    dlg.Commands.Add(new UICommand(c1.ID));		
    dlg.Commands.Add(new UICommand(c2.ID));			
    dlg.DefaultCommandIndex = 1; 
    var cmd = await dlg.ShowAsync();				
    // キャンセルの場合
    if (cmd == dlgCommands[0] ) {
		return c1;
	} else {
		return c2;
	}
}

ここで、問題が発生します。SelCard はロジックなので、非同期を想定していないし、メソッド呼び出しとして同期的に戻り値が取りたい。また SelCard に async を付けると、実は状態遷移になっているので、先のシーケンス図とは動作が異なってしまう。という罠です。UI が非同期なので、ロジック部分も非同期にしないといけないのは当然なのですが、このゲームロジックを作る前提として、「UI の動作には関係なくロジックを流用する」というのがスタートだったので、後から UI の動作によってロジックの動きを変更する、っていうのは未来予測的に違反な訳です…っていうか、落とし穴ですね。フレームワークの調査不足。

なので、Windows フォームの場合には、DoEvent を使って回避することが可能なのですが、WinRT の場合は回避できません。いや、できるとしても、なんかかなり妙なコードを書かないとダメな気が。

また、この ShowAsync メソッドで await を使ってしまうと、内部ロジックにも await を使わないといけなくて、さらに呼び出し元の呼び出し元も await を付けるという await 変更地獄に陥ります。UI から Async を呼び出すときにはいいのですが、ロジック内では、await を使うとかなり妙なことになりそう。

■なので、素直に非同期を考慮したシーケンスを作る

なので、WinRT では UI 待ちは非同期に必ず直す、っていう方針に変えてしまって、await を使わないようにします。UI のところでは、コールバック関数を用意しておいて、結果が出たときのメソッドを別にしておきます。これはあらかじめデリゲートで用意してもよいし、ラムダ式で用意しても OK。
実は、見た目上は、中身が同期なのか非同期なのかはわかりません。というのも Player クラスを AI にした場合には非同期ではない(問い合わせダイアログが必要ない)ので、同期メソッド的に動かないとダメなのです。このあたり、悩ましいところですが、WinRT の async/await に関係なく、同期/非同期を同じようにコードで扱う場合には、この方法がよいかと。

void Player1_EventSelCard(Card c1, Card c2)
{
	// 場コントロールで選択する
	baControl1.SelCard(c1, c2, SelCardResult);
}
void Player2_EventSelCard(Card c1, Card c2)
{
	baControl1.SelCard(c1, c2, SelCardResult);
}
void SelCardResult(Card card)
{
	// 選択後の処理
	var lst = _board.Game.SelCard( card );
	_board.Ba.GetCard(lst);
	_curPlayer.GetCard(lst);
	// 画面の更新
	ScrUpdate();
}

それで、非同期型の UI は、状態遷移型にしておいて、ユーザーのボタン押下待ちになります。AI の場合には、結果のコールバック関数を直接呼ぶロジックに変えてもよいし、そもそも Player クラスの実装が別になるので、この UI 問い合わせの SelCard は呼び出されません。

public void SelCard(Card c1, Card c2, Action<Card> result)
{
	// 指定の2枚に枠を付ける
	var pic1 = _pics.Find(p => p.Source == CardUI.GetResName(c1.ID));
	var pic2 = _pics.Find(p => p.Source == CardUI.GetResName(c2.ID));
	this.rectangleShape1.Margin = pic1.Margin;
	this.rectangleShape2.Margin = pic2.Margin;
	this.rectangleShape1.Visibility = Windows.UI.Xaml.Visibility.Visible;
	this.rectangleShape2.Visibility = Windows.UI.Xaml.Visibility.Visible;

	pic1.Tapped += selPic_Click;
	pic2.Tapped += selPic_Click;

	_selPic1 = pic1;
	_selPic2 = pic2;
	_selCard1 = c1;
	_selCard2 = c2;

	// 選択待ち
	_selCard = null;
	_result = result;	// 結果用のコールバック
}
private void selPic_Click(object sender, TappedRoutedEventArgs e)
{
	_selPic1.Tapped -= selPic_Click;
	_selPic2.Tapped -= selPic_Click;
	this.rectangleShape1.Visibility = Windows.UI.Xaml.Visibility.Collapsed;
	this.rectangleShape2.Visibility = Windows.UI.Xaml.Visibility.Collapsed;

	Image pic = (Image)sender;
	if (pic == _selPic1)
	{
		_selCard = _selCard1;
	}
	else
	{
		_selCard = _selCard2;
	}
	_result(_selCard);
}

■あらためて非同期を考慮してシーケンスを書く

そんな訳で、あらためて非同期を考慮してシーケンスを書き直します…というか、元の画像と同じですね。

右の SelCard のところで「非同期」ってのが明記されているのがミソです。この result が、SelCard メソッドの戻り値ではなくて、Result というメッセージが Player に流れるという感じなんですよね…実はややこしい。

なので、WinRT の場合には UI を操作する部分は「必ず非同期」を考慮しないといけない、ってのがスムースに流れます(awit 無しのモーダルダイアログが出てくるまでは)。シーケンスとしては、簡単な問い合わせをするためだけに非同期シーケンスを考慮しないといけないのが、面倒なところなのですが、そのあたりは必ず状態遷移を使うってことで了解しておけばよいかと。シーケンス的に、こんなに複雑ではない場合(UI から問い合わせダイアログを出すだけの場合)には、await を使えば Ok だし、先の自作モーダルダイアログのようなものを使えば ok です。要は、呼び出しメソッドをさかのぼって async 付けまくりのメソッド直しだけは避けたいかという話です。

■そういえば、オブジェクト指向のシーケンスは元はメッセージングだった

で、最初のオブジェクト指向のシーケンスの話に戻るのですが、オブジェクト指向の発端は、クラス図とかではなくて、メッセージングだよって話があります。メッセージングってのは、データを関数で伝えるのではなくて、イベントキューとして伝える方式ですね。MFC でいえば、SendMessage(即時処理) と PostMessage(遅延処理) の違いです。

クラスメソッドの場合、引数を渡して、戻り値で処理の結果を得るという形で関数(メソッド)呼び出しをしますが、クラスとクラスの間のデータ受け渡しには、元のクラスからデータを渡して、先のクラスでデータを受け取る、という一方向のメッセージングにするパターンがあります。この場合、処理の結果は、先のクラスから元のクラスに再びメッセージが来る、という流れになるわけで、関数呼び出しとはかなり違います。戻り値がないので常に void method を呼び出している感じですね。

で、最初のシーケンス図は、メッセージのやり取りを規定するものであったので、メソッドの戻り値のところはなくて、これを Java で実装するときにクラスメソッドの戻り値が導入されて、戻り値で処理結果を得ることが主流になって、その後に非同期処理というのが明示的に導入された、という歴史的な経緯があるので、ええ、そうなると実は、シーケンス図は、非同期→同期→非同期 っていう歴史的な経緯がある訳です。シーケンス図が状態遷移図をワンセットになるので、シーケンスの矢印が戻り値を必要としない一方向だったってのはうなずける話で、そういう経緯を考えると、WinRT の非同期 UI フレームワークと、オブジェクト指向型のクラス構造との受け渡しは「メッセージング方式」にするのがベターかなと思い直しています。

ちなみに、メッセージング方式は、メッセージを送るときのコストが高くて、正確なコーディングは、キューに設定した後に受け側がメッセージを受け取るという部分を実装しないといけません。単なるクラス間のデータのやり取りにいちいちキューを使うのもコストがかかるので、通常は同期的な処理でよいのですが、状態遷移の場合にはキューを使うしかないんですかね…。事前の策としては、状態遷移を持っているステータスマネージャのクラスを使って、そのクラスから処理を振り分けるという WinProc 的なことをやることになるのですが、これはこれで悩ましいところ。

カテゴリー: C#, WinRT パーマリンク

オブジェクト指向と await の相性は良いのか?の落とし穴を考える への1件のコメント

  1. masuda のコメント:

    関数のネストが深くなる件は、
    [雑記] 継続と先物 (C# によるプログラミング入門)
    http://ufcpp.net/study/csharp/misc_continuation.html#key_continuation
    のように、.NET の場合は、末尾の return で最適化されているハズなので、メソッドチェーンによる状態遷移でも大丈夫なんだが、「実装依存」になるので(Javaの場合は末尾returnは最適化されている?)ってことで、設計段階では避けてるべきなのか?と考えてみたり。
    実際は、実装依存であろうと、そのプラットフォームに最適な設計をするわけで、そのあたりはトレードオフかも。

コメントは停止中です。