はじめに
RxにつきもののHot Coldについて解説します。
HotColdについてはすでに様々な記事で解説がなされているとは思いますが、直感的には理解ができても、本質的な部分については理解できないことがありました。
先日、ようやく納得がいくまで本質部分を理解できましたので、記事にしてまとめます。
一応、他の記事とは違った視点での説明になっていると思います。
ちなみに、Hot変換については別記事で書いていますのでこちらを参照ください。ただし、本記事の知識が前提となっています。
その前にSubject<T>
クラスについて
Hot-ColdとSubject<T>
クラスには深い関係があります。
そのため、Hot-Coldの説明の前にSubject<T>
クラスについてもう一度復習します。
御存知の通り、Subject<T>
クラスはIObserver<T>
インターフェイスとIObservable<T>
インターフェイスを両方実装したクラスです。
したがって、IObserver
としてもIObservable
としても振る舞うことができるという特徴があります。
複数の購読者を管理する機能を持つ
ここで重要なのは、Subject
クラスは、__Subscribe
してきた購読者(つまりIObserver
)を内部にリストとして保持する__ということです。
購読者がSubject
クラスのSubscribe
メソッドを呼び出すと、Subject
クラスが内部に持つIObserver
のコレクション(購読者リスト)に、引数で渡されたIObserver
を追加します。
そして、Subject
クラスのOnNext
やOnError
、OnCompleted
が呼ばれたときに、保持している購読者リストにあるすべての購読者に対して、同様の通知を発行します。
つまり、Subject
クラスとは、__1つのObservableシーケンスを複数の購読先に分配する、マルチキャストするという役割を持っているクラス__なのです。
まとめると、Subject
クラスの特徴は次のとおりとなります。
- 複数の購読者を管理する機能を有する
- 1つのObservableシーケンスをすべての購読先にマルチキャストする
このことを踏まえた上で、Hot Coldの説明をします。
HotとCold
Hot Observableとは
__HotなIObservable
__とは、__購読者を複数持つことができるIObservable
__のことを指します。
そして、HotなIObservable
は「1つのObservableシーケンスを、すべての購読者に対してマルチキャストする」という特徴があります。
「複数の購読者を持つ」「1つのObservableシーケンスを複数の購読先にマルチキャストする」と言えばもう__Subject
クラス__しかありませんね。
Hot Observableは「Subject」
実は、Hot Observableとは、Subject
クラスそのものを指します。文字で説明されても意味がわからないと思うので、次のコードを見てください。
public class HotObservableProvider
{
private Subject<int> subject = new Subject<int>();
public void Fire()
{
subject.OnNext(1);
subject.OnNext(2);
subject.OnNext(3);
}
//「HotObservable」から取得できるIObservableはSubjectクラスそのものを指す
public IObservable<int> HotObservable => subject;
}
public class User
{
public User()
{
HotObservableProvider provider = new HotObservableProvider();
provider.HotObservable //このIObservable<int>はSubjectクラスそのものを指すのでHot
.Subscribe(n => { /*何らかの処理*/ });
}
}
このHotObservableProvider
クラスが提供するHotObservable
プロパティから取得できるIObservable<int>
インターフェイスは、その実体がまさにSubject<int>
クラスそのものを指しています。
このように、実体がSubject
クラスそのものを指すIObservable
は、Hotであると考えることができます。
また、実体がSubject
そのものではなくとも、__Subscribe
したときに実質的にSubject
をSubscribe
したのと同じような効果があるようなIObservable
__も、Hotと見做すことができます。これについても具体例を用意しました。
//IObservableの実体はSubjectクラスではない
public class HotObservable : IObservable<int>
{
private Subject<int> subject = new Subject<int>();
public void Fire()
{
subject.OnNext(1);
subject.OnNext(2);
subject.OnNext(3);
}
//Subscribeしたとき、SubjectクラスをSubscribeするのと同じ効果がなる
public IDisposable Subscribe(IObserver<int> observer)
{
return subject.Subscribe(observer);
}
}
public class User
{
public User()
{
IObservable<int> observable = new HotObservable();
//「observable」の実体はSubjectではないが、
// Subscribeすると実質SubjectをSubscribeしてるのと同じ
observable.Subscribe(n => { /*何らかの処理*/ });
}
}
先ほどの例と違って、HotObservable
クラス自体がIObservable
となっています。User
がHotObservable
クラスをIObservable
として使用するとき、当然このIObservable
の実体はHotObservable
でありSubject
ではありません。しかしながら、HotObservable
クラスは、Subscribe
すると内部に持つSubject
をSubscribe
するのと同じ効果となるように実装されています。したがって、この場合もHot Observableであると見做すことができます。
まとめると、Hot Observableは、__購読先がSubject
であるようなIObservable
__とも言えると思います。
Cold Observableとは
対して__ColdなIObservable
__とは、__購読者を1つしか持つことができないIObservable
__のことを指します。
ColdなIObservable
は購読者を1つしか持てないので、1つの購読者に対して常に専属のIObservable
が存在するということになります。HotなIObservable
は__Observable:Observer = 1:多
であったのに対して、ColdなIObservable
はObservable:Observer = 1:1
__となることが特徴です。
なぜCold Observableが「購読者を1つしか持てない」のかというと、__Subject
クラスを利用していないから__です。Subject
クラスを利用していないので、複数の購読者を管理することができず、ただ1つの購読者に対して専属のObservableが必要になってしまいます。
1つのObservableシーケンスを共有しない(つまり購読者が1つだけ)ならばColdのままで問題ないのですが、共有したい(つまり購読者が複数存在する)場合はHot変換が必要になる場合がある、というのはこのためです。
ちなみに、Rxのオペレータやファクトリメソッドから出てくるIObservable
は、そのほとんどがSubject
を使っていません。そのため、普通にRxを使っている分には、ほとんどがColdなIObservable
になります。
まとめると…
-
Hot Observable
- 購読先が
Subject
であるようなIObservable
- 複数の購読者を管理可能
Observable:Observer = 1:多
- 購読先が
-
Cold Observable
- 購読先が
Subject
ではないようなIObservable
- ただ1つの購読者しか管理できない
Observable:Observer = 1:1
- 購読先が
Observableソースのお話
これで終わりではなく、大事な__Observableソース__についてのお話があります。
Observableソースとは、__Observableシーケンスに流れてくる値の「発生源」__です。
そしてこのObservableソースにも__Hot__と__Cold__が存在し、この違いを認識していないと思わぬバグの原因となりますので、Rxを使う上では必ず理解する必要がある知識です。
Observableソースの例
ObservableソースはObservableシーケンスに流れてくる値の発生源などと言われてもいまいちピンとこないと思いますので、例を紹介します。
Subject<T>
がObservableソース
Subject<T>
クラスがObservableソースとなる例です。
Subject<int> subject = new Subject<int>();
subject.Subscribe(n => { /*何らかの処理*/ });
//ここでSubjectにOnNextすることによってObservableシーケンスに値が乗る
//→SubjectがObservableソース
subject.OnNext(1);
subject.OnNext(2);
subject.OnNext(3);
Subject
に対して直接値をOnNext
によって送出しています。この場合、Observableシーケンスを流れる値の発生源はこのOnNext
にあるので、ObservableソースはSubject
であることが言えます。
ファクトリメソッドがObservableソース
続いて、ファクトリメソッドがObservableソースとなる例です。
//ファクトリメソッドから値が送出されている
//→ファクトリメソッドがObservableソース
Observable.Range(1, 10)
.Subscribe(n => { /*何らかの処理*/ });
この場合、Observable.Range
からObservableシーケンスの値が送出されていることは言うまでもなく、Observableソースはこのファクトリメソッドということが分かります。
ObservableソースのHotとCold
ObservableソースにもHotとColdがあると書きました。
察しが良い方は勘付くかもしれませんが、上の例で紹介した__Subject<T>
がObservableソースのものがHot__で、__ファクトリメソッドがObservableソースのものはCold__です。理由は、Subject<T>
は複数の購読者を管理できるのでHot、ファクトリメソッドはSubject<T>
を使っていない1ので複数の購読者を管理できずCold、ただそれだけです。
では、ObservableソースのHotとColdが違うとどう影響してくるのかを説明します。
HotなObservableソース
HotなObservableソースとは、__Subject<T>
が発生源となるObservableソース__のことを指します。
HotなObservableソースには次のような特徴があります。
- 何度
Subscribe
しても同一のObservableシーケンス - シーケンスの途中から
Subscribe
した人には途中からしか流れてこない
これはまぁ、想像通りというか、詳しい説明はいらないと思います。
ところが、次のColdなObservableソースの場合、注意が必要です。
ColdなObservableソース
ColdなObservableソースとは、HotではないすべてのObservableソースを指します。
Rxに用意されているファクトリメソッドから作られるIObservable
は、そのほとんどが内部でSubject<T>
を使用していないため、ColdなObservableソースとして動作します。
ColdなObservableソースには次のような特徴があります。
Subscribe
するたびに新しいObservableシーケンス- __購読者一人ひとりに対して専用のObservableシーケンス__なので、途中からという概念が存在しない
HotなObservableソースと決定的に違う点は、下図にように「Subscribe
するたびに新しいObservableシーケンスが生成される」という点です。
Hotの場合と違って、ColdなObservableソースはSubscribe
するたびに、新しくObservableシーケンスが生成されています。一体なぜこのような動作になるのでしょうか?
答えは、「内部にSubject
を使っていないから」となります。Subject
を使わないということは、購読者に対して「直接」値を発行することを意味します。ちょっと何を言っているのかわからないと思うので、コードで説明します。ここでは、Observable.Range
ファクトリメソッドの動作を簡易的に実装した独自のMyObservable.MyRange
ファクトリメソッドを作成してみました。
public static class MyObservable
{
public static IObservable<int> MyRange(int start, int end) =>
new MyRange(start, end);
}
class MyRange : IObservable<int>
{
private int start, end;
public MyRange(int start, int end)
{
this.start = start;
this.end = end;
}
public IDisposable Subscribe(IObserver<int> observer)
{
//Subscribeしてくれた人に直接値を送出する
for (int i = start; i <= end; i++)
{
observer.OnNext(i);
}
observer.OnCompleted();
return new Subscription();
}
class Subscription : IDisposable
{
public void Dispose()
{
//SubscribeしたらすぐOnCompletedになるので何もしない
}
}
}
Subject
を使わずに値を発行するには、Subscribe
の引数に渡されたIObserver
(購読者)に対して__直接OnNext
するしかない__のです。必然的に、このObservableシーケンスは、__購読してきたIObserver
専用__となってしまうのです。したがって、複数回Subscribe
されれば、その分新しくObservableシーケンスを生成せざるを得なくなります。これが、上記の図の説明となります。
そして当然、このような仕様を理解していないと、想定とはまったく異なる動作をしてしまう危険性があります。例えば、以下のように1つのObservable.Interval
を時間差で複数回Subscribe
した場合。
//0.1秒ごとに0,1,2,…とインクリメントする値を発行するColdなObservableソース
IObservable<long> observableTimer = Observable.Interval(TimeSpan.FromSeconds(0.1));
observableTimer.Subscribe(n => Debug.Log(n));
await Task.Delay(1000); //1秒差でColdなObservableソースを2回購読
observableTimer.Subscribe(n => Debug.Log(n));
「同じ変数observableTimer
を2回Subscribe
してるんだから、Observableシーケンスは同じで、2個めのSubscribe
には10,11,12,…って値が来るんでしょ」と思っていたら、__大間違い__です。
Observable.Interval
はColdなObservableソースなので、Subscribe
するたびに新しく専用のObservableシーケンスが生成されるので、両方とも0,1,2,…と値が発行されます。
もし、前者のような動作をしてほしい場合はHot変換という作業が必要になります。
このように、ObservableソースのHot-Coldを意識することは非常に重要で、これらの違いを理解しないままRxを使い込むと、バグの原因となりますので十分に注意が必要です。
まとめると…
-
Subject
から発生するObservableソースはHot - ファクトリメソッドを始めとした、
Subject
以外から発生するObservableソースはCold - ColdなObservableソースは、
Subscribe
するたびに新しく専用のObservableシーケンスが生成される - 想定する動作に合わせて適切にHot変換が必要
さいごに
この記事では、IObservable
のHot-ColdとObservableソースのHot-Coldを分けて記述しましたが、本質的には(おそらく)同じで、Subject
を使っているか、使っていないか、それだけの違いになります。
ただその小さな違いが、大きな違い(バグ)を生み出す原因になるので、十分に注意してRxを使うことが必要です。
ご意見ご指摘等ありましたら是非コメント宜しくお願いします!
[2021/4/2追記]
Hot変換の記事を書きましたので、ぜひこちらも参照ください!
参考記事
-
一部例外もあるようです。詳しくは@acple@githubさんのこちらの記事を参照ください。 ↩