クラスの定義を変えずにプロパティを追加したい?ConditionalWeakTable でスマートに解決

コンピュータ

WPF のコントロールを拡張する Helper のコードを書いていて、

状態を管理するプロパティが一つでもあるだけで、
出来ることが格段に増える
と感じる場面があります。

思いつく方法としては、

FrameworkElement.Tag プロパティ

が挙げられます。

少し古い手法にも見えますが、FrameworkElement に元から用意されているプロパティなので、
大半のコントロールで共通して使える点は魅力的です。

型は object のため、何でも受け入れられる柔軟さがありますが、
取り出して使う際にはキャストが必須になります。

また、Tag プロパティは本来の用途もあるため、
ユーザープログラム側で常用するのは少しもったいないと感じます。

次に思いつくのは、

private static Dictionary<string, object> _state = [];

のように、static なメンバー変数で状態を管理する方法です。

キーにコントロール名を指定し、値は object 型で何でも入れられる器にします。
コントロール名は基本的に重複しないため、一見すると問題なさそうに見えます。

アプリケーションで使うのであれば、ライフサイクルはプロセスと同じで問題ないでしょう
(もちろん大きなデータを保持する場合は注意が必要ですが)。

ただし、Dictionary はキーを強参照するため、
管理を誤ると GC の対象にならず、リークの原因になる可能性があります。

調べてみたところ、

ConditionalWeakTable<TKey, TValue> クラス

が、まさにこのような用途のために用意されていることが分かりました。

見た目は Dictionary と似ていますが、
キーとして オブジェクト(インスタンス)そのものを使える点が大きな違いです。

Dictionary では、キーとして登録されている限り参照が残り続けますが、
ConditionalWeakTable ではキーのオブジェクトが GC の対象になると、
それに紐づく値も自動的に破棄されます。

つまり、明示的な削除処理を書かずに、安全に状態を管理できるというわけです。

ソースコード

ファイル名:ConditionalWeakTableDemo.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

</Project>

ファイル名:MainWindow.xaml


<Window x:Class="ConditionalWeakTableDemo.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:ConditionalWeakTableDemo"
        mc:Ignorable="d"
        FontSize="16"
        Title="MainWindow" Height="200" Width="300">
    <Grid>
        <Button x:Name="myButton">
            <Button.Width>100</Button.Width>
            <Button.Height>50</Button.Height>
            <Button.Content>null</Button.Content>
        </Button>
    </Grid>
</Window>

Viewにはボタンが一つだけセットされています。


ファイル名:MainWindow.xaml.cs

using System.Windows;

namespace ConditionalWeakTableDemo;

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

        TriStateButtonHelper.InitializeState(myButton);

        myButton.Click += (s, e) =>
        {
            TriStateButtonHelper.MoveToNextState(myButton);
        };
    }
}

メインウィンドウのコードビハインド。

コンストラクタで何やらボタン(myButton)を引数にHelperの初期化を行っているようです。
また、ボタンのクリックイベントでもHelperの処理が実行されているように見えます。


ファイル名:TriStateButtonHelper.cs

using System.Runtime.CompilerServices;
using System.Windows.Controls;

namespace ConditionalWeakTableDemo;

static class TriStateButtonHelper
{
    private static readonly List<string> _stateContents = [
        "開始",
        "停止",
        "リセット"
    ];

    private sealed class TriState
    {
        public int Index { get; set; } = 0;
    }
    private static readonly ConditionalWeakTable<Button, TriState> _table = [];

    public static void InitializeState(Button button, int initialIndex = 0)
    {
        var triState = _table.GetOrCreateValue(button);
        triState.Index = initialIndex;
        button.Content = _stateContents[triState.Index];
    }
    
    public static void MoveToNextState(Button button)
    {
        var triState = _table.GetOrCreateValue(button);
        
        triState.Index++;
        if (triState.Index > _stateContents.Count - 1)
        {
            triState.Index = 0;
        }

        button.Content = _stateContents[triState.Index];
    }
}

ConditionalWeakTableで状態を管理している部分。

今回はクリックの回数に応じて3つの状態をボタンのコンテンツに表示するコードにしてみました。

実行例

最初は「開始」

クリックすると「停止」

さらにクリックすると「リセット」

もう一度押すと「開始」に戻る

解説

TriStateクラスで現在のインデックスを管理しており、そのインスタンスをConditionalWeakTableでButtonのインスタンスと紐づけて管理しています。

生成と取り出しは
_table.GetOrCreateValue(button);
で行っています。

XAML 側で指定する「ビヘイビア(Behavior)」に相当する役割を持ちますが、
Blend の Behavior クラスそのものではありません。

コードビハインド(C#)側で、
より軽量な「外付けの挙動付与」を行うための仕組みと言えます。

コメント