【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#】TryParseExactとParseExactの使い方

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

DateTime.TryParseExact と DateTime.ParseExact の使い方です。

確認環境

  • VisualStudio2019
  • C# 9.0
    • すごい昔からあるからバージョン不問

DateTime.TryParseExact

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

using System.Globalization;

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 は入力される可能性のある文字列に対して正しく設定すること(=多言語対応になると変換できないトラブルが無限に出るので可能な限り避けたい)

DateTime.ParseExact

ついでに書式が指定できる ParseExact の使い方も紹介します。

using System.Globalization;

DateTime dt = 
    DateTime.ParseExact("20220413123045", "yyyyMMddHH:mm:ss",
        CultureInfo.InvariantCulture, DateTimeStyles.None);

引数の指定と意味はほぼ同じ。ただし変換できない文字列が指定されると FormatException が発生して処理が中断される。絶対に変換できるときはこっちを使ったほうがいいが、変換できるかわからないときは使用しない。

// 失敗すると例外が発生する
System.FormatException: 'String 'XXXXX' was not recognized as a valid DateTime.'

【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

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

以上。

【C#】前置・後置インクリメントの速度差

若干怪文書っぽいですが、タイトルの件を検証したいと思います。

古参のC言語系プログラマーなら一度は聞いたことがあるかもしれませんが、forループのカウンターとして使用する i や j 等の変数のインクリメントは「前置の方が高速だから前置インクリメントを使う説」が C# ではどうなっているか確認していきます。

確認環境

  • VisualStudio2019
  • .NET5 + C#1.0
  • SharpLab(ILの確認)

補足ですが IL2CPP で C# を C++ に変換してコンパイルする行為は本記事の対象外です。

確認するコード

確認するコードは以下の通りです。

public static void TestCase()
{
    for(int i = 0; i < 1000; i++) // (1) 後置インクリメント
    {
        // nop
    }
    
    for(int i = 0; i < 1000; i++) // (2) 前置インクリメント
    {
        // nop
    }
    
    // 以下ついでに
    // シンプルなデクリメントのケース

    // (a) 後置デクリメント
    int a = 10;
    a--;
    
    // (b) 前置デクリメント
    int b = 10;
    --b;
}

これをインクリメントの部分だけILで抜粋すると以下のようになります。

// i++
IL_0007: ldloc.2
IL_0008: ldc.i4.1
IL_0009: add
IL_000a: stloc.2

// ++i
IL_001e: ldloc.s 4
IL_0020: ldc.i4.1
IL_0021: add
IL_0022: stloc.s 4

// a--;
IL_0036: ldloc.0
IL_0037: ldc.i4.1
IL_0038: sub
IL_0039: stloc.0

// --a;
IL_003d: ldloc.1
IL_003e: ldc.i4.1
IL_003f: sub
IL_0040: stloc.1

この場合完全に同じですね。とうか前置の方が後置に統一されています。速度差は当然ありません。

次のコードは(まぁこんなコードを書く人はほとんどいないと思いますが)どうでしょう?

int i = 0;
while (i++ < 10)
{
    Console.WriteLine(i);
}

i = 0;
while (++i <= 10)
{
    Console.WriteLine(i);
}
// while (i++ < 10)
IL_000e: ldloc.0
IL_000f: dup
IL_0010: ldc.i4.1
IL_0011: add
IL_0012: stloc.0
IL_0013: ldc.i4.s 10
IL_0015: clt
IL_0017: stloc.1
// sequence point: hidden
IL_0018: ldloc.1
IL_0019: brtrue.s IL_0005

// while (++i <= 10)
IL_0028: ldloc.0
IL_0029: ldc.i4.1
IL_002a: add
IL_002b: dup
IL_002c: stloc.0
IL_002d: ldc.i4.s 10
IL_002f: cgt
IL_0031: ldc.i4.0
IL_0032: ceq
IL_0034: stloc.2
// sequence point: hidden
IL_0035: ldloc.2
IL_0036: brtrue.s IL_001f

これもほぼ同じですね。というか比較前のldloc~stlocは演算子によって dup -> add か add -> dul で順番が違いますが IL ステップ数がほぼ同じなので違うというほどではないと思います(まぁここからレジスタがどうとかキャッシュがというのは何とも言えないですが…

結論

C# では違いなし。

です。というか C# はアプリケーションレイヤーで使用する場合が多いと思いますが、インクリメントの極小の差を気にする前に、高度なライブラリやアプリ自体にチューニングすべき個所が他に存在する可能性が圧倒的に高いのでこの程度の事は気にする必要ないと思いました。

以上です。

【C#】タプルの色々な宣言方法と受け取り方

C#でタプルの宣言方法と受け取る方法ですがバリエーションがいくつかあって知らないと冗長な書き方になっているときがあるので、宣言のしかたと受け取り方を紹介したいと思います。

確認環境

  • .NET 5, C#9.0
  • VisualSturio 2019
  • Windows 10

宣言方法と受け取り方

メソッドの戻り値として使用するタプルの宣言方法は、(1) 各要素に名前を指定しないで方法と、(2) 各要素に名前を付けて返す2種類があります。宣言方法によって受け取り方も若干異なります。

// 要素に名前を付けないで返す
public static (int, int, int, int) GetValues1()
{
    return (0, 1, 2, 3);
}

// 受け取るときには Item1, Item2 のような名前が自動的に付与される
var v1 = GetValues1();
int v11 = v1.Item1;
int v12 = v1.Item1;

// 要素に名前を付けて返す
public static (int a, int b, int c, int d) GetValues2()
{
    return (0, 1, 2, 3);
}

// 自分でつけた変数名で受け取れる
var v2 = GetValues2();
int v21 = v2.a;
int v22 = v2.b;

基本的に後者の名前を付ける方が推奨です。

次に受け取り方です。

// (1) 完全な宣言で受け取る
(int, int, int, int) ret1 = GetValues1();
int a1 = ret1.Item1;
int b1 = ret1.Item2;
int c1 = ret1.Item3;
int d1 = ret1.Item4;

// (2) varで受け取る
var ret2 = GetValues1();
int a2 = ret2.Item1;
int b2 = ret2.Item2;
int c2 = ret2.Item3;
int d2 = ret2.Item4;

// (3) 各要素に名前を付けて受け取る
(int myA, int myB, int myC, int myD) ret3 = GetValues1();
int a3 = ret3.myA;
int b3 = ret3.myB;
int c3 = ret3.myC;
int d3 = ret3.myD;

// (4) 各要素の変数名を指定して受け取る
var (a, b, c, d) = GetValues1();

// (5) 匿名で受け取る
(int myA2, int myB2, int myC2, int myD2) = GetValues1();
int a4 = myA2;
int b4 = myB2;
int c4 = myC2;
int d4 = myD2;

また必要のない変数は _ (アンダーバー)を指定すると破棄できます。

// 2~4番目の値は破棄する
(int myA2, int _, int _, int _) = GetValues1();
int a = myA2;

書き方が色々あるので混乱してしまいそうですが使いやすいのは、(2) の名前を付けて var で受け取る + 宣言元のメソッドで名前を指定する、か、 (4) の変数名を指定して受け取るのがコードの記述が簡単です。

ただし、このタプルをさらに別のオブジェクトに渡すよう状態態になったときはクラスや構造体の利用を検討しましょう。何度もタプルで返すと追いずらいですし、宣言が繰り返し出現して冗長で実装効率が長期的に見ると低下します。短い区間で短期的な値の組み合わせの利用がタプルの使いどころです。

クラスをタプルに分解する

自作のクラスでは「分解」という言語機能を使用するとオブジェクトの中身をタプルとして取り出すことができます。例えば以下のようなクラスがあったときに「void Deconstruct(...」という特殊なメソッド宣言によって受け取り側は値をタプルで受け取ることができます。

public class Sample
{
    public string Name { get; }
    public int ID { get; }

    public Sample(string name, int id) => (Name, ID) = (name, id);

    // タプルとして返す分解宣言
    public void Deconstruct(out string name, out int id)
    {
        name = Name;
        id = ID;
    }
}

// オブジェクトを作成して
Sample sample = new("Takap", 123);

// (1) Tupleに代入する事ができる
var (name, id) = sample;

// (2) こんな感じで受け取ることもできる
(string name2, int id2) = sample;

この時は受け取り方にやや制限があって上記のような受け取り方しかできません(通常のイコールの代入は Sample 型となってしまいます)

この分解の構文は、複数の値を返すけどクラスを作成したくない時に一時的な値の組み合わせとして値を取り出したい、ようなケースで主に使用しますが、そんなに多様する事は無いと思います。逆に自分の作成したコードでこの分解が頻繁に使用される設計は若干問題がある可能性がります。したがって頻出する場合は少し見直した方が良いかと思います。

以上です。

【C#】Teamsを退席中にしないツールを作ってみた

TeamsをはじめとするいわゆるグループウェアってしばらくPCを操作しないと「退席中」と割とすぐに表示さますよね?実際は別の作業してるのに…みたいな状態で誤解を受けないように「退席中」表示になるのを防止するMouseKeeperというツールを作ってみました。

ただ、ジョークアプリの類なので使用は自己責任でお願いします。

作成環境

以下環境で作成・動作確認を行っています。

  • Windows10、Windows11
  • VisualStudio2019、VisualStudio2022
  • .NET Frmaework 4.7.2 & C# 7.2
  • .NET Framework 4.8.1 & C# 7.3

1.1.0 をリリースしたので VS2022 と .NET 4.8 に対応バージョンを変更しました。

1.2.0 をリリースしたので VS2022 と .NET 4.8.1 に対応バージョンを変更しました。

ダウンロード

Github のリリースページにコードと実行形式のファイルを配置しています。使い方などは以下を参照ください。

github.com

このツールを起動すると以下の画像のようにタスクトレイにアプリが常駐し始めます。アプリが起動した状態で 1分間 PC を操作しないとアプリが 30秒ごとにマウスカーソルの移動を行ってPCをユーザーが操作しているように偽装します。

技術的詳細

対象が Windows のみで C# なので .NET Framework 4.8 で動作します。このアプリを作成する際に使用したテクニックをいくつか紹介したいと思います。デスクトップアプリ技術は WPF ではなく Windows Form で作成しました。

タスクトレイにWinFormを常駐させる

これはテクニック的には有名だと思いますが、WinForm の NotifyIcon を使用します。フォームのデザイナーを表示しツールボックスから NotifyIcon をフォーム上にドラッグすると設定できます。

各プロパティの設定は以下の通りです。アイコンを設定しないとタスクトレイにアプリが表示されないのでアイコンをアイコン形式で自作して設定しています。

コンテキストメニューを設定するとタスクトレイのアイコンを右クリックしたときにメニューが表示されるようになります。アプリの終了だけは必要なので終了ボタンを持つコンテキストメニューを追加で設定しています。

起動時にウインドウが表示されないようにする

ウインドウがアプリ起動時に表示されないようにするためは以下のように Main 関数を変更します。

Application.Run にフォームのインスタンスを渡すのではなく、フォームのインスタンスは生成するが、Application.Run にはインスタンスを渡さないようにするとウインドウが表示されないけどタスクトレイにはアプリが常駐するようになります。

static void Main()
{
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);

    // 既存の処理はコメントアウト
    // Application.Run(new FormTaskTray());

    // 以下のように宣言する
    var form = new FormTaskTray();
    Application.Run();
}

この時、メインフォームの「ShowInTaskbar」のプロパティは「false」に設定する必要があります。これ設定しないと、ウインドウは表示されないのにタスクバーにはアイコンが表示されて気持ち悪い動作になっちゃいます。

マウス移動をOSに通知する

「一定時間操作が無ければマウスを移動する」という一番大事な処理ですが、WindowsForm の Cursor クラスに値を設定するとマウスポインターを移動できますがこれだとOSはマウスを動かしたと認識してくれません。したがって .NET の Cursor クラスの API でマウスを移動しても一定時間たつとスクリーンセーバーが起動したりディスプレイの電源が落ちてしまいます。

なので、 OS にマウス操作したことを通知するためには Win32 API を以下のように宣言して使用します。

// 宣言
[DllImport("USER32.dll", CallingConvention = CallingConvention.StdCall)]
private static extern void mouse_event(int dwFlags, int dx, int dy, int cButtons, int dwExtraInfo);

// 使用
mouse_event(1, 0, 0, 0, 0); // 現在位置から0の距離に移動

mouse_event の第一引数は MOUSEEVENTF_MOVE (0x01) で、2,3番目のパラメータが移動ピクセルですが、0を指定したら現在位置に移動という指定になります。これでOSに移動を指示したけど現在位置からポインターは移動しないという都合の良い処理ができます。

終了した後にアイコンが残らないようにする

アプリを終了した後にタスクトレイにアイコンが残らないようにタスクトレイ用のフォームのインスタンスは自分で Dispose しましょう。そうでないと終了後までアイコンがタスクトレイに残ってマウスオーバーした時に消える不思議な挙動になります。

using (FormTaskTray formTaskTray = new FormTaskTray()) // ★Disposeすること
{
    Application.Run();
}

1分間操作しないと30秒ごとにマウスを動かす

一定時間操作しなかったらN秒間隔でマウス移動を行う処理ですが、フォームのタイマーを 500ms 周期で起動してタイマーのイベントハンドラ内に以下の通り実装しています。

DateTime _lastMoved; // 最後に人間がマウスを移動した時間
DateTime _movedPos; // 最後にOSにマウス移動を通知した時間
Point _pos; // 前回のマウスポインターの位置

private void timer_Tick(object sender, EventArgs e)
{
    try
    {
        // 1分以上位置が変わらなかった場合30秒に一度ポインタを刺激する
        var now = DateTime.Now;

        var pos = Cursor.Position;
        if (_pos != pos)
        {
            _lastMoved = now;
        }
        _pos = pos;

        if (now - _lastMoved > TimeSpan.FromSeconds(60)) // 60秒操作しなかったら
        {
            if (now - _movedPos > TimeSpan.FromSeconds(30)) // 30秒ごとにマウス移動する
            {
                _movedPos = now;
                mouse_event(1, 0, 0, 0, 0); // OSにマウス位置の移動を通知
            }
        }
    }
    catch (Exception ex)
    {
        Trace.WriteLine(ex.ToString());
    }
}

以上です。