[Reactive Extensions]お題:5人揃ってゴレンジャイ!

最近 Reactive Extensions に入門した同僚に出したお題です。
ちょっとしたお遊びのつもりで出題したのですが、意外と楽しんでもらえたようです。

目次

お題

Reactive Extensionsを使って、次の処理を実装してください。

  1. ゴレンジャイのメンバー、「赤レンジャイ」「青レンジャイ」「黄レンジャイ」「緑レンジャイ」「桃レンジャイ」からランダムに1人選び、表示します。
  2. それを5回繰り返して、1人も重複していなければ成功です。「5人揃ってゴレンジャイ!」と表示して終了します。
  3. メンバーが重複した時点で失敗とします。1人目からやり直してください。
  4. 成功するまで繰り返し行います。

(元ネタはこちら)


ガキの使い ゴレンジャイゲーム

私の回答

やり方はいろいろあると思いますが、今回は「成功するまで繰り返す」部分でRetry()を使おう、と決めて全体の実装を考えました。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;


class Program
{
  static void Main(string[] args)
  {
      var goren = new[]
      { 
        "赤レンジャイ! ",
        "青レンジャイ! ",
        "黄レンジャイ! ",
        "緑レンジャイ! ",
        "桃レンジャイ! " 
      };
      Observable.Repeat(new Random(), 5)
        .Select(r => goren[r.Next(5)])
        .Scan(new { Value = "", IsOK = true, Buffer = new HashSet<string>() },
          (a, x) =>
          {
            var IsOK = a.Buffer.Add(x);
            if (!IsOK) { a.Buffer.Clear(); }
            return new {
              Value = x,
              IsOK,
              Buffer = a.Buffer
            };
          })   
        .SelectMany(a => a.IsOK
          ? Observable.Return(a.Value + " ")
          : Observable.Return(a.Value + " 被っとるやないかい!\n")
                      .Concat(Observable.Throw<string>(new Exception())))
        .Retry()
        .Subscribe(
          onNext: x => Console.Write(x),
          onCompleted: () => Console.WriteLine("5人揃ってゴレンジャイ!!"));
  }
}

実行結果

青レンジャイ!  青レンジャイ!  被っとるやないかい!
桃レンジャイ!  黄レンジャイ!  青レンジャイ!  桃レンジャイ!  被っとるやないかい!
赤レンジャイ!  桃レンジャイ!  黄レンジャイ!  黄レンジャイ!  被っとるやないかい!
桃レンジャイ!  桃レンジャイ!  被っとるやないかい!
黄レンジャイ!  赤レンジャイ!  赤レンジャイ!  被っとるやないかい!
… (中略)…
青レンジャイ!  赤レンジャイ!  緑レンジャイ!  青レンジャイ!  被っとるやないかい!
青レンジャイ!  赤レンジャイ!  黄レンジャイ!  緑レンジャイ!  青レンジャイ!  被っ とるやないかい!
桃レンジャイ!  赤レンジャイ!  緑レンジャイ!  黄レンジャイ!  緑レンジャイ!  被っ とるやないかい!
赤レンジャイ!  桃レンジャイ!  黄レンジャイ!  桃レンジャイ!  被っとるやないかい!
青レンジャイ!  緑レンジャイ!  赤レンジャイ!  青レンジャイ!  被っとるやないかい!
赤レンジャイ!  緑レンジャイ!  青レンジャイ!  黄レンジャイ!  桃レンジャイ!  5人揃ってゴレンジャイ!!

解説

  • Repeat() + Select() => ランダムに5人選ぶ
  • Scan() => 重複チェックのために値をまとめる
  • SelectMany() => 重複していたら 例外を流す
  • Retry() => 例外が流れて来たら、やり直し
  • Subscribe()-onNext => 流れてきた値を表示する。
  • Subscribe()-onCompleted => 無事に5人分流れて来たら完了!「5人揃ってゴレンジャイ!」

重複判定

Scan()のところが少々分かりずらいでしょうか。
ここでは、毎回流れてくる値を、以下をメンバに持つ匿名型にまとめています。

  • string Value: 現在の値
  • bool IsOK : 重複した(false)/していない(true)
  • HashSet<String> Buffer: 値をためておくバッファ(重複判定に使用)

HashSet<T> は重複した値を追加できないため、既に存在する要素を追加しようとすると、追加用メソッドAdd() は失敗しfalse を返してきます。
これを利用して「かぶった」/「かぶっていない」の判定を行っています。

var IsOK = Buffer.Add(x);

失敗したら例外を流す

Retry()は、Rxのストリームに例外が流れてきた場合に動作します。

失敗したら例外を発生させれば良いのですが、ストリーム上に流すためにはObservable.Throw()メソッドを使用してIObservable<T> にラップしてやる必要があります。

ここで、流れる値が IObservable<T>、(つまり IObservable<IObservable<T>>) となるので、通常の値も Observable.Return() を使用してIObservable<T> で流してやる必要があります。
そして、IObservable<IObservable<T>>IObservable<T> に戻すために SelectMany() が使われています。

.SelectMany(a => a.IsOK
  ? Observable.Return(a.Value + " ")
  : Observable.Return(a.Value + " 被っとるやないかい!\n")
              .Concat(Observable.Throw<string>(new Exception())))

(失敗時のメンバーも表示したいので、失敗時の値 と 例外 をConcat()でつなげて流すようにしています。 )

蛇足

失敗したら例外を流す処理 (SelectMany()の部分) は、拡張メソッドに切り出してThrowIfFalse() みたいにしたらもっと分かりやすくなりそうですね。他のケースでも使えそうな気もします。

ThrowIfFalse() 拡張メソッド

という事で、作ってみました。

ThrowIfFalse 条件式がFalseを返したら例外を発行するIObservable<T> 拡張メソッド · GitHub

回答のコードを置き換えてみます。
書いている内容はほとんど変わっていませんが、やりたい事の意図がより伝わりやすくなったと思います。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;


class Program
{
  static void Main(string[] args)
  {
      var goren = new[]
      { 
        "赤レンジャイ! ",
        "青レンジャイ! ",
        "黄レンジャイ! ",
        "緑レンジャイ! ",
        "桃レンジャイ! " 
      };
      Observable.Repeat(new Random(), 5)
        .Select(r => goren[r.Next(5)])
        .Scan(new { Value = "", IsOK = true, Buffer = new HashSet<string>() },
          (a, x) =>
          {
            var IsOK = a.Buffer.Add(x);
            if (!IsOK) { a.Buffer.Clear(); }
            return new {
              Value = x,
              IsOK,
              Buffer = a.Buffer
            };
          })   
        .ThrowIfFalse(a => a.IsOK, new Exception(),
          resultSelectorWhenTrue : a => a.Value + " ",
          resultSelectorWhenFalse : a => a.Value + " 被っとるやないかい!\n")
        .Retry()
        .Subscribe(
          onNext: x => Console.Write(x),
          onCompleted: () => Console.WriteLine("5人揃ってゴレンジャイ!!"));
  }
}