.NET Reactive Framework メソッド探訪第五回:Scan

Microsoft Silverlight を入手

var random = new Random();
Func<byte> nextByte = () => (byte)random.Next(0, byte.MaxValue + 1);

// CenterEllipseは真ん中の円のこと。
// 1000秒以内に3回クリックするとランダムで色が変わる
// GetMultiClickScan関数が今回の主題で、解説は後で。
CenterEllipse.GetMultiClickScan(3, 1000).Subscribe(() =>
    CenterEllipse.Fill = new SolidColorBrush(Color.FromArgb(255, nextByte(), nextByte(), nextByte())));

円を1秒以内にトリプルクリックすると色が変わります。右側のログ表示はミリセカンド単位でトリプルクリック時の経過時間を表しています。つまり、これが1000以内ならば色が変わり、1000より上ならば色は変わりません。このサンプルのテーマは、トリプルクリックの検出です。トリプルだけではなく、クアドラプルでもクインタプルでも、1秒以内じゃなくて5秒でも10秒でも応用できる形で書くとしたら、どう書く? グローバル変数に、クリックイベントを格納する配列でも置くかしらん。それがきっと簡単で分かりやすくて、でも……。

今回はRx Frameworkの関数から、Scanを取り上げます。この例題は私が考えたわけではなく、Ramblings of a Lazy Coder : My solutions to the Reactive Framework Tripple-click puzzleという問題からです。URLリンク先は解答編、というわけで、この解答を解説します。

考え方としては、クリックイベントに「クリックされた時間」という情報を付加。更に、クリック回数分の「前のデータ」を参照して指定時間内で連続クリックかどうかを確認する。時間情報の付加は匿名型を作るだけなので簡単ですが、「前のデータ」を参照するのが厄介。Linqは前にしか進まないし、送られてくるデータは過去のものなど知らない。このことはReactive Frameworkだけの話ではなく、以前にもLINQで左外部自己結合Scan?という記事で書きましたが、そこで出てくる解決策がScan。そうそう、ScanはAchiralにあるので(ということでlinq.jsにもあります)、動作は分かってます。ようするにAggregateの計算過程吐きだし版です。とりあえずlinq.js ReferenceでE.Range(1,10).Scan("x,y=>x+y")と打ってみてください(宣伝宣伝)

Scanならば「一個前」の情報が手に入る。しかし「複数個前」の情報はどうすれば? 答えは配列使えばいいぢゃない。だそうです。過程のデータをとりあえず配列に入れて、次に送り出してやれば、そりゃ簡単に取り出せますね。何だか邪道な気がしますが、気にしない気にしない。URL先の解答例では生の配列を使って、インデックスをゴニャゴニャとしていて非常に正しいとは思いますが、あまり生の配列のインデックスは扱いたくないので、Queueを使ってみました。別にQueueのClear()のコストなんてたかが知れてるっしょ(中ではArray.Clearを呼んでいて、Array.Clearは……以下略)という割り切りで。

public static IObservable<MouseButtonEventArgs> GetMultiClickScan
    (this UIElement element, int count, int multiClickSpeedInMilliSeconds)
{
    return Observable.FromEvent<MouseButtonEventArgs>(element, "MouseLeftButtonDown")
        .Select(e => e.EventArgs)
        .Scan(new
        {
            Times = new Queue<DateTime>(count),
            Hit = false,
            Event = (MouseButtonEventArgs)null // ダミーなのでnullをキャストするのが楽
        }, (a, e) =>
        {
            var isHit = false;
            var now = DateTime.Now;
            a.Times.Enqueue(now);
            if (a.Times.Count == count)
            {
                var first = a.Times.Dequeue();
                Debug.WriteLine((now - first).TotalMilliseconds); // Debug
                if ((now - first).TotalMilliseconds <= multiClickSpeedInMilliSeconds)
                {
                    isHit = true;
                    a.Times.Clear();
                }
            }

            return new
            {
                Times = a.Times,
                Hit = isHit,
                Event = e
            };
        })
        .Where(a => a.Hit)
        .Select(a => a.Event);
}

ScanはAggregateと全く同じで、accumlatorのみの実行の他に、第一引数でseedを渡すこともできます。Scanのseedで、変数と判定を行うためのフラグを保持するクラスを作り、accumlatorで判定と変数の持ち越しを行い、Whereで判定をフィルタリング。このコンボは非常に強力で、幾らでも応用が効きそう。LinqでのAggregateはほとんど使われませんが、RxにおけるScanはよく見かけることになるのではないかと思います。

で、理屈は分かったけれど、何だかゴチャゴチャとしてない? という感想は否めない。ただ、グローバル領域に変数を置く必要なくフラグを閉じ込められていること、そして、応用の効きそうな柔らかさが見えたり見えなかったりしませんか? 応用的なものも、追々考えていきたいです。

次元の狭間へ

Scanが出たので、Aggregateもついでにおさらい。これはLinq to ObjectsのAggregateと変わりません。ちなみにRx Frameworkでの内部実装はScan().Last()だったりするので、Scanとほんとーに丸っきり変わりません。じゃあ、無限リピートのFromEventにたいしてAggregateって、実行するとどうなるの?というのは気になるところですが、答えは次元の狭間に入ってしまって、その行からコードが一切進まなくなります。AggregateだけじゃなくCountやLast、ToEnumerableなど全てを列挙してから答えを返す系のメソッドは全て同じ結果になります。コンソールアプリの簡単なコードで試してみると、こうなる。

class MyClass
{
    public event EventHandler<EventArgs> Ev;

    public void Fire()
    {
        Ev(this, new EventArgs());
    }
}

static void Main(string[] args)
{
    var mc = new MyClass();
    var count = Observable.FromEvent<EventArgs>(mc, "Ev")
        .Count(); // ここで次元の狭間にダイブする

    mc.Fire(); // ここに到達することは未来永劫無い
}

恐ろしや。ただ、前段階でTakeやTakeWhileを挟めば、無限リストは有限リストとなるので、面白い感じに制限が出来ます。この辺も応用例として、そのうち紹介していければと思います。

カウンター

もう一度Scanを見ます。トリプルクリックの例題は捻りすぎな感が否めないので、もっとストレートに、Scanの「前の値を保持し続けることが出来る」という点を見せる例題を一つ。クロージャの例なんかでも定番のカウンターで。

Microsoft Silverlight を入手

Observable.FromEvent<RoutedEventArgs>(CounterButton, "Click")
    .Scan(0, (x, y) => ++x)
    .Subscribe(i => CounterButton.Content = i + "Clicked");

とても簡潔で、変数が全て閉じ込められていて、綺麗……。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive