Utakotoha - Windows Phone 7用の日本語歌詞表示アプリケーション
- C# WindowsPhone7 - 11.04/09
マーケットプレイスに通り、公開されました。フリーです(下で述べますがソースも公開しています)。再生中の音楽のアーティスト・タイトルを元に自動で検索して、歌詞を表示する、というものです。海外製の同種アプリとの違いは、対象が日本の曲ということになります。下記バナー、もしくはマーケットプレイス検索”utakotoha”でどうぞ。アプリケーション名は例によってヒネりなしで「歌詞」からそのままとりました。うたのことば。Utakata TextPadと名前が似ていますが偶然の一致です(ほんと)
スクリーンショットでは格好つけてズームインして隠蔽していますが、スクリーンショット用詐欺なだけで、実際はこんな感じです。
ようは、単純にWebBrowserにgoo歌詞を出しているだけです。ダブルタップでエリアに沿って幅一杯の拡大してくれるので、誤魔化し的にはまぁまぁ。WebBrowserを経由する理由は、APIが提供されているわけでもないので、権利関係考えるとこうでないとマズいかな、といったところで。goo歌詞を選んだ理由も直リンが許可されていたからです。
あと、WP7はテーマに黒背景のDarkと白背景のLightがあるわけですが、goo 音楽の背景は強制白で、どうもミスマッチなわけですね。アプリ自体を完全に白背景にしてしまうのもいいかな、とは思ったんですが、ブラウザのCSSを書き換えて黒背景にするという手を(ネタとして)取ってみました。オプションでOFFに出来るというか、デフォルトはOFFです。ズームすれば違和感はないんですが、画面いっぱいに広がってるものでは、微妙度極まりない。
ほか、Twitterに再生中楽曲を投稿する機能があります。
ソースコード
CodePlex上で公開しています。単機能アプリですので、コード規模は小さめです。あまりしっかりとはしてませんが、ユニットテストなども書いてあります。
WP7のReactive Extensions実践例サンプル、のつもりで書いたので、Rxを全面的に使っています。むしろRx縛りと言ってもいいぐらいにRxのみでやるのを無駄に貫いています。意味がなくてただ複雑化しただけの箇所多数。UIと絡めて使うのがイマイチ分からず振り回されてますねー。とはいえ、バッチリはまった部分も勿論あり。というわけで、Windows Phone 7においてReactive Extensionsがどのような場所で使えるのか、というのをコードとともに解説していきます。
コードはWindows Phone 7向けですが、通常のSilverlightでもWPFでも適用できる話なので、WP7関係ないからー、と言わず、Rxに興味ありましたら、眺めてみてください。
Linq to Event
Rxの特徴の一つはイベントのLinq変換です。Linq化して何が嬉しいかというと、柔軟なフィルタリングが可能になることです。WP7では、センサーからのデータ処理やタッチパネルなど、様々な箇所で威力を発揮すると思われます。UtakotohaではMediaPlayerの再生情報の変化に対してRx化とフィルタリングを施しました。
// Model/MediaPlayerStatus.cs public class MediaPlayerStatus { public MediaState MediaState { get; set; } public ActiveSong ActiveSong { get; set; } public static MediaPlayerStatus FromCurrent() { return new MediaPlayerStatus { MediaState = MediaPlayer.State, ActiveSong = MediaPlayer.Queue.ActiveSong }; } public static IObservable<MediaPlayerStatus> ActiveSongChanged() { return Observable.FromEvent<EventArgs>( h => MediaPlayer.ActiveSongChanged += h, h => MediaPlayer.ActiveSongChanged -= h) .Select(_ => MediaPlayerStatus.FromCurrent()); } public static IObservable<MediaPlayerStatus> MediaStateChanged() { return Observable.FromEvent<EventArgs>( h => MediaPlayer.MediaStateChanged += h, h => MediaPlayer.MediaStateChanged -= h) .Select(_ => MediaPlayerStatus.FromCurrent()); } // (省略)すぐ下に... }
Rx化は、ただFromEventをかますだけです。また、その際に、IEventではなく、本当に使う情報にだけ絞ったもの(この場合はMediaStateとActiveSong)をSelectで変換しておくと色々とコードが書きやすくなるのでお薦めです。
さて、「柔軟なフィルタリング」とは何か。ただデータを間引くだけなら、イベントの先頭でif(cond) return;を書けばいいだけです。RxではLinq化により、(自分自身を含めた)イベント同士の合成と、時間軸を絡めた処理が可能になります。これにより、従来一手間だった処理がたった一行で、連続した一塊のシーケンスとして処理することが可能になりました。
Utakotohaでも、生のイベントをそのまま扱わず、ある程度間引いて加工したものを渡しています。
/// <summary>raise when ActiveSongChanged and MediaState is Playing</summary> public static IObservable<Song> PlayingSongChanged(int waitSeconds = 2, IScheduler scheduler = null) { return ActiveSongChanged() .Throttle(TimeSpan.FromSeconds(waitSeconds), scheduler ?? Scheduler.ThreadPool) // wait for seeking .Where(s => s.MediaState == MediaState.Playing) .Select(s => new Song(s.ActiveSong.Artist.Name, s.ActiveSong.Name)); }
「再生中かつx秒間(デフォルトは2秒)新しいイベントが発生しなかった最新のものだけを流す」というものです。どういう意味かというと、連続で楽曲をスキップした時。Utakotohaでは再生曲の変更に合わせて、自動で歌詞検索をしますが、連続スキップにたいしても全て裏で検索に走っていたらネットワークの無駄です。なので、2秒間だけ間隔を置いて連続スキップされていない、と判断した曲のみを歌詞検索するようにしています。
こういう処理は、地味だけど必ず入れなければならない、けれど面倒くさい。でも、Rxを使えばなんてことはなく、Throttleで一撃です。Timer動かしたりDateTimeを比較したりなんて、もうしなくていいんだよって。
/// <summary>raise when MediaState Pause/Stopped -> Playing</summary> public static IObservable<Song> PlayingSongActive() { return MediaStateChanged() .Zip(MediaStateChanged().Skip(1), (prev, curr) => new { prev, curr }) .Where(a => (a.prev.MediaState == MediaState.Paused || a.prev.MediaState == MediaState.Stopped) && a.curr.MediaState == MediaState.Playing) .Select(s => new Song(s.curr.ActiveSong.Artist.Name, s.curr.ActiveSong.Name)); }
こちらは、状態が停止->再生になったことを検知するというもの。停止->再生でも自動検索を走らせたいので。
この source.Zip(source.Skip(1), /* merge */) は、一見奇妙に見えるかもしれませんが、ある種のイディオムです。一つ先(Skip(1))の値と合流するということは、Skip(1)時の値を基準にすると、現在値と一つ前の値で合流させることができる、ということになります。それにより、一つ前の状態を参照して停止->再生を検知しています。
過去の値を参照するには、他に、Scan(Aggregateの列挙版、そう考えると現在値と一つ前の値が使えることのイメージつくでしょうか?)やBufferWithCount(Listでバッファを持つ、第二引数でずらす範囲を指定可能)など、幾つかやり方がありますが、このZip(Skip(1))が最も扱いやすいところ。ただし、通常のものとSkipしたものとで、二つ分Subscribeされるということは留意したほうがいいかもしれません。そのことが問題になるケースもあるので、キャッシュを使う(Pulish(xs=>xs.Zip(xs.Skip(1)))など回避策を頭に入れておくと良いケースもあります。
Linq to Asynchronous
Rxのもう一つの大きな特徴は非同期のLinq変換です。そうすることにより、コールバックの連鎖で扱いにくかった非同期が、一本の流れに統一されます。
Utakotohaでは、歌詞の検索部分が代表的です。歌詞検索の背景ですが、goo歌詞のものを表示しているのだからgoo歌詞の検索を呼んでやるかなあ、と思ったのですが、それは色々マズいので、Bing Apiのサイト内検索を経由して、表示しました。Bing Apiについては若干苦労話もあるので、いつかまた。
// Model/Bing/BingRequest.cs public IObservable<SearchWebResult> Search(params SearchWord[] keywords) { var req = WebRequest.Create(BuildUrl(keywords)); return Observable.Defer(() => req.GetResponseAsObservable()) .Select(res => { var serializer = new DataContractJsonSerializer(typeof(SearchWebStructure)); using (var stream = res.GetResponseStream()) { return (SearchWebStructure)serializer.ReadObject(stream); } }) .SelectMany(x => (x.SearchResponse.Web.Results != null) ? x.SearchResponse.Web.Results : Enumerable.Empty<SearchWebResult>()); }
割とあっさり。Bing APIならびにJSONに関しては、以前Windows Phone 7でJSONを扱う方法について(+ Bing APIの使い方)として書きました。ほとんどそのままです。SearchResponse.Web.Resultsは、配列なので、SelectManyで分解してやります(nullの場合は空シーケンスを流す)。すると、後続に繋げるのが非常にやりやすくなります。実際に
// Model/Song.cs public IObservable<SearchWebResult> SearchLyric() { return new BingRequest() .Search(MakeWord(Artist), MakeWord(Title), LyricSite, Location, Language) .Where(sr => sr.Url.EndsWith("index.html")) .Do(Clean); }
といったように、Searchから更に続いて、若干のフィルタリングが入っています。これを使う場面では、勿論、更にチェーンが続きます。といったように、Rxは一つの流れを構築するわけですが、それらを徹底的に分解・分割して適切な場所への配置・組み合わせが可能になっています。もし、通常の非同期処理のようなコールバックの連鎖だったら、組み合わせは大変でしょう(だから、その場だけで処理したくなって、ネストが嵩んでしまう)
Orchestrate and Coordinate
Rx全体の特徴として、また、他の非同期を扱うライブラリと最も異なる、しかし重要な点として、中に流れるデータを区別しません。非同期もイベントもタイマーもオブジェクトシーケンスも、全て同列に扱います。それはどういうことかというと、データの種別を超えて合成処理が可能になるということです。つまり、Rxは、あらゆるデータソースを統合する基盤といえます。上のAsynchronousの例でも、非同期のWebResponseが、SelectMany以降はオブジェクトシーケンスに摩り替わっていました。
Utakotohaでは、歌詞の表示部分で色々なデータを混ぜあわせる処理を行っています。
// View/MainPage.xaml.cs LyricBrowser.NavigatedAsObservable() .Where(ev => ev.EventArgs.Uri.AbsoluteUri.Contains(GooLyricUri)) .SelectMany(ev => { // polling when can get attribute return Observable.Timer(TimeSpan.Zero, TimeSpan.FromSeconds(3)) .ObserveOnDispatcher() .Select(_ => (ev.Sender as WebBrowser).SaveToString()) .Select(s => Regex.Match(s, @"s.setAttribute\('src', '(.+?)'")) .Where(m => m.Success) .Take(1); }) .Select(m => WebRequest.Create(GooUri + m.Groups[1].Value).DownloadStringAsync()) .Switch() .Select(s => Regex.Replace(s.Trim(), @"^draw\(|\);$", "")) .Where(s => !string.IsNullOrEmpty(s)) .ObserveOnDispatcher() .Subscribe(jsonArray => { // insert json array to html LyricBrowser.InvokeScript("eval", @" var array = " + jsonArray + @"; var sb = []; for(var i = 0; i < array.length; i++) sb.push(array[i]); document.getElementById('lyric_area').innerHTML = sb.join('<br />')"); // (省略) }, e => MessageBox.Show("Browser Error")) .Tap(disposables.Add);
歌詞の表示といっても、WebBrowserに表示しているこということは、歌詞のURLを渡すだけです。それだけで済むはずでした。が、……妙に複雑怪奇です。歌詞のURLが渡されて表示が完了してからが起点として(Navigated)、処理を開始しています。
何故こうなったか。WP7が現在積んでるIEは7。ついでにFlashはまだ未対応で見れません。それとこれとがどう関係あるかというと、歌詞表示に関係あります。日本の歌詞サイトは大抵はコピペ禁止のために右クリック禁止、だけでなく、Flashで表示していたりします。それじゃあ手が出せない。では今回利用しているgoo歌詞は、というと、少し変わっていてHTML5 Canvasに描画しています。勿論Canvasは古いブラウザじゃ動かないので、互換用JavaScriptも挟んでいるよう。uuCanvas.js を使っているようですが、環境貧弱なWP7版のIE7じゃあ、土台動きませんでした。
このまんまじゃあ歌詞が表示出来なくて困ったわけですが、幸いgoo歌詞はJSONPで歌詞データを別途取得してCanvasにデータを流しているようなので、HTMLからJSONPの発行先を割り出して、歌詞データを頂いてしまえば問題ない(この時点で規約的にはグレーな気が)。生の歌詞データが手に入ってしまっ……。こいつをキャッシュするようにしてオフラインでも見れるといった機能を提供してあげられれば幸せになれるのですが、そういうのは利用規約に違反、してますね、明らかに。
じゃあどうするか。手元には歌詞の表示されていないブラウザ上のHTMLと、歌詞データがある。よし、じゃあブラウザにこちらからはめ込んでやればいいんじゃなイカ?
WP7ではWebBrowserのDOMは触れません。DOMを外から触ってappendChildしてサクッと終了、というわけにはいかず。ただ、外部から干渉出来る口が一つだけ用意されています。それがInvokeScript。外から実行関数を指定して、戻り値を受け取れます(DOMは無理なので、Stringで貰うのが無難)。ならば、evalして外から実行関数自体を注入してやれば、何だって出来る。どうにも馬鹿らしい気もしますが、このぐらいしか手がないのでShoganai(なお、予めWebBrowserのプロパティでスクリプト実行を許可しておかないと例外が出ます)。
Timer and Polling
イベント(Navigated)→タイマー(SaveToString)→非同期(DownloadStringAsync)という直列の合成でした。直列の合成を行うメソッドはSelectMany(もしくはSelect+Switch、両者には若干の違いがあるのですが、それに関しては後日説明します)で、Rxの中でも頻繁に使うことになるメソッドです。
ところで、タイマーが唐突なのですが、何故タイマーを仕込んでいるのか。どうもNavigated直後にSaveToString(WebBrowser内のHTMLを文字列化)だと、タイミング次第で上手く抽出できないことが多かったので(JSで色々処理されてる影響かな?)、必要なJSONPの書かれた属性が取れるまでSaveToStringをリトライするようにしました。つまり、ポーリング(定期問い合わせ)です。
ポーリングは普通だと面倒くさいはずなんですが、Rxだと恐ろしく簡単に書ける上に、こうして通常の処理の流れと合成することが可能になっているのが何よりも強力です。.NET Frameworkには幾つものTimerクラスがあって、何を使えばいいのかと戸惑ってしまうところがありますが、答えは出ました。Observable.Timerがベスト。大変扱いやすい。
これで、本来Canvasのあった領域にテキストデータとして歌詞を表示させられました。全く違和感のない、完璧なハメコミ合成。無駄なコダワリです。地味すぎて一手間かけてることなんてさっぱり分からない。だがそれがいい。……いや、ちょっと悲しい。それにしてもでしかし、Canvas, Flash対応になればこんなやり方は不要になるわけで、今年後半のアップデートでのIE9搭載が待ち遠しい。
Unit Testing
Silverlight向けのユニットテスト環境って、全然ない。ブラウザ上のSilverlightで動かす、というタイプは幾つかありますが、Visual Studioと統合された形のでないと、そんなのアタシが許さない。
TDDするわけでも、熱心にテスト書くわけでも、特段カバレッジを気にするわけでもない私ですが、テストが書けることは重要視しています。何故かというと、メソッドの動作確認が最も素早く行えるから。テスト(が出来ること)に何を期待しているかというと、確認したいんです、メソッドの動作を。手軽に、コンソールアプリを書く感覚で、素早く。処理をコンソールアプリにコピペって、もしくは並走してコンソールアプリを立てながら開発していたりなどを以前よくしたのですが、それを単体テストのメソッド部分に書けば、動作確認のついでに、テストまで手に入るので、それは素敵よね?と。
(単体)テストが第一の目的ではない、(動作確認)テストが目的なのだ。だから、テストフレームワークはVisual Studioと完全な統合を果たしていなければならない。ショートカットでIDE内のウィンドウで即座に実行。スピードが大事。また、シームレスなデバッグ実行への移行も。故にMSTestを選択するのである(キリッ
などなどはその辺にしておいて、それはともかくで、素直にフル.NET Frameworkで動くMSTestを使います。一応Silverlightのアセンブリはフル.NETからも参照可能のようなので、普通にテストプロジェクトを立ててDebug.dll を参照してやる(プロジェクト参照は警告出るので)という手も使えなくはなさそうなのですが、完全な互換を持つコアライブラリは全体のごく一部で、それ以外を使っていると普通に実行時例外でコケるなど、正直使えないと私は判断しました。よって、アセンブリ参照でやるのは諦め。プロジェクト参照で警告が出る理由も分かりました、あまり実用的な機能ではない……。
代わりにWP7プロジェクトとテストプロジェクトの間に、.NET4ライブラリプロジェクトを立てて、「追加->既存の項目->リンクとして追加」で、フルフレームワークとWP7間で.csファイルを共有してやります(Viewは勿論共有出来ないので、基本はModelのみ)。
勿論、コードレベルで互換が取れていないと動きません。そんなわけで、少なくともModelに関してはWPF/SL/WP7で共通で使いまわせるように意識して作りたいところです。移植性というだけじゃなく、MSTestの恩恵を受けれるので。ただまあ、無理な部分は無理で諦めちゃってもいいとは思います(SLのほうにだけあるクラスとかもありますから)。非同期のテストなどは、幸いRxを使っていれば非常に簡単なので、バシバシ書いちゃいましょう。
// Utakotoha.Test/SongTest.cs [TestMethod] [Timeout(3000)] public void SearchLyric() { var song = new Song("吉幾三", "俺ら東京さ行ぐだ"); var array = song.SearchLyric().ToEnumerable().ToArray(); array.Count().Is(1); array.First().Title.Is("俺ら東京さ行ぐだ 吉幾三"); array.First().Url.Is("http://music.goo.ne.jp/lyric/LYRUTND1127/index.html"); }
ToEnumerableしてToArrayするだけです!非同期のテストなんて怖くない。
Portable Library Tools CTPという、各環境で互換性の取れるライブラリが作成出来るもの、なども出てきているので、リンクで追加とかいう間抜け(そして面倒)なことじゃなく、プロジェクトごと分離して、Modelは互換で生成、というのが将来的には良いやり方になるかなあ、などと思っています。そういう事情から、私は移植性とは関係ない立場からも、Portable Library Toolsの発展に期待しています。
なお、アサーションはChaining Assertion使っています。ドッグフードドッグフード。というかもう、必需品なので、これないと書けない、書きたくない……。
Mocking Event with Moles and Rx
さて、このやり方の利点として、PexやMolesが使えます(Pexは一応SLをサポートしたものの、Molesは依然としてSL未サポート)。Moles(Microsoft Researchが提供するモックフレームワーク、フリー)の乗っ取り機構は強力なので、テスト可能範囲が大幅に広がります。詳しくはRx + MolesによるC#での次世代非同期モックテスト考察をどうぞ。
今回Linq to Eventで紹介したPlayingSongActiveは、以下のようにテストしています。MediaPlayer周りはXNAなので、Test側の参照DLLとしてXNA Game Studio v4.0のMicrosoft.Xna.Framework.dllを参照。そしてMolesで乗っ取り。
// Utakotoha.Test/MediaPlayerStatusTest.cs private MediaPlayerStatus CreateStatus(MediaState state, string artist, string name) { return new MediaPlayerStatus { MediaState = state, ActiveSong = new Microsoft.Xna.Framework.Media.Moles.MSong { NameGet = () => name, ArtistGet = () => new MArtist { NameGet = () => artist } } }; } [TestMethod, HostType("Moles")] public void PlayingSongActiveTest() { // event invoker var invoker = new Subject<MediaPlayerStatus>(); MMediaPlayerStatus.MediaStateChanged = () => invoker; // make target observable var target = MediaPlayerStatus.PlayingSongActive().Publish(); target.Connect(); // at first, stopped using (target.VerifyZero()) { invoker.OnNext(CreateStatus(MediaState.Stopped, "", "")); } // next, playing using (target.VerifyOnce(song => song.Is(s => s.Title == "song" && s.Artist == "artist"))) { invoker.OnNext(CreateStatus(MediaState.Playing, "artist", "song")); } // pause using (target.VerifyZero()) { invoker.OnNext(CreateStatus(MediaState.Paused, "", "")); } // play again using (target.VerifyOnce(song => song.Is(s => s.Title == "song2" && s.Artist == "artist2"))) { invoker.OnNext(CreateStatus(MediaState.Playing, "artist2", "song2")); } }
元のコード自体がイベント発火部分はRxで包んであるので、そのイベント発火だけを差し替え。SubjectとはイベントのRxでの表現。OnNextでイベント発火の代用が可能になっています。これで、任意のイベント(今回はMediaPlayerなので、再生停止であったり再生開始であったり)を発行して、その結果の挙動を確認しています。
VerifyなんたらはIObservableへの自前拡張メソッドで、発火されたか/回数の検証です。「イベントは発生したけれどフィルタリングされて値が届かなかった」ことの、フィルタリングが正常に出来たかの確認って、そのままだと難しい。如何せんSubscribeまで届いてくれないということですから。そのため、その辺を面倒みてくれるものを用意しました。
// Utakotoha.Test/Tools/ObservableVerifyExtensions.cs /// <summary>verify called count when disposed. first argument is called count.</summary> public static IObservable<T> Verify<T>(this IObservable<T> source, Expression<Func<int, bool>> verify) { var count = 0; return source .Do(_ => count += 1) .Finally(() => { var msg = verify.Parameters.First().Name + " = " + count + " => " + verify.Body; Assert.IsTrue(verify.Compile().Invoke(count), "Verifier " + msg); }); } /// <summary>verify called count when disposed. first argument is called count.</summary> public static IDisposable VerifyAll<T>(this IObservable<T> source, Expression<Func<int, bool>> verify, Action<T> onNext = null) { return source.Verify(verify).Subscribe(onNext ?? (_ => { })); } /// <summary>verify not called when disposed.</summary> public static IDisposable VerifyZero<T>(this IObservable<T> source) { return source.VerifyAll(i => i == 0); } /// <summary>verify called once when disposed.</summary> public static IDisposable VerifyOnce<T>(this IObservable<T> source, Action<T> onNext = null) { return source.VerifyAll(i => i == 1); }
usingによるスコープを抜けるとFinallyで検証が入ります。RxでイベントをラップするとIDisposableになる、そのことの利点が生きてきます。
今後の改善
Pivotのヘッダーのデザインがどうも間抜け(マージンの取り方が変だし文字サイズも違和感あり)なのが気になってるので、変えたいです。HeaderTemplateの編集の仕方がよくわからずで放置なのですけれど、ゆったり紐解けば出来るでしょう。多分。
レジュームへの配慮が全くなくて、別画面にいくと真っ白になるのがビミョい。WebBrowserが絡むので完全な復元は無理だから、いっかー、とか思ったのが半分はあるのですが、いやまてその理屈はオカシイ。ので、ちょっと何とかさせないとですね。
xaml.csのコードが全体的にマズい。特にOAuth認証の部分はありえない強引さなのでとっとと変更。あと、もう少し適切な分割。MVVMはわからんちん。
Settingsが何か変。IsolatedStorageSettingsというか、その内部のDataContractSerializerの都合というか。これだ!というやり方ないかしら。今のやり方は、非常に間抜け。
ブラウザ画面黒背景はビミョーなので、設定でアプリ全体を白背景に変更するオプションを入れるのもいいかなー。それとアプリ起動時は楽曲を再生中でも自動検索しないのだけど、自動検索してくれたほうが嬉しいかなー。など、細かい点では色々考えること、追加することあります。
まとめ
上手く決まった部分しか解説してないので、実際のコードは残念ながらスパゲッティです:) というか、UI絡みのコードってほとんど書いたことないので、経験の無さが如実に現れていて苦すぃ。WP7で勝手がわからないというもの若干はありますが、それ以前の問題がかなり。サンプルアプリとしても、もう少し良くしたいので、コードは徐々に洗練させていきたいですます。
アプリとしては、まあまあいい出来というか、実用品として悪くないフィーリングだと思うのですが、どうでしょうか?画面周りは、このレイアウトで決まるまで何度も試して投げてを繰り返してこれに落ち着きました。実際に作りながら、試しながらでないとこういうの決められないよね。コードに関してもそうだけど。ともあれ、Pivotいいよねー。やはりWP7といったらPivot。
そんなわけで、RxはWP7開発のお供として欠かせない代物なので、是非使ってみてください。また、WP7で欠かせないということはSilverlightで欠かせないということであり、Silverlightで欠かせないということはWPFでも欠かせないということでも、あったりなかったりするので、Rxによるプログラミングパラダイムの変化を是非とも楽しんでみてください。Linq to Anything!
一記事に収めるため少々駆け足気味だったので、なにか不明な点、質問などありましたら気楽にコメントどうぞ。突っ込みも勿論歓迎です。
Comment (2)
- MB.okomok : (04/14 22:02)
すばらしい!ついにThrottleの意味が分かりました:)
ちなみにC++界隈ではZip(Skip(1))をadjacentと呼んだりしますんで- neuecc : (04/14 23:26)
どもです!
adjacent、なるほどー。
F#には同種のものがpairwiseという名前で入っていますし、
標準でその手のものがあったほうが嬉しいところです(自前の補助メソッドとしてはPairwiseを用意してはいるのですが)