C#で1次元配列と2次元配列を相互に変換する

タイトルの通り、1次元配列と2次元配列の相互変換を行う処理の紹介です。

考え方

このような2次元配列を

f:id:Takachan:20200229161039p:plain

このような配列に変換することができます。

f:id:Takachan:20200229164959p:plain

実装コード

早速実装例を紹介したいと思います。

ArrayUtilityクラス

配列に対する操作のため以前紹介したArrayUtiityクラスを今回も使用します。

// ArrayUtility.cs

using System;
using System.Collections.Generic;

/// <summary>
/// 配列に対する汎用機能を提供します。
/// </summary>
public static class ArrayUtility
{
    /// <summary>
    /// 指定した2次元配列を1次元配列に変換します。
    /// </summary>
    public static T[] ToOneDimensional<T>(T[,] src)
    {
        int ymax = src.GetLength(0);
        int xmax = src.GetLength(1);
        int len = xmax * ymax;
        var dest = new T[len];

        for (int y = 0, i = 0; y < ymax; y++)
        {
            for (int x = 0; x < xmax; x++, i++)
            {
                dest[i] = src[y, x];
            }
        }
        return dest;
    }

    /// <summary>
    /// 組み込み型のみを対象に2次元配列を1次元配列に変換します。
    /// </summary>
    public static T[] ToOneDimensionalPrimitives<T>(T[,] src)
    {
        int ymax = src.GetLength(0);
        int xmax = src.GetLength(1);
        int len = xmax * ymax;
        var dest = new T[len];

        var size = Marshal.SizeOf(typeof(T));
        Buffer.BlockCopy(src, 0, dest, 0, len * size);
        return dest;
    }

    /// <summary>
    /// 指定した2次元配列を1次元配列に変換します。
    /// <para>T[height, width] 範囲を超える分は切り捨て、不足している分は(T)の初期値になります。</para>
    /// </summary>
    public static T[,] ToTowDimensional<T>(T[] src, int width, int heigth)
    {
        var dest = new T[heigth, width];
        int len = width * heigth;
        len = src.Length < len ? src.Length : len;
        for (int y = 0, i = 0; y < heigth; y++)
        {
            for (int x = 0; x < width; x++, i++)
            {
                if (i >= len)
                {
                    return dest;
                }
                dest[y, x] = src[i];
            }
        }

        return dest;
    }

    /// <summary>
    ///  組み込み型のみを対象に1次元配列を2次元配列に変換します。
    /// <para>T[height, width] 範囲を超える分は切り捨て、不足している分は(T)の初期値になります。</para>
    /// </summary>
    public static T[,] ToTowDimensionalPrimitives<T>(T[] src, int width, int heigth)
    {
        var dest = new T[heigth, width];
        int len = width * heigth;
        len = src.Length < len ? src.Length : len;

        var size = Marshal.SizeOf(typeof(T));
        Buffer.BlockCopy(src, 0, dest, 0, len * size);
        return dest;
    }
}

使い方

使い方はシンプルに変換したい配列を指定し、X, Y のサイズを指定し変換を行います。

// AppMain.cs

using System;
using System.Collections.Generic;

public static void Main(string[] args)
{
    // 1次元配列を宣言
    int[] array = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 };

    // 2x2 の 2次元配列に変換、はみ出した分は無視される
    var tow = ArrayUtility.ToTowDimensional(array, 2);
    // 0, 1
    // 2, 3

    // 2次元配列を1次元配列に変換
    var one = ArrayUtility.ToOneDimensional(array);
    // 0, 1, 2, 3

    Console.WriteLine(one);
}

ArrayExtension:拡張メソッド版

上記の処理を配列の拡張メソッドとして定義したいと思います。

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

先ほどのUtilityの処理を中で呼び出すようにしています。

/// <summary>
/// 配列に対する拡張機能を提供します。
/// </summary>
public static class ArrayExtension
{
    /// <summary>
    /// 指定した2次元配列を1次元配列に変換します。
    /// </summary>
    public static T[] ToOneDimensional<T>(this T[,] array)
    {
        ArrayUtility.ToOneDimensional(array);
    }

    /// <summary>
    /// 指定した2次元配列を1次元配列に変換します。
    /// </summary>
    public static T[,] ToTowDimensional<T>(this T[] array, int size)
    {
        ArrayUtility.ToTowDimensional(array, size);
    }
}

使い方

使用方法は先ほどとほぼ同じですが配列のインスタンスを直接指定して処理を呼び出せるようになっていると思います。

// AppMain.cs

using System;
using System.Collections.Generic;

public static void Main(string[] args)
{
    // 1次元配列を宣言
    int[] array = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 };

    // 2x2 の 2次元配列に変換、はみ出した分は無視される
    var tow = array.ToTowDimensional(2);
    // 0, 1
    // 2, 3

    // 2次元配列を1次元配列に変換
    var one = array.ToOneDimensional();
    // 0, 1, 2, 3

    Console.WriteLine(one);
}

こちらも最初のコードと同じように相互変換することができました。

コードばかりになってしまいましたが以上です。

C#でHexタイルの位置を計算する

今回は6角形のタイルの座標の計算を行うライブラリの紹介をしたいと思います。

計算した位置をいい感じに描画すると以下のような感じに並べることができます。

f:id:Takachan:20200224222537g:plain

HexLayoutクラス:6角形のタイルを並べる

結構強引に位置を計算していますが、指定した位置を中心に同心円状に並べる処理とX, Yで指定した位置を1つ取得する処理の2種類を用意しています。

/// <summary>
/// 2Dの6角形を配置するための位置を計算するクラス
/// </summary>
public class HexLayout
{
    /// <summary>
    /// 同心円状に配置する位置を取得します。
    /// </summary>
    public static IEnumerable<(float x, float y)> GetPosByConcentricCircle(float size, int level)
    {
        float SIZE_X = size;
        float SIZE_Y = SIZE_X * 0.75f;

        // 左上
        for (int i = 0; i < level; i++)
        {
            float xpos = SIZE_X / 2 * level;
            xpos += SIZE_X / 2 * i;
            float ypos = SIZE_Y * level;
            ypos -= SIZE_Y * i;
            yield return (-xpos, ypos);
        }

        // 左
        for (int i = 0; i < level; i++)
        {
            float xpos = SIZE_X * level;
            xpos -= SIZE_X / 2 * 1 * i;
            float ypos = 0;
            ypos -= SIZE_Y * i;
            yield return (-xpos, ypos);
        }

        // 左下
        for (int i = 0; i < level; i++)
        {
            float xpos = SIZE_X / 2 * level;
            xpos -= SIZE_X * i;
            float ypos = SIZE_Y * -level;
            yield return (-xpos, ypos);
        }

        // 右下
        for (int i = 0; i < level; i++)
        {
            float xpos = SIZE_X / 2 * level;
            xpos += SIZE_X / 2 * i;
            float ypos = SIZE_Y * -level;
            ypos += SIZE_Y * i;
            yield return (xpos, ypos);
        }

        // 右
        for (int i = 0; i < level; i++)
        {
            float xpos = SIZE_X * level;
            xpos -= SIZE_X / 2 * 1 * i;
            float ypos = 0;
            ypos += SIZE_Y * i;
            yield return (xpos, ypos);
        }

        // 右上
        for (int i = 0; i < level; i++)
        {
            float xpos = SIZE_X / 2 * level;
            xpos -= SIZE_X * i;
            float ypos = SIZE_Y * level;
            yield return (xpos, ypos);
        }
    }

    /// <summary>
    /// 指定した座標のHexの位置を取得します。
    /// </summary>
    public static (float x, float y) GetHexPos(int x, int y, float size)
    {
        //
        // 以下のような並び順で配置します:
        //
        // (-1,-2) ( 0,-2) ( 1,-2)
        //     ( 0,-1) ( 1,-1)
        // (-1, 0) ( 0, 0) ( 1, 0)
        //     ( 0, 1) ( 1, 1)
        // (-1, 1) ( 0, 1) ( 1, 1)
        //

        float xpos = Mathf.Abs(size * x);
        if (y % 2f != 0)
        {
            xpos -= size / 2.0f;
        }
        float ypos = Mathf.Abs(size * 0.75f * y);

        if (y > 0)
        {
            ypos *= -1;
        }

        return (xpos, ypos);
    }
}

考えかた

同心円計のGetPosByConcentricCircleメソッドは引数のレベルが何週目の円の位置かを表し、例えば2レベルでは以下のように左回りに位置の計算を行います。

f:id:Takachan:20200224223748p:plain

座標指定のGetHexPosは指定したXとYに従って以下座標系で位置を返します。

f:id:Takachan:20200224224128p:plain

これらの結果を受け取ってUnity上でSpriteに位置の指定を行ったものが冒頭のGifになります。

短いですが以上です。

【C#】の1次元配列と多次元配列、リストのアクセス速度の違い

前回の記事で紹介した2次元配列の管理クラスですが中身のデータを「1次元配列を2次元配列扱いする」か「C#固有機能の多次元配列」で行ったときの実行速度に触れましたが今回は実際に速度の違いを計測してみました。

takachan.hatenablog.com

タイトルの通り、2次元配列のデータ格納方式各々のアクセス速度を計測します。

計測対象

2次元配列をC#上で扱うためにはいくつかの実装方法があり、各々のアクセス速度を計測します。対象とするデータ形式は以下の通りです。

Item 説明
int[ ] 1次元配列、データアクセスは int[y * STRIDE + x] で行う
int[ ][ ] 2次元配列、ジャグ配列と呼ぶ。アクセス方法は int[y][x]
int[, ] 2次元配列、C#固有の宣言方法。アクセス方法は int[y, x]
List<List> リストを2次元配列に見立てる。アクセス方法は list[y][x]

確認環境

この実行速度計測は以下環境で確認しています。

  • .NET 7 + VisualStudio2022
  • BenchmarkNet 0.13.10
  • Windows11 22H2
  • Ryzen 5900X 3.7GHz
  • 32GB メモリー

.NET 7 Release ビルドの Exe をコマンドラインから実行して速度を計測しています。

実行結果

先に実行結果を載せておきます。それぞれ 3238 x 3238 の 1048万回アクセスを試行して計測しています。

アクセス方法 int[ ] int[ ][ ] int[, ] List<List>
シーケンシャルアクセス(時間) 2.259ms 4.456ms 5.595ms 7.784ms
シーケンシャル(割合) x1.0 x1.97 x2.47 x3.44
ランダムアクセス(時間) 15.35ms 44.58ms 217.97ms 32.34ms
ランダム(割合) x1.0 x2.9 x14.19 x2.1

総合的に見て1次元配列を(x, y)でアクセスするのが一番よさそうでした。

なんか C# 固有の [, ] という二次元配列はランダムアクセスでめちゃくちゃパフォーマンスが悪いですがこれどうなってるんでしょうね?

あと List クラスが .NET 7以降高速化されてるのでアクセス速度がめちゃくちゃ早くなってるみたいです。動的にサイズを変えるケースがあるなら List でもいいのかもしれません。

実行コード

ここでは速度計測に使用したコードを掲載します。

シーケンシャルアクセス

シーケンシャルアクセスは先頭から末尾までの要素を順にアクセスして要素へのアクセス時間を計測します。

using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
[RankColumn]
public class Test1
{
    // 合計1048万グリッド
    const int WIDTH = 3238;
    const int HEIGHT = 3238;

    // (1) 1次元配列を2次元配列扱いする
    int[] _array;
    // (2) 2次元配列(=ジャグ配列)
    int[][] _jagged;
    // (3) 2次元配列(C#固有機能)
    int[,] _csarray;
    // (4) List<T>で2次元配列
    List<List<int>> _listArray;

    [GlobalSetup]
    public void Setup()
    {
        // (1)のデータ構を初期化
        _array = new int[WIDTH * HEIGHT];

        // (2)のデータ構造を初期化
        _jagged = new int[HEIGHT][];
        for (int i = 0; i < WIDTH; i++)
        {
            _jagged[i] = new int[WIDTH];
        }
        
        // (3)のデータ構造を初期化
        _csarray = new int[HEIGHT, WIDTH];
        
        // (4)のデータ構造を初期化
        _listArray = new List<List<int>>();
        for (int i = 0; i < HEIGHT; i++)
        {
            _listArray.Add(new List<int>());
        }
        
        // (1)~(4)のデータ構造に初期値を設定
        var r = new Random();
        for (int y = 0; y < HEIGHT; y++)
        {
            for (int x = 0; x < WIDTH; x++)
            {
                int p = r.Next();
                _array[HEIGHT * y + x] = p;
                _jagged[y][x] = p;
                _csarray[y, x] = p;
                _listArray[y].Add(p);
            }
        }
    }

    // (1) に対するシーケンシャルアクセス
    [Benchmark]
    public void _1_Seq()
    {
        //var temp = new List<int>();
        for (int y = 0; y < HEIGHT; y++)
        {
            for (int x = 0; x < WIDTH; x++)
            {
                int value = _array[y * HEIGHT + x];
                //temp.Add(value);
            }
        }
    }
    // (2) に対するシーケンシャルアクセス
    [Benchmark]
    public void _2_Seq()
    {
        //var temp = new List<int>();
        for (int y = 0; y < HEIGHT; y++)
        {
            for (int x = 0; x < WIDTH; x++)
            {
                int value = _jagged[y][x];
                //temp.Add(value);
            }
        }
    }
    // (3) に対するシーケンシャルアクセス
    [Benchmark]
    public void _3_Seq()
    {
        //var temp = new List<int>();
        for (int y = 0; y < HEIGHT; y++)
        {
            for (int x = 0; x < WIDTH; x++)
            {
                int value = _csarray[y, x];
                //temp.Add(value);
            }
        }
    }
    // (4) に対するシーケンシャルアクセス
    [Benchmark]
    public void _4_Seq()
    {
        //var temp = new List<int>();
        for (int y = 0; y < HEIGHT; y++)
        {
            for (int x = 0; x < WIDTH; x++)
            {
                int value = _listArray[y][x];
                //temp.Add(value);
            }
        }
    }
}

| Method | Mean     | Error     | StdDev    | Median   | Rank | Allocated |
|------- |---------:|----------:|----------:|---------:|-----:|----------:|
| _1_Seq | 2.259 ms | 0.0364 ms | 0.0340 ms | 2.233 ms |    1 |       2 B |
| _2_Seq | 4.456 ms | 0.0063 ms | 0.0059 ms | 4.456 ms |    2 |       4 B |
| _3_Seq | 5.595 ms | 0.0151 ms | 0.0134 ms | 5.593 ms |    3 |       4 B |
| _4_Seq | 7.784 ms | 0.0122 ms | 0.0114 ms | 7.786 ms |    4 |       4 B |

ランダムアクセス

ランダムアクセスは要素内の適用な要素を選択し(1)~(4)全て同じ位置にアクセスし各々の時間を計測します。

[MemoryDiagnoser]
[RankColumn]
public class Test2
{
    const int WIDTH = 3238;
    const int HEIGHT = 3238;

    // (1) 1次元配列を2次元配列扱いする
    int[] _array;
    // (2) 2次元配列(=ジャグ配列)
    int[][] _jagged;
    // (3) 2次元配列(C#固有機能)
    int[,] _csarray;
    // (4) List<T>で2次元配列
    List<List<int>> _listArray;

    // ランダムアクセスの回数
    const int CNT = WIDTH * HEIGHT;
    // ランダムアクセス用のインデックス
    List<(int x, int y)> _randList = new List<(int x, int y)>();

    [GlobalSetup]
    public void Setup()
    {
        // (1)のデータ構を初期化
        _array = new int[WIDTH * HEIGHT];

        // (2)のデータ構造を初期化
        _jagged = new int[HEIGHT][];
        for (int i = 0; i < WIDTH; i++)
        {
            _jagged[i] = new int[WIDTH];
        }

        // (3)のデータ構造を初期化
        _csarray = new int[HEIGHT, WIDTH];

        // (4)のデータ構造を初期化
        _listArray = new List<List<int>>();
        for (int i = 0; i < HEIGHT; i++)
        {
            _listArray.Add(new List<int>());
        }

        // (1)~(4)のデータ構造に初期値を設定
        var r = new Random();
        for (int y = 0; y < HEIGHT; y++)
        {
            for (int x = 0; x < WIDTH; x++)
            {
                int p = r.Next();
                _array[HEIGHT * y + x] = p;
                _jagged[y][x] = p;
                _csarray[y, x] = p;
                _listArray[y].Add(p);
            }
        }

        // ランダムアクセス用の乱数の初期化
        var rand = new Random();
        for (int i = 0; i < CNT; i++)
        {
            _randList.Add((rand.Next(0, WIDTH), rand.Next(0, HEIGHT)));
        }
    }

    // (1) に対するランダムアクセス
    [Benchmark]
    public void _1_Rand()
    {
        //var temp = new List<int>();
        foreach (var (x, y) in _randList)
        {
            int value = _array[y * HEIGHT + x];
            //temp.Add(value);
        }
    }
    // (2) に対するランダムアクセス
    [Benchmark]
    public void _2_Rand()
    {
        //var temp = new List<int>();
        foreach (var (x, y) in _randList)
        {
            int value = _jagged[y][x];
            //temp.Add(value);
        }
    }
    // (3) に対するランダムアクセス
    [Benchmark]
    public void _3_Rand()
    {
        //var temp = new List<int>();
        foreach (var (x, y) in _randList)
        {
            int value = _csarray[y, x];
            //temp.Add(value);
        }
    }
    // (4) に対するランダムアクセス
    [Benchmark]
    public void _4_Rand()
    {
        //var temp = new List<int>();
        foreach (var (x, y) in _randList)
        {
            int value = _listArray[y][x];
            //temp.Add(value);
        }
    }
}

| Method  | Mean      | Error    | StdDev   | Rank | Allocated |
|-------- |----------:|---------:|---------:|-----:|----------:|
| _1_Rand |  15.35 ms | 0.255 ms | 0.226 ms |    1 |       8 B |
| _2_Rand |  44.58 ms | 0.886 ms | 1.055 ms |    2 |      42 B |
| _3_Rand | 217.98 ms | 3.918 ms | 3.473 ms |    4 |     168 B |
| _4_Rand |  82.34 ms | 1.640 ms | 3.600 ms |    3 |      72 B |

長くなりましたが以上です。

【C#】グリッドマップを管理するクラスを作成する

2D のゲーム実装でマップを扱うときは x と y の2次元のマップデータを使うことがありますがこのデータ構造の実装方法を紹介します。

x と y の 2次元のデータですが、中身は 1次元の配列として扱います。なので 1列の配列データを 2次元のグリッドとして扱う方法となります。

こんな感じのイメージの並び順の2次元配列を想像して書いています。

確認環境

以下の環境で動くことを確認しています。

  • VisualStudio2019
  • .NET Framwwork 4.7.2
  • .NET Core 3.1
  • Unity2019.3

実装コード

では早速実装例を紹介したいと思います。

Map2Dクラス

いちおう2次元配列を直接触ることもできますが、メソッド経由の方が脳にやさしいと思います。

各座標に対するよくある操作を定義しています。

(T)には任意の型を指定できるのでオブジェクトを指定することもできます。

/// <summary>
/// 任意の型(T)の2次元配列を表します。
/// </summary>
public class Map2D<T>
{
    //
    // Descriptions
    // - - - - - - - - - - - - - - - - - - - -
    #region...
    //
    // 以下モデルを想定
    //
    //     xi → → → →
    //  yi 00 01 02 03 04
    //  ↓ 10 11 12 13 14
    //  ↓ 20 21 22 23 24
    //  ↓ 30 31 32 33 34
    //  ↓ 40 41 42 43 44
    //
    // もしくはこう
    //
    // ↑ 40 41 42 43 44
    // ↑ 30 31 32 33 34
    // ↑ 20 21 22 23 24
    // ↑ 10 11 12 13 14
    // yi 00 01 02 03 04
    //    xi → → → →
    //
    #endregion

    /// <summary>
    /// このオブジェクトが内部で管理している配列を取得します
    /// (外から内容を操作してもいいけど自己責任)
    /// </summary>
    public T[] Array { get; private set; }

    /// <summary>
    /// マップの横幅を取得します。
    /// </summary>
    public int Width { get; private set; }

    /// <summary>
    /// マップの縦幅を取得します。
    /// </summary>
    public int Height { get; private set; }

    //
    // Operators
    // - - - - - - - - - - - - - - - - - - - -

    /// <summary>
    /// 多次元配列のインデクサーにより値を設定または取得します。
    /// </summary>
    public T this[int xi, int yi] { get => this.GetItem(xi, yi); set => this.SetItem(xi, yi, value); }

    //
    // Constructors
    // - - - - - - - - - - - - - - - - - - - -

    /// <summary>
    /// 既定の初期値でオブジェクトを新規作成します。
    /// </summary>
    public Map2D() { }

    /// <summary>
    /// マップの縦・横の大きさを指定してオブジェクトを新規作成します。
    /// </summary>
    public Map2D(int width, int height) => this.Init(width, height);

    //
    // Public Methods
    // - - - - - - - - - - - - - - - - - - - -

    /// <summary>
    /// 指定した値でオブジェクトを初期化します。
    /// </summary>
    public void Init(int width, int height)
    {
        this.Width = width;
        this.Height = height;
        this.Array = new T[height * width];
    }

    /// <summary>
    /// 指定した2次元配列でオブジェクトを初期化します。
    /// </summary>
    public void Init(T[] src)
    {
        this.Array = src;
        this.Width = src.GetLength(1);
        this.Height = src.GetLength(0);
    }

    /// <summary>
    /// 指定位置にIDを設定します。
    /// </summary>
    public void SetItem(int xi, int yi, T id) => this.Array[yi * this.Width + xi] = id;

    /// <summary>
    /// 指定位置のIDを取得します。
    /// </summary>
    public T GetItem(int xi, int yi) => this.Array[yi * this.Width + xi];

    /// <summary>
    /// <para>
    /// 指定した位置がマップの範囲内かどうかを判定します。
    /// true : 範囲内 / false : 範囲外
    /// </para>
    /// <para>
    /// 基本的にすべてのメソッドは範囲チェックを行わないので指定するxi, yiが範囲内かどうかは
    /// このメソッドを使用して判定もしくは外部でチェックされていることを想定します。
    /// </para>
    /// </summary>
    public bool IsIn(int xi, int yi) => !(xi < 0 || yi < 0 || xi >= this.Width || yi >= this.Height);

    /// <summary>
    /// 指定したYの行要素をすべて列挙します。
    /// </summary>
    public IEnumerable<(int x, int y, T item)> GetRow(int yi)
    {
        for (int x = 0; x < this.Width; x++)
        {
            yield return (x, yi, this.GetItem(x, yi));
        }
    }

    /// <summary>
    /// 指定したXの列要素をすべて列挙します。
    /// </summary>
    public IEnumerable<(int x, int y, T item)> GetColumn(int xi)
    {
        for (int y = 0; y < this.Height; y++)
        {
            yield return (xi, y, this.GetItem(xi, y));
        }
    }

    /// <summary>
    /// 指定したYの行要素に対し述語で一括で処理を行います。
    /// </summary>
    public void ForRow(int yi, Action<int/*xi*/, int/*yi*/, T/*id*/> func)
    {
        foreach ((int x, int y, T id) in this.GetRow(yi))
        {
            func(x, y, id);
        }
    }

    /// <summary>
    /// 指定したYの列要素に対し述語で一括で処理を行います。
    /// </summary>
    public void ForColumn(int xi, Action<int/*xi*/, int/*yi*/, T/*id*/> func)
    {
        foreach ((int x, int y, T id) in this.GetColumn(xi))
        {
            func(x, y, id);
        }
    }

    /// <summary>
    /// 全ての要素を列挙し述語で処理を行います。
    /// </summary>
    public void ForEach(Action<int/*xi*/, int/*yi*/, T/*id*/> func)
    {
        for (int y = 0; y < this.Height; y++)
        {
            for (int x = 0; x < this.Width; x++)
            {
                func(x, y, this.GetItem(x, y));
            }
        }
    }
}

Map2Diクラス

マップチップなどでCSVから読み取ったデータはint型の配列なことが多いと思うので以下のクラスをあらかじめ定義しておきます。

//
// 一番よく使うと思われるので事前に定義しておく
//

/// <summary>
/// <see cref="int"/> 型の2次元配列を表します。
/// 
/// </summary>
public class Map2Di : Map2D<int>
{
    public Map2Di() { }
    public Map2Di(int width, int hegiht) : base(width, hegiht) { }
}

使い方

上記クラスの使い方は以下の通りです。

値の出し入れと、一括処理の方法です。

public static void Main(string[] args)
{
    // (1) 新規に配列を作成
    var map1 = new Map2Di(10, 10);

    // (2) もともとある配列を指定して初期化
    int[,] _m = new int[,]
    {
        { 0, 0, 0, 0, 0, 1, 1, 1, 1, 1 },
        { 1, 1, 1, 1, 1, 2, 2, 2, 2, 2 },
        { 2, 2, 2, 2, 2, 3, 3, 3, 3, 3 },
        { 3, 3, 3, 3, 3, 4, 4, 4, 4, 4 },
        { 4, 4, 4, 4, 4, 5, 5, 5, 5, 5 },
        { 5, 5, 5, 5, 5, 6, 6, 6, 6, 6 },
        { 6, 6, 6, 6, 6, 7, 7, 7, 7, 7 },
        { 7, 7, 7, 7, 7, 8, 8, 8, 8, 8 },
        { 8, 8, 8, 8, 8, 9, 9, 9, 9, 9 },
        { 0, 0, 0, 0, 0, 1, 1, 1, 1, 1 },
    };
    var map2 = new Map2Di();
    map2.Init(_m);

    // (3) 管理オブジェクトのサイズを取得する
    Console.WriteLine($"({map2.Width}, {map2.Height})");

    // (4) 値の出し入れ
    map2.SetItem(1, 1, 999);
    int item = map2.GetItem(1, 1);

    // (5-1) 1行目のデータを全部取得
    foreach ((int x, int y, int id) p in map2.GetRow(1))
    {
        Console.WriteLine($"({p.x}, {p.y})={p.id}");
    }
    // (5-2) 1行目のデータに対して一括処理
    map2.ForRow(1, (x, y, id) => Console.WriteLine($"({x}, {y})={id}"));

    // (6-1) 1列目のデータを全部取得
    foreach ((int x, int y, int id) p in map2.GetColumn(1))
    {
        Console.WriteLine($"({p.x}, {p.y})={p.id}");
    }
    // (6-2) 1列目のデータに対して一括処理
    map2.ForColumn(1, (x, y, id) => Console.WriteLine($"({x}, {y})={id}"));

    // (7) 全部のデータを列挙
    map2.ForEach((x,y,id)=> Console.WriteLine($"({x}, {y})={id}"));
}

【説明】管理クラスの必要性

2次元配列で実装しているので、この配列にアクセスするときはカッコ内に指定する値はXとYが逆になっています(大抵の人はそうすると思います…

そもそも2次元配列なら管理クラスは必要無いのかもしれませんが、インデックスに指定する順序がY→Xの順のため稀にX→Yの順で指定してアクセス位置を誤る場合があるので誤操作防止の意味があります。

public T GetItem(int xi, int yi)
{
    // YとXの指定は逆、X→Yの順序で指定してアクセス間違いしないようにしている
    return array[y, x];
}

例えば以下のイメージでデータが並んでるときに

上記の色のついているグリッドへのアクセス方法は以下のように直観的に操作できるようになります。

var map2 = new Map2Di();
// ...(中略)...

// 赤い場所のデータを取得
int item1 = map2.GetItem(1, 2);

// 青い場所のデータを取得
int item2 = map2.GetItem(3, 5);

また配列に対する汎用操作をクラスに追加したい場合もクラスに機能追加すれば良いのでクラス化しておけば何かと便利です。

【余談】何故1次元配列で実装するのか?

単純に一番アクセス速度が速いからです。1次元配列を二次元配列として利用するにはインデックスを毎回計算しないといけませんがその計算をしてもほかのデータ形式より実行速度で有利だからです。

速度については以下の記事にまとめました。

takachan.hatenablog.com

上記の気の通りランダムアクセス、シーケンシャルアクセスで最大速度が得られます。C#の2次元配列表現は1次元配列を2次元配列として扱うより速度が低下します。

こういう基本クラスはアクセス頻度が結構高いと思うので処理コストが軽いほうがよいという理由から1次元配列を2次元配列のように扱っています。

以上です。

【C#】2次元配列から2次元配列を切り出す

タイトルの通りなのですが、絵にするとこんな感じです。

以下のような2次元配列があった時に

f:id:Takachan:20200220221712p:plain

以下のようにある部分を範囲選択して切り取り新しい2次元配列を作成する処理になります。

f:id:Takachan:20200220221727p:plain

以下コードですが最新版なら普通のC#でもUnityでもどちらでも行けます。

実装コード

早速実装例を紹介したいと思います。

ArrayUtilityクラス

まず、2次元配列に対する操作を行うUtilityクラスを以下の通り作成します。

冒頭の図のような処理を行うのは中ほどにあるCutメソッドです。

中心座標を指定してその周囲を切り取るCutByCenterも併せて実装しました。

// 配列に対する汎用機能を提供します。
public static class ArrayUtility
{
    // 指定した2次元配列を複製します。
    public static T[,] Clone<T>(T[,] src)
    {
        int h = src.GetLength(0);
        int w = src.GetLength(1);
        var map = new T[h, w];
        for (int y = 0; y < h; y++)
        {
            for (int x = 0; x < w; x++)
            {
                map[y, x] = src[y, x];
            }
        }
        return map;
    }

    // 指定した2次元配列の位置 (x, y) から (w to h) の範囲を切り取ります。
    // (w, h)の 指定が src の範囲を超える場合 src の範囲内で切り取りを行います。
    public static T[,] Cut<T>(T[,] src, int x, int y, int w, int h)
    {
        // コピー元の配列の大きさ
        int ymax = src.GetLength(0);
        int xmax = src.GetLength(1);

        // 入力値が範囲内に収まるかどうか
        if (x < 0 || x > xmax || y < 0 || y > ymax)
        {
            string msg = $"Parameter is out of range. src[y={ymax},x={xmax}], x={x}, y={y}";
            throw new ArgumentOutOfRangeException(msg);
        }
        if (w == 0 || h == 0)
        {
            throw new ArgumentException($"Invalid parameter. w={w}, h={h}");
        }

        // コピー先の配列の大きさ
        int rh = y + h <= ymax ? h : h - (y + h - ymax);
        int rw = x + w <= xmax ? w : w - (x + w - xmax);
        
        // 元の大きさからコピーする
        var map = new T[rh, rw];
        for (int my = 0, _y = y; my < rh; my++, _y++)
        {
            for (int mx = 0, _x = x; mx < rw; mx++, _x++)
            {
                map[my, mx] = src[_y, _x];
            }
        }

        return map;
    }

    // 2次元配列の中心位置 (cx, cy) から指定した半径 (rw, rh) の範囲を切り取ります。
    // (rw, rh) が src の範囲を超える場合 src の範囲内で切り取りを行います。
    // 
    // 戻り値のタプルには src から切り取った範囲の値が設定されます。
    public static (T[,] map, Rect2Di rect) CutByCenter<T>(T[,] src, int cx, int cy, int rw, int rh)
    {
        int xmax = src.GetLength(1);
        int ymax = src.GetLength(0);

        if (cx < 0 || cx > xmax || cy < 0 || cy > ymax)
        {
            string msg = $"Parameter is out of range. src[y={ymax},x={xmax}], x={cx}, y={cy}";
            throw new ArgumentOutOfRangeException(msg);
        }

        // 切り取る範囲の最大・最小値を取得
        int min_x = cx - rw;
        int max_x = cx + rw;
        int min_y = cy - rh;
        int max_y = cy + rh;
        if (min_x < 0)
        {
            min_x = 0;
        }
        if (max_x >= xmax)
        {
            max_x = xmax - 1;
        }
        if (min_y < 0)
        {
            min_y = 0;
        }
        if (max_y >= ymax)
        {
            max_y = ymax - 1;
        }

        var map = new T[max_y - min_y + 1, max_x - min_x + 1];

        for (int my = 0, _y = min_y; _y <= max_y; my++, _y++)
        {
            for (int mx = 0, _x = min_x; _x <= max_x; mx++, _x++)
            {
                map[my, mx] = src[_y, _x];
            }
        }

        return (map, new Rect2Di(min_x, max_x, min_y, max_y));
    }

    // [デバッグ用] 指定した配列の内容を文字列に変換します。
    public static string TostringByDebug<T>(T[,] src)
    {
        var a = new StringBuilder();
        var b = new StringBuilder();
        int ymax = src.GetLength(0);
        int xmax = src.GetLength(1);
        for (int y = 0; y < ymax; y++)
        {
            for (int x = 0; x < xmax; x++)
            {
                b.Append($" {src[y,x]},");
            }
            a.Append(b.ToString().Trim(' ', ','));
            a.Append(Environment.NewLine);
            b.Clear();
        }
        return a.ToString();
    }
}

// 一時的なデータの入れ物
public readonly struct Rect2Di
{
    public int XMin { get; }
    public int YMin { get; }
    public int XMax { get; }
    public int YMax { get; }
    public Rect2Di(int xmin, int ymin, int xmax, int ymax)
    {
        this.XMin = xmin;
        this.YMin = ymin;
        this.XMax = xmax;
        this.YMax = ymax;
    }
}

使い方

上記実装の使用方法です。

public static void Main(string[] args)
{
    int[,] _m = new int[,]
    {
        { 0, 0, 0, 0, 0, 1, 1, 1, 1, 1 },
        { 1, 1, 1, 1, 1, 2, 2, 2, 2, 2 },
        { 2, 2, 2, 2, 2, 3, 3, 3, 3, 3 },
        { 3, 3, 3, 3, 3, 4, 4, 4, 4, 4 },
        { 4, 4, 4, 4, 4, 5, 5, 5, 5, 5 },
        { 5, 5, 5, 5, 5, 6, 6, 6, 6, 6 },
        { 6, 6, 6, 6, 6, 7, 7, 7, 7, 7 },
        { 7, 7, 7, 7, 7, 8, 8, 8, 8, 8 },
        { 8, 8, 8, 8, 8, 9, 9, 9, 9, 9 },
        { 0, 0, 0, 0, 0, 1, 1, 1, 1, 1 },
    };

    // 左上基準に範囲を切り取る
    var map1 = ArrayUtility.Cut(_m, 0, 0, 5, 5);
    Console.WriteLine(map1.ToStringByDebug());
    Console.WriteLine("");
    // 0, 0, 0, 0, 0
    // 1, 1, 1, 1, 1
    // 2, 2, 2, 2, 2
    // 3, 3, 3, 3, 3
    // 4, 4, 4, 4, 4


    // 中心座標を指定して範囲を切り取る
    var (map, rect) = ArrayUtility.CutByCenter(_m, 4, 4, 2, 2);
    Console.WriteLine(map.ToStringByDebug());
    Console.WriteLine("");
    
    // (4, 4)を中心に上下左右に2ずつ(縦横5x5)の範囲を切り取る
    // 2, 2, 2, 3, 3
    // 3, 3, 3, 4, 4
    // 4, 4, 4, 5, 5
    // 5, 5, 5, 6, 6
    // 6, 6, 6, 7, 7
}

ArrayExtension:拡張メソッド版

上記の処理を2次元配列の拡張メソッドとして定義したいと思います。

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

先ほどのUtilityの処理を中で呼び出すようにしています。

// 配列に対する拡張機能を提供します。
public static class ArrayExtension
{
    // <see cref="ArrayUtility.Clone{T}(T[,])"/> と同じ機能を持つ拡張メソッド
    public static T[,] Clone<T>(this T[,] src) => ArrayUtility.Clone(src);

    // <see cref="ArrayUtility.Cut{T}(T[,], int, int, int, int)"/> と同じ機能を持つ拡張メソッド
    public static T[,] Cut<T>(this T[,] src, int x, int y, int w, int h)
    {
        ArrayUtility.Cut(src, x, y, w, h);
    }

    // <see cref="ArrayUtility.CutByCenter{T}(T[,], int, int, int, int)"/> と同じ機能を持つ拡張メソッド
    public static (T[,] map, Rect2Di rect) CutByCenter<T>(this T[,] src, int cx, int cy, int rw, int rh)
    {
        ArrayUtility.CutByCenter(src, cx, cy, rw, rh);
    }

    // <see cref="ArrayUtility.TostringByDebug{T}(T[,])"/> と同じ機能を持つ拡張メソッド
    public static string ToStringByDebug<T>(this T[,] src)
    {
        ArrayUtility.TostringByDebug(src);
    }
}

使い方

上記実装例の使用方法です。

ほぼ同じですが、2次元配列のメソッドとして使用できるようになっています。多分こっちの方がすっきりすると思います。

public static void Main(string[] args)
{
    int[,] _m = new int[,]
    {
        { 0, 0, 0, 0, 0, 1, 1, 1, 1, 1 },
        { 1, 1, 1, 1, 1, 2, 2, 2, 2, 2 },
        { 2, 2, 2, 2, 2, 3, 3, 3, 3, 3 },
        { 3, 3, 3, 3, 3, 4, 4, 4, 4, 4 },
        { 4, 4, 4, 4, 4, 5, 5, 5, 5, 5 },
        { 5, 5, 5, 5, 5, 6, 6, 6, 6, 6 },
        { 6, 6, 6, 6, 6, 7, 7, 7, 7, 7 },
        { 7, 7, 7, 7, 7, 8, 8, 8, 8, 8 },
        { 8, 8, 8, 8, 8, 9, 9, 9, 9, 9 },
        { 0, 0, 0, 0, 0, 1, 1, 1, 1, 1 },
    };

    // 左上基準に範囲を切り取る
    var map1 = _m.Cut(0, 0, 5, 5);
    Console.WriteLine(map1.ToStringByDebug());
    Console.WriteLine("");
    // 0, 0, 0, 0, 0
    // 1, 1, 1, 1, 1
    // 2, 2, 2, 2, 2
    // 3, 3, 3, 3, 3
    // 4, 4, 4, 4, 4


    // 中心座標を指定して範囲を切り取る
    var (map, rect) = _m.CutByCenter(4, 4, 2, 2);
    Console.WriteLine(map.ToStringByDebug());
    Console.WriteLine("");
    
    // (4, 4)を中心に上下左右に2ずつ(縦横5x5)の範囲を切り取る
    // 2, 2, 2, 3, 3
    // 3, 3, 3, 4, 4
    // 4, 4, 4, 5, 5
    // 5, 5, 5, 6, 6
    // 6, 6, 6, 7, 7
}

こちらも最初のコードと同じように切り取ることができました。

コードばかりになってしまいましたが以上です。

C#でオブジェクト初期化子を使った時の丸カッコの有無

C#には「オブジェクト初期化子」というインスタンス作成時に使用できる初期化方法があります。

例えば以下のようなクラスがあった場合、公開されているプロパティに対して初期化と同時に値が設定できます。

// クラス宣言
public class Point2Di
{
    public int X { get; set; }
    public int Y { get; set; }
}

// オブジェクト初期化子による初期化
var p = new Point2Di
{
    X = 10,
    Y = 20 // プロパティに対して作成と同時に値の設定ができる
};

また配列やコレクションの初期化を行うことができます。

var list = new List<int>
{
   1, 2, 3, 4, 5
};

var array = new int[] { 1, 3, 5, 7 };

この機能でリストの初期化やコンストラクターに引数が含まれないフィールドを作成と同時に初期化することができます。

で、この初期化子ですが以下のように2種類書き方があります。

// (1) オブジェクト初期化子で初期化。【丸カッコ付き】
var p2 = new Point2Di()
{
    X = -3,
    //Y = -4
};

// (2) オブジェクト初期化子で初期化。【丸カッコ無し】
var p3 = new Point2Di
{
    X = -5,
    //Y = -6,
};

何が言いたいのかと言いうと、このカッコの有無で挙動の違いはありません。

動作確認

動作に差異が無いことを確認するために冒頭のクラスでコンストラクタが呼び出されるとログが出力されるようにコンストラクターを追加します。

public class Point2Di
{
    public int X { get; set; }
    public int Y { get; set; }

    // 呼び出されるとログが出る
    public Point2Di() => Console.WriteLine("ctor");
}

次に以下コードを実行します。

// 確認用のコード

// (1) オブジェクト初期化子で初期化。【丸カッコ付き】
var p1 = new Point2Di()
{
    X = -3,
    //Y = -4
};
// 出力:
//> ctor

// (2) オブジェクト初期化子で初期化。【丸カッコ無し】
var p2 = new Point2Di
{
    X = -5,
    //Y = -6,
};
// 出力:
//> ctor

//Console.WriteLine($"p1({p1.X}, {p1.Y})");
Console.WriteLine($"p1({p1.X}, {p1.Y})");
Console.WriteLine($"p2({p2.X}, {p2.Y})");
// 出力:
//> p1(-3, 0)
//> p2(-5, 0)
//  → 結果は同じ

それぞれで、(1) コンストラクターが呼び出される → (2) メンバーは規定値に初期化される → (3) メンバー初期化子の内容がオブジェクトに設定されるという挙動が同じことが確認できたと思います。

以上です。

C#の配列をインデックス付きforeachする

前置き

C#の配列をインデックス付きでforeachする方法は純粋なC#では2種類あります。シンプルにforで回すか、LinqでSelectするかです(ループの外にインデックスを宣言すればどの方法でも処理できますが、ここではインデックス用の変数(i)が外に見えない形式を指しています)

int[] array = new int[] { 100, 101, 102, 103, 104 };

// 通常のfor文の利用
for (int i = 0; i < array.Length; i++)
{
    Console.WriteLine($"[{i}] = {array[i]}");
    // > 出力:
    // > [0] = 100
    // > [1] = 101
    // > [2] = 102
    // > [3] = 103
    // > [4] = 104
}

また、配列にインデックス付きのforeachするならLinqでも以下のように記述すればできます。

int[] array = new int[] { 100, 101, 102, 103, 104 };

// Tupleを使ったインデックス付きforeachの記述例
foreach ((int item, int i) in array.Select((x, i) => (x, i)))
{
    result.Add(item);
}

ですが何度も上記の処理を毎回記述すると結構面倒です。

また上記記述方法以外でループの外でインデックス用の変数を用いれば上記手法に囚われずループ中にインデックスが利用できます。

しかし、宣言したインデックスの変数スコープがループ外に見える事が邪魔なケースもあり、出来ればインデックス用の変数はループ外部から見えないほうがいいです。

またLinqでSelectすると処理が結構遅い事もあり、わざわざ配列を選択した局面で遅いアルゴリズムを選択することも少し気になります。PC環境では気になることはそれほど無いですがモバイルだと影響が結構出るかもしれません。

そこで拡張メソッドの仕組みを利用してインデックス付きのforeachを実現したいと思います。

確認環境

確認環境は以下の通りです。

  • C# 7.3
  • .Net Core3.2
  • VisualStudio2019

ArrayExtensionクラス

まず、ArrayExtensionクラスを宣言して以下のように拡張メソッドを定義します。

配列を引数にとり内部でforを回しています。

/// <summary>
/// 配列に対する拡張機能を定義します。
/// </summary>
public static class ArrayExtension
{
    /// <summary>
    /// 指定したコレクションに対してインデックス付き foreach を実行します。途中で中断できません。
    /// 
    /// Func:
    ///   T1 : Tuple(i:インデックス, item:配列要素)
    /// </summary>
    public static void ForEach<T>(this T[] array, Action<(int i/*index*/, T item)> func)
    {
        for (int i = 0; i < array.Length; i++)
        {
            func((i, array[i]));
        }
    }

    /// <summary>
    /// 指定した配列に対してインデックス付き foreach を実行します。途中で中断可能版。
    /// 
    /// Func:
    ///   T1 : Tuple(i:インデックス, item:配列要素)
    ///   TRet : ループを継続するかどうかのフラグ, true:継続する / false:ループを終了
    ///   
    /// 戻り値:
    ///   true : 全要素に対して処理完了 / false : 途中でループ終了
    /// </summary>
    public static bool ForEach<T>(this T[] array, Func<(int i/*index*/, T item), bool> func)
    {
        for (int i = 0; i < array.Length; i++)
        {
            if (!func((i, array[i])))
            {
                return false; // 中断終了
            }
        }
        return true;
    }
}

使用方法

上記クラスの使い方は以下の通りです。

配列のインスタンスに対してForEachを使えるように機能が拡張されます。

メソッドに渡すデリゲートにインデックスと配列要素を格納したTupleが渡されるのでループ中はこれを利用します。

public static void Main(string[] args)
{
    // 配列に対する操作
    int[] array = new int[] { 100, 101, 102, 103, 104 };

    // 全部の要素を列挙して処理を行う
    array.ForEach(p =>
    {
        // p.i にインデックス
        // p.item に配列要素が入ってくる
        Console.WriteLine($"[{p.i}] = {p.item}");
    });
    // > 出力:
    // > [0] = 100
    // > [1] = 101
    // > [2] = 102
    // > [3] = 103
    // > [4] = 104

    // ある条件になったらループを途中終了する
    bool ret1 = array.ForEach(p =>
    {
        if (p.item > 102)
        {
            Console.WriteLine($"[{p.i}]で処理を中断");
            return false;
        }
        Console.WriteLine($"[{p.i}] = {p.item}");
        return true;
    });
    // > 出力:
    // > [0] = 100
    // > [1] = 101
    // > [2] = 102
    // [3]で処理を中断
}

上記メソッドはforで記述したときの処理時間を1としたときにForEachメソッドは1.07倍程度のコストで使用できます。

LinqでSelectすると3.1倍程度なので視認性を天秤にかけてまぁ許せる速度かなと思います。

という訳なのでもしよかったら使用してみてください。

.NET Core3.xとUnityでAES暗号化を利用する

タイトルの環境でAES暗号化をしてみたので実装方法の紹介をしたいと思います。

AESの実装方法はサンプルがネットに結構転がっているので簡単に説明するだけでいきます。

いちおう、以前 Cocos2d-x 上で AES 暗号化ライブラリを実装しましたのですが、C++に比べてC#だと実装量が少量 & 環境依存をそこまで考えて処理を分けないで済むので簡単な記述ができます。

確認環境

確認環境は以下の通り

  • Unity 2019.2.17f
  • .NET Core3.1
  • Visual Studio2019
  • Windows 10

(UnityはEditorとWindowsで動作確認済み。Android/iOSの実機動作は未確認、たぶん動くの精神)

AesCypherクラス:AES暗号化の実装

AES暗号化は繰り返し使用する & 少し使い方があったのでAesCypherクラスにライブラリ化しました。

バイト配列のいわゆるバイナリデータを暗号化したバイナリデータに変換と復元を行えます。

サポートする形式は特に指定しなければAES-256, CBC形式で現在ほぼ最強水準のセキュリティとなります。

// AesCypher.cs

using System.Security.Cryptography;

/// <summary>
/// AES暗号化クラス
/// </summary>
public static class AesCypher
{
    /// <summary>
    /// 指定したパラメーターで文字列を暗号化します。
    /// </summary>
    public static byte[] Encrypt(EnctyptionInfo info, byte[] buffer)
    {
        using (var aes = CreateEngine(info))
        {
            ICryptoTransform e = aes.CreateEncryptor();
            return e.TransformFinalBlock(buffer, 0, buffer.Length);
        }
    }

    /// <summary>
    /// 指定したパラメーターで文字列を復号化します。
    /// </summary>
    public static byte[] Decrypt(EnctyptionInfo info, byte[] buffer)
    {
        using (var aes = CreateEngine(info))
        {
            ICryptoTransform e = aes.CreateDecryptor();
            return e.TransformFinalBlock(buffer, 0, buffer.Length);
        }
    }

    /// <summary>
    /// 暗号化を行うためのエンジンを生成します。
    /// </summary>
    public static AesManaged CreateEngine(EnctyptionInfo info)
    {
        return new AesManaged
        {
            KeySize = info.KeySize,
            BlockSize = info.BlockSize,
            Mode = info.Mode,
            IV = info.IV,
            Key = info.Key,
            Padding = info.Padding
        };
    }
}

上記で使用する引数情報を設定するためのデータコンテナです。

// EnctyptionInfo.cs

using System.Security.Cryptography;

/// <summary>
/// AES暗号化を行うときに指定するパラメータを格納するコンテナを表します。
/// </summary>
public class EnctyptionInfo
{
    /// <summary>
    /// 暗号化キーサイズを設定または取得します。256bit固定
    /// </summary>
    public int KeySize { get; } = 256;

    /// <summary>
    /// 暗号化ブロックサイズを設定または取得します。128bit固定
    /// </summary>
    public int BlockSize { get; } = 128;

    /// <summary>
    /// 暗号化モードを取得します。CBC固定。
    /// </summary>
    public CipherMode Mode { get; } = CipherMode.CBC;

    /// <summary>
    /// 初期ベクトルを設定または取得します。
    /// </summary>
    public byte[] IV { get; set; }

    /// <summary>
    /// 暗号化キーを設定または取得します。
    /// </summary>
    public byte[] Key { get; set; }

    /// <summary>
    /// パディングモードを設定または取得します。
    /// </summary>
    public PaddingMode Padding { get; } = PaddingMode.PKCS7;
}

使い方

先にEnctyptionInfoクラスのプロパティにキーと初期ベクトルを設定してAesCypherに引数として指定します。

暗号化・復元のメソッドの第1パラメータに上記コンテナ、第2パラメータに暗号化したいコンテンツのバイナリを指定します。

実際はバイトデータをオブジェクトに相互に変換する処理が必要ですがここでは説明は割愛します。

大抵の場合、JSONで文字列やバイナリのシリアライズからデータを生成しておいて、GetBytesでそのバイナリを取ったりするケースが多いと思います。

// AppMain.cs

using System;
using System.Collections.Generic;
using System.Text;

internal static void Main(string[] args)
{
    // Key(= キー用の)32byte(256bit)のバイナリ
    var key = new List<byte>()
    {
        0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00
    };

    // IV(= 初期ベクトル用)の16byte(128bit)のバイナリ
    var iv = new List<byte>()
    {
        0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00,
        0x00
    };

    // 重要:
    // 以下のような文字列から生成すると数値の範囲が狭くなって
    // セキュリティ強度が低下するので必ず上記のようなバイナリの数列を指定してください
    // 
    // 悪い例:
    // byte[] _key = Encoding.ASCII.GetBytes("12345678901234567890123456789012");
    // byte[] _iv = Encoding.ASCII.GetBytes("1234567890");

    // 暗号化の初期情報を設定
    //  → 基本的にKeyとIVだけ設定すればOK
    var encInfo = new EnctyptionInfo
    {
        Key = key.ToArray(),
        IV = iv.ToArray(),
    };

    // 暗号化対象の文字列
    string message = "ああいいううええおおかかききくくけけここ";

    // 暗号化前のデータのバイナリ
    byte[] plane = Encoding.UTF8.GetBytes(message);
    ShowBytes(plane);
    // > 0xE3 0x81 0x82 0xE3 0x81 0x82 0xE3 0x81 0x84 0xE3...(60byte)

    // 文字列を暗号化
    byte[] encrypted = AesCypher.Encrypt(encInfo, plane);
    ShowBytes(encrypted);
    // > 0x90 0xD1 0xEC 0xFE 0x6D 0xD5 0x29 0x79 0x98 0xFD...(64byte)

    // 暗号化を解除
    byte[] decrypt = AesCypher.Decrypt(encInfo, encrypted); // 暗号化と同じ情報で復号化
    ShowBytes(decrypt);
    // > 0xE3 0x81 0x82 0xE3 0x81 0x82 0xE3 0x81 0x84 0xE3...(60byte)

    // 文字列に戻す
    string message2 = Encoding.UTF8.GetString(decrypt);
    Console.WriteLine(message2);
}

// バイト配列の内容をコンソールに表示する
public static void ShowBytes(byte[] bs)
{
    Array.ForEach(bs, b => Console.Write($"0x{b:X2} "));
    Console.WriteLine("");
}

C#だと簡潔に処理が記述できるのでとても良いです。

コメントでも記載していますが、特にリリース時はKEYとIVはは文字列リテラルからGetByteで生成はやめましょう。バイト列の場合も容易に値を知られないように別途仕組みが必要です。

C#のスクリプトにリテラルで書いたりするとせっかく暗号化してもバレバレで一瞬で暗号化が突破されてしまうので気を付けましょう。

関連記事

takap-tech.com

以上です。

C#の標準機能でデータを圧縮・展開する

C#には結構昔から標準機能として「DeflateStream」と「GZipStream」というデータを圧縮する2種類のライブラリが実装されています。

MSDNに説明が書いてあるのですが読んでもよくわからないと思うので簡単にまとめてみました。

名前 説明
Deflate 可逆圧縮アルゴリズムの名前
GZipStream Defrate圧縮 + ヘッダーとフッターを付与したもの

両方とも中身の実装は「zlib ライブラリ」を使用しているみたいです。 「.NET Framework 4.5以降は」と書いてありますがまぁ大抵の場合、標準的な実装が利用できそうです。

なので、Deflateで圧縮すると純粋なデータ圧縮、GZipStreamで圧縮するとDelfateの圧縮データ + ヘッダー(+フッダー)になります。

使い分けの方針ですが、Deflateだとヘッダーなどがない只のバイナリデータなので元が何のデータが分からなくなるので身元が明らかなローカルシステム内のストレージや、エンドポイント間のデータ転送などで限定的に使用します。GZipはファイルに保存したりするときに.zipや.gzipなど拡張子を付けて保存したり間をあけて生成者と利用者が異なるケースで使用することが多いと思います。

当方説明以外でも説明しているページがあったのですが掲載コードがやや微妙だったので余計なことをせず簡潔なライブラリをC#で実装してみました。

確認環境

確認環境は以下の通り

  • .NET Core 3.1
  • Unity 2019.2.17f1
  • Visual Studio2019
  • Windows 10

(UnityはEditorとWindowsで動作確認済み。Android/iOSの実機動作は未確認、たぶん動くの精神)

DataOperationクラス:圧縮・解凍ライブラリ

文字列を直接圧縮・解凍する方法と、バイナリと圧縮・解凍する処理を提供するクラスを"DataOperation"クラスにまとめました。

// DataOperation.cs

using System.Text;
using System.IO;
using System.IO.Compression;

/// <summary>
/// デフレートアルゴリズムを使用したデータ圧縮・解凍昨日を定義します。
/// </summary>
public static class DataOperation
{
    /// <summary>
    /// 文字列を圧縮しバイナリ列として返します。
    /// </summary>
    public static byte[] CompressFromStr(string message) => Compress(Encoding.UTF8.GetBytes(message));

    /// <summary>
    /// バイナリを圧縮します。
    /// </summary>
    public static byte[] Compress(byte[] src)
    {
        using (var ms = new MemoryStream())
        {
            using (var ds = new DeflateStream(ms, CompressionMode.Compress, true/*msは*/))
            {
                ds.Write(src, 0, src.Length);
            }

            // 圧縮した内容をbyte配列にして取り出す
            ms.Position = 0;
            byte[] comp = new byte[ms.Length];
            ms.Read(comp, 0, comp.Length);
            return comp;
        }
    }

    /// <summary>
    /// 圧縮データを文字列として復元します。
    /// </summary>
    public static string DecompressToStr(byte[] src) => Encoding.UTF8.GetString(Decompress(src));

    /// <summary>
    /// 圧縮済みのバイト列を解凍します。
    /// </summary>
    public static byte[] Decompress(byte[] src)
    {
        using (var ms = new MemoryStream(src))
        using (var ds = new DeflateStream(ms, CompressionMode.Decompress))
        {
            using (var dest = new MemoryStream())
            {
                ds.CopyTo(dest);

                dest.Position = 0;
                byte[] decomp = new byte[dest.Length];
                dest.Read(decomp, 0, decomp.Length);
                return decomp;
            }
        }
    }
}

"DeflateStream" の個所を "GZipStream" に変更すればGZipStreamが同じように使用できます。

用途によると思うので選べるように汎用性を作りこんでもいいのかもしれません。

使い方

上記のクラスの使い方は以下のとおりです。プレーンテキストなら大抵半分よりは小さくなる場合が多いです(データが10バイトのように短すぎると逆にサイズが大きくなります)

// AppMain.cs

using System;
using System.Text;

internal static void Main(string[] args)
{
    // 圧縮対象の文字列
    string message = "ああいいううええおおかかききくくけけここ";

    // 圧縮しない場合の文字のバイト配列
    byte[] planeBytes = Encoding.UTF8.GetBytes(message);
    ShowBytes(planeBytes);
    // > 0xE3 0x81 0x82 0xE3 0x81... 60byte

    // --- ★圧縮する ---

    // 文字列を圧縮する
    byte[] compressed = DataOperation.CompressFromStr(message);
    ShowBytes(compressed);
    // > 0x7B 0xDC 0xD8 0xF4 0x18... 30byte(半分に減ってる

    // バイト配列を圧縮
    byte[] compressed2 = DataOperation.Compress(planeBytes);
    ShowBytes(compressed2);
    // > 0x7B 0xDC 0xD8 0xF4 0x18... 30byte(半分に減ってる

    // --- ★元に戻す ---

    // 圧縮した文字列を元に戻す
    string restore = DataOperation.DecompressToStr(compressed);
    Console.WriteLine(restore);
    // > ああいいううええおおかかききくくけけここ

    // バイト配列を元に戻す
    string restore2 = Encoding.UTF8.GetString(DataOperation.Decompress(compressed2));
    Console.WriteLine(restore2);
    // > ああいいううええおおかかききくくけけここ
}

// バイト配列の内容をコンソールに表示する
public static void ShowBytes(byte[] bs)
{
    Array.ForEach(bs, b => Console.Write($"0x{b:X2} "));
    Console.WriteLine("");
}

それぞれ変換 → 元に戻す操作ができていると思います。

大した事ないので短めですが以上です。

参考資料

dobon.net: GZIPやデフレートでファイルを圧縮する

Gushwell's Dev Notes: データの圧縮と展開

MSDN: DeflateStream クラス

C#で属性を利用して処理に制約の説明を追加する

属性とは

C#に属性(Attribute)という機能があり、これを付ける事でクラスやメンバーに情報を追加することができます。

.NET で使用されている有名なものでは、デバッグ時だけコンパイルされる"Conditionat"属性や、廃止予定を予告するための"Obsolete"属性が有名です。

これらは付与するとIDEや言語と連携して何らかの機能が提供されたりします(例えばメッセージが表示されたり、実装が追加されたり etc...)

また属性は自作することもできます。自作の属性は付与しても特に何が起きるわけではないので純粋な情報追加という意味になります。

もちろん外部ツールでアセンブリからメタ情報を読み取ってCI時にチェックするという使用方法もあります。最終的にかなり手間がかかるので面倒なため限られた人しかやっていません。

後から特別な方法で付与した属性の値は取り出すことができるのでこれを利用してenumを拡張するのような使い方をするのが一番よくある利用のされかたかと思います。

自作の属性でプロパティやメソッドに説明を付与する

で、アイデアレベルになりますが、の属性を使ってメソッドや属性に説明を追加したいと思います。

コメントだと自由記述過ぎるのため、特定の属性が付与されていることで制約を表すことに使えないかなと。

ImplementAttribute:インターフェースの実装を明示する

C#は言語仕様上インターフェースを具象クラスに実装した場合、メソッドにoverrideキーワードをつけません。

"abstract" や "virtual" メソッドは実装した場合 "overide" キーワード指定が必須で後からコードを見たときに区別が一瞬つかないときがあります。またAPIコメントがインターフェースと実装でコピペされているのも結構無駄な感じなのでImplementAttributeを作成してoverrideキーワードの代わりにしたいと思います。

属性の宣言は以下の通りです。付与して説明するだけなのでコンストラクターで引数を指定するだけで値の保存などはしません。

/// <summary>
/// インターフェースを実装していることを表します。
/// 付与するだけで特にシステム上の意味はありません。
/// </summary>
[AttributeUsage(AttributeTargets.Method | 
                AttributeTargets.Property, 
                Inherited = false, AllowMultiple = false)]
public sealed class ImplementAttribute : Attribute
{
    public ImplementAttribute(string name) { }
    public ImplementAttribute(Type type) { }
}

上記属性の使用方法です。

// Sample.cs

using System;

public class Sample : IDisposable
{
    // インターフェースを実装したメソッドに以下のように付与する
    [Implement(nameof(IDisposable.Dispose))]
    public void Dispose()
    {
        // hoge
    }
}

後から見たときにインターフェースを実装していることを属性で明示できたような気がします。

ReqireWithNoCheckAttribute:指定が必須だけどチェックしない

パラメーターとして渡した値が渡した先でチェックされるのかされないのかを属性によって明示します。

チェックしないけど必須パラメーター(パフォーマンス上の都合など)や、プロパティに何か指定しないと後の処理が動かなくなるなどを属性で明示します。

// ReqireAttribute.cs

/// <summary>
/// 値の設定がインスタンス生成時や以降の処理実行時に必須かどうかを表します。
/// (未設定の場合、以降の操作で未チェックの例外が発生する可能性を表す)
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, Inherited = false, AllowMultiple = true)]
public sealed class ReqireAttribute : Attribute
{
    /// <summary>
    /// 設定が必須のパラメーターの旨を明示してオブジェクトを作成します。
    /// </summary>
    public ReqireAttribute() { }

    /// <summary>
    /// 必須パラメーターがメソッド内でチェックされるかどうかを指定してオブジェクトを作成します。
    /// true  : メソッド内でチェックあり
    /// false : パラメーターは渡した先でチェックされない。未検査でパラメーターの仕様で落ちる可能性がある。
    /// </summary>
    public ReqireAttribute(bool inspection) { }
}

上記属性の使用方法です。

// Sample.cs

public class Sample
{
    // 引数がチェックされないで使用される旨を属性で明示する
    public void Execute([Reqire(inspection: false)] Around around)
    {
        // aroundオブジェクトにnullチェックをしないでいきなりアクセスする
        around.Foo();
    }

    // 有意の値をプロパティに設定する必要がある旨を表す。
    [Reqire]
    public string Message { get; set; } = "";

    public void Foo()
    {
        // このメソッド内でMessageプロパティを検査せずに使用する
        if (this.Message == "Foo")
        {
            // hoge
        }
    }
}

呼び出すときにパラメータやプロパティの内容に注意が必要な旨を属性で表明した気になれます。

これも付与して説明するだけでシステム上何か特別な作用があるわけではないのですが、他人のコードにこれがついてると多少ありがたい時があります。

SideEffectAttribute:呼び出すと副作用が発生する

プロパティに値を設定すると連動してほかのプロパティが変化する(クソ設計臭がすごいですが…)や、呼び出すとオブジェクトの状態が変化するメソッドなどに付与して副作用が発生することを属性を使って明示します。

// SideEffectAttribute.cs

using System;

/// <summary>
/// 呼び出しに副作用があることを明示します。
/// 付与するだけで特にシステム上の意味はありません。
/// </summary>
[AttributeUsage(AttributeTargets.Method | 
                AttributeTargets.Property | 
                AttributeTargets.Parameter, 
                Inherited = false, AllowMultiple = true)]
public sealed class SideEffectAttribute : Attribute
{
    public string[] Names { get; private set; }

    /// <summary>
    /// 副作用のある対象を列挙してオブジェクトを新規作成します。
    /// </summary>
    /// <param name="names"></param>
    public SideEffectAttribute(params string[] names) => this.Names = names;
}

上記属性の使用方法です。

// Sample.cs

public class Sample
{
    public int Count { get; private set; }

    // 呼び出すとCountプロパティに影響があることを指定する
    [SideEffect(nameof(Count))]
    public void Execute()
    {
        this.Count++;
    }
}

呼び出すと関係ない個所に影響があることを属性の引数で説明文でも良いですし、変数名でも良いので指定して属性を付与します。

副作用が無いのが一番いいですが理想通りに実装できるケースの方が少ないので他のドキュメントで説明するよりは属性で示した方が後で幸せになれると思います。

DoNotChangeAttribute:値を変えてはいけない

リリース後にリテラルやEnumなどを変更してはいけないことを明示します。

既定値のあるSerializeAttributeやDataContract、SerializeFieldが付与されているプロパティの名称の変更の禁止は当然ですが、定数リテラルとかEnumを数字として扱っている場合、変更されると境界面や境界外で影響が発生するものに付与し変更を禁止する旨を明示します。

/// <summary>
/// フィールド、プロパティ、パラメータの設定値が変更禁止な事を表します。
/// 特にリリースした後に変更してはいけない定数などに付与します。
/// 目印として付与するだけでシステム上の意味は特にありません。
/// </summary>
[AttributeUsage(AttributeTargets.Field | 
                           AttributeTargets.Property | AttributeTargets.Parameter, Inherited = false, AllowMultiple = true)]
public sealed class DoNotChangeAttribute : Attribute
{
    /// <summary>
    /// 既定の初期値でオブジェクトを生成します。
    /// </summary>
    public DoNotChangeAttribute()
    {
    }

    /// <summary>
    /// 禁止の理由や対象を説明する文字列を指定してオブジェクトを生成します。
    /// </summary>
    public DoNotChangeAttribute(string message)
    {
    }
}

上記属性の使用方法です。

// Sample.cs

using System;
using System.Runtime.Serialization;
using UnityEngine;

[DoNotChange("並び順及び名称変更NG")]
[DataContract]
public enum Hoge
{
    [EnumMember] AAA,
    [EnumMember] BBB,
    [EnumMember] CCC,
}

public class Sample
{
    [DoNotChange("名称変更禁止")]
    [SerializeField]
    public GameObject Sprite;
}

名前を指定してない "DataMember" のプロパティの名前を他人が変更してデシリアライズに失敗とか最高にあるあるなので付けておいた方が身を守れます。

あとはSerializeFieldの名前をリリース後に変えてしまって参照が外れたまま気が付かないとか割とよくありそうです。

Enumをintにキャストしてるちょっとアレなコードの中ほどに定義を一個追加してシステムがおかしくなるとかも稀によくあるのでコードに書いておいた方がいいです。

実装を直した方がいいと思いますがそういう訳にもいかない場合、属性を付与して防衛しておくのもアリかなと。。

最後に

ConstAttribte とかやり始めるとキリがないのに実効性が無いものもたくさん出てくると思いますが個人的にあって幸せになれそうな4つを紹介しました。

属性は実装に一切影なく宣言に追加できるのでこういった自己説明的な属性があってもいいのではないかなと思います。

やりすぎると属性だらけになってしまう(というかそうなったら設計直した方がいいですが)のでご利用は計画的に。

また、頑張ればCI時にインスペクションで指摘が出せる可能性もあるのでよかったら使ってみてください。

以上です。

C#で文字列にSQLのIN句のようなメソッドを追加する

SQLにあるIN句をC#の文字列に適用し、リストに格納された文字列がある文字列に一致するかどうかを判定する処理をstringに追加したいと思います。

例えば"ABC123"という文字列の中に"AB", "12"という文字が含まれているかという処理は以下のように書けば判定できます。

string str = "ABC123";

// ★判定方法(1)
if(str == "AB" || str == "12")
{
    // 見つかった
}
else
{
    // 見つからなかった
}

// ★判定方法(2)
List<string> keys = new List<string>()
{
    "AB", "12"
};
foreach(string item in keys)
{
    if(str == item)
    {
        // みつかった
    }
}

(1)の場合、対象が個数が増えていくと重複したコードが増えるし(2)だと見つからないときに何かしら追加が必要です。

どうせなら1行でかけたらいいのに…という実装アイデアです。

コード例

早速ですがコード例です。

stringクラスにInメソッドを拡張メソッドとして定義します。

IN句なので完全一致判定となります。

// StringExtension.cs

/// <summary>
/// <see cref="string"/>  クラスの拡張メソッドを定義します。
/// </summary>
public static class StringExtension
{
    // ★パターン(1)
    // 指定した文字列中に複数の要素の中から1つでも一致するものが含まれているかどうかを確認する
    public static bool In(this string str, params string[] items)
    {
        return items.Contains(str); // 逆にすれば Linq で判定できる (完全一致判定)
    }
    public static bool In(this string str, bool ignoreCase, params string[] items)
    {
        return items.Contains(str, comparer); // 逆にする + 大文字小文字を区別しない (完全一致反転)
    }
    
    // ★パターン(2)
    // 指定した文字列中に複数の要素の中から1つでも一致するものが含まれているかどうかを確認する
    public static bool In(this string str, IEnumerable<string> items)
    {
        return items.Contains(str);
    }
    public static bool In(this string str, bool ignoreCase, IEnumerable<string> items)
    {
        return ignoreCase ? items.Contains(str, comparer) : In(str, items);
    }

    // 大文字/小文字を区別しない実装
    private static readonly Exp comparer = new Exp();
    private class Exp : IEqualityComparer<string>
    {
        public bool Equals(string x, string y)
        {
            return string.Compare(x, y, true) == 0;
        }
        public int GetHashCode(string obj)
        {
            return obj.GetHashCode();
        }
    }
}

使い方

上記クラスの使い方は以下の通りです。

リストと配列、IEnumerableが全て指定できます。Spanでも実装できますが…

大文字と小文字を区別しない場合、第1引数はtrueにします。

static void Main(string[] args)
{
    string str = "ABC123";

    // パターン(1)でチェック その1
    bool contains = str.In(false, "XX", "99", "AB", "12");
    if (contains)
    {
        // 見つかった
    }
    else
    {
        // 見つからない
    }

    // パターン(1)でチェック その2
    string[] items = new string[] { "XX", "99", "AB", "12" };
    contains = str.In(false, items);
    if (contains)
    {
        // 見つかった
    }
    else
    {
        // 見つからない
    }


    // パターン(2)でチェック
    IList<string> list = new List<string>() { "XX", "99", "AB", "12" };
    contains = str.In(false, list);
    if (contains)
    {
        // 見つかった
    }
    else
    {
        // 見つからない
    }

    // パター(2)でチェック
    IEnumerable<string> f()
    {
        yield return "XX";
        yield return "99";
        yield return "AB";
        yield return "12";
    }

    contains = str.In(false, f());
    if (contains)
    {
        // 見つかった
    }
    else
    {
        // 見つからない
    }
}

これで複数の候補の中から完全一致するものがあるかどうかをstringクラスで判定できます。

Containsを拡張する

余談ですが、string.Contains を複数の文字列の中から部分一致するものがあるか判定する処理も記載しておきます。

public static class StringExtension
{
    public static bool Contains(this string str, params string[] keywords)
    {
        foreach (var word in keywords)
        {
            if (str.Contains(word)) return true;
        }
        return false;
    }
}

こうしておくと指定したキーワードの中に部分一致するものがあれば true を返すようになります。こっちのほうがよく使うかもしれません。

C#の共有メモリで簡単にオブジェクトを共有する方法

C#を使ってプロセス間でデータ共有をする際にオブジェクトを共有する方法です。

プロセス間でオブジェクトを共有したい場合、大抵の場合構造体を定義してメモリに書き込めばすれば良いとネットに書いてあります。ですが、普段クラスで扱っているデータをその時だけ構造体にするのは結構手間です。

また、オブジェクトを構造体で定義するのも手間です。定義の方法もC#ではすごくマイナーな相互運用で構造体を記述する知識が必要なのでもっと簡単にオブジェクトを共有できる方法を紹介したいと思います。

考え方

考え方は以下の通りです。

// ◆サーバー側
//
// C#のオブジェクトを作成
//   ↓
// オブジェクトをJsonにシリアライズ
//   ↓
// Jsonを共有メモリに展開

// ◆クライアント側
//
// Jsonを共有メモリから読み取る
//   ↓
// Jsonをオブジェクトにデシリアライズ
//   ↓
// C#のオブジェクトの使用

昨今のWebのプロトコル風にJSONを利用します。この方法だとバイナリ配列のやり取りと違ってオーバーヘッドが発生します。その代わり非常に扱いやすいです。

C++と連携してネイティブから読み出した場合も割と簡単に読み出せると思います。

準備

データを共有するための実装を作先に準備します。

共有するクラスの準備

まず共有したい型の定義です。後でシリアライズするのでクラスに以下のように[DataContract]および、[DataMember]属性を目印として付与します。既存クラスの場合も属性の追加だけで大丈夫です。

using System.Runtime.Serialization;

[DataContract]
public class Item
{
    [DataMember(Name = "id")]
    public int ID { get; set; }

    [DataMember(Name = "rate")]
    public double Rate { get; set; }

    [DataMember(Name = "name")]
    public string Name { get; set; }

    public override string ToString()
    {
        return $"{nameof(this.ID)}={ID}, {nameof(this.Rate)}={this.Rate}, {nameof(this.Name)}={this.Name}";
    }
}

JSONを読み書きするクラス

C#のライブラリにJSONを扱えるクラスがあるのでそれを使用してオブジェクト ⇔ JSON文字列の相互変換を定義します。

public static class JsonUtility
{
    // 任意のオブジェクトを JSON メッセージへシリアライズします。
    public static string Serialize(object graph)
    {
        using (var stream = new MemoryStream())
        {
            var serializer = new DataContractJsonSerializer(graph.GetType());
            serializer.WriteObject(stream, graph);
            return Encoding.UTF8.GetString(stream.ToArray());
        }
    }

    // Jsonメッセージをオブジェクトへデシリアライズします。
    public static T Deserialize<T>(string message)
    {
        using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(message)))
        {
            //var setting = new DataContractJsonSerializerSettings()
            //{
            //    UseSimpleDictionaryFormat = true,
            //};
            var serializer = new DataContractJsonSerializer(typeof(T)/*, setting*/);
            return (T)serializer.ReadObject(stream);
        }
    }
}

使用方法

上記のクラスを使って実際にオブジェクトを共有します。

サーバー側

サーバー側ではItemオブジェクトを作成してJSONに変換後に共有メモリにデータを展開します。

// サーバー側実装
public static void Main(string[] args)
{
    // 共有するオブジェクト
    var item = new Item
    {
        ID = 1,
        Rate = 2.553,
        Name = "sample1234567890",
    };

    var sharedMemory = MemoryMappedFile.CreateNew("share", 1024 * 1024 * 5); // 5MB分領域を確保
    using (MemoryMappedViewAccessor view = sharedMemory.CreateViewAccessor())
    {
        string jsonStr = JsonUtility.Serialize(item);
        char[] jsonCharArray = jsonStr.ToCharArray(0, jsonStr.Length); // C#のcharは2byte

        // 受け取り手はサイズが分からないので先頭にサイズを書いておく
        view.Write(0, jsonCharArray.Length);

        // Json文字列を共有メモリに書き込み
        view.WriteArray(sizeof(int), jsonCharArray, 0, jsonCharArray.Length);
    }

    Console.ReadLine(); // プロセスが終了するとデータが消えるのでここで止めておく

    using (sharedMemory) { }
}

読み取り側は共有メモリに実際にどれくらいデータが書き込まれているかわかりません。

そのため、共有メモリの先頭4バイトの領域にデータサイズを書き込んでいます。

クライアント側

クライアント側の実装です。

共有メモリからサイズを読んでJSONの読み取り → オブジェクトにデシリアライズを行います。

public static void Main(string[] args)
{
    string json = ReadDataByJson();

    // 読み取ったデータをデシリアライズしてオブジェクトに戻す
    var item = JsonUtility.Deserialize<Item>(json);

    Console.WriteLine($"{item}");
    // > ID=1, Rate=2.553, Name=sample1234567890
}

// 共有メモリからデータを読み取る
public static string ReadDataByJson()
{
    using (var sharedMemory = MemoryMappedFile.OpenExisting("share"))
    using (MemoryMappedViewAccessor view = sharedMemory.CreateViewAccessor())
    {
        // 共有メモリに先頭に書き込まれている配列サイズを取得する
        int size = view.ReadInt32(0);
        char[] jsonCharArray = new char[size];

        // 共有メモリからデータを取得する
        view.ReadArray(sizeof(int), jsonCharArray, 0, jsonCharArray.Length);

        // 扱いやすいように文字列にして返す
        return new string(jsonCharArray);
    }
}

関連記事