LoginSignup
10
11

More than 3 years have passed since last update.

「IEnumerableの遅延評価」と「IObservableのCold-Hot」の共通点についてまとめた

Last updated at Posted at 2021-03-21

はじめに

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()メソッドの役割を簡単に説明すると、次のようになります。

  1. 入力となるIObservable<T>Subject<T>を内部に保持する。
  2. Connect()メソッドが呼ばれると、入力のIObservableSubscribeして、得た通知をそのままSubject<T>に伝達する。
  3. 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」は関係が薄いように見えても、本質的な部分を見ると意外と関係があるのではないか?と思えてきたので記事にして投稿してみました。

やはり他人に説明できるように記事にすると、理解が曖昧だった部分を強制的に勉強することになるので理解が深まって良いですね。
異論やご指摘はもちろん受け付けますので、是非コメントお寄せください。

最後までありがというございました。

10
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
11