C#にはLINQという素晴らしい機能があります。平たく言えば(LINQ to Objectsを念頭に説明するならば)、配列の内容を自由に変形させるための機能で、これにより、配列から特定のデータを抽出したり、並び替えたり、変換したりすることが非常に容易にできるようになりました。
そして、Reactive Extensions (Rx)というライブラリが生まれました。LINQ to Objectsが配列を変形させるものだったのに対して、Rxはオブジェクトの時系列での変化をLINQで変形させてしまおうという発想のものでした。これまた革命的で、いわゆるC#の「イベント」という機能をレガシーに追いやったものでした。
しかし、Rxでの配列の取り扱いはイマイチでした。配列の中身だって時系列で変化するので、それをReactive(反応的)に変形して観測したいというニーズはあるはずです。RxにはReactiveCollectionクラスがあるものの、実際はINotifyCollectionChangedインターフェースで発生するイベントをRxでラッピングした程度のもので、LINQ to Objectsの感覚でコレクションを変形し、それをそのままObservableCollectionの感覚で観測するということまではできませんでした。
例えば、あるObservableCollectionの要素のうち偶数の要素のみを取り出したObservableCollectionを欲しくなったとします。元のObservableCollectionに3が追加されても変形後のObservableCollectionでは何もイベントが発生されませんが、4が追加されたらAddイベントが発生するようなものです。そうすると以下のようなコードを書きたくなりますが、これではうまく動きません。
var list = new ObservableCollection<int>();
var filtered = list
.Where(p => p % 2 == 0)
.ToReadOnlyObservableCollection();
そもそもこのコードはToReadOnlyObservableCollectionのところでコンパイルエラーになります。それもそのはず、Whereメソッドは単にLINQ to Objectsの機能に過ぎないのです。
そこで登場したのがDynamicDataです。まさに、LINQ to Objectsの感覚でコレクションを変形し、その結果をそのままReadOnlyObservableCollectionに投影してくれるのです。Rxの痒い所に手が届く、もしくはLINQ to ObjectsとRxを統合させた最終形態とも言うべきライブラリです。今回はこれを掘り進めていきます。
DynamicDataの基本
コレクション型
DynamicDataには2つのコレクション型があります。
var list = new SourceList<string>(); var chache = new SourceCache<string, int>(p => p.Length);
SourceListはただのリストです。一方でSourceCacheはいわゆるHashSetのようなもので、キーの値が同じ要素は最大で1つまでしかコレクション内には存在しません。キーはコンストラクタでキーセレクタを与えることにより定義します。
要素の編集
要素の追加、削除などは直感的な名前の拡張メソッドを介して行うことができます。
list.Add("abc");
list.Remove("abc");
cache.AddOrUpdate("abc");
cache.Remove("abc");
Add系/Remove系はこれで問題なくできますが、あらゆるコレクション操作がここでできるわけではありません。実はこのような使い方はDynamicDataの真髄ではありません。 DynamicDataにはEditメソッドがあります。
list.Edit(l => {
l.AddRange(["abc", "def", "ghi"]);
l[0] = "ABC";
});
こうすることで、Editメソッド内のデリゲートの処理が終わった後にまとめて変更通知を行ってくれるようになるのです。これにより無駄な変更通知呼び出しが発生せず、負荷の削減につながります。AddやRemoveなどの拡張メソッドは、実はこのEditを呼び出しているだけに過ぎないのです。
要素の閲覧
単純に配列のように要素を読み取りたかったとしても、実はSourceListやSourceCacheはIReadOnlyCollection<T>やIEnumerable<T>を実装していません。Itemsプロパティを経由して閲覧する必要があります。
foreach(var item in list.Items) {
Console.WriteLine(item);
}
コレクションの変更監視
ついに来ました。やりたかったことです。早速ですがこんな感じになります。
ReadOnlyObservableCollection<string> output;
var list = new SourceList<string>();
list.Connect()
.Transform(p => p.ToUpper())
.Bind(out output)
.Subscribe();
まず、DynamicDataでは、IObservable<IChangeSet<T>>型に対して様々な演算を与えていきます。IChangeSet<T>型はコレクションの変化(追加とか削除とか)を含んだ内容で、これをRxで流すことで時系列を追従できるようにしているのです。SourceList / SourceCacheからこのIObservable<IChangeSet<T>>を作るメソッドがConnect()になります。
続いて、ある要素を別の形に変換するメソッドがTransformになります。LINQ to ObjectsでのSelectメソッドに相当します。Connectが吐き出してくる方がIObservableなので、Rxと被らないようなメソッド名にせざるを得なかったのでしょうね。
Bindメソッドで、ReadOnlyObservableCollectionにその変形後のコレクションをバインディングしています。これにより、ReadOnlyObservableCollectionとして変形後のコレクションの変更通知を受けられるようになります。
DynamicDataのメソッド一覧
DynamicDataでは、LINQ to Objectsとは異なる名前のメソッドでコレクションを変形させていくことになります。わかりやすいように対応表を作りました。
変換系演算子
| LINQ | DynamicData | 説明 |
|---|---|---|
| Select | Transform | 要素の変換 |
| SelectMany | TransformMany | 複数のコレクションを単一のコレクションに展開する |
| Where | Filter | 要素のフィルタリング |
| OrderBy / OrderByDescending | Sort | 並び替え。SortメソッドにはComparerを引き渡す必要があるが、便利なSortExpressionComparer<T>が用意されている。 |
| ThenBy / ThenByDescending | Sort | ComparerでThenByの機能を実現する必要がある(SortExpressionComparerで対応できる)。 |
特にポイントになるのは、例えばFilterやSortの引数にはIObservableな条件を渡せるというところです。例えば、以下のようなコードを書くことで、ソート条件をReactiveに変更することができ、ソート条件が変わって並び変わったタイミングでReadOnlyObservableCollectionの変更通知も発火します。
public record Person(string Name, string Pronunciation, int Age, double TallCentimeter);
var personComparer = new Subject<IComparer<Person>>();
ReadOnlyObservableCollection<Person> output;
var list = new SourceList<Person>();
list.Connect()
.Sort(personComparer)
.Bind(out output)
.Subscribe();
personComparer.OnNext(SortExpressionComparer<Person>.Ascending(p => p.Age));
list.AddRange([
new Person("宮内 れんげ", "みやうち れんげ", 6, 116.0),
new Person("一条 ほたる", "いちじょう ほたる", 12, 164.0),
new Person("越谷 夏海", "こしがや なつみ", 13, 155.0),
new Person("越谷 小鞠", "こしがや こまり", 14, 140.0),
]);
foreach(var person in output) {
Console.WriteLine(person);
}
// ソート条件を変更する(ここでコレクションの変更通知が発火する)
personComparer.OnNext(SortExpressionComparer<Person>.Ascending(p => p.TallCentimeter));
foreach(var person in output) {
Console.WriteLine(person);
}
ちなみに、DynamicData.Alias名前空間には、Select / SelectMany / Whereという名前でTransform / TransformMany / Filterをラッピングした拡張メソッドが用意されています。LINQ to Objectsとなどと混同しそうなので見やすくなるのかは少し懐疑的ですが、必要に応じて活用しても良いでしょう。
グルーピング / 連結演算子
| LINQ | DynamicData | 説明 |
|---|---|---|
| GroupBy | GroupOn | キーを指定してグループ化する |
| Join | InnerJoin | SourceCache専用。いわゆる内部結合。 |
| - | InnerJoinMany | SourceCache専用。いわゆる内部結合だが、同じキーを持つ要素をグルーピングする。 |
| - | LeftJoin | SourceCache専用。いわゆる左外部結合。 |
| GroupJoin | LeftJoinMany | SourceCache専用。いわゆる左外部結合だが、同じキーを持つ要素をグルーピングする。 |
| - | RightJoin / RightJoinMany | SourceCache専用。いわゆる右外部結合。 |
| - | FullJoin / FullJoinMany | SourceCache専用。いわゆる完全外部結合。 |
そもそものLINQ to ObjectsのGroupJoinは結合とグルーピングを同時に行うようなメソッドで、SQLには同等なものは存在しません。DynamicDataでは、SQLに倣って内部結合と3つの外部結合を実装していますが、LINQ to Objectsとも対応が取れるようにグルーピング機能を備えた~Manyメソッドも用意してくれているようです。例えば、GroupJoinっぽい使い方をするとしたら以下のようなコードになるでしょう。
public record Family(int Id, string Name);
public record Person(int Id, string Name, string Pronunciation, int Age, double TallCentimeter, int familyId);
var families = new SourceCache<Family, int>(f => f.Id);
var people = new SourceCache<Person, int>(p => p.Id);
ReadOnlyObservableCollection<(Family, Person[])> output;
families.Connect()
.LeftJoinMany(people.Connect(), p => p.familyId, (f, g) => (family: f, people: g.Items.ToArray()))
.Bind(out output)
.Subscribe();
families.AddOrUpdate(new Family(1, "宮内家"));
families.AddOrUpdate(new Family(2, "一条家"));
families.AddOrUpdate(new Family(3, "越谷家"));
families.AddOrUpdate(new Family(4, "富士宮家"));
people.AddOrUpdate(new Person(1, "宮内 れんげ", "みやうち れんげ", 6, 116.0, 1));
people.AddOrUpdate(new Person(2, "一条 ほたる", "いちじょう ほたる", 12, 164.0, 2));
people.AddOrUpdate(new Person(3, "越谷 夏海", "こしがや なつみ", 13, 155.0, 3));
people.AddOrUpdate(new Person(4, "越谷 小鞠", "こしがや こまり", 14, 140.0, 3));
foreach((var family, var persons) in output) {
Console.WriteLine($"{family}, People: [{string.Join(", ", persons.Select(p => p.Name))}]");
}
ちなみに、説明に書いてあるようにJoin系メソッドはSourceCacheでしか使えません。
Merge演算子
| LINQ | DynamicData | 説明 |
|---|---|---|
| - | MergeChangeSets | 複数のコレクションを一つのコレクションにマージします。 |
| - | MergeManyChangeSets | コレクションの内部のコレクションをマージし、単一のコレクションとして扱います。(SelectManyのような挙動。) |
LINQ to Objectsには存在しないMerge系メソッドです(Rxには存在します)。複数のコレクションを連結して一つのコレクションに仕立て上げます。ですが、単純に連結したものというより、Merge元のコレクションで行ったAdd / Remove / Moveなどの操作がMerge後のコレクションでも行われるみたいなイメージになります。SourceListの場合はその理解でそんなに困らないのですが、SourceCacheの場合は同じキーの要素が1つしか存在できないので、少し変わった挙動をします。
var miyauchi = new SourceCache<Person, int>(p => p.Id);
var ichijo = new SourceCache<Person, int>(p => p.Id);
var koshigaya = new SourceCache<Person, int>(p => p.Id);
ReadOnlyObservableCollection<Person> output;
miyauchi.Connect()
.MergeChangeSets(ichijo.Connect())
.MergeChangeSets(koshigaya.Connect())
.Bind(out output)
.Subscribe();
((INotifyCollectionChanged)output).CollectionChanged += (s, e) => ShowCollectionChanged(e);
koshigaya.AddOrUpdate(new Person(1, "越谷 夏海", "こしがや なつみ", 13, 155.0));
koshigaya.AddOrUpdate(new Person(2, "越谷 小鞠", "こしがや こまり", 14, 140.0));
miyauchi.AddOrUpdate(new Person(3, "宮内 れんげ", "みやうち れんげ", 6, 116.0));
ichijo.AddOrUpdate(new Person(4, "一条 ほたる", "いちじょう ほたる", 12, 164.0));
// 追加した順に4人が表示される
foreach(var person in output) {
Console.WriteLine(person);
}
koshigaya.AddOrUpdate(ichijo.Items[0]); // 一条ほたるを越谷小鞠の後に追加
// 4人しか表示されない(重複しているインスタンスは除外される)
foreach(var person in output) {
Console.WriteLine(person);
}
ichijo.RemoveKey(4); // 一条ほたるを削除
// 4人が表示される(越谷家に一条ほたるが残っているため)
foreach(var person in output) {
Console.WriteLine(person);
}
koshigaya.RemoveKey(4); // 一条ほたるを削除
// 3人が表示される(一条ほたるがどこにも残っていないため)
foreach(var person in output) {
Console.WriteLine(person);
}
このように、同じキーの要素が別々のコレクションに複数あると、Merge後のデータでは先に入っていたデータが勝ちます(後から追加したデータは追加されません)。しかし、先に入っていたデータが削除された場合は、後か入ったデータに置き換えられたという形で残ります。すべてから削除されたタイミングで、Merge後のデータからも削除されることになります。
MergeManyChangeSetsはSelectに対するSelectManyのような挙動です。
集計系演算子
| LINQ | DynamicData | 説明 |
|---|---|---|
| Count | Count | コレクション内の要素数をIObservable<int>で取得します。条件を指定するデリゲートは渡せないので、必要に応じてFilterメソッドを事前に呼びましょう。 |
| Any | IsNotEmpty | 要素数が空ではないかをIObservable<bool>で取得します。これもCount同様に条件は指定できません。 |
| - | IsEmpty | IsNotEmptyの逆です。 |
| All | - | DynamicDataにはAll()メソッドに相当するものはないです(条件を渡すものが無いので)。Filterで補集合の条件を渡してIsEmptyを呼ぶのが良いでしょう。 |
| Max | Maximum | コレクション内の最大値を取得します。 |
| Min | Minimum | Maximumと同様です。 |
| Sum | Sum | コレクションの合計値を取得します。 |
| Average | Avg | コレクションの平均値を取得します。 |
| - | StdDev | コレクションの標準偏差を取得します。 |
Count / SumメソッドはLINQのメソッドと名前がかぶっているので、呼び出し時に間違ったほうを呼ばないように注意してください。
仮想化演算子
DynamicDataには仮想化という概念があります。大きなデータの一部を画面に表示したいなどの状況で使うことを想定して、コレクションから一部分を抜き出してきて操作することを目的としたものですが、LINQで言うところのSkip / Take / Firstなどに近い機能を提供しますが、目的が少し違うため、完全に同じものを提供するわけではありません。
| LINQ | DynamicData | 説明 |
|---|---|---|
| Skip / Take | Virtualise | コレクションの一部区間を切り出してくるメソッド。 |
| - | Page | コレクションを一定サイズの区間に切り分けて、指定したページ番号のものを引き出してくるメソッド。 |
| Take | Top | 先頭から指定した個数を切り出す。 |
見ての通りVirtualiseとスペルがイギリス訛りなので注意して下さい。
var list = new SourceList<int>();
list.Connect()
.Virtualise(Observable.Return(new VirtualRequest(20, 10))) // 20個目から10個を切り出す
.Bind(out var output)
.Subscribe();
// 0~99の計100個を追加
foreach(var num in Enumerable.Range(0, 100))
list.Add(num);
// 20~29のみが表示される
foreach(var num in output)
Console.WriteLine(num);
このようにVirtuariseメソッドでは配列の区間を切り出すことになります。LINQで言うところのSkip / Takeの組み合わせとなります。
var list = new SourceList<int>();
list.Connect()
.Page(Observable.Return(new PageRequest(3, 10))) // 1ページ当たり10件、3ページ目(20~29)
.Bind(out var output)
.Subscribe();
// 0~99の計100個を追加
foreach(var num in Enumerable.Range(0, 100))
list.Add(num);
// 20~29のみが表示される
foreach(var num in output)
Console.WriteLine(num);
Pageメソッドは一定区間ごとにコレクションを区切ります。ページ番号は0スタートではなく1スタートであることには注意が必要です。
var list = new SourceList<int>();
list.Connect()
.Top(10) // 先頭10件
.Bind(out var output)
.Subscribe();
// 0~99の計100個を追加
foreach(var num in Enumerable.Range(0, 100))
list.Add(num);
// 0~9のみが表示される
foreach(var num in output)
Console.WriteLine(num);
Topはシンプルで先頭N件を引っ張ってくるだけですね。
最終手段
対応するメソッドが無い!困った!となった場合の最終手段があります。QueryWhenChangedです。
var list = new SourceList<int>();
list.Connect()
.QueryWhenChanged(p => p.LastOrDefault()) // IObservable<int>としてLastOrDefaultの値を返す
.DistinctUntilChanged()
.Subscribe(Console.WriteLine);
//((INotifyCollectionChanged)output).CollectionChanged += (s, e) => ShowCollectionChanged(e);
// 0~99の計100個を追加
foreach(var num in Enumerable.Range(0, 100))
list.Add(num);
コレクションが変化するたびにコレクション全体が流れてきますので、良く知るLINQメソッドをそのまま適用することができます。ただし、都度全体が流れてきてそれを計算することになるので、気を付けないと膨大な計算が必要になってしまうことがあります。
INotifyCollectionChangedからの変換
冒頭で説明した通り、DynamicDataでは標準でSourceList<T>とSourceCache<T, TKey>という2種類のコレクションが標準で用意されています。しかし、世の中にはそれ以外の変更通知コレクション型はたくさんありますので、それらが使えないと誰もこのライブラリを使いたいとは思えません。
安心してください。その時のために、ToObservableChangeSet()があります。
var list = new ObservableCollection<int>();
list.ToObservableChangeSet()
.Top(10) // 先頭10件
.Bind(out var output)
.Subscribe();
ObservableCollectionは見ての通りそのまま使えます(それ用のオーバーロードが用意されています)。ObservableCollectionを実装していないものを使用する場合は以下のようになります。
var list = new Livet.StatefulModel.ObservableSynchronizedCollection<int>();
list.ToObservableChangeSet<ObservableSynchronizedCollection<int>, int>()
.Top(10) // 先頭10件
.Bind(out var output)
.Subscribe();
オーバーロードでは、渡すコレクション型と要素の型を渡す必要があります。渡すコレクションの制約は
where TCollection : INotifyCollectionChanged, IEnumerable<T>
となっていますので、たいていの変更通知コレクションは使えるかと思います。
-------------------------------
これくらいが使えればある程度は使いこなせるのではないでしょうか。
公式ドキュメントにはこの記事では言及できていない機能もありますし、私自身もまだ充分に使いこなせているとは言えないレベルだとは思いますが、ひとまずこれを備忘録に使い込んでみようと思っています。
