【C#】ViewModelの実装をスニペットで軽減する

WPF/UWP などの XAML 系実装で使用する ViewModel は OSS(Livet, Prism ReactiveProperty) などを使用しない場合、INotifyPropertyChanged 周りの実装が冗長で、繰り返しが面倒なので軽減策の紹介したいと思います。

アプローチ方法は以下の通りです。

  • 共通基底クラスを使用
  • プロパティはスニペット化

確認環境

  • VisualStudio2019
  • C# 9.0

Bindableクラス

まず ViewModel の共通基底クラスとして Bindableクラスを以下のように宣言します。

using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;

/// <summary>
/// ViewModel の共通基底クラス
/// </summary>
public abstract class Bindable : INotifyPropertyChanged, IDisposable
{
    // INotifyPropertyChanged impl
    public event PropertyChangedEventHandler PropertyChanged;

    /// <summary>
    /// 指定した名称で <see cref="INotifyPropertyChanged.PropertyChanged"/> を呼び出します。
    /// </summary>
    protected virtual void RaisePropertyChanged([CallerMemberName] string propertyName = "")
    {
        if (this.PropertyChanged == null)
        {
            return;
        }
        this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }

    #region IDisposable

    private bool disposedValue;

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (!disposedValue)
        {
            if (disposing)
            {
                PropertyChanged = null;
            }
            disposedValue = true;
        }
    }

    #endregion
}

スニペットの作成

次に以下のコードを propvm.snippet としてファイルに保存し、以下のメニューからスニペットを VisualStudio にインポートします。

// インポート方法
> 画面上部の ツール > コード スニペット マネージャー
  > [言語] > CSharp を選択 > インポートボタンを押す

スニペットは以下の通りです。

<?xml version="1.0" encoding="utf-8" ?>
<CodeSnippets  xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet">
  <CodeSnippet Format="1.0.0">
    <Header>
      <Title>Bindable Property</Title>
      <Shortcut>propvm</Shortcut>
      <Description>Property for INotifyPropertyChanged</Description>
      <Author>Takap</Author>
    </Header>
    <Snippet>
      <Declarations>
        <Literal>
          <ID>valueType</ID>
          <Default>int</Default>
          <ToolTip>公開する変数の型で置き換えます。</ToolTip>
        </Literal>
        <Literal>
          <ID>propName</ID>
          <Default>Sample</Default>
          <ToolTip>公開するプロパティの名前で置き換えます。</ToolTip>
        </Literal>
        <Literal>
          <ID>fieldName</ID>
          <Default>sample</Default>
          <ToolTip>内部で使用する変数の名前で置き換えます。</ToolTip>
        </Literal>
      </Declarations>
      <Code Language="csharp"><![CDATA[private $valueType$ _$fieldName$;
public $valueType$ $propName$
{
    get => _$fieldName$;
    set
    {
        if ($valueType$.Equals(_$fieldName$, value))
        {
            _$fieldName$ = value;
            this.RaisePropertyChanged();
        }
    }
}$end$]]></Code>
    </Snippet>
  </CodeSnippet>
</CodeSnippets>

使い方

Bindable が継承されているクラス上で使用する前提です。

インポート後にエディター上で propvm > Tab > Tab と入力すると以下のようにコードが展開されるので必要個所を入力します。

public class MyViewModel : Bindable // Bindable を継承していること
{ 

    // propvm > Tab > Tab で展開されるコード
    
    // 型、フィールド名、プロパティ名の順に入力する
    private int _sample;
    public int Sample
    {
        get => _sample;
        set
        {
            if (int.Equals(_sample, value))
            {
                _sample = value;
                this.RaisePropertyChanged(); // Bindable を継承してないとエラー
            }
        }
    }

以上

【C#】WMIでOSのメモリ使用量を取得する

WMI(Windows Management Infrastructure)という機能を使うとWindowsのシステムの各種情報を取得することができます。今回はこの機能を使用して

確認環境

  • Windows10
  • VisualStudio 2019
  • .NET Framework 4.7.2 / .NET 5

プロジェクトの設定

WMI の機能を使用するために System.Management.dll をプロジェクトの参照に追加する必要があります。

.NET Framework系

.NET Framework のプロジェクトの場合以下操作でアセンブリを追加します。

ソリューションエクスプローラー > 対象のプロジェクト > 参照 > 参照の追加

表示されるウインドウで

アセンブリ > System.Management

にチェックを入れ「OK」を選択します。

.NET 系

Windows 専用の機能のため標準のライブラリには入っていないため nuget から別途入手します。

.NET Core 及び .NET 5 以降だと System.Management は nuget から別途導入します。

VisualStudio の GUI から操作する場合、以下手順で導入できます。

ソリューションエクスプローラー > 対象のプロジェクト > 依存関係 > NuGet パッケージの管理

表示されるタブに「System.Management」と入力し「インストール」を選択します。

パッケージマネージャー経由の場合、以下の操作でコンソールを表示し

ツール > NuGet パッケージマネージャー > パッケージ マネージャー コンソール

コンソールに以下を入力してパッケージを導入します。

// .NET 5
Install-Package System.Management -Version 5.0.0

// .NET 6
Install-Package System.Management -Version 6.0.0

メモリ使用量を取得する

以下、メモリ使用量を WMI から1秒ごとに取得する場合の実装例となります。

using System;
using System.Management;
using System.Threading;


internal class Program
{
    private static void Main(string[] args)
    {
        using (var wt = new WmiTrance())
        {
            wt.Init();

            while (true)
            {
                WmiMemoryInfo info = wt.GetInfo();
                Console.Write($"{info.TotalMemory}, ");
                Console.Write($"{info.FreePhysicalMemory}, ");
                Console.Write($"{info.TotalVirtualMemorySize}, ");
                Console.WriteLine($"{info.FreeVirtualMemory}");
                Thread.Sleep(1000);
            }
        }
    }
}

/// <summary>
/// WMIからトレース用にPCの統計情報出力します。
/// </summary>
public class WmiTrance : IDisposable
{
    private ManagementClass mc;

    public void Init()
    {
        this.mc = new ManagementClass("Win32_OperatingSystem");
    }

    public WmiMemoryInfo GetInfo()
    {
        using (ManagementObjectCollection moc = mc.GetInstances())
        {
            foreach (ManagementObject mo in moc)
            {
                using (mo)
                {
                    // 物理メモリ合計
                    ulong a = (ulong)mo["TotalVisibleMemorySize"];
                    // 物理メモリFree
                    ulong b = (ulong)mo["FreePhysicalMemory"];
                    // 総メモリ合計
                    ulong c = (ulong)mo["TotalVirtualMemorySize"];
                    // 総メモリFree
                    ulong d = (ulong)mo["FreeVirtualMemory"];
                    
                    return new WmiMemoryInfo(a, b, c, d);
                }
            }
        }
        return default;
    }

    public void Dispose()
    {
        using (this.mc) { }
        this.mc = null;
    }
}

/// <summary>
/// WMIから取得したメモリ情報を記録します。
/// </summary>
public struct WmiMemoryInfo
{
    // 物理メモリ合計
    public readonly ulong TotalMemory;
    // 物理メモリ空き容量
    public readonly ulong FreePhysicalMemory;
    // 総メモリ合計
    public readonly ulong TotalVirtualMemorySize;
    // 総メモリ空き容量
    public readonly ulong FreeVirtualMemory;

    public WmiMemoryInfo(ulong totalMemory, ulong freePhysicalMemory, ulong totalVirtualMemorySize, ulong freeVirtualMemory)
    {
        this.TotalMemory = totalMemory;
        this.FreePhysicalMemory = freePhysicalMemory;
        this.TotalVirtualMemorySize = totalVirtualMemorySize;
        this.FreeVirtualMemory = freeVirtualMemory;
    }
}

「new ManagementClass("Win32_OperatingSystem");」でオブジェクトを作成し、「GetInstances」メソッドで情報を取得します。取れる情報はコレクションになっているので foreach などで回す必要があります(ただし今回は複数要素数入って来ることは無いので初回でループを終了します)

ManagementObjectCollection はプロパティ名の文字列でインデクサでアクセスすると対応した値が取得できます。

また、GetInstances メソッドは処理速度がかなり遅いので(実行すると ~50ms 程度かかってしまうので)高頻度の実行は避けましょう。非同期版のメソッドは用意されていないためメインスレッド上で高頻度で実行した場合GUIの応答性が低下する可能性があります。

【C#】Taskのキャンセル

C# 標準の Task のキャンセルの方法です。どちらも同じ方法でキャンセルできます。

標準で CancellationTokenSource から得られる CancellationToken を Task.Run の第2 引数に渡すことでキャンセルをハンドルできるようになります。

Taskのキャンセル

static CancellationTokenSource _cs = new();

private static void Main(string[] args)
{
    Foo(_cs.Token);

    while (true)
    {
        Console.Write(">");
        string input = Console.ReadLine();
        if (string.Compare(input.Trim(), "cancel", true) == 0)
        {
            _cs.Cancel();
        }
    }
}

private static async void Foo(CancellationToken ct)
{
    await Task.Run(() =>
    {
        try
        {
            for (int i = 0; i < 100; i++)
            {
                if (ct.IsCancellationRequested)
                {
                    // 検出されたかどうか?
                    Console.WriteLine("キャンセルを検出しました。");
                }

                // キャンセルされてたら OperationCanceledException を投げる
                ct.ThrowIfCancellationRequested();

                Trace.WriteLine($"{DateTime.Now:HH:mm:ss} [{i}]");

                Task.Delay(1000).Wait();
            }
        }
        catch (OperationCanceledException ex)
        {
            Console.WriteLine(ex);
        }
    }
    , ct);
}

上位側で Cancel() を呼び出しても自動で停止したり、例外が勝手に発生したりはしません。

CancellationToken の IsCancellationRequested を参照することで Task 内でキャンセルを検出することが可能です。検出したいタイミングでプロパティを都度確認します。

キャンセルされたのを検出したら例外を投げる場合 ThrowIfCancellationRequested メソッドを使用すると OperationCanceledException が発生します。

Unityの場合

Unity の場合は、Task.Run(... を UniTask.Run(... に変更する + 戻り値がある場合 Task から UniTask に変更することで対応できます。扱い方は完全に同じです。

以上

【C#】INIファイルを読み書きする(DllImportなし)

INI ファイルを C# で読み書きするために DllImport で Windows の User32.dll 使うのは確かに簡単ですが汎用性に欠けると思うので、純粋な C# のみかつ、.NET 4.6 くらいからコピペで使えるライブラリを作成してみました。

ライブラリの性能

このライブラリの性能は以下の通りです。

  • DllImport は使わない, Win32APIは不使用
  • C#6.0~準拠、.NET Framework4.6以降であれば動作可能
  • iniファイルの読み取り・編集・保存ができる
  • 新規にiniファイルを作成できる
  • ★コメントや空白行, 形式がが元のまま保持される
  • ★コメントや空白行は編集・削除・変更可能
  • ★セクション・キー・コメントの並び順が変わらない

★の個所が他のライブラリと異なる点です。

ini ファイル読み書き参照できること、保存時や無効行が消えない事、設定の並び順は可能な限り元のファイルの状態を保持する性能があります。業務だとこの特徴があると助かるかもしれません。

また、利用に当たり注意事項以下の2点あるのでご注意ください

  • 読み取り・保存は非同期APIになっている
  • マルチスレッドでの動作は保証しない

コードはGithub上に公開しているので、何か不都合が各自修正も可能です。

確認環境

このライブラリは以下環境で動作確認しています。

  • .NET Framework 4.6.2(C#6.0), UnityでもOK
  • .NET 5(C#9.0)
  • VisualStudio 2019
  • Windows10

形式とサンプルファイル

INIファイルのフォーマット

INIファイルのフォーマットは以下の通りです。

[セクション]
キー=値
キー=値
;コメント行
(空白行)
[セクション2]
キー=値
キー=

ルールは概ね以下の通りです。

  • セクション名は "[" ~ "]" で囲まれている
  • 行中に = があれば左がキー、右が値
    • イコールが行中に複数ある場合一番最初の = から左をキーとして扱う、残りはすべて値
  • 行頭に ; がある場合コメントとして扱う
  • 何も記載がない空白行が存在する

使用するサンプルファイル

今回動作確認には以下のようなファイルを使用します。読み取って値を編集せずに保存した場合、以下の形式が完全に保存されることを保証しようと思います。

;;;asdf
;123
;zxcv

[aaa]
key1=aaa
key2=bbb
key3=ccc
;key4=ddd

;key5=eee
rgahwgargaga;agr=se

key6=fff=fff

[bbb]
a=1
b=2
c=3

使い方

先ずはライブラリの使い方です。

既存のファイルを読み書きする

既存のファイルを読み取って内容を編集・保存する場合以下のように記述します。

public async static Task SaveAndLoad()
{
    // .NET Core以降だけ必要が必要以下をコメントイン
    Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

    // (1) オブジェクトの作成とファイルの読み取り
    var doc = new IniDocument();
    await doc.Load(@"d:\sample.ini", Encoding.GetEncoding("Shift-JIS"));

    // (2) 既存のセクションを取得
    IniSection s1 = doc.GetSection("aaa");

    // (3) セクションを全部取得
    foreach (IniSection s in doc.GetSections())
    {
        string name = s.Name;
        // aaa
        // bbb
    }

    // (4) 値の取得 & 書き換え
    string value1 = doc.GetValue("bbb", "a");
    doc.SetValue("bbb", "a", "999");
    string value2 = doc.GetValue("bbb", "a");
    // > value1=1
    // > value1=999

    // (5) コメントの追加
    doc.SetText("aaa", "-x-x- comment -x-x-x");

    // (6) ファイル保存
    await doc.Save(@"d:\sample2.ini", Encoding.GetEncoding("Shift-JIS"));
}

新規にINIファイルを作成する

IniDocument クラスを新規に作成して INI ファイルを作成する場合、以下のように記述します。

public async static Task CreateNew()
{
    var doc = new IniDocument();

    // (1) セクションだけ追加
    doc.CreateSection("aaa");

    // (2) セクションと値の追加
    doc.SetValue("section", "b", 1);
    doc.SetValue("section", "c", "2");
    doc.SetText("section", ";This is sample key & value."); // コメントの追加
    doc.SetValue("section", "d", 4.25);
    doc.SetText("section", ""); // セクションの最後に空白行

    // (3) セクションと値の追加
    doc.SetValue("section2", "b", 1);
    doc.SetValue("section2", "c", "2");
    doc.SetText("section2", ";This is sample key & value."); // コメントの追加
    doc.SetValue("section2", "d", 4.25);
    doc.SetText("section2", ""); // セクションの最後に空白行

    // (4) ファイル保存
    await doc.Save(@"d:\sample3.ini", Encoding.GetEncoding("Shift-JIS"));
}

実装コード

すごい長くなってしまったので Github にアップロードしました。以下リポジトリ参照ください。

github.com

基本的に IniDocument クラスからファイルに対するすべての処理が行えるように実装しています。IniDocument クラス内のメソッドの説明は以下の通りです。

メソッド名 説明
Save ファイルパスか Stream に内容を書き出す
Load ファイルパスか Stream から内容を読み取る
GetSections セクションを列挙する
GetSection 名前を指定してセクションを取得する
CreateSection セクションを新規に作成する
RemoveSection 名前を指定してセクションを削除する
ChangeIndex セクションの位置を変更する
GetElements セクション内の行をすべて列挙する
SetValue キーを指定して値を変更する
SetText コメント行を追加する
InsertValue 指定した位置にキーを追加する
InsertText 指定した位置のコメントを取得する
GetValue キーに対する値を取得する
GetValue 指定した位置のコメントを取得する
RemoveValue キーを削除する
RemoveText 位置を指定してコメントを削除する

詳細はリポジトリにアップしますが IniDocumentクラスのメソッド定義を以下に記載しておきます。

public class IniDocument
{
    // -x-x- ファイルの読み書き -x-x- 

    // 指定したファイルパスに現在のオブジェクトの内容を保存する
    public async Task Save(string filePath, Encoding encoding)

    // 指定したストリームに現在のオブジェクトの内容を保存する
    public async Task Save(StreamWriter sw)

    // 指定したファイルパスを読み取って内容をロードします。
    public async Task Load(string filePath, Encoding encoding)

    // 指定したストリームを読み取って内容をロードします。
    public async Task Load(StreamReader sr)

    // -x-x- セクションの操作 -x-x-x

    // セクションを列挙する
    public IEnumerable<IniSection> GetSections()

    // セクションを取得する
    //  → 存在しない場合例外が出る
    public IniSection GetSection(string name)

    // try - parse パターンでセクションを取得する
    public bool TryGetSection(string name, out IniSection section)

    // セクションを追加する
    public IniSection CreateSection(string name)

    // セクションを削除する
    //  → 存在しないセクションでも例外でない
    public void RemoveSection(string name)

    // セクションの位置を変更する
    public void ChangeIndex(string name, int newIndex)

    // -x-x- セクション内の操作 -x-x-

    // セクション内の要素を全て取得する
    public IEnumerable<IIniElement> GetElements(string sectionName)

    // 指定した値を設定する
    //  → セクション・キーが無い場合末尾に新規作成して追加
    public void SetValue<T>(string sectionName, string key, T value)

    // コメントなどのテキストを追加する
    //  → このメソッドで key=value と入力してもこのオブジェクト中はテキスト扱いになる
    public void SetText(string sectionName, string text)

    // 途中に値を挿入する
    public void InsertValue(string sectionName, int index, string key, string value)

    // 途中にコメントなどのテキストを挿入する
    public void InsertText(string sectionName, int index, string text)

    // 指定したキーの値を取得する
    //  → 存在しない場合例外が出る
    public string GetValue(string sectionName, string key)

    // 値を try - parse パターンで取得する
    public bool TryGetValue(string sectionName, string key, out IIniElement elem)

    // 指定した位置のテキストを取得する
    //  → コメント行はキーが無いのでインデックスアクセスする
    public string GetText(string sectionName, int index)

    // 指定したキーを削除する
    public void RemoveValue(string sectionName, string key)

    // 指定したインデックスの要素を削除します
    public void RemoveText(string sectionName, int index)
}

以上

【C#】正規表現で一致した部分を取得する

C#の正規表現で、パターンに一致した部分を取得することをグループ化する、などと呼びます。この正規表現のある条件に一致した部分を取得する方法の紹介です。

確認環境

  • VisualStudio2019
  • C# 9.0(バージョン不問

名前空間

using System.Text.RegularExpressions;

使用するメソッド

Match result = Regex.Match(対称の文字列, 正規表現);
if(result.Success)
{
    // 条件に一致したときの処理
}

グループ化の指定

例えば14個の数字の並びを抽出する方法は以下の通り

(?<group>\d{14})

ダイヤモンドカッコで囲んだ中身がグループ化したときの変数名、外側の(?のカッコ内でくくった範囲内がグループ化される。

使用例

ファイル名から数字部分を取り出す場合以下のように記述する

Match m = Regex.Match("Sample_202204031025_log.log", @"(?<date>\d{14})");
if(m.Success)
{
    string str = m.Groups["date"].Value;
    // str=202204031025
}

以上

【C#】DateTime.TryParseExactの使い方

すぐ忘れて何度も調べることになるのでメモ

確認環境

  • VisualStudio2019
  • C# 9.0(バージョン不問

コード例

具体的にフォーマットを指定する場合以下のように指定する

bool isOK = 
    DateTime.TryParseExact("20220413123045", "yyyyMMddHH:mm:ss", 
        CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result);

if (isOK)
{
    // 変換できた時の処理
}
  • 上記引数は雑に以下認識でOK
    • CultureInfo.InvariantCulture = 言語形式に依存しない形式、
    • DateTimeStyles.None = 具体的に形式を指定する場合NoneでOK

具体的とは、"yyyyMMdd"のこと。逆に形式を "s"、"D"、"r"、"F" など言語によって表示が変わる形式を Parse する場合、カルチャーは対象言語を正しく指定して、DateTimeStyles は入力される可能性のある文字列に対して正しく設定すること(=多言語対応で無限にトラブルが出るのでこれは全く推奨できない)

【C#】TypeCodeを使った高速な型判定を行う?

ライブラリなどの比較的低レイヤー寄りの実装をしていると受け取った、ジェネリックな型や、object 型で受け取った変数がどの型なのかを判定するケースがありますが、タイトルの通り TypeCode を使うと Type を使った判定より、効率的かつ高速な処理にできるケースがあります。

今回はそんな TypeCode を使った高速な型判定の方法を紹介します。

確認環境

  • .NET 5, C#9.0
  • VisualStudio2019
  • Windows10

この記事はリリースビルドをコンソールから実行して動作検証をしています。

初めに

まず始めに、対象となる状況ですが以下のようなケースを想定します。

入力されたジェネリックの型 <T> に応じて int なら 1、double なら 2、それ以外なら -1 を返すような処理です。特に何も考慮しない場合以下のような処理を書くと思います。

// 入力された変数の型に応じて数値を返す
public static int Foo<T>(T input)
{
    Type inputType = input.GetType();
    if (inputType == typeof(int))
    {
        return 1;
    }
    else if (inputType == typeof(double))
    {
        return 2;
    }
    return -1;
}

これだと毎回 typeof で型を取る処理が無駄なので static な変数に値をとって型をキャッシュしたりします。

// staticな変数にTypeをキャッシュする
private static readonly Type INT_TYPE = typeof(int);
private static readonly Type DOUBLE_TYPE = typeof(double);

public static int Foo<T>(T input)
{
    Type inputType = input.GetType();
    if (inputType == INT_TYPE) // 判定時に計算済みのキャッシュを利用する
    {
        return 1;
    }
    else if (inputType == DOUBLE_TYPE)
    {
        return 2;
    }
    return -1;
}

この記事では、こういった処理を TypeCode で置き換えることで特定のケースで高速化できるよという話です。

TypeCodeを使った型判定

まず TypeCode が何かですが、TypeCode とは以下のように定義される enum になります。

// System.Runtime.dll

namespace System
{
    //
    // 概要:
    //     Specifies the type of an object.
    public enum TypeCode
    {
        Empty = 0,
        Object = 1,
        DBNull = 2,
        Boolean = 3,
        Char = 4,
        SByte = 5,
        Byte = 6,
        Int16 = 7,
        UInt16 = 8,
        Int32 = 9,
        UInt32 = 10,
        Int64 = 11,
        UInt64 = 12,
        Single = 13,
        Double = 14,
        Decimal = 15,
        DateTime = 16,
        String = 18
    }
}

このように、C# の基本の組み込み型 + null, DBNull, DateTime, string の 4 つの型を加えた 19個が定義されています。

先述の型判定であらかじめ処理する型が static は変数にキャッシュできる場合、まぁまぁ高速に処理することができますが、TypeCode を使うと、特にプリミティブ型などを対象とした処理の速度を僅かに向上させることができます。

この TypeCode を使った実装は以下のように記述できます。

public static string ParseByTypeCode<T>(T input)
{
    TypeCode code = Type.GetTypeCode(input.GetType());
    switch (code)
    {
        case TypeCode.Empty: return nameof(TypeCode.Empty);
        case TypeCode.Object: return nameof(TypeCode.Object);
        case TypeCode.DBNull: return nameof(TypeCode.DBNull);
        case TypeCode.Boolean: return nameof(TypeCode.Boolean);
        case TypeCode.Char: return nameof(TypeCode.Char);
        case TypeCode.SByte: return nameof(TypeCode.SByte);
        case TypeCode.Byte: return nameof(TypeCode.Byte);
        case TypeCode.Int16: return nameof(TypeCode.Int16);
        case TypeCode.UInt16: return nameof(TypeCode.UInt16);
        case TypeCode.Int32: return nameof(TypeCode.Int32);
        case TypeCode.UInt32: return nameof(TypeCode.UInt32);
        case TypeCode.Int64: return nameof(TypeCode.Int64);
        case TypeCode.UInt64: return nameof(TypeCode.UInt64);
        case TypeCode.Single: return nameof(TypeCode.Single);
        case TypeCode.Double: return nameof(TypeCode.Double);
        case TypeCode.Decimal: return nameof(TypeCode.Decimal);
        case TypeCode.DateTime: return nameof(TypeCode.DateTime);
        case TypeCode.String: return nameof(TypeCode.String);
        default: return "unknown";
    }
    
    // C# 9.0~の switch ステートメントを使うと以下のように記述できる
    return Type.GetTypeCode(input.GetType()) switch
    {
        TypeCode.Empty => nameof(TypeCode.Empty),
        TypeCode.Object => nameof(TypeCode.Object),
        TypeCode.DBNull => nameof(TypeCode.DBNull),
        TypeCode.Boolean => nameof(TypeCode.Boolean),
        TypeCode.Char => nameof(TypeCode.Char),
        TypeCode.SByte => nameof(TypeCode.SByte),
        TypeCode.Byte => nameof(TypeCode.Byte),
        TypeCode.Int16 => nameof(TypeCode.Int16),
        TypeCode.UInt16 => nameof(TypeCode.UInt16),
        TypeCode.Int32 => nameof(TypeCode.Int32),
        TypeCode.UInt32 => nameof(TypeCode.UInt32),
        TypeCode.Int64 => nameof(TypeCode.Int64),
        TypeCode.UInt64 => nameof(TypeCode.UInt64),
        TypeCode.Single => nameof(TypeCode.Single),
        TypeCode.Double => nameof(TypeCode.Double),
        TypeCode.Decimal => nameof(TypeCode.Decimal),
        TypeCode.DateTime => nameof(TypeCode.DateTime),
        TypeCode.String => nameof(TypeCode.String),
        _ => "unknown",
    };
}

これで、何がうれしいかというと switch 文で処理ができる点です。冒頭の if 判定だと型を判定する場合、if → else if → else if → else if → 目的の処理、という風に型が見つかるまで if で繰りかえしの判定を行いますが、この TypeCode の場合 switch 文で処理できるので TypeCide → switch → 目的の処理という風に無駄なく目的の処理に飛ぶことができます。

但し、判定できるのは、TypeCode に定義された方だけなので、自作の型の場合は冒頭の判定処理を行う必要があります。

Typeを使った型判定

上述の実装と同様の処理を Type を使ったって行う場合以下のような実装になります。

// static な Type のキャッシュ
public static readonly Type Object = typeof(object);
public static readonly Type DBNull = typeof(DBNull);
public static readonly Type Boolean = typeof(bool);
public static readonly Type Char = typeof(char);
public static readonly Type SByte = typeof(sbyte);
public static readonly Type Byte = typeof(byte);
public static readonly Type Int16 = typeof(short);
public static readonly Type UInt16 = typeof(ushort);
public static readonly Type Int32 = typeof(int);
public static readonly Type UInt32 = typeof(uint);
public static readonly Type Int64 = typeof(long);
public static readonly Type UInt64 = typeof(ulong);
public static readonly Type Single = typeof(float);
public static readonly Type Double = typeof(double);
public static readonly Type Decimal = typeof(decimal);
public static readonly Type DateTime = typeof(DateTime);
public static readonly Type String = typeof(string);

public static string Parse<T>(T input)
{
    Type type = input.GetType();
    if (type == Object) return nameof(Object);
    else if (type == DBNull) return nameof(DBNull);
    else if (type == Boolean) return nameof(Boolean);
    else if (type == Char) return nameof(Char);
    else if (type == SByte) return nameof(SByte);
    else if (type == Byte) return nameof(Byte);
    else if (type == Int16) return nameof(Int16);
    else if (type == UInt16) return nameof(UInt16);
    else if (type == Int32) return nameof(Int32);
    else if (type == UInt32) return nameof(UInt32);
    else if (type == Int64) return nameof(Int64);
    else if (type == UInt64) return nameof(UInt64);
    else if (type == Single) return nameof(Single);
    else if (type == Double) return nameof(Double);
    else if (type == Decimal) return nameof(Decimal);
    else if (type == DateTime) return nameof(DateTime);
    else if (type == String) return nameof(String);
    else return "unknown";
}

事前に型をキャッシュしておかないといけないですし、判定は if なので、プリミティブ型を判定したい場合は、TypeCode + switch が割といい感じかと思います。

速度計測

では、実際の速度計測をしてみたいと思います。

internal class AppMain
{
    private static void Main(string[] args)
    {
        Stopwatch sw1 = new();
        Stopwatch sw2 = new();

        ArrayList list = new()
        {
            new MyClass(),
            DBNull.Value,
            true,
            'a',
            sbyte.MaxValue,
            byte.MaxValue,
            short.MaxValue,
            ushort.MaxValue,
            int.MaxValue,
            uint.MaxValue,
            long.MaxValue,
            ulong.MaxValue,
            float.MaxValue,
            double.MaxValue,
            decimal.MaxValue,
            DateTime.Now,
            "string",
        };

        for (int loop = 0; loop < 10; loop++) {

            for (int i = 0; i < 10000; i++)
            {
                foreach (var item in list)
                {
                    sw1.Start();
                    string a = PrimitiveInfo.Parse(item);
                    sw1.Stop();

                    sw2.Start();
                    string b = PrimitiveInfo.ParseByTypeCode(item);
                    sw2.Stop();
                }
            }

            Console.WriteLine($"[{loop+1}]回目");
            Console.WriteLine($"{sw1.Elapsed.TotalMilliseconds}ms");
            Console.WriteLine($"{sw2.Elapsed.TotalMilliseconds}ms");
        }

        Console.WriteLine($"[合計]");
        Console.WriteLine($"{sw1.Elapsed.TotalMilliseconds}ms");
        Console.WriteLine($"{sw2.Elapsed.TotalMilliseconds}ms");
    }
}

public class MyClass
{
    public int ID { get; set; }
}

public static class PrimitiveInfo
{
    public static readonly Type Object = typeof(object);
    public static readonly Type DBNull = typeof(DBNull);
    public static readonly Type Boolean = typeof(bool);
    public static readonly Type Char = typeof(char);
    public static readonly Type SByte = typeof(sbyte);
    public static readonly Type Byte = typeof(byte);
    public static readonly Type Int16 = typeof(short);
    public static readonly Type UInt16 = typeof(ushort);
    public static readonly Type Int32 = typeof(int);
    public static readonly Type UInt32 = typeof(uint);
    public static readonly Type Int64 = typeof(long);
    public static readonly Type UInt64 = typeof(ulong);
    public static readonly Type Single = typeof(float);
    public static readonly Type Double = typeof(double);
    public static readonly Type Decimal = typeof(decimal);
    public static readonly Type DateTime = typeof(DateTime);
    public static readonly Type String = typeof(string);

    public static string Parse<T>(T input)
    {
        Type type = input.GetType();
        if (type == Object) return nameof(Object);
        else if (type == DBNull) return nameof(DBNull);
        else if (type == Boolean) return nameof(Boolean);
        else if (type == Char) return nameof(Char);
        else if (type == SByte) return nameof(SByte);
        else if (type == Byte) return nameof(Byte);
        else if (type == Int16) return nameof(Int16);
        else if (type == UInt16) return nameof(UInt16);
        else if (type == Int32) return nameof(Int32);
        else if (type == UInt32) return nameof(UInt32);
        else if (type == Int64) return nameof(Int64);
        else if (type == UInt64) return nameof(UInt64);
        else if (type == Single) return nameof(Single);
        else if (type == Double) return nameof(Double);
        else if (type == Decimal) return nameof(Decimal);
        else if (type == DateTime) return nameof(DateTime);
        else if (type == String) return nameof(String);
        else return "unknown";
    }

    public static string ParseByTypeCode<T>(T input)
    {
        TypeCode code = Type.GetTypeCode(input.GetType());
        switch (code)
        {
            case TypeCode.Empty: return nameof(TypeCode.Empty);
            case TypeCode.Object: return nameof(TypeCode.Object);
            case TypeCode.DBNull: return nameof(TypeCode.DBNull);
            case TypeCode.Boolean: return nameof(TypeCode.Boolean);
            case TypeCode.Char: return nameof(TypeCode.Char);
            case TypeCode.SByte: return nameof(TypeCode.SByte);
            case TypeCode.Byte: return nameof(TypeCode.Byte);
            case TypeCode.Int16: return nameof(TypeCode.Int16);
            case TypeCode.UInt16: return nameof(TypeCode.UInt16);
            case TypeCode.Int32: return nameof(TypeCode.Int32);
            case TypeCode.UInt32: return nameof(TypeCode.UInt32);
            case TypeCode.Int64: return nameof(TypeCode.Int64);
            case TypeCode.UInt64: return nameof(TypeCode.UInt64);
            case TypeCode.Single: return nameof(TypeCode.Single);
            case TypeCode.Double: return nameof(TypeCode.Double);
            case TypeCode.Decimal: return nameof(TypeCode.Decimal);
            case TypeCode.DateTime: return nameof(TypeCode.DateTime);
            case TypeCode.String: return nameof(TypeCode.String);
            default: return "unknown";
        }

        // C# 9.0~の switch ステートメントを使うと以下のように記述できる
        return Type.GetTypeCode(input.GetType()) switch
        {
            TypeCode.Empty => nameof(TypeCode.Empty),
            TypeCode.Object => nameof(TypeCode.Object),
            TypeCode.DBNull => nameof(TypeCode.DBNull),
            TypeCode.Boolean => nameof(TypeCode.Boolean),
            TypeCode.Char => nameof(TypeCode.Char),
            TypeCode.SByte => nameof(TypeCode.SByte),
            TypeCode.Byte => nameof(TypeCode.Byte),
            TypeCode.Int16 => nameof(TypeCode.Int16),
            TypeCode.UInt16 => nameof(TypeCode.UInt16),
            TypeCode.Int32 => nameof(TypeCode.Int32),
            TypeCode.UInt32 => nameof(TypeCode.UInt32),
            TypeCode.Int64 => nameof(TypeCode.Int64),
            TypeCode.UInt64 => nameof(TypeCode.UInt64),
            TypeCode.Single => nameof(TypeCode.Single),
            TypeCode.Double => nameof(TypeCode.Double),
            TypeCode.Decimal => nameof(TypeCode.Decimal),
            TypeCode.DateTime => nameof(TypeCode.DateTime),
            TypeCode.String => nameof(TypeCode.String),
            _ => "unknown",
        };
    }
}

[1]回目
6.1562ms
4.1258ms
[2]回目
12.1973ms
8.0042ms
[3]回目
18.2653ms
11.9062ms
[4]回目
24.3487ms
15.8772ms
[5]回目
30.4158ms
19.7781ms
[6]回目
36.5826ms
23.7604ms
[7]回目
42.801ms
27.6657ms
[8]回目
48.8249ms
31.5796ms
[9]回目
54.841ms
35.4524ms
[10]回目
60.9232ms
39.362ms
[合計]
60.9232ms
39.362ms

大体1.5倍くらい高速ですかね?まぁまぁ効果的だと思います。

最後に、とはいっても、、、

ちなみに TypeCode の方が早いと確認しましたが、input.GetType() を利用して型情報を取得しているところを typeof(T) で取得すれば従来の記述方法でも速度的には同じです(input に触ってボクシングが発生しない方が早いに決まってますよね)となると object 型で受けてしまった時の判定だけで有効かと思いますがそもそもそれは避けたいパターンなのでジェネリックでかけるところはジェネリックだし、、、

と考え始めると実際は色々悩ましいです。

TypeCode の取得は IConvertible に GetTypeCode メソッドが定義されているのでプリミティブ型などは基本これを持っているので

TypeCode code;
if (input is IConvertible con)
{
    code = con.GetTypeCode();
}
else
{
    code = Type.GetTypeCode(input.GetType());
}

などとすれば一見効率的そうですが、判定事態にコストが発生するため逆に遅くなってしまいま(汗

実際は、IConvertible を継承したい型だけ受け取るとか、制約としては厳しすぎてライブラリのコードとして適用範囲が狭くなって使いづらいですしなんとも微妙です。このパターンで実際に処理速度が速くなる適用可能なケースも、そう多くなさそうなため、型判定で処理速度を稼ぎたいと思った時に、なんとなく TypeCode でも型判定できるよーって覚えていればいい程度だと思います。

以上。

【Unity】2D用のNavMeshでTimeMap以外の領域や障害物をベイクする

2D 用の NavMesh の導入方法とタイルマップへの適用方法は記事がいくつかあるので、導入~タイルマップに適用するまでは比較的簡単にできると思います。ただ、実際にトップダウン系のゲームなどで使用する場合、障害物を設置したり経路を動的に足したりできないと実用は厳しいと思います。

そこで今回は以下の3点を紹介します。

  • タイルマップとNavMeshのレイヤーの分け方
  • TimeMap以外のオブジェクトでNavMeshの領域を追加する
  • NavMesh上に障害物配置して通行不可領域を設定する

f:id:Takachan:20220214011942g:plain

確認環境

上記ブランチからソースを取得して、NavMeshComponents/Assets 以下の Gizmos, NavMeshComponents をプロジェクトに取り込んだ後、TimeMapをゲーム上に配置したところから説明を始めます。

タイルマップとNavMeshのレイヤーの分け方

2Dのトップダウンのゲームでタイルマップを使用してキャラクターの移動をコントロールする場合以下の設定を行う事が多いと思います。

  • キャラクターに RigitBody2D + Collder2D
  • タイルマップのキャラクターが移動できない部分にCollder2D

一方、NavMesh2Dの通行可能な範囲の指定は

  • 移動可能な領域をCollider2Dで指定する

となっているため、両方をデフォルトの設定で有効化するとキャラクターがNacMesh用のCollider2Dに衝突して移動できなくなってしまいます。

この場合の設定方法ですが以下のように「プレイヤーが移動するタイルマップ」と「NavMesh用」の2つのタイルマップを作成します。「TileMap」がプレイヤーの移動範囲が設定されたタイルマップで「NavMesh」がNavMeshで使用するタイルマップになります。

f:id:Takachan:20220214012813p:plain

プレイヤーが移動するタイルマップは移動できない範囲にColliderを設定している通常のマップを用意します。この時「TileMap」のゲームオブジェクトのレイヤーは初期値から変更せずに「Default」にしておきます。

f:id:Takachan:20220214013013p:plain

こんな感じで、移動できない範囲が緑色になっています。

次に、NavMesh用のレイヤーですが。前準備として、移動可能な範囲を可視化するために通行可能な場所は「OK」と文字の付いた Collider Type が Grid のタイルを作成します。

f:id:Takachan:20220214013539p:plain

また、移動不能な範囲(これは本当は必要ないですが)も見えるようにするために Collider Type が None のタイルを作成します。

f:id:Takachan:20220214013659p:plain

次にこの「OK」のタイルで移動可能な範囲を塗りつぶします。

f:id:Takachan:20220214013726p:plain

そうしたらもう、この表示が邪魔なので Timemap Renderer のチェックを外しておきます。

f:id:Takachan:20220214014445p:plain

そうすると通行可能な範囲が表示されなくなるので以下のように Collider の緑枠だけが残ります。

f:id:Takachan:20220214014531p:plain

次にプレイヤー用の衝突判定から NavMesh 用のレイヤーを除外する設定を行います。

まず、NavMesh のゲームオブジェクトの Layer を変更します。右側の下矢印をクリックして「Add Layer」を選択します。

f:id:Takachan:20220214014007p:plain

適当な User Later に「TimeMapNavMesh」を入力します(名前はこれじゃなくても任意で大丈夫です。

f:id:Takachan:20220214014120p:plain

作成したレイヤーを NavMesh ゲームオブジェクトに設定します。

f:id:Takachan:20220214014219p:plain

次に Edit > Project Settings > Physics 2D から「Layer Collision Matrix」を以下の通り設定します。

f:id:Takachan:20220214014321p:plain

TimeMapNavMesh の縦の列を全て選択解除します。

これで、NavMeshのColliderとキャラクターが衝突しなくなりました。

余談ですが、この NavMesh 用の Tilemap Collider2D に Composit Collider 2D を追加して、Use By Composote にチェックを入れると NavMesh が生成されなくなるのでコンポーネントを追加してはいけません。

TimeMap以外のオブジェクトでNavMeshの領域を追加する

タイルマップ以外にも任意の範囲を通行可能にする設定です。

f:id:Takachan:20220214014942g:plain

このようにタイルマップ以外のオブジェクトで動的に NavMesh の範囲を増やすことができます。

実装方法ですが、まず任意のゲームオブジェクトに「Box Collider 2D」と「Nav Mesh Source Tag 2D」を追加したものを用意します。ここではBoxCollider の大きさの調整がしやすいように半透明の Sprite Renderer も持ったオブジェクトを作成します。

f:id:Takachan:20220214015236p:plain

これで、任意の位置にこの四角形を配置して「Nav Mesh Builder 2D」を「Bake」するか、Nav Mesh Builder 2D の Update Method を Update か Fix Update に変更してEditor 上でオブジェクトを移動するとベイク範囲にこのオブジェクトの分が足されることが確認できます。

タイルマップの移動範囲の指定だけだとゲームに配置した障害物をエージェントが迂回する表現ができないためこれを実現する実装方法です。

f:id:Takachan:20220214015732g:plain

このように領域を指定してNavMeshから除外するとができるようになります。

これはゲームオブジェクトに「Nav Mesh Obstacle」を設定する事で実現できます。今回も視覚的把握しやすいように Sprite Renderer に Nav Mesh Obstacle を追加しています。インスペクターの内容は以下のようになります。

f:id:Takachan:20220214020259p:plain

最も大切なのは「Carve」にチェックを入れる事です。これをしないとベイクの範囲の反映されません。

f:id:Takachan:20220214020338p:plain

2D だと Z 方向の事を忘れがちですがこのように NavMesh を貫通するように配置してください。

この状態で先ほどと同じように Editor 上で「Nav Mesh Builder 2D」を「Bake」するか、Nav Mesh Builder 2D の Update Method を Update か Fix Update に変更してEditor 上でオブジェクトを移動するとベイク範囲にこのオブジェクトの分が足されることが確認できます。ゲーム中で「Nav Mesh Obstacle」を持つオブジェクトを追加した場合、Manualだと Bake に相当する NavMeshBuilder2D.RebuildNavmesh を呼び出すとスクリプトからでもNavMeshを更新することができます。

【おまけ】プレイヤーを追尾するエージェントの実装

この 2D 用の NavMesh は AI のエージェントは自分で実装しないといけないらしいので冒頭で動いていた追尾してくる白い四角にアタッチしているスクリプトを以下に紹介します。

using System.Collections.Generic;
using Takap.Utility;
using UnityEngine;
using UnityEngine.AI;

/// <summary>
/// プレイヤーを追尾するダミーの敵キャラクターを表します。
/// </summary>
/// <remarks>
/// NavMeshを使った移動方法のリファレンス実装
/// </remarks>
public class Enemy : MonoBehaviour
{
    //
    // Inspector
    // - - - - - - - - - - - - - - - - - - - -

    // 追尾対象
    [SerializeField] Player _player;
    // 移動速度
    [SerializeField] float _moveSpeed = 1f;
    // どれくらい近づいたら次のポイントに移るか
    [SerializeField] float _minDistance = 0.05f;
    // プレイヤーへの経路再計算をする間隔
    [SerializeField] float _reCalcTime = 0.5f;

    //
    // Fields
    // - - - - - - - - - - - - - - - - - - - -

    // 次の移動先
    private Vector2 _nextPoint;
    
    // キャッシュ類
    private Transform _plyaerTransform;
    private Transform _myTransform;
    
    // AI用
    private NavMeshPath _navMeshPath;
    private Queue<Vector3> _navMeshCorners = new();

    // 計算したときのプレイヤーの位置
    Vector3 _calcedPlayerPos;
    // 次に再計算するまでの時間
    private float _elapsed;

    //
    // Runtime impl
    // - - - - - - - - - - - - - - - - - - - -

    public void Awake()
    {
        _myTransform = transform;
        _plyaerTransform = _player.transform;
        _nextPoint = _myTransform.position;
        _navMeshPath = new NavMeshPath();
    }

    public void Update()
    {
        if (_calcedPlayerPos != _plyaerTransform.localPosition)
        {
            _elapsed += Time.deltaTime;
            if (_elapsed > _reCalcTime)
            {
                _elapsed = 0;
                
                NestStep();
                _calcedPlayerPos = _plyaerTransform.localPosition; // ルート出したときの位置
            }
        }

        Vector2 currentPos = _myTransform.localPosition;
        if (Vector2.Distance(_nextPoint, currentPos) < _minDistance)
        {
            if (_navMeshCorners.Count == 0)
            {
                _nextPoint = _myTransform.localPosition;
                return;
            }
            _nextPoint = _navMeshCorners.Dequeue();
        }

        Vector2 diff = _nextPoint - currentPos;
        if (diff == Vector2.zero)
        {
            return;
        }

        Vector2 step = _moveSpeed * Time.deltaTime * diff.normalized;
        _myTransform.Translate(step);
    }

    private void NestStep()
    {
        // NavMeshで経路を計算する
        // 自分の位置 → プレイヤーの位置
        bool isOk = NavMesh.CalculatePath(_myTransform.position,
            _plyaerTransform.position, NavMesh.AllAreas, _navMeshPath);
        if (!isOk)
        {
            Debug.LogWarning("Failed to NavMesh.CalculatePath.", this);
        }
        
        _navMeshCorners.Clear();
        _navMeshCorners.EnqueueRange(_navMeshPath.corners);
        _nextPoint = _myTransform.localPosition;
    }
}

/// <summary>
/// <see cref="Queue{T}"/> の拡張機能を定義します。
/// </summary>
public static class QueueExtension
{
    public static void EnqueueRange<T>(this Queue<T> self, IEnumerable<T> items)
    {
        foreach (var item in items)
        {
            self.Enqueue(item);
        }
    }
}

プレイヤーが移動したことを0.5秒ごとに検出してルートを再設定するようにして追尾を行っています。

追尾対象は「Player」というここでは紹介していないスクリプトですが Transform 以外参照していないので、使用する際は任意の GameObject に変更できます。また、移動速度やコーナーに到着したとみなす幅、再計算時間はインスペクターから指定できるようにしています。

f:id:Takachan:20220214021742g:plain

実行するとこんな感じになります。

参考資料

この記事は以下を参考にさせて頂きました。

ありがとうございます。

watablog.tech

www.matatabi-ux.com

tsubakit1.hateblo.jp

以上。

【C#】2つのキーを管理するDictionary

よく2つのキーを持つ Dictionary が必要になるときがありますが以下のように Dictionary を2重にすると「管理が面倒」「後から見たときに意味が分からない」など技術的負債になりがちです。

// 2つのキーで管理したいのでDictionaryを2重に宣言する
pribate Dictionary<string, Dictionary<string, MyObject> _table = new();

なので、2つのキーを管理できる Dictionary として「DualKeyDictionary」を紹介したいと思います。

作成環境

この記事は以下環境で作成しています。

  • .NET5 + C#9.0
  • VisualStudio2019
  • Windows 10

純粋な C# なので Unity 上でも使用可能です。

実装コード

DualKeyDictionaryクラス

概要

宣言は以下の通りです。

DualKeyDictionary<TKey1, TKey2, TValue>

管理したいキーを TKey1, TKey2 に指定します。

// int2つをキーにしてstringを値として管理する
DualKeyDictionary<int, int,  string> table = new();

// stringとintをキーにして自作のクラスMyObjectを値として管理する
DualKeyDictionary<string, int,  MyObject> table = new();

このクラスに実装されているメソッドは以下の通りです

メソッド名 説明
Add 要素を追加します
GetValue 要素を取得します
ContainsKey 指定した値が存在するか確認します
Remove(key1) key1に関係する要素を全て削除します
RemoveValue(key1, key2) 要素を削除します(要素が無くても例外は出ない)
Count (プロパティ)現在管理中の全ての要素数を取得します
TryCountKey1 key1に管理中のデータの個数を取得します
ClearAll 管理データを全て破棄します

以下は特殊な場合に使用します。

メソッド名 説明
SetRemoveAction 要素がオブジェクトから取り除かれる時に呼び出される処理を設定します
SetDefaultValue 値が存在しない時に返すデフォルト値を設定します(Get のとき)
実装コード

実装コードは以下の通りです。

using System;
using System.Collections.Generic;

/// <summary>
/// 2つのキーを持つデータを管理します。
/// </summary>
public class DualKeyDictionary<TKey1, TKey2, TValue>
{
    // 
    // Fields
    // - - - - - - - - - - - - - - - - - - - -

    private Dictionary<TKey1, Dictionary<TKey2, TValue>> _table = new();
    private Action<TValue> _removeAction;
    private TValue _defaultValue = default;

    // 
    // Methods
    // - - - - - - - - - - - - - - - - - - - -

    /// <summary>
    /// 要素がオブジェクトの管理テーブルから取り除かれる時に呼び出される処理を設定します。
    /// </summary>
    public void SetRemoveAction(Action<TValue> removeAction)
    {
        _removeAction = removeAction;
    }

    /// <summary>
    /// 値が存在しない時に返すデフォルト値を設定します。
    /// </summary>
    public void SetDefaultValue(TValue value)
    {
        _defaultValue = value;
    }

    /// <summary>
    /// 要素を追加します。
    /// </summary>
    public void Add(TKey1 key1, TKey2 key2, TValue value)
    {
        if (!_table.ContainsKey(key1))
        {
            _table[key1] = new Dictionary<TKey2, TValue>();
        }

        var sub = _table[key1];
        if (sub.ContainsKey(key2))
        {
            TValue item = sub[key2];
            _removeAction?.Invoke(item);
        }

        sub[key2] = value;
    }

    /// <summary>
    /// 要素を取得します。
    /// </summary>
    public TValue GetValue(TKey1 key1, TKey2 key2)
    {
        if (!_table.ContainsKey(key1))
        {
            return _defaultValue;
        }

        var sub = _table[key1];
        if (!sub.ContainsKey(key2))
        {
            return _defaultValue;
        }

        return sub[key2];
    }

    /// <summary>
    /// 指定した値が存在するか確認します。
    /// </summary>
    public bool ContainsKey(TKey1 key1, TKey2 key2)
    {
        if (!_table.ContainsKey(key1))
        {
            return false;
        }
        return _table[key1].ContainsKey(key2);
    }

    /// <summary>
    /// 要素を削除します。
    /// </summary>
    public void Remove(TKey1 key1)
    {
        if (!_table.ContainsKey(key1))
        {
            return;
        }
        try
        {
            foreach (var item in _table[key1].Values)
            {
                _removeAction?.Invoke(item);
            }
        }
        finally
        {
            _table.Remove(key1);
        }
    }

    /// <summary>
    /// 要素を削除します。
    /// </summary>
    public void Remove(TKey1 key1, TKey2 key2)
    {
        if (!_table.ContainsKey(key1))
        {
            return;
        }

        var sub = _table[key1];
        if (!sub.ContainsKey(key2))
        {
            return;
        }

        try
        {
            TValue item = sub[key2];
            _removeAction?.Invoke(item);
        }
        finally
        {
            sub.Remove(key2);
        }
    }

    /// <summary>
    /// 現在管理中の全ての要素数を取得します。
    /// </summary>
    public int Count
    {
        get
        {
            int count = 0;
            foreach (var sub in _table.Values)
            {
                count += sub.Count;
            }
            return count;
        }
    }

    /// <summary>
    /// <see cref="TKey2"/> で管理されているデータの個数を取得します。
    /// </summary>
    public bool TryCountKey1(TKey1 key1, out int count)
    {
        if (_table.ContainsKey(key1))
        {
            count = _table[key1].Count;
            return true;
        }

        count = -1;
        return false;
    }

    /// <summary>
    /// 管理データを全て破棄します。
    /// </summary>
    public void ClearAll()
    {
        try
        {
            foreach (Dictionary<TKey2, TValue> sub in _table.Values)
            {
                foreach (TValue item in sub.Values)
                {
                    _removeAction?.Invoke(item);
                }
            }
        }
        finally
        {
            foreach (var item in _table.Values)
            {
                item.Clear();
            }
            _table.Clear();
            _table.TrimExcess();
        }
    }
}

使い方

2つの int をキーに持ち、string を値をして管理する場合以下のような形式になります。

static void Main(string[] args)
{
    // 2つのintがキーでstringを値として管理する
    DualKeyDictionary<int, int, string> table = new();

    // テーブルから管理が外れたときに呼び出される処理の登録
    table.SetRemoveAction(item => Console.WriteLine($"Remove={item}"));

    // 値を追加
    table.Add(0, 0, "aaaa");
    table.Add(0, 1, "aazz");
    table.Add(0, 2, "yyyy");
    table.Add(0, 3, "xxxx");
    table.Add(1, 0, "bbbb");
    table.Add(1, 1, "bbbc");

    // 存在する値を上書き
    table.Add(0, 0, "aaab"); // > Remove=aaaa
    table.Add(0, 0, "aaac"); // > Remove=aaab

    // 値の存在確認
    bool a1 = table.ContainsKey(0, 0); // a1=true
    bool a2 = table.ContainsKey(0, 1); // a2=true

    // 値を削除
    table.Remove(0, 0); // > Remove=aaac
    table.Remove(0, 1); // > Remove=aazz

    // 値の存在確認
    bool b1 = table.ContainsKey(0, 0); // b1=false
    bool b2 = table.ContainsKey(0, 1); // b2=false

    // 値の取得
    var r1 = table.GetValue(1, 0); // r1=bbbb
    var r2 = table.GetValue(1, 1); // r2=bbbc

    // 存在しない値を要求, 例外は出ない
    var r3 = table.GetValue(999, 999); // r3=null, stringのデフォルト値

    // 存在しない値を要求したときの応答値を変更
    table.SetDefaultValue("990909090");
    var r4 = table.GetValue(999, 999); // r4=990909090

    // 管理中のデータ個数を取得
    int c1 = table.Count; // c1=4
    if (table.TryCountKey1(0, out int c2))
    {
        // c2=2
    }

    // 値の削除, key1=0に関係する値を全て削除
    table.Remove(0);
    // >Remove=yyyy
    // >Remove=xxxx

    // 全ての値をクリア
    table.ClearAll();
    // > Remove=bbbb
    // > Remove=bbbc

    // 管理中のデータ個数を取得
    int c3 = table.Count; // c3=0
}

これを使用すれば意味不明になりがちな2重 Dictionary は使用しなくて良くなります。3重 Dictionary とかもありますが、そうなるともう設計ミスの可能性が高いので少し考え直した方がいいかもしれませんね。

以上です。

Dispose呼び出しを簡潔に書く

IDisposable を継承しているクラスは使い終わったら Dispose メソッドを呼び出して破棄を明示しますが using 構文の外で開放したい場合も using 構文を使って簡単に開放できます。

// こんなクラスがあったときに
public class Sample : IDisposable { //...

public static Close(Sample s1, Sample s2)
{
    // ★Disposeは以下のように書けば簡単に呼び出せる
    using(s1) { }
    using(s2) { }
    
    // ↓↓↓↓↓↓
    
    // ★内部的に以下のように安全に開放されるように展開されている
    try {
    }
    finally {
        if (s1 != null) {
            ((IDisposable)s1).Dispose();
        }
    }
    try {
    }
    finally {
        if (s2 != null) {
            ((IDisposable)s2).Dispose();
        }
    }
}

自分で何か書くより圧倒的に簡潔かつ安全な(=コンパイル時にが勝手に生成してくれる)ので、可能な限りこの記述をした方がいいと思います。

ちなみに中括弧のありなしで展開のされ方が微妙に変わります。

public static Close(Sample s1, Sample s2)
{
    
    using(s1)
    using(s2) { } // ★★s1 に中括弧を付けない
    
    // ↓↓↓↓↓↓
    
    // ★★中括弧が無いとネストされた開放になる
    try
    {
        try {
        }
        finally {
            if (s2 != null) {
                ((IDisposable)s2).Dispose(); // ★★s2はネストされて展開される
            }
        }
    }
    finally {
        if (s1 != null) {
            ((IDisposable)s1).Dispose();
        }
    }
}

C# 8.0から追加された「Using Declaration」を使用しても同じ結果になります。

Disposeで開放目的だけの場合こっちの構文は通常使用しないと思います。

public void Close(Sample s1, Sample s2)
{
    using var _1 = s1;
    using var _2 = s2; // ★★Using Declaration 構文を使う
    
    // ↓↓↓↓↓↓
    
    // ★★ネストされた開放になる
    try
    {
        try {
        }
        finally {
            if (s2 != null) {
                ((IDisposable)s2).Dispose(); // ★★s2はネストされて展開される
            }
        }
    }
    finally {
        if (s1 != null) {
            ((IDisposable)s1).Dispose();
        }
    }
}

【C#】デリゲートの引数は呼び方でパフォーマンスが違う

引数がデリゲートのメソッドは呼び出し方がいくつかあります。書き方でコンパイラが展開する方法が異なるります。このため一部実行コストやパフォーマンスにも差が出るようなのでまとめてみました。

確認環境

この記事は以下環境で確認しています。

  • VisualStudio 2019
  • .NET 5(C#9.0)
  • SharpLabでコード確認

呼び出し方の種類

まず、例えば、以下のメソッドがあったとします。

// Actionデリゲート(戻り値も引数もないデリゲート)を引数に取るメソッド
public static void Foo(Action act)
{
    act();
}

次にこのメソッドを呼び出す方法はだいたい以下の4通りがあります。

public static void Sample()
{
    Init();

    // ★(1) デリゲートをnewして呼び出す
    Foo(new Action(OnAction));

    // ★(2) メソッド名だけ指定する
    Foo(OnAction);

    // ★(3) ラムダ式にして呼び出す
    Foo(() => OnAction());
    Foo(() => Console.WriteLine("called"));

    // ★(4) 定義済みのデリゲートを呼び出す
    Foo(_act1); // 4-1
    Foo(_act2); // 4-2
    Foo(_act3); // 4-2
}

// -x-x- 以下、上記処理に必要な実装 -x-x-
private static Action _act1 = () => { Console.WriteLine("called"); };
private static Action _act2;
private static Action _act3;

private static void Init()
{
    _act2 = new Action(OnAction);
    _act3 = () => { Console.WriteLine("called"); };
}

private static void OnAction()
{
    Console.WriteLine("called");
}

public static void Foo(Action act)
{
    act();
}

展開のされ方

上記のコード中のコメントの通りですが、以下4通りの展開のされ方を各々見ていきたいと思います。

  • (1) デリゲートをnewして呼び出す
  • (2) メソッド名だけ指定する
  • (3) ラムダ式にして呼び出す
  • (4) 定義済みのデリゲートを呼び出す

(1)と(2)の展開のされかた

(1) と (2) は結果が同じになります。毎回デリゲートをnewしてデリゲートを作成してメソッドに渡されています。

// ★(1) デリゲートをnewして呼び出す
Foo(new Action(OnAction));
// ★(2) メソッド名だけ指定する
Foo(OnAction);

// ↓↓↓↓

// 同じように展開される
Foo(new Action(OnAction));
Foo(new Action(OnAction));

(3)の展開のされかた

(3) のラムダ式はコンパイラーがかなりインテリジェンスに展開してくれます。

以下少し読みにくいですが展開されたコードをそのまま載せています。少し読み取りにくいですが、初回に new して以降は使いまわしてるようです。メソッドを直接指定と違って毎回 new しませんが、存在チェックが呼び出し毎に入ります。

// (3) ラムダ式にして呼び出す
Foo(() => OnAction());
Foo(() => Console.WriteLine("called"));

// ↓↓↓↓

// 以下のようにいい感じに自動生成される
[Serializable]
[CompilerGenerated]
private sealed class <>c
{
    public static readonly <>c <>9 = new <>c();

    public static Action <>9__1_0;

    internal void <Sample>b__1_0()
    {
        OnAction();
    }

    internal void <Sample>b__1_1()
    {
        Console.WriteLine("called");
    }
}

Foo(<>c.<>9__1_0 ?? (<>c.<>9__1_0 = new Action(<>c.<>9.<Sample>b__1_0)));
Foo(<>c.<>9__1_1 ?? (<>c.<>9__1_1 = new Action(<>c.<>9.<Sample>b__1_1)));

(4)の展開のされかた

(4) はメソッド呼び出しはコード変わらないですが、呼び出し方によって自動生成されたりされなかったりします。ラムダ式を途中で使うと自動生成されるコードが出現します。従って "4-1", "4-3" のような書き方は無駄以外の何物でもないのでやめましょう。最速を狙うなら "4-2" 以外ありません。

// ★(4) 定義済みのデリゲートを呼び出す
Foo(_act1); // 4-1
Foo(_act2); // 4-2
Foo(_act3); // 4-2

private static Action _act1 = () => { Console.WriteLine("called"); };
private static Action _act2;
private static Action _act3;

private static void Init()
{
    _act2 = new Action(OnAction);
    _act3 = () => { Console.WriteLine("called"); };
}

private static void OnAction()
{
    Console.WriteLine("called");
}

// ↓↓↓↓

Foo(_act1); // ここは変わらない
Foo(_act2);
Foo(_act3);

// 以下の自動生成がされる

[Serializable]
[CompilerGenerated]
private sealed class <>c
{
    public static readonly <>c <>9 = new <>c();

    public static Action <>9__5_0;

    internal void <Init>b__5_0()
    {
        Console.WriteLine("called");
    }

    internal void <.cctor>b__9_0()
    {
        Console.WriteLine("called");
    }
}

private static Action _act1 = new Action(<>c.<>9.<.cctor>b__8_0);
private static Action _act2;

private static void Init()
{
    _act2 = new Action(OnAction);
    _act3 = <>c.<>9__5_0 ?? (<>c.<>9__5_0 = new Action(<>c.<>9.<Init>b__5_0));
}

結論

結論は以下の通りです。

  • (a) 引数にメソッド名を指定するのは効率が悪い(=呼び出し毎に new される)
  • (b) ラムダ式はいい感じにしてくれる
  • (c) 一番効率がいいのは"4-2"だけど実装が結構面倒

(a) のメソッド名をデリゲートに直接指定するのは毎回デリゲートを new するため高頻度な実行個所では使わないほうがいいです。Unity のフレーム毎に呼び出される Update メソッドや高頻度で実行される個所にこの書き方を使うと破棄されたデリゲートの GC でスパイクの原因になりやすそうです。

(b) は基本的にメソッドをラムダ式で包んでおけば、あとは「いい感じ」にしてくれます。通常この書き方問題ないです。ベストチョイスです。

(c) はコンパイラーの自動生成を抑止しつつチェックもしないので最速で実行できますが実装はかなり面倒です。(b) と呼び出しの時に null チェックする or しないかだけの差しかないので、このチェック有無は最適化の最後でやっと対象になるかどうか程度のためパフォーマンスを狙って最初からこの実装にする必要は一切無いと思います。

// まとめ

// この呼び出し方はNG
Foo(OnAction);


// いい感じにしてくれるのでラムダを積極的に使う
Foo(() => OnAction());
Foo(() => Console.WriteLine("called"));


// 一番効率が良いが実装が面倒(可読性も落ちる)
Foo(_act2);

private static Action _act2;
private static void Init()
{
    _act2 = new Action(OnAction);
}
private static void OnAction()
{
    Console.WriteLine("called");
}

「ラムダ式本体によるメソッドの記述」はちょっと微妙

C# 6.0 から追加された「Expression bodies on method-like members(ラムダ式本体によるメソッドの記述)」という機能ですが、これをメソッドに使うのは少し考えた方がいいという話です。この機能を利用すると以下のようにメソッドで中カッコ({})を使用せずラムダ式風(=>)に記述することができます。

// 以下のようにメソッドの中身をラムダ風に記述できる
public static void Foo() => Console.WriteLine("Hey!");

VisualStudio では以下のように単一行のメソッドのリファクタリングの候補として「メソッドに式本体を使用する」という項目が(さもオススメという風に)表示されるので適用したことがある人もいるかと思います。

f:id:Takachan:20220115191309p:plain

この提案によって「メソッドに式本体を使用する」を適用するとメソッドは以下の形式に変換されます。

// 適用前
public static void Foo()
{
    Console.WriteLine("Hey!");
}

// 適用後
public static void Foo() => Console.WriteLine("Hey!");

メソッドへの使用の是非について

この構文ですが、プロパティに対して以下のように記述できますが

pribate int _no = -1;
public int No => _no;

// 旧来の記述方法
// public int No { get { return _no; } }
// public int No { get => _no; }

と記述するのは従来の記述に比べ、文字数の削減効果が高いです。もともとプロパティ自体このように単純な実装になる事が多いため使用の是非を考慮することはありません。

しかし、通常のメソッドの場合はこの構文を適用する場合にはタイプ量が大幅に減るわけではありません(=> と { } でタイプ量が減りません(ただし、4行が1行になって圧縮効果はあります)

また、メソッド内に 2行以上の処理を記述したい場合、結局今まで通りのブロック式に戻してから追加の処理を記述することになりますが、そもそも 1行の処理と 2行の処理で書き方を切り替えるなんて実装は(よほどその記述方法に慣れてない限り)通常しません。

また、少し前に(流行ってるんだか流行ってないんだか分かりませんが)ラムダが関数型言語から流入した影響で、OOP 言語の C# を一部の関数言語ライクな構文で記述できるため、こういった機能が実装されたのかもしれませんが、そもそも C# は関数型言語ではないですし、こういった形式ですべての処理が書ける訳でもありません(当然このように書いてもメソッドに副作用がある事もあります)

以上のことからこの構文をメソッドに適用するのは控えた方がいいと思います。

組み合わせることで問題の起きるケース

このラムダ式本体によるメソッドの記述の構文と、他の機能を組み合わせることで実装効率やメンテナンス性に問題が発生する機能を2つほど紹介したいと思います。

Tupleを使用したコンストラクタ代入

C# 7.0 で実装されたタプル機能で組み合わせを作成して同時に代入できる以下の構文があります。

// タプルを使用した代入
int a = 10;
int b = 20;
(a ,b) = (b, a); // 一時変数を使用することなく入れ替えられる

これをコンストラクタで使用することができます。

public class Sample
{
    public int _num;
    public string _str;
    
    // 適用前
    public Sample(int num, string str)
    {
       _num = num;
       _str = str;
    }
    
    // 適用後
    public Sampke(int num, string str) => (_num, _str) = (num, str);
}

この構文ですが、これくらいシンプルな時は特に問題ないですが、同じ型の引数が4つ5つと増えたり、なると順番を間違えたりしやすいです。

また代入以外の処理が入り2行以上になる場合、結局通常の構文に戻す必要がありますが手で代入を書き直すのは割と面倒です。

public class Sample
{
    public int _num1;
    public int _num2;
    public int _num3;
    public string _str;
    
    // 増えると訳が分からなくなって間違えやすい(これも間違えていますが分かりますか?
    public Sample(int num1, int num2, int num3, string str) 
        => (_num1, _num3, num3, _str) = (num1, num2, num3, str);

    // コンストラクタに処理が増えた場合戻すのがしんどい
    public Sample(int num1, int num2, int num3, string str)
    {
        _num1 = num1;
        _num2 = num2;
        _num3 = num3;
        _str = str;
    }
}

一瞬 C++ 風のコンストラクターの初期化にも見えなくはないですがこの構文は C# では使用してはいけないと思います。

switch式でラムダ式本体を使う

switch 式という構文が C# 8.0~ 利用できますが、switch をパターンマッチングという構文とラムダ式の概念で以下のように記述できるようになりました。

public void Foo(EnumValue e)
{
    // 適用前
    int i = 0;
    switch(e)
    {
        case Hoge:
            i = 1;
            break;
        case Bar:
            i = 2;
            break;
        default:
            i = 0;
            break;
    }
    
    // 適用後
    int i = e switch
    {
        Hoge => 1,
        Bar => 2,
        _ => 0
    }
}

この構文ですが、switch の結果を変数に代入するケースで有用で積極的に使用してもよさそうです。ただしこれもラムダ式本体を組み合わせると途端にしんどそうな感じになります。

// 適用前
public int Foo(EnumValue e)
{
    switch(e)
    {
        case Hoge: return 1;
        case Bar: return 2;
        default: return 0l
    }
   
}

// 適用後
public int Foo(EnumValue e) => e switch
{
    Hoge => 1,
    Bar => 2,
    _ => 0
};

一時変数に受ける必要がなくなりますが、これ処理が追加になったときにブロック式に書き換えるのは結構しんどいです。そもそも1行と2行以上でスタイルを切り替える必要性がありません。

IDEの提案を無効化する

最後に、この提案自体してほしくない人向けに無効化する方法を紹介します。

ファイル内で無効化するには以下をファイルの先頭で宣言します。

// ファイル内で無効化する

// コードの先頭に以下を記述する
#pragma warning disable IDE0022

ちなみに以下のように「.editorconfig」に記述しても IDE 上にはなぜか表示されてしまいます。

// .editorconfig

[*.cs]

# IDE0022: メソッドにブロック本体を使用する
csharp_style_expression_bodied_methods = false

新しい機能だからって全て良いものだとは限らないですよね。よく意味を考えて構文などは使うか使わないか決めた方がいいと思います。

以上。