はじめに
LINQの「IEnumerable
の遅延評価」と、ReactiveExtensionsの「IObservable
のHod-Cold」は、どちらもLINQやRxを使い始めてしばらくするとぶち当たる壁ですね。避けては通れない道です。
先日、この両者にとある共通点を発見したので、記事にしてまとめました。
想定読者
この記事は次のような方をターゲットにしています。
- LINQとRxをある程度使っている
- 遅延評価やHot-Coldについての理解が曖昧
IEnumerable
まずはIEnumerable
です。
コレクションクラスが必ず実装する必要があるインターフェイスですね。
IEnumerable
の遅延評価
IEnumerable
の遅延評価とは、「必要になるときまで計算しない」というIEnumerable<T>
インターフェイスがもつ性質のことです。
例えば、以下のようなコードがあるとします。
1~10までの整数のうち偶数のみを各2倍にして変数enumerable
に格納して、それをforeach
で回して出力するコードです。
IEnumerable<int> enumerable = //変数「enumerable」に
Enumerable.Range(1, 10) //1~10までのうち
.Where(n => n % 2 == 0) //偶数に絞り込んだものを
.Select(n => n * 2); //2倍にしたものを代入する
foreach (var num in enumerable)
{ //結果を出力
Console.WriteLine(num);
}
もちろん結果は「4, 8, 12, 16, 20」の順に出力されるのですが、この「4, 8, 12, 16, 20」という結果は、実はforeach
で回したときに初めて計算されています。
普通の感覚だと、変数enumerable
にLINQメソッドチェーンを代入した時点で、enumerable
には「4, 8, 12, 16, 20」という計算結果が入っていると考えがちです。
しかし、実際には__enumerable
に代入した時点では、「4, 8, 12, 16, 20」という計算結果は入っていません__。
では何が入っているのかというと、「1〜10のうち偶数に絞り込んでそれらを2倍にしてね」という「命令」が入っています。
「命令」なので、計算自体はまだ実行しません。
ではいつ実行するのかというと、必要になったときに初めて計算を実行します。
つまり、foreach
で回したときになって初めて、「1〜10のうち偶数に絞り込んでそれらを2倍にする」という命令が実行され、結果が出力されることになります。
これが、「必要になるときまで計算しない」というIEnumerable
の遅延評価の特性です。
遅延評価の注意点
さてこの遅延評価の注意点ですが、まだ評価されていない「命令」が入ったIEnumerable
を複数回foreach
すると、__「命令」が複数回実行されてしまう__という点です。
IEnumerable<int> enumerable = //「命令」を代入
Enumerable.Range(1, 10)
.Where(n => n % 2 == 0)
.Select(n => n * 2);
foreach (var num in enumerable) //「命令」を実行
{
Console.WriteLine(num);
}
foreach (var num in enumerable) //「命令」を実行
{
Console.WriteLine(num);
}
enumerable
に入っているのはあくまで「命令」なので、2回foreach
すると、命令が2回実行されてしまいます。
これは明らかに無駄な処理ですね。本来であれば、この「命令」は1回実行すれば十分で、2回目以降は最初の実行結果を流用することができるはずです。
この特性を理解していないと、無駄な処理が走るだけではなく、思わぬバグの原因となります。
次の例を見てください。
public class Hoge
{
public Hoge()
{
Fugas = Enumerable.Range(1, 10)
.Select(n => new Fuga());
}
public IEnumerable<Fuga> Fugas { get; }
}
この例では、IEnumerable<Fuga>
を外部に公開しています。
もうお気づきとは思いますが、外部からFugas
プロパティが読まれるたびに、Fuga
インスタンスが新しく10個生成されます。
これが意図した動作ならば良いのですが、「最初に生成した10個のFuga
インスタンスを使いまわしたい」という場合はこのコードではうまく動かないことになります。
即時評価
IEnumerable
の遅延評価の紹介を行いましたが、それと対極となる言葉が「即時評価」です。
「遅延評価」は「必要になるときまで評価しない」という考え方でしたが、
「即時評価」の場合はその名の通り「即座に評価する」となります。
遅延評価のように、「本当に必要になるときまで演算を実行しない」のではなく、「将来必要になるかどうかは分からないけど、とりあえずすべて計算して、計算した結果をキャッシュしておく」というのが即時評価の特徴です。
事前にすべての計算を完了させ、その計算結果をキャッシュするので、前述のような「無駄な処理が走る」「意図せずインスタンスが大量生成されてしまう」などといった遅延評価の懸念点は、即時評価に変更することで解決することができます。
即時評価するには、List<T>
やT[]
などの具象オブジェクトに変換する作業が必要になります。
とは言っても、ToList()
やToArray()
などの拡張メソッドが用意されているため、LINQメソッドチェーンの最後にこれらを付けるだけで、即時評価に変更することができます。
IEnumerable<int> enumerable =
Enumerable.Range(1, 10)
.Where(n => n % 2 == 0)
.Select(n => n * 2)
.ToList(); //即時評価。即命令が実行されenumerableには計算結果が入る
foreach (var num in enumerable)
{
Console.WriteLine(num);
}
foreach (var num in enumerable)
{ //評価済みなので2回foreachしても無駄な処理は走らない
Console.WriteLine(num);
}
このように、遅延評価によって引き起こされる懸念を解消するためには、即時評価を適度に利用することが必要になります。
ここまでがIEnumerable
の遅延評価のお話でした。
続いて、IObservable
のCold-Hotのお話です。
IObservable
みんな大好きRx。RxといえばIObservable<T>
。
しかし、一口にIObservable<T>
といっても「Cold」と「Hot」があります。
ちょうど、IEnumerable<T>
にも「遅延評価」と「即時評価」があるように。
HotとCold
まずは簡単にHotとColdの説明をします。
Hot
HotなIObservable
とは、__購読者(Observer)を複数もつことができるIObservable
のこと__を言います。
HotなIObservable
は、Subscribe
されたときに、その購読者を購読者リストに保持します。
そして、値を発行するときには、購読者リストにあるすべての購読者に対して、一斉に値を発行します。
Observable(発行者)とObserver(購読者)が__1:多__の関係になるようなIObservable
と言えます。
Cold
対してColdなIObservable
とは、__購読者(Observer)を1つしか持つことができないIObservable
__を指します。
ColdなIObservable
は、購読者リストを保持する機能がなく、ただ一つの購読者しか管理することができないのが特徴です。
Observable(発行者)とObserver(購読者)が__1:1__の関係になるようなIObservable
と言えます。
HotとColdの見分け方
HotとColdの見分け方は、__値がSubject<T>
クラスから送出されているかどうか__で判断することができます。
HotなIObservable
は複数の購読者を管理することができると書きましたが、Subject<T>
クラスはまさにこの、複数の購読者(Observer)を保持し、すべての購読者に対して同時に値を発行することができる機能を持っています。
__HotなIObservable<T>
とは、実質的にはSubject<T>
クラスそのものを指す__のです。
したがって、__自分でSubject<T>
クラスを用意するなどして公開したIObservable
は、Hotである__と判断することができます。
対して、Rxに用意されている各種オペレータやファクトリメソッドの多くは、Subject<T>
クラスを利用していません。
そのため、後述する一部を除き、__Rxのオペレータやファクトリメソッドから生成されたIObservable
はColdである__と判断することができます。
ColdなIObservable
前述したように、ColdなIObservable
とは、__購読者(Observer)を1つしか持てないようなIObservable
__です。
値の発行者(Observable)と値の購読者(Observer)が__1対1の関係__になるようなIObservable
とも言えます。
例えば、以下のコードを見てください。
//変数「observable」に1〜10までの値を発行するIObservableを代入
IObservable<int> observable =
Observable.Range(1, 10);
//observableを購読
observable.Subscribe(Console.WriteLine);
Observable.Range
ファクトリメソッドから生成されるIObservable
はColdです。
Coldなので、購読者は1つしか持てません。
この場合、購読(Subscribe
)しているのは1つのみのため、何も問題なく購読することができます。
では、次のように__複数の購読者__がいた場合はどうなるのでしょうか。
//変数「observable」に1〜10までの値を発行するIObservableを代入
IObservable<int> observable =
Observable.Range(1, 10);
//observableを複数の購読者が購読
observable.Subscribe(Console.WriteLine);
observable.Subscribe(Console.WriteLine);
ColdなIObservable
なのに複数回Subscribe
されています。
しかしながら、この場合でも、Coldだから1回しかSubscribe
を受け付けないということはなく、きちんとSubscribe
した分だけ購読することができます。
ColdなIObservable
は1つしか購読者が持てないのに、複数回Subscribe
ができるのは矛盾しているように感じます。
なぜ、ColdなIObservable
なのに複数の購読者を登録できるのかというと、__ObservableソースがSubscribe
された分だけ生成されている__からです。
結果的には、__Observableソースと購読者は1対1__となるため、ColdなIObservable
の要件は満たすというわけです。
ColdなIObservable
の注意点
Subscribe
した分だけObservableソースが生成されるとはいえ、ColdなIObservable
でも複数購読が可能であれば、何が問題なのか?と思うかもしれませんが、次のような場合に注意が必要になってきます。
IObservable<int> observable = //変数「observable」に、
Observable.Range(1, 10) //「1~10までの整数を発行するObservableソースを生成して
.Where(n => n % 2) //そのうち偶数のものだけを次に通し、
.Select(n => n * 2); //2倍にしたものを発行する命令」を格納
//observableを複数の購読者が購読
observable.Subscribe(Console.WriteLine);
observable.Subscribe(Console.WriteLine);
このコードでは、「1~10までの整数を発行するObservableソースを生成して、そのソースから発行された値を偶数で絞り込んで、絞り込んだ結果に対して2倍したものを発行する命令」を変数observable
に代入しています。
そして、そのobservable
を複数回Subscribe
しています。
変数observable
には、「1~10までの整数を発行するObservableソースを生成して、そのソースから発行された値を偶数で絞り込んで、絞り込んだ結果に対して2倍したものを発行する命令」が入っているのでした。
これを複数回Subscribe
すれば、「1~10までの整数を発行して、それを偶数で絞り込んで、その絞り込み結果を2倍したものを発行するIObservable
」が複数個複製されてしまうことになります。
つまり、__Observableソースの生成処理とLINQオペレータの処理が、Subscribeされた回数分だけ走る__ことになってしまいます。
これが意図した動作ならば良いのですが、そうでない場合(1個のObservableソースを共有したい場合)には、無駄な処理が走ってしまったり、場合によってはバグの原因となってしまうため、注意が必要です。
HotなIObservable
Coldの対極となるのがHotです。
ColdなIObservable
が1つの購読者しか持てないのに対して、
HotなIObservable
は__複数の購読者を同時に持つことができます__。
つまり、HotなIObservable
ならば、複数回Subscribe
されても、Observableソースを複製することなく、各購読者に対して値を発行することができます。
したがって、前述したようなColdなIObservable
の問題点は、__HotなIObservable
に変換することで解決する__ということになります。
幸い、RxにはColdをHotに変換するオペレータが用意されているため、Hot変換したいタイミングで簡単にHot変換することができます。
その代表例が、Publish()
メソッドです。
Publish()
メソッドの役割を簡単に説明すると、次のようになります。
- 入力となる
IObservable<T>
とSubject<T>
を内部に保持する。 -
Connect()
メソッドが呼ばれると、入力のIObservable
をSubscribe
して、得た通知をそのままSubject<T>
に伝達する。 -
Subject<T>
のIObservable<T>
を外部に公開する。
つまり、入力となるColdなIObservable
から発せられる通知を、__複数の購読者を管理する機能を持つSubject<T>
に仲介させる__ことによって、複数の購読者を受付可能なHotなObservable
に変換するという仕組みとなっています。
また、Connect()
メソッドを呼び出す必要がありますので、Publish()
メソッドの戻り値は__IConnectableObservable
インターフェイス__となります。
使用例としては次のようになります。
IConnectableObservable<int> observable =
Observable.Range(1, 10)
.Where(n => n % 2)
.Select(n => n * 2)
.Publish(); //Publish以前のIObservableを保持したIConnectableObservableにする
//Publishの内部が持つSubjectを購読
observable.Subscribe(Console.WriteLine);
observable.Subscribe(Console.WriteLine);
//Publish以前のIObservableをSubscribeして、Subjectに伝える
observable.Connect();
詳しい使い方については他記事で紹介されているため、そちらを参照ください。
IEnumerable
の遅延評価とIObservable
のHotColdの関係性
前置きが非常に長くなりここからが本題ですが、「IEnumerable
の遅延評価・即時評価」と「IObservable
のHot・Cold」って、似ているものがある思いませんか?
違いを以下の表にまとめてみました。
IEnumerable |
IEnumerable |
IObservable |
IObservable |
|
---|---|---|---|---|
遅延評価 | 即時評価 | Cold | Hot | |
使用する命令 | foreach |
foreach |
Subscribe |
Subscribe |
変数に入っているもの | コレクション__操作の命令__が入っている | コレクションの操作済みの__値そのもの__が入っている |
IObservable からの通知結果を__操作する命令__が入っている |
__Observableソースそのもの__が入っている |
特徴 | 複数回foreach されると__命令が毎回実行__される |
複数回foreach されても__命令の実行は1回__だけ |
__単一__の購読者しか持てない。 複数回 Subscribe されるとObservableソースから複製される |
__複数__の購読者を持てる |
変換 |
即時評価に変換:ToList() , ToArray() など |
- |
Hotに変換:Publish() , Multicast() など |
- |
変数に「命令」が入っているか「そのもの」が入っているか
1つ目の共通点として、変数に入っているものの特性が挙げられます。
「遅延評価」と「Cold」は「命令」が入っている
表からもわかるように、「IEnumerableの遅延評価」と「IObservableのCold」は、どちらも変数に「命令」が入っているということがわかります。
ここでいう「命令」というのは、具体的にはLINQオペレータによる操作のことです。
変数に「命令」、すなわちLINQオペレータによる操作が入っているということは、複数回使用するとLINQオペレータによる操作がその分毎回実行されることを意味します。
すでに挙げた例ですが、以下のような例で考えます。
IEnumerable<int> enumerable =
Enumerable.Range(1, 10)
.Where(n => n % 2 == 0)
.Select(n => n * 2);
このとき、遅延評価IEnumerable<int>
型の変数「enumerable」には、1〜10までの整数のコレクションを作って、偶数で絞り込んで、2倍して、という「命令」が入ります。
「命令」が入っているので、複数回使用すれば、当然「命令」も複数回実行されてしまいます。
Cold Observableの場合も同様です。
IObservable<int> observable =
Observable.Range(1, 10)
.Where(n => n % 2 == 0)
.Select(n => n * 2);
ColdなIObservable<int>
型の変数「observable」には、1〜10までの整数を発行するObservableソースを作って、偶数で絞り込んで、2倍して、という「命令」が入ります。
「命令」が入っているので、複数回購読すれば、当然「命令」も複数回実行されます。
「即時評価」と「Hot」は「そのもの」が入っている
「そのもの」とは、その変数から最終的に得られる情報のことです。
IEnumerable
であれば、Enumerableソースから情報を受け取ってLINQによる演算をした結果そのものであるし、
IObservable
であれば、Observableソースからの通知を受けてLINQによる演算を行った結果に対する購読権のことです。
最終的に得られる情報そのものが入っているので、複数回foreach
しようが、複数回Subscribe
しようが、LINQによる操作は重複して実行されることはありません。
最初の1回だけ実行され、あとは、計算済みの値を使い回すことができます。
「命令」から「そのもの」への変換は可能、ただし逆は不可能
2つ目の共通点として、「命令」から「そのもの」への変換は可能だが、逆は不可能という点があります。
IEnumerable
ならばToArray()
やToList()
、
IObservable
ならばPublish()
やMulticast()
といった、「命令」から「そのもの」への変換メソッドが用意されています。
どちらも、LINQメソッドチェーンの末尾に付けるだけで、簡単に変換することが可能です。
ただし、逆は不可能です。
一度「そのもの」、つまり演算結果になってしまったものを、「命令」、つまり演算する方法に戻すことはできないからです。
つまり何が言いたいのか
- コレクション版LINQもRx版LINQも、ひとつのソースを複数で共有して使うことがあったら、遅延評価とColdには気をつけましょう。
- 遅延評価やColdのまま複数箇所で共有して使用すると、ソースまで遡ってLINQ演算命令が複製されてしまいます。
- 回避するには即時評価・Hot変換を行いましょう。
最後に
「IEnumerable
の遅延評価・即時評価」と「IObservable
のCold・Hot」は関係が薄いように見えても、本質的な部分を見ると意外と関係があるのではないか?と思えてきたので記事にして投稿してみました。
やはり他人に説明できるように記事にすると、理解が曖昧だった部分を強制的に勉強することになるので理解が深まって良いですね。
異論やご指摘はもちろん受け付けますので、是非コメントお寄せください。
最後までありがというございました。