2017年11月26日日曜日

ListView Extensions ver.1.0.0リリース

さて、ついに正式リリースしましたListView Extensions ver.1.0.0。

ListView Extensions - Nuget

beta4からはほとんど変わっていないはずです。そのままだと使いにくいWPFのListViewを使いやすくすることを目的に、View、ViewModel、Modelの全領域で便利な機能を提供するライブラリです。

ListView Extensionsの使用例はこんな感じです。


見た目はただのWPFのListViewに見えますが、よくよく見ると「Name」ヘッダーの上部にソートしていることを表す「▲」が表示されています。ListViewと言えばこのように名前、読み、年齢など様々なプロパティを持つデータをソートするときに使い、さらに、ユーザーが好きなプロパティでソートする機能を提供するようなものをイメージするはずです。Windowsのエクスプローラーだってそうですよね。ファイル名でソートしたり、更新日時でソートしたり、ファイルの種類でソートしたりすることがあるはずです。
ただ、WPFは標準でそのような機能をサポートしていません。自分でそれを実装しようとするととてつもなく手間がかかりますので、それをやってくれるライブラリが欲しくなります。そこで生まれたのがListView Extensionsというわけです。

せっかく正式リリースにしたので使い方をなぞってみます。

1. 使い方

実はbeta-1をリリースしたときにもある程度書いてあります。


ViewにはWPF標準のListView、ViewModelにはListViewViewModel、ModelにはSortableObservableCollectionを使うのが基本になります。それ以外の使い方はこのライブラリは想定していません。
SortableObservableCollectionは名前の通りソート可能コレクションです。必ずしもソートされているとは限らず、Sortメソッドを呼び出した時点でその時に指定したプロパティ名でソートします。いかにもListViewのソートに沿った機能ですね。

さて、Viewにはあらゆる機能が凝縮されていますので、これを見るのが手っ取り早いです。
まずはXAMLにListViewViewModelの名前空間を追加しておきましょう。
xmlns:lv="http://schemas.eh500-kintarou.com/ListViewExtensions"
Viewに対応するXAMLはこんな感じです。
<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <lv:SortingConditionConverter x:Key="ConditionToDirectionConverter" />
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Name}">
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Name" >
                    <lv:SortedHeader Content="Name" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Name'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="150" DisplayMemberBinding="{Binding Pronunciation}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Pronunciation" >
                    <lv:SortedHeader Content="Pronunciation" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Pronunciation'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="70" DisplayMemberBinding="{Binding Age}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Age" >
                    <lv:SortedHeader Content="Age" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Age'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Birthday}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Birthday" >
                    <lv:SortedHeader Content="Birthday" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Birthday'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="100" DisplayMemberBinding="{Binding Height}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Height_cm" >
                    <lv:SortedHeader Content="Height" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Height_cm'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
        </GridView>
    </ListView.View>
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem">
            <Setter Property="ContextMenu">
                <Setter.Value>
                    <ContextMenu>
                        <MenuItem Header="Increment the age" Command="{Binding IncrementAgeCommand}" />
                        <MenuItem Header="Decrement the age" Command="{Binding DecrementAgeCommand}" />
                    </ContextMenu>
                </Setter.Value>
            </Setter>
            <!--<Setter Property="lv:DoubleClickBehavior.Command" Value="{Binding DoubleClickCommand}" />-->
            <Setter Property="lv:DoubleClickBehavior.MethodTarget" Value="{Binding}" />
            <Setter Property="lv:DoubleClickBehavior.MethodName" Value="DoubleClicked" />
        </Style>
    </ListView.ItemContainerStyle>
    <i:Interaction.Triggers>
        <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="SelectedItemsMirroring" >
            <lv:ListViewSelectedItemsAction Source="{Binding People.SelectedItemsSetter}" />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
</ListView>

ちなみに、「People」がViewModelでのListViewViewModelのインスタンスになります。

順に機能を見ていきます。

a. ソート

ListViewの機能のよく使う機能としてリストのソートがありますが、WPFでは標準でサポートされていません。それを行います。

ヘッダーをクリックして並び替える

ListViewのヘッダーをクリックするとCommandが発生します。ですので、そこでソートをする機能を呼び出します。
/// <summary>
/// プロパティ名をパラメーターに与えてソートするコマンド
/// </summary>
public ICommand SortByPropertyCommand { get; }
ListViewViewModelにはプロパティ名でソートするコマンドが備わっています。ですので、ヘッダーのクリックに合わせてこのコマンドを呼ぶようにしましょう。プロパティ名はパラメーターで渡してあげる必要があります。

ヘッダーにソートを示す▲▼を表示する

これはWPFの標準の機能がないので、ListView Extensionsが提供するSortedHeaderコントロールをGridViewColumnHeaderContentとして使います。GridViewColumnHeaderはContentControlを継承しているので、任意のコントロールをContent表示することができるのです。

SortedHeaderコントロールもContentControlクラスを継承しており、1個だけプロパティが追加されています。
/// <summary>
/// ソートの方向
/// </summary>
public SortingDirection SortingDirection
{
    get { return (SortingDirection)GetValue(SortingDirectionProperty); }
    set { SetValue(SortingDirectionProperty, value); }
}
SortingDirectionもListView Extensionsが提供するEnumで、Ascending、Descending、Noneの3つの状態があります。これに合わせて▲▼を表示してくれるようになります。

しかし、ListViewViewModelはソート状態をソート方向、プロパティ名の2つの値で保持しています。それらをひっくるめて、SortingConditionというプロパティがあります。
/// <summary>
/// 現在のソート条件
/// </summary>
public SortingCondition SortingCondition
{
    get { ... }
    private set
    {
        ...
    }
}
ですので、これから各ヘッダーに対応したSortingDirectionに変換するValueConverterが必要になります。

ご安心ください。そのConverterもListView Extensionsには装備されています。
SortingConditionConverterがそれで、パラメーターにプロパティ名を与えてやればそのヘッダーのSortingDirectionを返してくれます。

というわけでこのようなXAMLが出来上がります。
<GridViewColumn Width="120" DisplayMemberBinding="{Binding Name}">
    <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Name" >
        <lv:SortedHeader Content="Name" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Name'}" />
    </GridViewColumnHeader>
</GridViewColumn>
DisplayMemberBindingでプロパティ名、ヘッダーをクリックされたときのCommandParameterでプロパティ名、SortingConditionをSortingDirectionに変換するValueConverterのパラメーターでプロパティ名が必要で、計3回書く必要があります。SortedHeaderのContentもプロパティ名になる場合は4回出てきます。コピペミス等に気を付けてください。

b. アイテムのダブルクリック

これがなかなかやろうとすると難しいところです。
左クリックは通常選択程度の意味を成しませんからそんなに自分でコントロールしようと思うことはないですし、右クリックはたいていコンテキストメニューですからContextMenuプロパティにメニューを設定してやればいいです。しかし、ダブルクリックはアプリケーションを実行したり、編集画面を出したりと何かしらの処理を行う系になります。すなわち、ViewModelのメソッドやコマンドを呼び出したい何かになるわけです。

そこで、DoubleClickBehaviorです。この添付ビヘイビアはメソッド直接バインディングとコマンドのバインディングの両方を用意しています。Viewを見ればわかるかと思います。メソッドやコマンドは当然ですがアイテムのViewModelに書きます。

c. 選択

これもなかなか厄介です。ListViewのSelectedItemSelectedIndexは単一選択しか想定されていないうえに、SelectedItemsはgetのみでバインディングしようとするとうまくいきません。
ListViewItemはIsSelectedプロパティを持っているからこれを使えばいいのではと思うとそれも違います。ListViewItemは表示していない部分のインスタンスは消えてしまいますので、選択されているアイテムが表示範囲外に行くとIsSelectedは期待通りに動作してくれません。

実はListViewでの選択はこのSelectedItemsプロパティがすべて握っていて、このリストに対してAddやRemoveなどの編集操作をすると選択アイテムがそれに応じて増減します。さらに、このリストはICollectionChangedも実装していて、これで選択の変化を捉えることも可能です。
これはどう見てもコードビハインドで使うためのメソッドです。ですが、何とかそれをViewModelで使えるようにしました。手順としては次の通りになります。

SelectedItemsへの参照をViewModelに渡す

まずはこれを行う必要があります。これはListView Extensionsが提供しているListViewSelectedItemsActionを使います。ListViewViewModelはSelectedItemsSetterプロパティを持っており、ListViewSelectedItemsActionはこれにSelectedItemsの参照を渡してくれます。
<i:Interaction.Triggers>
    <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="SelectedItemsMirroring" >
        <lv:ListViewSelectedItemsAction Source="{Binding People.SelectedItemsSetter}" />
    </l:InteractionMessageTrigger>
</i:Interaction.Triggers>
今回はLivetを使っているので、LivetのInteractionMessageTriggerを使っています。別のMVVMフレームワークでも、何かしらViewModelからViewのアクションを動かす機構を持っているはずなのでそれを使ってください。
タイミングは、例えばContentRenderedなどのListViewインスタンス生成直後に呼ばれるイベントのタイミングでコピーをすべきでしょう。そのタイミングでViewModelからViewにListViewSelectedItemsActionを動かすように呼び出します。
Messenger.Raise(new InteractionMessage("SelectedItemsMirroring"));

選択の読み書き

SelectedItemsは非ジェネリックのコレクションで非常に使いにくいです。ですので、ListViewViewModelのSelectedItemsSetterはSet-Onlyプロパティにしており、読み取りはできないようにしています。

選択の状態を読み取るには、SelectedItemsSetterをミラーリングしているSelectedItemsプロパティを使ってください。
/// <summary>
/// 選択中の項目をIListじゃ使いにくいから使いやすくミラーリングしたクラス
/// </summary>
public ReadOnlyObservableCollection<TViewModel> SelectedItems
{
    get
    {
        ...
    }
}
逆に、選択をするにはViewModelに用意されたメソッドを使います。
/// <summary>
/// 指定したアイテムを選択します
/// </summary>
/// <param name="item">選択するアイテム</param>
public void SelectItem(TViewModel item);

/// <summary>
/// 指定したインデックスの要素を選択します。
/// </summary>
/// <param name="index">選択するインデックス</param>
public void SelectAt(int index);

/// <summary>
/// 指定した要素の選択を解除します
/// </summary>
/// <param name="item">選択を解除する要素</param>
public void UnselectItem(TViewModel item);

/// <summary>
/// 指定したインデックスの要素の選択を解除します
/// </summary>
/// <param name="index">インデックス</param>
public void UnselectAt(int index);

/// <summary>
/// 選択されているアイテムかを調べます
/// </summary>
/// <param name="item">選択されているかどうか調べたい要素</param>
/// <returns>選択されていればtrue</returns>
public bool IsSelectedItem(TViewModel item);

/// <summary>
/// 選択されているアイテムかを調べます
/// </summary>
/// <param name="item">選択されているかどうか調べたい要素</param>
/// <returns>選択されていればtrue</returns>
public bool IsSelectedAt(int index);

/// <summary>
/// 指定したアイテムの選択を反転します
/// </summary>
/// <param name="item">選択を反転するアイテム</param>
public void ToggleItemSelection(TViewModel item);

/// <summary>
/// 指定したインデックスの要素の選択を反転します。
/// </summary>
/// <param name="index">インデックス</param>
public void ToggleItemSelectionAt(int index);
まあ、メソッドの使い方は見ればわかるでしょう。

選択に対する操作

さらにもう一歩先の機能として、選択に対する操作も用意しています。
リスト系のソフトだと、選択しているアイテムを削除したり、選択しているアイテムを上や下へ動かすという用途もたくさん出てくるでしょう。ListViewViewModelにはそのような操作をするコマンドも用意されています。
/// <summary>
/// 選択中の項目を削除するコマンド
/// </summary>
public ICommand RemoveSelectedItemCommand { get; }

/// <summary>
/// 選択中の項目を上へ移動するコマンド
/// </summary>
public ICommand MoveUpSelectedItemCommand { get; }

/// <summary>
/// 選択中の項目を上へ移動するコマンド
/// </summary>
public ICommand MoveDownSelectedItemCommand { get; }

/// <summary>
/// 選択を反転するコマンド
/// </summary>
public ICommand ToggleSelectionCommand { get; }
適当なボタンなどを作って、必要に応じてこのコマンドを呼び出してあげれば良いでしょう。

選択に関する制約

ListView.SelectedItemsプロパティを見てもらっても分かりますが、ListViewは「選択されているアイテム」で選択を管理しており、例えばインデックスなどでは管理されていません。これは何を意味しているかというと、ListViewの要素として同じインスタンスを複数登録すると選択が正常に動作しないということです。

ですが、通常はListViewの要素はアイテムのViewModelになります。ViewModelのインスタンスをあらゆるアイテムに対して生成しておけば同じインスタンスが複数登録されることはないでしょう。

これは、ListViewViewModelのコンストラクタで制御します。
public ListViewViewModel(ISortableObservableCollection<TModel> source, Func<TModel, TViewModel> converter, Dispatcher dispatcher) { ... }
第2引数のconverterは、Modelでのコレクションの要素をViewModelに変換するためのデリゲートです。ここでは、必ず
person => new PersonViewModel(person)

のように新しいインスタンスを作るように実装しておけばいいだけです。

d. スレッド安全性

ListView Extensionsはスレッドセーフに設計されています。
複数スレッドからの同時アクセスなどがあってもデータの整合性は維持できます。ただ、固有のデッドロックの問題等もありますので、十分注意して使ってください。
そのあたりは、beta4のときの記事に詳しく書いてあります。

2. サンプルプログラム

さて、機能の概略は説明しましたが、実際のプログラムは見てみないとわかりにくいところもあるかと思います。ですので、細かい部分はサンプルプログラムを参考にして使ってください。

ListViewExtensionsSample ver.1.0.0

 実はbeta1の時に公開したサンプルプログラムとほとんど変わっていません。ただ、Model側にてスレッド安全性を確かめるための激しいプログラムにしています。
void AddLoop()
{
    Task.Run(async () => {
        while(true) {
            await Task.Delay(TimeSpan.FromSeconds(1));
            
            Parallel.ForEach(Enumerable.Range(0, 200), p => {
                if(p % 2 == 0 && People.Count > 0) {
                    lock(People.SyncRoot)
                        People.RemoveAt(random.Next(People.Count));
                } else
                    Add();
            });                
        }
    });
}

1秒おきに複数スレッドから100回のデータの追加と100回のデータ削除を行っています。もしもスレッド安全性に問題があれば、このコードをしばらく実行していれば不具合が起きるでしょう。

3. 最後に

(少なくとも私としては)結構使いやすいライブラリになったのではないかなと自負しています。WPFのListViewで不便さを感じている皆さん、ぜひご活用ください!

定義に従って行列式を求める

ここでいう行列式の定義とはこれです。 \[{\rm det}A=\sum_{\sigma \in S_n} {\rm sgn}(\sigma)\prod_{i=1}^n a_{i \sigma(i)}\] ここで言う$\sigma$は置換で、すべての置換の総和で行列式を定義したものですね。ちなみに${\rm sgn}$は偶置換、奇置換それぞれで1,-1を取る関数です。詳しくは線形代数の教科書でも見てください。

この定義、何が恐ろしいって計算量が${\rm O}(n \times n!)$なんですよね。すべての置換パターンで積を計算しなければならないので、n個の数の順列分の計算量が生まれるというわけです。行列の次数が上がるにつれて指数オーダーよりも激しく計算量が増えていきます。
ただ、それぞれの置換パターンは独立していますので並列処理も可能です。ですので、ベンチマークに使いやすいわけです。

主たる課題は下記のとおりです。
  • n次の行列のときnつの数の積を取るためオーバーフローしやすい(=多倍長整数型などを作る必要がある)
  • すべての置換パターンを求める必要がある
  • 並列処理の効率化(クリティカルセクション等々)
大学1年生のときC言語で計算するプログラムを書いて遊んでいましたが、なかなか大変でした。でも、C#は様々なライブラリがあり、並列処理も簡単に書けます。というわけで、C#で書いてみました。
コンピューターでの小数には「精度」が伴ってしまいますので、行列の要素は整数に限定してプログラムを作っていきます。

多倍長整数

C#にはintやlongなどの型があります。それぞれ32bit、64bitとビット数は決まっており、それより巨大な計算結果になるような演算をすると正確な値が得られなくなってしまいます。

そこで、必要に応じてビット数を拡張し、特にビット数の縛りなくメモリが許す限りどんな大きな整数でも扱えるような機構が欲しくなります。C#のような言語ならばそういったクラスや構造体を作れば良でしょう。
というのは誰もが思うことで、すでにそういうのが用意されています。

BigInteger 構造体

使うときに参照を追加してやる必要がありますが、それだけで簡単に多倍長整数で計算してくれます。演算子もオーバーロードされており、特に違和感なく計算ができるところがとても良いです。

ですが、さすがにLINQのメソッドまでは用意されておりませんので、今回のプログラムで使う分を実装しておきましょう。

public static class BigIntegerEnumerable
{
    public static IEnumerable<BigInteger> Range(BigInteger start, BigInteger count)
    {
        for(BigInteger i = 0; i < count; i++)
            yield return start + i;
    }
}

public static class BigIntegerUtil
{
    public static BigInteger Factorial(int start) => Enumerable.Range(1, start).Aggregate(new BigInteger(1), (total, now) => total * now);

    public static BigInteger Sum(this IEnumerable<BigInteger> source) => source.Aggregate(BigInteger.Zero, (now, next) => now + next);

    public static BigInteger Product(this IEnumerable<BigInteger> source) => source.Aggregate(BigInteger.One, (now, next) => now * next);
}

置換は(1, 2, ..., n)と並んでいる数列を入れ替えていくという話なので、Enumerable.Rangeみたいなメソッドがあるととても便利です。
また、多倍長整数を使うモチベーションとして「階乗」というのは非常に大きいはずですが、なぜかBigInteger構造体には階乗を求めるメソッドが無いので実装しておきます。あとは総和と総乗を取るのでSumとProductという拡張メソッドを作っておきます。Aggregate大活躍です。

順列と置換

例えば(1,2,3)の3つの数の順列は、(1,2,3),(1,3,2),(2,1,3),(2,3,1),(3,1,2),(3,2,1)の6パターンあります。そして、順列を小さい順?に並べるとこの順序になるはずです。
じゃあ任意の数列に対してi番目の並び替えは何か、というのを求めるメソッドを用意します。

static int[] Permutation(int[] source, BigInteger index)
{
    if(source.Length <= 1)
        return source.ToArray();

    var list = source.ToList();
    var div = (int)BigInteger.DivRem(index, BigIntegerUtil.Factorial(source.Length - 1), out BigInteger remainder);

    var first = list[div];
    list.RemoveAt(div);

    return new int[] { first }.Concat(Permutation(list.ToArray(), remainder)).ToArray();
}

今の配列の一番前に来るべき数を決めて、残りの項目を1つの数列として再帰的にこの関数を呼び出すことでindex番目の並び順を求めています。

ただ、このindexと置換の回数は直接的な相関はありません(たぶん)。ですので、置換の回数を求めるメソッドも用意しました。

static int ReplaceCount(int[] array1, int[] array2)
{
    if(array1.Length != array2.Length)
        throw new ArgumentException("Invalid array lengthes");

    int count = 0;            

    for(int i = 0; i < array1.Length; i++) {
        if(array1[i] != array2[i]) {
            var nextIndex = array2.ToList().IndexOf(array1[i], i + 1);
            if(nextIndex < 0)
                throw new InvalidOperationException("Cannot find same value.");

            var swap = array2[i];
            array2[i] = array2[nextIndex];
            array2[nextIndex] = swap;
            count++;
        }
    }

    return count;
}

やってることは選択ソートみたいなもんです。選択ソートについて置換の回数を見ているので、別のアルゴリズムを使えば回数は異なる可能性があります。ただ、偶奇性は一意に定まります。

並列実装

ここまで来れば、定義に従って行列式を求めるだけです。

Random rnd = new Random();
int[,] matrix = new int[order, order];

for(int i = 0; i < order; i++) {
    for(int j = 0; j < order; j++)
        matrix[i, j] = rnd.Next(10);
}

for(int j = 0; j < order; j++) {
    for(int i = 0; i < order; i++)
        Console.Write($"{matrix[i, j]} ");
    Console.WriteLine();
}

Dictionary<int, BigInteger> threaddet = new Dictionary<int, BigInteger>();
object threadlock = new object();

Stopwatch sw = new Stopwatch();
sw.Start();

var source = Enumerable.Range(0, order).ToArray();
Parallel.ForEach(BigIntegerEnumerable.Range(0, BigIntegerUtil.Factorial(order)), patternIndex => {
    var pattern = Permutation(source, patternIndex);
    var res = source.Zip(pattern, (r, c) => (BigInteger)matrix[r, c]).Product() * (ReplaceCount(source, pattern) % 2 == 0 ? 1 : -1);

    var id = System.Threading.Thread.CurrentThread.ManagedThreadId;
    if(!threaddet.ContainsKey(id)) {
        lock(threadlock)
            threaddet.Add(id, 0);
    }
    threaddet[id] += res;
});

var det = threaddet.Values.Sum();

sw.Stop();

Console.WriteLine($"det = {det}");
Console.WriteLine($"Elapsed: {sw.Elapsed.TotalSeconds}s");

まずはorder次の正方行列をランダムに生成します。わかりやすく各要素は0~9の整数にしています。
元の行列が決まったらorderの階乗だけ置換パターンがあるのでそれをParallel.ForEachで並列処理をしています。それぞれのパターンについて要素を取ってきて積を取ってから符号をつけます。
積が求まったら総和を取らなければなりませんが、和を取るためにはスレッド間での同期をとらなければなりません。ですので、それは一番最後に回すことにしています。具体的にはそれぞれのスレッドで計算した分の総和のみを求めており、すべてのスレッドの処理が終わったら各スレッドの総和を足し合わせる、という処理になっています。

スレッドの総和を求めるためには、スレッドIDをキーとしたDictionaryを用意して求めます。キーの追加時はロックをしないと、おそらく内部的なリストの「最後尾」にキーを追加というタイミングで複数スレッド間での競合が起きバグります。

私のPCはi7-4790Kを搭載していますが、11次の行列で2分半ちょっと計算にかかりました。行列を計算し始めてからOctaveを起動して行列を打ち込んで行列式を求めてもまだ定義に従った計算では求まらない、といったくらいのペースになります。

昔、C言語で作った時はえらく苦労をした記憶がありますが、C#だとこんなにすんなりできてしまいました。
やはり、並列化が体系化されていたり、演算子オーバーロード等で非常に使いやすい多倍長整数型が用意されていたり、あとはLINQで配列を簡単に扱えたりするのはとても大きいですね。

もうちょっとアルゴリズムが改良できるぞ、とかあったら是非教えてください。
特に置換パターンを求めたり置換回数を求めるあたりはまだ何かやりようがある気がしています。あ、「掃き出し法を使うと速いぞ」とか言うのは無しで。あくまでも定義に従った計算方式ということで。

2017年11月18日土曜日

MVVMのサンプルプログラム - TwitterViewer

前回の記事でMVVMとは何ぞやという説明をしましたが、今回はMVVMで実装する例としてTwitterのサンプルプログラムでも作ってみようかと思います。MVVMライブラリはLivetを使っています。

具体的なイメージを最初に持ってもらうために、完成形を最初に示しておきます。


短縮URLやRTの展開などはしていませんが、なかなかそれっぽいクライアントに見えるかと思います。

1. メインウィンドウのView

メインウィンドウのXAMLはこんな感じです。

<Window x:Class="TwitterViewer.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:TwitterViewer.Views"
        xmlns:vm="clr-namespace:TwitterViewer.ViewModels"
        Title="MainWindow" Height="1000" Width="500">
    
    <Window.DataContext>
        <vm:MainWindowViewModel/>
    </Window.DataContext>
    
    <i:Interaction.Triggers>
    
        <!--WindowのContentRenderedイベントのタイミングでViewModelのInitializeメソッドが呼ばれます-->
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize"/>
        </i:EventTrigger>

        <!--Windowが閉じたタイミングでViewModelのDisposeメソッドが呼ばれます-->
        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction/>
        </i:EventTrigger>

        <!--WindowのCloseキャンセル処理に対応する場合は、WindowCloseCancelBehaviorの使用を検討してください-->

    </i:Interaction.Triggers>
    
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>
        <Button Grid.Row="0" Content="Load" >
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="Click" >
                    <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="LoadButtonClicked" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Button>
        <ListBox Grid.Row="1" ItemsSource="{Binding Statuses}" ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.CanContentScroll="False" >
            <ListBox.ItemTemplate>
                <DataTemplate DataType="{x:Type vm:TwitterStatusViewModel}">
                    <DockPanel HorizontalAlignment="Stretch">
                        <DockPanel.Background>
                            <LinearGradientBrush StartPoint="0,0" EndPoint="0,1" >
                                <GradientStop Color="White" Offset="0" />
                                <GradientStop Color="#FFF0F0F0" Offset="1" />
                            </LinearGradientBrush>
                        </DockPanel.Background>
                        <Image DockPanel.Dock="Left" Source="{Binding IconSource}" Width="50" VerticalAlignment="Top" />
                        <DockPanel DockPanel.Dock="Top">
                            <StackPanel DockPanel.Dock="Left" Orientation="Horizontal">
                                <TextBlock Text="@" Foreground="Blue" FontSize="12" />
                                <TextBlock Text="{Binding ScreenName}" Foreground="Blue" FontSize="12" />
                                <TextBlock Text="{Binding UserName}" Foreground="Blue" FontSize="12" FontWeight="Bold" Margin="5,0,0,0" />
                            </StackPanel>
                            <TextBlock DockPanel.Dock="Right" Text="{Binding CreatedAt}" Foreground="DarkGray" FontSize="12" HorizontalAlignment="Right"/>
                        </DockPanel>
                        <TextBlock DockPanel.Dock="Bottom">
                            via 
                            <Hyperlink ToolTip="{Binding ViaLink}" >
                                <TextBlock Text="{Binding ViaName}" />
                                <i:Interaction.Triggers>
                                    <i:EventTrigger EventName="Click">
                                        <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="ViaLinkClicked" />
                                    </i:EventTrigger>
                                </i:Interaction.Triggers>
                            </Hyperlink>
                        </TextBlock>
                        <TextBlock Text="{Binding Text}" FontSize="14" TextWrapping="Wrap" Margin="0,5,0,5" />
                    </DockPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
            <ListBox.ItemContainerStyle>
                <Style TargetType="ListBoxItem">
                    <Setter Property="HorizontalContentAlignment" Value="Stretch"></Setter>
                </Style>
            </ListBox.ItemContainerStyle>
        </ListBox>
    </Grid>
</Window>

このクライアントはツイートを画面に表示する以外に、読み込みボタンとviaクライアントのクリックという2つの操作がユーザーでできるようになっています。
これはLivetのメソッド直接バインディング機能を使って、クリック時にViewModelのメソッドを呼び出すようにしています。

表示はListBoxのカスタマイズで行っています。
個々の要素(ツイート)の表示部分をテンプレートとしてListBox.ItemTemplateに渡しています。
若干複雑に見えるかもしれませんが、上の画像を見ながら追っていけば特段難しくないかと思います。

2. メインウィンドウのViewModel

メインウィンドウのViewModelはこんな感じです。

public class MainWindowViewModel : ViewModel
{
    Model model;

    public MainWindowViewModel()
    {
        model = Model.GetInstance();

        Statuses = model.Statuses.ToSyncedSynchronizationContextCollection(p => new TwitterStatusViewModel(p), System.Threading.SynchronizationContext.Current);
    }

    public void Initialize()
    {
    }

    public async void LoadButtonClicked()
    {
        await model.LoadHomeTimeline();
    }

    #region Statuses変更通知プロパティ

    public SynchronizationContextCollection<TwitterStatusViewModel> Statuses
    {
        get { return _Statuses; }
        set
        {
            if(_Statuses == value)
                return;
            _Statuses = value;
            RaisePropertyChanged(nameof(Statuses));
        }
    }
    private SynchronizationContextCollection<TwitterStatusViewModel> _Statuses;

    #endregion
}

Loadボタンが押されたときにそれを受け取るメソッドと、ツイートの表示内容を示すコレクションを用意しています。

SynchronizationContextCollectionはStatefulModelというライブラリのものです。「特定のコンテキストでコレクションの変更通知を発行するスレッドセーフなコレクション」で、WPFの制約である「単一スレッドでしかUIを触ることができない」を吸収してくれる便利なコレクションです。

ちなみに、Statusesプロパティはmodel.Statuses.ToSyncedSynchronizationContextCollectionメソッドで生成しています。ここでmodel.Statusesという変更通知コレクション(詳しくはModelの項を見てください)のイベントを拾えるようリスナーを登録しています。ViewModel側のStausesではModel側のイベントを拾って自身もそれに同期し変更通知イベントを発生させますが、その際にUIスレッドに転送してイベントを発生させてくれます。まさにMVVMのためのコレクション機構ですね。

3. メインウィンドウのModel

Modelはこちらです。

public class Model : NotificationObject
{
    #region Singleton

    static Model instance;

    public static Model GetInstance()
    {
        if(instance == null)
            instance = new Model();
        return instance;
    }

    #endregion

    TwitterContext twitterContext;

    private Model()
    {
        Statuses = new SortedObservableCollection<TwitterStatus, DateTime>(s => s.CreatedAt, true);

        SingleUserAuthorizer auth = new SingleUserAuthorizer()
        {
            CredentialStore = new SingleUserInMemoryCredentialStore()
            {
                ConsumerKey = "****",
                ConsumerSecret = "****",
                AccessToken = "****",
                AccessTokenSecret = "****",
            }
        };
        twitterContext = new TwitterContext(auth);
    }

    public async Task LoadHomeTimeline()
    {
        await Task.Run(() => {
            foreach(var status in twitterContext.Status.Where(p => p.Type == StatusType.Home && p.Count == 100)) {
                if(!Statuses.Any(p => p.StatusID == status.StatusID))
                    Statuses.Add(new TwitterStatus(status));
            }
        });
    }
    
    #region Statuses変更通知プロパティ

    public SortedObservableCollection<TwitterStatus, DateTime> Statuses
    {
        get { return _Statuses; }
        set
        { 
            if(_Statuses == value)
                return;
            _Statuses = value;
            RaisePropertyChanged(nameof(Statuses));
        }
    }
    private SortedObservableCollection<TwitterStatus, DateTime> _Statuses;

    #endregion        
}

TwitterにアクセスするのにはLinq2Twitterを使っています。
SortedObservableCollectionはStatefulModelの提供するクラスの1つです。タイムラインは常に時間順でソートされるべきですので、そうなるようにSortedなコレクションを使っています。ViewModel側でこれをウォッチしてUIのスレッドで変更通知をViewに伝えるのです。

ちなみに、このModelはシングルトンパターンを取っています。
ここはちょっとしたこだわりですが、ViewModelがModelを作るわけではないので、その気持ちを込めてこういう構造にしています。


4. ツイートのViewModel

さて、ここまで来てメインウィンドウに対応するViewModel、Model以外にもTwitterStatusViewModelとTwitterStatusというクラスがあることに気づいたことでしょう。個々のツイートに対応するViewがありますから、それに対応するViewModelを作るのは自然かと思います。

public class TwitterStatusViewModel : ViewModel
{
    TwitterStatus Source;

    public TwitterStatusViewModel(TwitterStatus source)
    {
        Source = source;

        Text = Source.Text;
        IconSource = Source.IconSource;
        ScreenName = Source.ScreenName;
        UserName = Source.UserName;
        CreatedAt = Source.CreatedAt;
        ViaName= Source.ViaName;
        ViaLink = Source.ViaLink;
    }

    #region Text変更通知プロパティ

    public string Text
    {
        get { return _Text; }
        set
        {
            if(_Text == value)
                return;
            _Text = value;
            RaisePropertyChanged(nameof(Text));
        }
    }
    private string _Text;

    #endregion

    #region IconSource変更通知プロパティ
    private string _IconSource;

    public string IconSource
    {
        get
        { return _IconSource; }
        set
        {
            if(_IconSource == value)
                return;
            _IconSource = value;
            RaisePropertyChanged(nameof(IconSource));
        }
    }
    #endregion

    #region ScreenName変更通知プロパティ

    public string ScreenName
    {
        get { return _ScreenName; }
        set
        {
            if(_ScreenName == value)
                return;
            _ScreenName = value;
            RaisePropertyChanged(nameof(ScreenName));
        }
    }
    private string _ScreenName;

    #endregion

    #region UserName変更通知プロパティ

    public string UserName
    {
        get { return _UserName; }
        set
        {
            if(_UserName == value)
                return;
            _UserName = value;
            RaisePropertyChanged(nameof(UserName));
        }
    }
    private string _UserName;

    #endregion
    
    #region CreatedAt変更通知プロパティ

    public DateTime CreatedAt
    {
        get { return _CreatedAt; }
        set
        { 
            if(_CreatedAt == value)
                return;
            _CreatedAt = value;
            RaisePropertyChanged(nameof(CreatedAt));
        }
    }
    private DateTime _CreatedAt;

    #endregion
    
    #region ViaName変更通知プロパティ

    public string ViaName
    {
        get { return _ViaName; }
        set
        {
            if(_ViaName == value)
                return;
            _ViaName = value;
            RaisePropertyChanged(nameof(ViaName));
        }
    }
    private string _ViaName;

    #endregion

    #region ViaLink変更通知プロパティ

    public string ViaLink
    {
        get { return _ViaLink; }
        set
        {
            if(_ViaLink == value)
                return;
            _ViaLink = value;
            RaisePropertyChanged(nameof(ViaLink));
        }
    }
    private string _ViaLink;

    #endregion

    public void ViaLinkClicked()
    {
        Source.OpenViaLink();
    }
}


やたら長いですが、大部分が変更通知プロパティです。
反射的に変更通知ができるように作ってしまいましたが、ツイートは一度つぶやくと後から変更できないので、Immutableに作っても良かってもよかったかもしれません。

今回は面倒なので実装しませんでしたが、例えば、ツイート時刻の相対表記(何秒前のツイートかを表示するもの)だったら定期的に表示内容を変えなければならないので、そういうものはModelの変更通知を監視し、変更されたら自身を更新するように作り替えねばなりません。

ちなみに、一番最後にはviaのリンクをクリックされたときに呼ばれるメソッドがありますが、これはそのままModelに横流ししています。

5. ツイートのModel

最後にツイートのModelです。

public class TwitterStatus : NotificationObject
{
    static Regex regViaUrl = new Regex("href=\"([^\"]+)\"", RegexOptions.Compiled | RegexOptions.IgnoreCase);
    static Regex regViaName = new Regex(">([^<]+)<", RegexOptions.Compiled | RegexOptions.IgnoreCase);

    public TwitterStatus(Status Source)
    {
        Text = Source.Text;
        StatusID = Source.StatusID;
        IconSource = Source.User.ProfileImageUrlHttps;
        ScreenName = Source.User.ScreenNameResponse;
        UserName = Source.User.Name;
        CreatedAt = Source.CreatedAt.ToLocalTime();

        Match m = regViaName.Match(Source.Source);
        ViaName = m.Success ? m.Groups[1].Value : Source.Source;

        m = regViaUrl.Match(Source.Source);
        ViaLink = m.Success ? m.Groups[1].Value : string.Empty;
    }

    public string Text { get; }

    public ulong StatusID { get; }

    public string IconSource { get; }

    public string ScreenName { get; }

    public string UserName { get; }

    public DateTime CreatedAt { get; }

    public string ViaName { get; }

    public string ViaLink { get; }

    public void OpenViaLink()
    {
        try {
            System.Diagnostics.Process.Start(ViaLink);
        }
        catch(Win32Exception) {
            throw;
        }
        catch(FileNotFoundException) {
            throw;
        }
    }
}

基本的にViewModelに対応させていますが、viaのURLとクライアント名を分離するためのロジックをコンストラクタにちょろっと入れました。

また、viaのリンクを開くロジックもここに書いています。まあ、Process.Startを呼ぶだけなのですが。
ですが、何かしらの理由で開けないことがあるかと思います。例えばTwitterの仕様変更でURLが含まれなくなったり、WindowsにURLを開くアプリケーションが関連付けられていなかったり。そういう場合はProcess.Startメソッドが例外を吐いてきますが、その処理はModel側でやるべきです(今回はめんどいんで結局再スローしちゃっています)。 「URLが開けませんでした」みたいなエラーメッセージは改めてModel側からイベントを発生させてそれをViewModel経由で表示させるべきですね。ViewModelはあくまでもUIの制約を吸収するためだけの層です。URLが開けなかったというのはUIの制約じゃないですよね。ということは、それをViewModelに押し付けるのは間違っています。

まとめ

今回は初めて連載っぽい記事にしましたがいかがでしたでしょうか。
ここまできて気づいた方も多いかと思いますが、ViewModelとModelは同じようなプロパティやメソッドがたくさん登場してきます。当然です。なぜならば、ViewModelはViewの制約を吸収するための層なので、制約がない部分は素通りさせるべきなのです。なので、素通りする部分にとっては一層余計に挟んでいるような状態になってしまい、同じようなコードが生まれてしまうのです。

プログラマーと言えばコピペコードを嫌う生物ですから、このようなスタイルに懐疑の念を抱く人は多いかと思います。知ったうえで「俺はこっちのほうがいい」と思ったのなら、MVVMになっていないスタイルでプログラムを書いてもいいでしょう。ただし、非同期プログラミングで泣いても私は知りません。

少なくとも、ここまで読んでくださった方たちはMVVMが何かというのはわかってきたかと思います。

MVVMは問題領域の切り分け方です。UIの制約はViewModelに押し付けて、後はすべてModelが引き受ける、それだけです。なので、おそらくはWPFでMVVMを着崩していくよりかは、MVVMの形をキープしながら様々な便利なライブラリなどのアクセサリを身に着けていくのが一番スマートなやり方なのではないかなと私は思います。

MVVMとは何か

MVVMとは何か、それはWPFプログラミングを始めた人は誰しもが気になることでしょう。
私はあまりWindows以外のプラットフォームは触らないのですが、最近はAndroidでもMVVMが流行ってるとかなんとか。

そのせいか、MVVM+適当なワードでググるといくらでもQi○taなどの記事がヒットします。ですが、多くは「データバインディングを使えばMVVM」くらいの浅はかな理解で、MVVMとは何かがまるでわかっていないようです。
まあ「初心者」というのは誰もが通る道ですからそういうのをはなから否定する気はないですが、情報汚染されているのは悲しいので私の理解の範囲を記事にしておこうと思います。

まず忘れてほしいこと

MVVMとは何かというのを調べ始めるとまず見つかることですが、それはMVVMの本質ではないというのをいくつか挙げていきます。私の今回の記事を読むうえでまずは次の3点のことを忘れてください。

1. データバインディングをするのがMVVMである

MVVM初学者は真っ先にこれにぶち当たるかもしれませんが、データバインディングはあくまでもViewとViewModelのデータのやり取りの手段に過ぎません。データバインディングをするのがMVVMではありません。
似たようなものとして「ユーザーインターフェースとビジネスロジックの疎結合を実現するのがMVVMである」というのも大嘘です。

確かにWin32APIやWinFormsなどをいじっていた人からしたら、今までUIとビジネスロジックを一体的に記述していたわけですから、それは大きなモチベーションに見えるでしょう。それぞれを分離して設計できれば、設計を分担するのもやりやすいですし、再利用性、可読性も上がります。何しろ手続き型言語であるC言語やC#などでUIを設計するそれらのフレームワークに比べて、XAMLとかいうマークアップ言語でUIが設計でき、ビジネスロジックをC#で書ければそれはたいそうなメリットに見えるでしょう。私も当初はそうでした。

でも考えてください。それならばView-Modelで良いんです。UIとビジネスロジックを分離するって、それってView-Modelですよね。で、ModelとViewの接続にはデータバインディングを使うと。
実際、この手のモチベーションだけでMVVMを書こうとすると、ViewModelが実質Model的な役割になって、結局のところView-Modelになってしまいます。それじゃあMVVMじゃないんです。

この記事の下のほうへ行くと見えてくると思いますが、MVVMは「UIとビジネスロジックの疎結合」なんていうのは当たり前なんです。それだけだと不都合が起こるから、ViewModelが介在しているんです。

2. MVVMはコードビハインドを書かない

これは結果論としては間違っていないとは思いますが、違います。
何も考えずにコードビハインドを書くとたいていそれがビジネスロジックになりますから、UIとビジネスロジックの分離すらできていない状態になります。それは"論外"以前の問題です。
正確には「WPFはコードビハインドを書かなくて済む」なのです。データバインディング、コマンド、ビヘイビアなどの様々な「Viewとやり取りする方法」があるため、結果としてコードビハインドを書く必要が無いケースがほとんどなのです。

ですので、WPF初心者は「コードビハインドを書かない」をテーマにいろいろ調べてみるといいと思います。いろいろな手段が出てきていい勉強になるでしょう。ですが、あくまでもそれが目的ではないのです。

3. Modelはビジネスロジックなので、UIに依存しない

これは見方によっては間違ってはいないのですが、間違った意味にも取れるので書いておきます。そして、多くの人は間違った意味に取るように思います。
「依存しない」の意味を正しく理解しているかがキーです。

「UIに依存しない」というのは「UIのフレームワークで生まれた制約を知らなくていい」というニュアンスの意味なのです。UIのフレームワークの制約を知らなくていいだけなので、ModelはUIの存在を強く意識します

これだけだと何を言っているのかよくわからないので、メール送信ソフトを作っている場面を想像しながら説明します。
例えば送信ボタンを押したとき、ボタンのコマンドが発動します。コマンドはViewModelに記述しますが、そのViewModel内でTextプロパティやToAddressプロパティ、Subjectプロパティなどの値を取得し、
mail.Send(ToAddress, Subject, Text);
などといったメソッドを呼ぶなどといった処理を記述しがちかと思います。なおこのMailクラスは当然Modelにあるものとします。

これは大間違いなのです。

これだとModelは「ライブラリ」です。このスタイルではView-Model-Libraryになってしまいます。Modelとライブラリの関係なんてUIフレームワークの側からなんかは知ったこっちゃないですよね。ですのでこれはView-Modelと何ら変わらないのです。
なぜこういう書き方をしてしまうかというと、MVVM初心者はViewModelが何のためにあるのかがわかっていないのです。そのため、「ViewとModelをつなぐもの」という説明からViewの範囲をUI、Modelの範囲をビジネスロジックライブラリに限定してしまい、上記のような誤った実装をしてしまうのです。

ではViewModelは何のためにあるのか。実は、ViewModelとはView(=UI)の制約を吸収するためだけの層なのです。なので、Viewがあれば必然的にViewModelも出来上がります。そして残りの領域がすべてModelになるわけです。

「ModelはUIに依存しない」によく似た表現として、「ViewとModelがあって、その間を取り持つのがViewModel」 というのがありますが、これも大いに誤解を生む表現です。正しくは「ViewとViewModelがあって、それ以外の領域がModel」なのです。
「ありき」なのはViewとModelではなく、ViewとViewModelなのです。「Modelはビジネスロジック」といったようにModelの範囲を限定している時点でそれはもう間違っているのです。

WPFの制約

ここからは、UIをWPFに限定してお話をしていきます。
さて、先ほど「UIの制約を吸収するのがViewModel」と言いました。では、WPFにおけるUIの制約とは何なのでしょうか。下記に2例示します。

1. UIは単一スレッドでしか操作できない

WPFはUIを操作できるスレッドは1つしかありません。
複数のスレッドからUIをいじれるようにしようとすると排他制御などで逆に効率が落ちるらしく、それならばと単一スレッドでしか使えないという縛りを入れているようです。
なので、UI以外のスレッドからアクセスしようとしたら例外が吐かれます。

これを吸収するのがViewModelの役目です。
ViewModelは、Modelで何かしらのプロパティの値が変更されたら、それをUIのスレッドでViewに変更通知をします。それ以下のこともそれ以上のこともしません。

MVVMライブラリは、ViewModelの基底クラスでこの処理を行っています。PropertyChangedイベントは必ずUIのスレッドで発生するように作ってあります。ですので初心者は「WPFは単一スレッドでしかUIを触れない」という制約すら知らずに済むのです。MVVMが成功している証です。

ここで注意しなければならないのは、コレクションの変更通知はプロパティ変更通知とはまた別ということです。
ObservableCollectionは何もしなければコレクションを更新したスレッドからそのままCollectionChangedイベントが発生しますので、UIスレッド以外から更新すると例外が飛びます。ですので、ObservableCollectionもコレクションとUIを結びつけるものとしてViewModelとしてUIのスレッドでCollectionChangedイベントを発生させるようなものを作ってやる必要があるのです。
//BindingOperations.EnableCollectionSynchronizationメソッドというものがありこれを使えばUIスレッドに転送できますが、なんでこのような取ってつけたような実装にしたのか理解に苦しみます…。

2. リスト系コントロールは表示されている分しか要素のインスタンスを持たない 

これはどういうことかというと、リストの要素を操作しようとしたときに実体(インスタンス)が無い場合があるということです。
理解するために次のようなコードを書いてみます。

public partial class TestUserControl : UserControl
{
    static int counter = 0;

    public TestUserControl()
    {
        Console.WriteLine(counter++);
        InitializeComponent();
    }
}

まずはこんなコントロールを作ってみます。このコントロールのインスタンスが作られた回数を標準出力に書き出すだけのコントロールです。

<Window x:Class="WpfListControlTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfListControlTest"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <ListBox Name="list">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <local:TestUserControl Height="20" />
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </Grid>
</Window>

こちらがMainWindowです。Listの要素としてTestUserControlを表示しています。

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        for(int i = 0; i < 100; i++)
            list.Items.Add(new object());
    }
}


めんどいのでMainWindowのコードビハインドでアイテムを100個追加してみます。

直観的に考えたらソフトを起動したときに100個のTestUserControlが生成されそうな気がします。


14個しかインスタンスが生成されていません。
これは表示されている分で、それ以上のインスタンスは生成されていないのです。

何往復かスクロールしてみます。


100個しかアイテムが無いはずなのに269回インスタンスが作られています。
表示外に外れたインスタンスは消去され、再び表示されたらまたインスタンスから作り直しているんですね。
こうすることで、確かにヒープに負荷はかかりますが何万個とアイテムを持ったリストなどの生成を可能としているのです。

もしもこれにViewModelを用意しなかったらModelでViewをいじろうとしても表示外だったらぬるぽが発生するのです。
そこで、ViewModelでワンクッションを置くことでModelからはいつでもすべての表示項目のViewModelをいじれるようにしてあげます。項目が表示されればViewのインスタンスが生成され、同時にViewModelにバインディングすることであたかも隠れていた項目が今表示されたかのようにふるまうことができるのです。

これもUIの都合ですので、それを吸収するのがViewModelの役目です。

WPFの制約まとめ


どうでしょう、案外WPFにはいろいろな制約があることが分かったと思います。ほかにもいろいろあるかもしれませんが、これだけでMVVMの必要性を説明するのには十分だと思います。

Modelにとっては、UIは単一スレッドでしか触れないことや表示されていない項目のコントロールのインスタンスが存在しないことなんて知ったこっちゃありません。それを吸収するのがViewModelの役割なのです。なので、「これとこれを表示するUI(TextBoxやListViewなど)がある」とか「ボタンが押された」などといったUIの存在自体やUIの主体となる動作はModelは意識する必要があるのです。

このような目的から、Modelから見たViewModelはViewのように見えなければなりません。ViewModelはViewの制約をなくすためのラッパーなのですから、当然機能としてはただのViewと同等になるわけです。

MVVMとは

MVVMの根底には確かにUIとビジネスロジックの分離があります。でも、単純にViewとModelに分けようとすると、WPFの場合はViewに様々な制約があり、Modelがその制約に引きずられたものになってしまいます。そうすると、Modelと言っておきながらWPFに深く入り込んだものが出来上がり、分離とは言い難い形になってしまうのです。
そこで、Viewの制約を吸収してあげる層、すなわちViewModelを作って制約なくModelからViewを操作できるようにしてあげようというのがModel-View-ViewModelこと、MVVMなのです。

概念だけ説明されてもわからないよ、という人もいるかと思いますので、次回は実際にソフトを作りながら解説していきます。

2017年11月13日月曜日

ListView Extensions ver.1.0.0-beta4

beta2に引き続いてbeta4をリリースしました。
えっ?beta3はどこに行ったって?beta2リリース直後にバグを見つけてこっそり差し替えていました。テヘペロッ(๑´ڡ`๑)

ListView Extensions - Nuget

バグフィックスのbeta2,3に比べたら今回はかなり大きなアプデです。バージョン変えようかと思いましたが、1.0.0-beta*という名前である以上、1.0.0をリリースしない限りバージョン変えられないなと思い、結局このままになっています…。
これで安定していたら今度こそ正式リリースするぞ!

今回の変更点

さて、今回のアプデ内容はただ一つにして大きい内容です。コレクションのスレッドセーフ化です。
シングルスレッドが許されるのは小学生までだよねー。

名前は「SyncedSortableObsevableCollection」にしようかと思いましたが、いまどきスレッドセーフじゃないCollectionなんてWPFで使う上では用途が無いと判断し、名前は「SortableObservableCollection」のままとしました。インターフェースは互換性を維持していますので、そのままコンパイルは通るはずです。
一応今までの実装は残していて、同じ名前空間にUnsyncedSortableObservableCollectionとしてObsolate属性を付けています。問題がある方はこっちを使ってください。

なお、スレッドセーフ化する過程で継承構造をより素直な形にしました。以前のバージョンは「SortableCollection」というクラスを継承していましたが、こちらもObsolateになっており、基底クラスは「SyncedObservableCollection」にしております。

SyncedObservableCollection

これは私がフルで実装したクラスで、スレッドセーフな変更通知可能なコレクションです。ReaderWriterLockSlimを使って効率的なロックを行っております。要素技術としては詳しくは前回の記事を見て下さい。

なお、このクラスは非ジェネリックのICollectionインターフェースを継承しているため、IsSynchronizedプロパティSyncRootプロパティを実装しています。コレクションを外部から操作するときに複数回のメソッド呼び出しにわたってlockが必要な場合はこのSyncRootを使ってlockを行ってください。

例えば次のようなケースです。

SortableObservableCollection<Person> People = new SortableObservableCollection<Person>();

//中略

lock(People.SyncRoot)
    People.RemoveAt(People.Count - 1);    //Remove last element

最後の要素を削除する処理ですが、これは
  1. コレクションの長さを取得
  2. コレクションの長さ-1の要素を削除
の2回のコレクションへのアクセスを行っています。
この1.と2.の間に他のスレッドからの書き込みアクセスがあって要素数が増減した場合、「コレクションの長さ-1」が最後の要素のインデックスではなくなってしまいます。これは問題です。

そこで、対策としてSyncRootを使ったlockを行っております。これによって他のスレッドからの書き込みアクセスを阻止しています。
これこそ「ReaderWriterLockSlimを使うべきじゃないの?」と思うかもしれませんが、このようなケースは書き込みアクセスを伴うことが圧倒的に多く、書き込みアクセスをする場合はReaderWriterLockもlock構文も大差がありません。ですので、既存の枠組みで簡単に実現できるこの方法を使うことにしました。
もちろん、コレクションの外でlock構文を使って書き込みをしている間も、中でReaderWriterLockSlimを併用していますので、書き込みのタイミング以外(上記で言えばPeople.Count読み取り時など)は他のスレッドからの読み込みアクセスは許可されます。

ちなみに、本家のObservableCollectionはAddRange/InsertRange/RemoveRangeといった複数の項目を一気に操作する機能は付いていません。しかし、INotifyCollectionChangedインターフェースの機能としてはNotifyCollectionChangedEventArgsクラスを見てもわかりますが複数項目のコレクション操作機能にも対応しています。
なので、このSyncedObservableCollectionではそれらの機能にも対応しています。ループでAddメソッドを複数回呼び出すとその呼び出しごとにイベントが発生しますが、AddRangeだと1回で済むので効率が格段と高まります。

ListViewViewModel

ListViewViewModelは今までもModelをUI以外のスレッドから操作した場合に発生した変更通知をUIのDispatcher経由でViewに反映させる機能を持っていました。ただし、肝心のスレッドセーフ設計は(面倒なので)やっていませんでした。

今回は、このSyncedObservableCollectionを活用してスレッドセーフに設計しています。今までの実装はSortableObservableCollectionと同じくUnsyncedListViewViewModeに移したうえでObsolate属性を付けています。

デッドロックに注意

ここで、ListViewViewModelを使う上での注意を紹介します(私もハマってしばらく原因探しに苦戦していました)。

ListViewViewModelはViewに設置されたボタンなどの入力を受け付けてModelに渡すためのコマンドをいくつか持っています。
例えばRemoveCommandはListViewのうち選択されている項目を削除するコマンドです。Viewのボタン押下を受けてCommandが発火し、Model(SortableObservableCollection)のRemoveメソッドを呼び出します。

気を付けないとここでデッドロックが発生しうるのです。




UIスレッドからのSortableObservableCollectionへのアクセスの直前にその他のスレッドから同じくSortableObservableCollectionに書き込みアクセスがあったとします。
そうすると、先に書き込みアクセスを始めたほうがLockを保持するのでUIスレッドはそのLockが解放されるまで待機します。

しかし、その他のスレッドは変更後に変更通知イベントを発生させ、UIのスレッドでUIの更新作業をしようとします。このときは、すでにUIスレッドはSortableObservableCollectionのLockが解放されるのを待っているのでUIスレッドでのInvokeが行えずデッドロックが発生してしまいます。

AとBの排他制御機構があって、AをLockしたうえでBをLockしようとするスレッドと、BをLockしたうえでAをLockしようとするスレッドがあるとデッドロックが発生する、というのはマルチスレッドプログラミングの基本中の基本です。
今回はUIスレッドとSortableObservableCollectionがそれぞれの排他制御機構となっています。


さてさて、これに対抗する手段もListViewViewModelには追加しております。


UIスレッドが直接SortableObservableCollectionへアクセスしなければよいのです。
UIスレッドからSortableObservableCollectionへのアクセスを行いたいときは別のスレッドに書き込みアクセスを依頼したうえですぐに処理をUIスレッドに戻すようにします。
そうすることで、先にその他のスレッドがSortableObservableCollectionのLockを保持していたとしてもUIスレッドはアイドル状態になっており、その他のスレッドからのUI更新作業もブロックされることはありません。この機構によってデッドロックが回避されるわけです。

ただ、重い作業を並列実行するわけでもないのに別スレッドを起動するので単純に処理が重い上、UI起点の処理をやっているのにその処理が終わる前にUIがいじれるようになるので不都合が起きることもあります。

そこで、これはオプションとし、デフォルトではOFFにしています。
別スレッドからの書き込みとUIスレッドからの書き込みが衝突する可能性がある場合は、ListViewViewModelにてUseNonUIThreadWhenCallingModelプロパティをtrueにしてください。
また、DisableCommandWhenCommandTaskRunningを使うことによってCommand起点のタスクが別スレッドで実行されているときにCommandを無効化することができます。Command起点のタスクが実行中かどうかはCommandTaskRunningプロパティでわかります。


なお、他の注意点として、変更通知イベントを受けたUI更新作業のときに、UIスレッドから直接SortableObservableCollectionに書き込みアクセスをしようとするとやはりデッドロックが起こりますので注意してください。この場合は確実に起きます。
ただ、UI更新作業中にSortableObservableCollectionを書き込むなんておかしな話です。なんでUIの更新作業の名目でデータの変更作業までしてんねんって話です。そんなデッドロックを起こすようなコードを書く人のことは知りません。

ListViewViewModelは同一インスタンスの要素を複数持ってはいけない

これは実は前からある制約です。
ですが、自分自身ですら忘れていたので書いておきます。

SortableObservableCollectionは問題ないのですが、ListViewViewModelは同一インスタンスの要素を複数持てません。
もしも同一インスタンスを追加しようとすると例外(ArgumentException)が発生します。ListViewViewModelのTViewModelが構造体やプリミティブ型の場合は、同値だった場合に例外が発生してしまいます。

なぜこんな仕様になっているかというと、ひとえにSelectedItemsのせいです。

WPFのListViewは複数のアイテムを選択している場合はSelectedItemsでしかその選択されたアイテムをすべて取得することができません。インデックスではなく選択されているアイテムのViewModelインスタンスのコレクションとなります。
この際、複数の同じインスタンスがあった場合、どちらのアイテムが選択されているのか求めることができません。ですので、このような不便な制約があるわけです。


しかし、MVVMモデルをなぞって実装しているのならば、ListViewの各行のアイテムとしてバインディングするのは、通常はSortableObservableCollectionの要素の型に対応するViewModelになるはずです。 なので、SortableObservableCollectionが複数の同一インスタンスを持っていたとしてもViewModelの要素としては別個のインスタンスを作ればいいだけなのです。

これはどのように実現するかというと、ListViewViewModelのコンストラクタで指定します。

public ListViewViewModel(ISortableObservableCollection<TModel> source, Func<TModel, TViewModel> converter, Dispatcher dispatcher);
public ListViewViewModel(ISortableObservableCollection<TModel> source, Func<TModel, TViewModel> converter, Dispatcher dispatcher, DispatcherPriority priority);

ListViewViewModelは2つのコンストラクタを持っています。ですが、Dispatcherの優先度を指定するかデフォルト値(Normal)を採用するかの違いだけで、それ以外は同じです。

sourceはModelのコレクションとなります。SortableObservableCollection<T>を指定する場合が圧倒的に多いでしょう。

問題は2番目の引数です。
このconverterがSortableObservableCollectionで要素が追加されたときに、ViewModelの要素を追加するために呼ばれるデリゲートになります。
ModelをViewModelに変換(ラッピング)するわけですから、ここで

model => new ElementViewModel(model)

のようなラムダ式を渡してやれば十分でしょう。
これでどのようなインスタンスが追加されたときも全自動で新しいインスタンスが生成されていくのでListViewViewModelに同一インスタンスが複数追加されることはありません。

めんどくさがって

model => model

にした場合は例外が飛ぶことがあるので注意してください。


ちなみに、要素がRemoveされたり、別の要素で書き換えられたときは、ElementViewModelがIDisposableを継承していればちゃんとDispose()を呼びますのでご安心を。



さーて、次回こそ正式リリースをするぞ!!!

2017年11月5日日曜日

C#の排他制御 - UpgradeableReadLock

.NET4.0/C#5.0以降、どんどん非同期処理に関する機能が増強されてきています。
特にasync/await構文なんかは、今までは非同期で書こうとすると非常に読みにくいコードになっていたのを、上から下へ流れるようにプログラムを書くだけで非同期処理が実現できてしまうという非常に画期的なものでした。

しかし、そこには非同期処理特有の問題が付きまといます。いくら簡単に非同期処理が書けるからと言って、何も知らずに非同期処理を確実に動かすことはできないのです。

さて、非同期処理で注意しなければならないことの1つとして、「データの整合性」が挙げられます。
非同期処理は複数のスレッドが同時に走るわけですから、それぞれのスレッドから1つのデータにアクセスした場合どのような結果になるか、ということを常に想像しながらコードを書かなければなりません。
そして、必要ならば排他制御を行い、片方のスレッドからデータにアクセスしている場合はもう片方のスレッドがデータにアクセスするのを待ってもらう必要があります。

はじめに - lock構文

C#にはそれに関して便利な構文が用意されています。
ご存知、lock構文です。
Stopwatch sw = new Stopwatch();
sw.Start();

Parallel.ForEach(Enumerable.Range(0, 4), i => {
    Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: Start");
    Thread.Sleep(TimeSpan.FromSeconds(1));
    Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: Finished");
});

sw.Stop();
まず、こちらがlock構文を使わない場合です。各スレッドで1秒待ってもらっていますが、すべてが並列で実行されるので下記のような実行結果になります(実行結果は環境によって異なります。以下同じ)。

 42ms, 0: Start
 44ms, 2: Start
 44ms, 1: Start
 44ms, 3: Start
1068ms, 0: Finished
1068ms, 1: Finished
1068ms, 2: Finished
1068ms, 3: Finished

基本ですね。

続いてlock構文を入れて、Sleepに入れるのを1つのスレッドのみに絞ってみます。
Stopwatch sw = new Stopwatch();
object lockobj = new object();

sw.Start();

Parallel.ForEach(Enumerable.Range(0, 4), i => {
    lock(lockobj) {
        Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: Start");
        Thread.Sleep(TimeSpan.FromSeconds(1));
        Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: Finished");
    }
});

sw.Stop();
 41ms, 0: Start
1068ms, 0: Finished
1068ms, 1: Start
2070ms, 1: Finished
2070ms, 2: Start
3073ms, 2: Finished
3073ms, 3: Start
4078ms, 3: Finished

今度は1つのスレッドが確実に終わってから次のスレッドへ移るような実行結果になりました。

今回はlock構文の動作を確かめるための簡単なプログラムですが、通常はデータの読み書きなど複数のスレッドから同時に行われたら都合が悪い部分をlockで囲み排他制御を行うのに使います。

ReadLockとWriteLock

さて、ここまで来て勘のいいひとなら気づいたでしょう。
何でもかんでもデータアクセスにlockをかけるのは決して効率の良いことではないことだと。

データを読み込むだけなら複数のスレッドから同時に行っても問題ないはずです。
ですが、データを読み込んでいる途中に他のスレッドから書き込みが行われたり、データを書き込むときに他のスレッドから書き込みや読み込みを行われては困ります。
//データを書き込みしている途中でも書き込み前後のどちらかの値が取得できさえすればいいなら読み込みを許可してもいいのでは?と思うかもしれませんが、場合によっては書き込み中には書き込み前後のどちらの値でもない値になりうるのです。

lock構文は有無も言わせず排他制御をする構文です。たとえ複数のスレッドがすべて読み込みだけをしたくても同時に行うことを許しません。


.NETにはそれをサポートするためのクラスがあります。

ReaderWriterLockSlim
//名前がいびつですが、実はReaderWriterLockというクラスもあります。このクラスを整理してもっとシンプルにしたものということで、このような名前になっているようです。

機能は上記の通りで、読み取りロックが取得されているときは他のスレッドからも読み取りロックを取得することができますが書き込みロックは取得できません。書き込みロック取得中は読み取り/書き込みロック両方とも取得できません。
ReaderWriterLockSlim rwlock = new ReaderWriterLockSlim();
Stopwatch sw = new Stopwatch();
sw.Start();

Parallel.ForEach(Enumerable.Range(0, 12), i => {
    if(i % 2 == 0) {
        try {
            rwlock.EnterReadLock();
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: EnterReadLock");

            Thread.Sleep(TimeSpan.FromSeconds(1));
        }
        finally {
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: ExitReadLock");
            rwlock.ExitReadLock();
        }
    } else if(i % 2 == 1) {
        try {
            rwlock.EnterWriteLock();
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: EnterWriteLock");

            Thread.Sleep(TimeSpan.FromSeconds(1));
        }
        finally {
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: ExitWriteLock");
            rwlock.ExitWriteLock();
        }
    }

});

sw.Stop();
一気にインデントが多くなって見にくくなってしまいました。Lockは取得したら確実に解放しなければならないので、try-finally構文を使っているためです。
//lock構文も内部的にはMonitorクラスを使ったtry-finally文に展開されます。

これを実行すると次のような実行結果になります。

 70ms, 0: EnterReadLock
 71ms, 6: EnterReadLock
 71ms, 4: EnterReadLock
 71ms, 2: EnterReadLock
 95ms, 8: EnterReadLock
1071ms, 0: ExitReadLock
1095ms, 6: ExitReadLock
1095ms, 4: ExitReadLock
1096ms, 8: ExitReadLock
1096ms, 2: ExitReadLock
1096ms, 11: EnterWriteLock
2096ms, 11: ExitWriteLock
2097ms, 7: EnterWriteLock
3098ms, 7: ExitWriteLock
3098ms, 1: EnterWriteLock
4099ms, 1: ExitWriteLock
4099ms, 5: EnterWriteLock
5099ms, 5: ExitWriteLock
5099ms, 3: EnterWriteLock
6100ms, 3: ExitWriteLock
6100ms, 9: EnterWriteLock
7101ms, 9: ExitWriteLock
7101ms, 10: EnterReadLock
8102ms, 10: ExitReadLock


最初は5つのスレッドが同時にReadLockに入っていますが、WriteLockはおそらくブロックされていることがわかります。そして、ReadLockがすべて解放されると待っていたWriteLockに入っています。WriteLockは1つずつ入っており、10番のReadLockもブロックされていることが分かります。

lock構文のような便利な構文が無いのでtry-finallyの冗長さが目立ってしまう書き方ですが、単純なlockより効率的なデータアクセスが見込めるでしょう。

UpgradeableReadLock

さて、話がどんどん大きくなっていきます。

例えばスレッドセーフなDictionaryを設計しているとき、新たな値を渡されたらどういう処理になるでしょうか。
  1. すでにキーが存在するか確かめる
  2. キーが存在しなければキーと値を追加する
  3. キーが存在すればそのキーの値が同じか確かめる
  4. 値が違っていたら値を書き換える
という流れになるかと思います。
ここで、青色で書いたのは読み取りで、赤色で書いたのは書き込みです。
じゃあこれを実装するときは、青の部分のコードはReadLockを掛けて、赤の部分のときはWriteLockを掛ければいいのかと言えば、答えはNoです。

ReadLockをExitしてからWriteLockを取得するまでの間に別のスレッドがWriteLockを取得して値を書き換えたらどうなるでしょう。1.をやって例えばNoと判定したのに、その直後に別スレッドがそのキーを追加していたら2.を実行するときに意図していない状態になってしまいます。
かといって、ReadLockを取得したままWriteLockを取ろうとするとデッドロックを起こします。他のスレッドもReadLock取得中にWriteLockへ昇格しようとしていた場合、互いに他のスレッドのReadLockが解放されるのを待ってしまい、どちらのスレッドもそこから先へ進めなくなってしまうのです。

じゃあどうするか、一つの答えは全体をWriteLockにすれば良いでしょう。ですが、1.や3.を行っている間は別スレッドが読み取り操作をするだけなら許されるべきです。全体をWriteLockにしてしまうとそれすら許されなくなってしまいます。lock構文は条件が厳しすぎて効率が悪いからReadLockとWriteLockに分けようという話になったのに、結局それが生かせなくなってしまいます。


そこで登場するのがUpgradeableReadLockです。
「WriteLockに昇格予定があるReadLock」は唯一にすることで、昇格時にデッドロックを起こすのを防ぎます。逆に言えば、ただのReadLockは昇格予定の無いReadLockということになります。

具体的には、下表のような動きをします。
現在のLock\取得しようとしているLockReadLockUpgradeableReadLockWriteLock
なし
ReadLock×
UpgradeableReadLock××
WriteLock×××

○がブロックされない(取得できる)Lock、×がブロックされるLockということになります。

実際に動作を試してみましょう。
ReaderWriterLockSlim rwlock = new ReaderWriterLockSlim();
Stopwatch sw = new Stopwatch();
sw.Start();

Parallel.ForEach(Enumerable.Range(0, 12), i => {
    if(i % 3 == 0) {
        try {
            rwlock.EnterUpgradeableReadLock();
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: EnterUpgradeableReadLock");

            Thread.Sleep(TimeSpan.FromSeconds(1));
        }
        finally {
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: ExitUpgradeableReadLock");
            rwlock.ExitUpgradeableReadLock();
        }
    } else if(i % 3 == 1) {
        try {
            rwlock.EnterReadLock();
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: EnterReadLock");

            Thread.Sleep(TimeSpan.FromSeconds(1));
        }
        finally {
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: ExitReadLock");
            rwlock.ExitReadLock();
        }
    } else if(i % 3 == 2) {
        try {
            rwlock.EnterWriteLock();
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: EnterWriteLock");

            Thread.Sleep(TimeSpan.FromSeconds(1));
        }
        finally {
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: ExitWriteLock");
            rwlock.ExitWriteLock();
        }
    }

});

sw.Stop();
 59ms, 0: EnterUpgradeableReadLock
 59ms, 7: EnterReadLock
 59ms, 1: EnterReadLock
 59ms, 4: EnterReadLock
1059ms, 0: ExitUpgradeableReadLock
1060ms, 4: ExitReadLock
1060ms, 1: ExitReadLock
1060ms, 7: ExitReadLock
1061ms, 8: EnterWriteLock
2062ms, 8: ExitWriteLock
2063ms, 11: EnterWriteLock
3064ms, 11: ExitWriteLock
3064ms, 5: EnterWriteLock
4065ms, 5: ExitWriteLock
4065ms, 2: EnterWriteLock
5066ms, 2: ExitWriteLock
5066ms, 10: EnterReadLock
5066ms, 6: EnterUpgradeableReadLock
6068ms, 10: ExitReadLock
6069ms, 6: ExitUpgradeableReadLock
6069ms, 3: EnterUpgradeableReadLock
7069ms, 3: ExitUpgradeableReadLock
7069ms, 9: EnterUpgradeableReadLock
8070ms, 9: ExitUpgradeableReadLock

UpgradeableReadLock中はReadLockは許されていますが、WriteLockや他のUpgradeableReadLockはブロックされているであろうことがわかります。


ちなみに、UpgradeableReadLock中にWriteLockに昇格するには単にEnterWriteLock()~ExitWriteLock()の一連の構文を入れればいいだけです。
ReaderWriterLockSlim rwlock = new ReaderWriterLockSlim();
Stopwatch sw = new Stopwatch();
sw.Start();

Parallel.ForEach(Enumerable.Range(0, 20), i => {
    if(i % 10 == 0) {
        try {
            rwlock.EnterUpgradeableReadLock();
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: EnterUpgradeableReadLock");
            //Thread.Sleep(TimeSpan.FromSeconds(1));
            try {
                rwlock.EnterWriteLock();
                Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: Upgraded");
                Thread.Sleep(TimeSpan.FromSeconds(1));
            }
            finally {
                Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: Downgraded");
                rwlock.ExitWriteLock();
            }
            Thread.Sleep(TimeSpan.FromSeconds(1));
        }
        finally {
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: ExitUpgradeableReadLock");
            rwlock.ExitUpgradeableReadLock();
        }
    } else {
        try {
            rwlock.EnterReadLock();
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: EnterReadLock");
            Thread.Sleep(TimeSpan.FromSeconds(1));
        }
        finally {
            Console.WriteLine($"{sw.ElapsedMilliseconds,4}ms, {i,2}: ExitReadLock");
            rwlock.ExitReadLock();
        }
    }
});

sw.Stop();
 49ms, 3: EnterReadLock
 49ms, 1: EnterReadLock
 49ms, 2: EnterReadLock
 49ms, 4: EnterReadLock
 49ms, 0: EnterUpgradeableReadLock
 83ms, 5: EnterReadLock
 86ms, 6: EnterReadLock
 86ms, 7: EnterReadLock
 88ms, 8: EnterReadLock
1050ms, 3: ExitReadLock
1084ms, 1: ExitReadLock
1084ms, 2: ExitReadLock
1085ms, 4: ExitReadLock
1085ms, 5: ExitReadLock
1087ms, 7: ExitReadLock
1087ms, 6: ExitReadLock
1088ms, 8: ExitReadLock
1088ms, 0: Upgraded
2089ms, 0: Downgraded
2089ms, 17: EnterReadLock
2089ms, 15: EnterReadLock
2089ms, 13: EnterReadLock
2089ms, 12: EnterReadLock
2089ms, 14: EnterReadLock
2089ms, 18: EnterReadLock
2089ms, 11: EnterReadLock
2089ms, 16: EnterReadLock
2089ms, 9: EnterReadLock
3037ms, 19: EnterReadLock
3089ms, 0: ExitUpgradeableReadLock
3089ms, 10: EnterUpgradeableReadLock
3092ms, 15: ExitReadLock
3092ms, 17: ExitReadLock
3093ms, 12: ExitReadLock
3093ms, 14: ExitReadLock
3094ms, 11: ExitReadLock
3093ms, 13: ExitReadLock
3095ms, 16: ExitReadLock
3095ms, 9: ExitReadLock
3093ms, 18: ExitReadLock
4037ms, 19: ExitReadLock
4037ms, 10: Upgraded
5038ms, 10: Downgraded
6038ms, 10: ExitUpgradeableReadLock

UpgradeableReadLockからWriteLockに昇格している間は他スレッドがReadLockを取得するのをブロックされていますが、WriteLock終了後(降格後)はUpgradableReadLockをExitしていなくても再びReadLockが取れるようになっていることがわかります。


この節の最初で「UpgradeableReadLockはスレッドセーフなDictionaryを作るのに使える」と言いましたが、多分Dictionary程度だとこの程度の効率化はたかが知れています。
しかし、MVVMなどのフレームワークにおいては大きく効果が出ます。値の変更後に逐一イベントが飛んで、イベントでいろいろな更新作業を行います。 その更新作業中ずっとWriteLockにして他のReadアクセスをブロックしていたら効率がかなり落ちてしまいます。このUpgradeableReadLockそのような場面で威力を発揮する排他制御手法となるでしょう。