変数

【C#】readonlyで実現する定数配列の安全な運用とArray.AsReadOnly活用法

配列を完全な定数として宣言する方法はC#にはなく、constで指定できるのは値型と文字列に限られます。

参照だけを固定したい場合はreadonlystatic readonlyフィールドが適切で、要素まで変更不可にしたいときはReadOnlyCollection<T>ImmutableArray<T>などのラッパー型を利用して不変性を確保します。

目次から探す
  1. C#における「定数配列」が存在しない理由
  2. constキーワードでは解決できないポイント
  3. readonlyフィールドで参照を固定する
  4. static readonlyで共通配列を共有する
  5. ReadOnlyCollection<T>で要素更新を防ぐ
  6. ImmutableArray<T>で完全不変を実現
  7. ReadOnlySpan<T>と不変配列の併用
  8. レコード型・initプロパティとの連携
  9. struct内のfixed配列でサイズを固定
  10. C#バージョン別サポート状況
  11. パフォーマンス比較
  12. 実運用での選定チェックリスト
  13. よくあるエラーとデバッグ例
  14. コーディングスタイルと命名規則
  15. まとめ

C#における「定数配列」が存在しない理由

C#では、constキーワードを使って定数を宣言できますが、配列をconstとして宣言することはできません。

これは、配列が参照型であることと、コンパイル時定数の要件が関係しています。

ここでは、なぜC#に「定数配列」が存在しないのか、その理由を詳しく解説します。

コンパイル時定数の要件

C#のconstはコンパイル時に値が確定している必要があります。

つまり、constで宣言された値は、コンパイル時に完全に決定されているため、実行時に変更されることはありません。

constが許される型は、主にプリミティブ型(intdoubleboolなど)やstringに限定されています。

配列は参照型であり、constとして宣言する場合は、その参照自体がコンパイル時に決定されている必要があります。

しかし、配列の要素は実行時に変更可能であり、配列の参照先も実行時に動的に生成されるため、コンパイル時に完全に決定できません。

これが、配列をconstとして宣言できない大きな理由です。

例えば、以下のようにconstで配列を宣言しようとするとコンパイルエラーになります。

const int[] numbers = { 1, 2, 3 }; // コンパイルエラー

このエラーは、配列の参照がコンパイル時に確定できないため発生します。

constは値の不変性を保証しますが、配列のような参照型はその性質上、constに適用できません。

また、constで宣言できる参照型はstringのみで、これは文字列リテラルがコンパイル時に確定するためです。

その他の参照型はconstとして宣言できず、nullのみが許されます。

参照型としての配列の特性

配列はC#における参照型の一種です。

参照型は、実際のデータが格納されているヒープ領域のメモリアドレスを指し示す「参照」を持っています。

配列変数はこの参照を保持しており、変数自体は参照を指し示すポインターのような役割を果たします。

このため、配列変数の参照を固定することは可能ですが、配列の中身(要素)を不変にすることは別の話です。

readonly修飾子を使うと、配列変数の参照先を変更できなくなりますが、配列の要素は変更可能なままです。

readonly int[] myArray = { 1, 2, 3 };
myArray[0] = 10; // これは可能
// myArray = new int[] { 4, 5, 6 }; // これはコンパイルエラー

この例では、myArrayの参照先は固定されていますが、配列の中身は自由に書き換えられます。

つまり、配列の「参照の不変性」と「要素の不変性」は別の概念です。

さらに、配列の要素を不変にするためには、ReadOnlyCollection<T>ImmutableArray<T>などのラッパーや不変コレクションを使う必要があります。

これらは配列の要素を読み取り専用にし、変更を防止します。

まとめると、配列は参照型であり、参照自体をconstにできないこと、そして要素の不変性を保証する仕組みが標準の配列にはないことから、C#には「定数配列」という概念が存在しません。

代わりにreadonlyや不変コレクションを活用して、参照や要素の変更を制限する方法が用いられています。

constキーワードでは解決できないポイント

値型・文字列との違い

constキーワードは、C#でコンパイル時に値が確定している定数を宣言するために使います。

値型(例えばintdouble)や文字列stringは、constで宣言するとコンパイル時にその値が確定し、プログラム全体で不変の値として扱われます。

例えば、以下のようにconstで整数や文字列を宣言できます。

const int MaxValue = 100;
const string Greeting = "こんにちは";

これらはコンパイル時に値が決まっているため、どこから参照しても同じ値が使われ、変更はできません。

一方、配列は参照型であり、constで宣言できるのは参照自体がコンパイル時に決定できる場合に限られます。

しかし、配列の参照は実行時に生成されるため、constとして扱えません。

さらに、配列の要素は実行時に変更可能であり、constの不変性の要件を満たしません。

この違いは、値型や文字列が「値そのもの」を保持しているのに対し、配列は「参照」を保持している点にあります。

値型や文字列はイミュータブル(不変)であることが多いですが、配列はイミュータブルではありません。

null以外を指定できない仕組み

C#では、参照型のconstフィールドはnull以外の値を指定できません。

これは、参照型の値は実行時にヒープ上に割り当てられるため、コンパイル時にその参照先を確定できないからです。

例えば、以下のようにconstで参照型を宣言するとエラーになります。

const string message = "Hello"; // これはOK(文字列リテラルは特別扱い)
const object obj = new object(); // コンパイルエラー

ただし、stringは特別にコンパイル時定数として扱われるため、文字列リテラルはconstにできます。

その他の参照型はnullのみが許されます。

const object obj = null; // これはOK

配列も参照型なので、constで宣言する場合はnullのみ許されます。

配列のインスタンスをconstで初期化することはできません。

この仕組みは、コンパイル時に参照先が確定しない参照型の不変性を保証できないためです。

したがって、配列のような参照型をconstで扱うことはできず、readonlyや不変コレクションを使って参照や要素の変更を制限する方法が推奨されます。

readonlyフィールドで参照を固定する

インスタンスフィールドでの基本形

readonly修飾子は、フィールドの参照を固定し、初期化後に別のオブジェクトを代入できなくするために使います。

配列の参照を不変にしたい場合、readonlyを使うことで、配列変数が指す先を固定できます。

以下は、インスタンスフィールドでreadonlyを使って配列の参照を固定する基本的な例です。

using System;
class SampleClass
{
    // readonlyで配列の参照を固定
    private readonly int[] numbers = { 1, 2, 3 };
    public void PrintNumbers()
    {
        foreach (var num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
class Program
{
    static void Main()
    {
        var sample = new SampleClass();
        sample.PrintNumbers();
    }
}
1
2
3

この例では、numbersフィールドはreadonlyで宣言されているため、SampleClassのインスタンス生成後にnumbersの参照を別の配列に変更することはできません。

ただし、配列の要素自体は変更可能です。

コンストラクター内初期化の制限

readonlyフィールドは、宣言時に初期化するか、コンストラクター内で初期化することができます。

コンストラクター内で初期化する場合、複数のコンストラクターがある場合はそれぞれで初期化が必要です。

以下は、コンストラクター内でreadonly配列を初期化する例です。

using System;
class SampleClass
{
    private readonly int[] numbers;
    public SampleClass()
    {
        numbers = new int[] { 10, 20, 30 };
    }
    public SampleClass(int[] initialValues)
    {
        numbers = initialValues;
    }
    public void PrintNumbers()
    {
        foreach (var num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
class Program
{
    static void Main()
    {
        var sample1 = new SampleClass();
        sample1.PrintNumbers();
        var sample2 = new SampleClass(new int[] { 100, 200, 300 });
        sample2.PrintNumbers();
    }
}
10
20
30
100
200
300

このように、readonlyフィールドはコンストラクター内で初期化できるため、動的に配列の参照を設定できます。

ただし、コンストラクターの外でreadonlyフィールドに再代入しようとするとコンパイルエラーになります。

要素変更が許容されるケース

readonlyはフィールドの参照を固定するだけで、配列の要素の変更を禁止するものではありません。

つまり、readonly配列の要素は自由に書き換えられます。

以下の例をご覧ください。

using System;
class SampleClass
{
    private readonly int[] numbers = { 1, 2, 3 };
    public void ModifyElement(int index, int newValue)
    {
        numbers[index] = newValue; // 要素の変更は可能
    }
    public void PrintNumbers()
    {
        foreach (var num in numbers)
        {
            Console.WriteLine(num);
        }
    }
}
class Program
{
    static void Main()
    {
        var sample = new SampleClass();
        sample.PrintNumbers();
        sample.ModifyElement(0, 99);
        Console.WriteLine("変更後:");
        sample.PrintNumbers();
    }
}
1
2
3
変更後:
99
2
3

このように、readonlyは配列の参照を固定するだけで、配列の中身は変更可能です。

配列の要素を不変にしたい場合は、ReadOnlyCollection<T>ImmutableArray<T>などの不変コレクションを使う必要があります。

まとめると、readonlyフィールドは配列の参照を固定し、誤って別の配列を代入することを防ぎますが、配列の要素の変更は許容されるため、要素の不変性を保証するものではありません。

static readonlyで共通配列を共有する

アプリ全体での利便性

static readonlyフィールドは、クラス単位で共有される不変の参照を保持するために使います。

配列をstatic readonlyで宣言すると、アプリケーション全体で同じ配列インスタンスを共有できるため、メモリ効率が良くなり、複数のインスタンス間で同じデータを使い回せます。

例えば、定数的な値の集合を複数の場所で使いたい場合に便利です。

以下の例では、曜日の名前を格納した配列をstatic readonlyで定義しています。

using System;
class Weekdays
{
    public static readonly string[] Days = { "日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日" };
}
class Program
{
    static void Main()
    {
        foreach (var day in Weekdays.Days)
        {
            Console.WriteLine(day);
        }
    }
}
日曜日
月曜日
火曜日
水曜日
木曜日
金曜日
土曜日

このように、Weekdays.Daysはアプリケーション全体で共有され、どのインスタンスやメソッドからも同じ配列を参照できます。

新たに配列を生成するコストを抑えられ、コードの可読性も向上します。

スレッドセーフの観点

static readonlyで宣言された配列の参照は変更できませんが、配列の要素自体は変更可能です。

そのため、複数のスレッドから同時にアクセスされる場合、要素の変更が発生するとスレッドセーフではなくなります。

以下の例では、static readonly配列の要素を複数スレッドで書き換えると問題が起きる可能性があります。

using System;
using System.Threading.Tasks;
class SharedData
{
    public static readonly int[] Values = { 1, 2, 3 };
}
class Program
{
    static void Main()
    {
        Parallel.For(0, 3, i =>
        {
            SharedData.Values[i] = SharedData.Values[i] * 10;
            Console.WriteLine($"Thread {i}: {SharedData.Values[i]}");
        });
    }
}

このコードは動作しますが、複数スレッドが同時に配列の要素を変更するため、競合状態や予期しない結果が発生するリスクがあります。

配列の要素を不変にしたい場合は、static readonly配列の要素を書き換えないようにするか、ReadOnlyCollection<T>ImmutableArray<T>を使って不変コレクションとして扱うことが推奨されます。

例えば、ReadOnlyCollection<T>でラップすると、要素の変更を防止できます。

using System;
using System.Collections.ObjectModel;
class SharedData
{
    private static readonly int[] values = { 1, 2, 3 };
    public static readonly ReadOnlyCollection<int> Values = new ReadOnlyCollection<int>(values);
}
class Program
{
    static void Main()
    {
        foreach (var val in SharedData.Values)
        {
            Console.WriteLine(val);
        }
    }
}

この場合、SharedData.Valuesは読み取り専用であり、スレッドセーフに共有できます。

まとめると、static readonlyは配列の参照を固定し、アプリ全体で共有するのに便利ですが、要素の変更はスレッドセーフではないため、要素の不変性を確保する工夫が必要です。

ReadOnlyCollection<T>で要素更新を防ぐ

配列ラップの手順

ReadOnlyCollection<T>は、既存の配列やリストなどのコレクションをラップして、読み取り専用のコレクションとして扱うためのクラスです。

これを使うことで、元の配列の要素を外部から変更できないように制限できます。

配列をReadOnlyCollection<T>でラップする手順は非常にシンプルです。

まず、元となる配列を用意し、それをReadOnlyCollection<T>のコンストラクターに渡すだけです。

以下は、配列をReadOnlyCollection<int>でラップする例です。

using System;
using System.Collections.ObjectModel;
class Program
{
    static void Main()
    {
        int[] numbers = { 1, 2, 3, 4, 5 };
        ReadOnlyCollection<int> readOnlyNumbers = new ReadOnlyCollection<int>(numbers);
        foreach (var num in readOnlyNumbers)
        {
            Console.WriteLine(num);
        }
        // readOnlyNumbers[0] = 10; // コンパイルエラー:読み取り専用のため代入不可
    }
}
1
2
3
4
5

このように、ReadOnlyCollection<T>は元の配列をラップし、外部からの要素の変更を防ぎます。

ただし、元の配列自体は変更可能なので、ラップ後も元配列を変更するとReadOnlyCollection<T>の内容も変わります。

メリットとデメリット

ReadOnlyCollection<T>を使うメリットとデメリットを整理します。

項目内容
メリット– 外部からの要素変更を防止できる
– 既存の配列やリストを簡単に読み取り専用にできる
– .NET標準ライブラリでサポートされているため追加の依存が不要
デメリット– 元の配列が変更されると読み取り専用コレクションの内容も変わる
– 不変コレクションではないため完全なイミュータブルではない
– ラップによるオーバーヘッドがわずかに発生する

LINQ・foreach時のパフォーマンス

ReadOnlyCollection<T>は内部的に元の配列やリストを参照しているため、foreachやLINQでの列挙は元のコレクションとほぼ同じパフォーマンスです。

ただし、ReadOnlyCollection<T>自体はインデクサーや列挙子を提供するため、若干のメソッド呼び出しオーバーヘッドがあります。

パフォーマンスが極めて重要なケースでは、直接配列を使うほうがわずかに高速ですが、通常のアプリケーションではほとんど差を感じません。

変更可能な内部配列のリーク防止策

ReadOnlyCollection<T>は元の配列をラップするだけなので、元の配列が外部からアクセス可能な場合、配列の要素を変更されてしまうリスクがあります。

これを防ぐためには、以下のような対策が考えられます。

  • 元の配列をprivateにして外部に公開しない
  • ReadOnlyCollection<T>を公開し、元の配列は外部からアクセスできないようにする
  • 元の配列のコピーを作成してからReadOnlyCollection<T>でラップする

例えば、コピーを作成してラップする例は以下の通りです。

using System;
using System.Collections.ObjectModel;
class Sample
{
    private readonly ReadOnlyCollection<int> readOnlyNumbers;
    public Sample(int[] numbers)
    {
        // 配列のコピーを作成してからラップすることで元配列の変更を防ぐ
        int[] copy = new int[numbers.Length];
        Array.Copy(numbers, copy, numbers.Length);
        readOnlyNumbers = new ReadOnlyCollection<int>(copy);
    }
    public ReadOnlyCollection<int> Numbers => readOnlyNumbers;
}
class Program
{
    static void Main()
    {
        int[] original = { 1, 2, 3 };
        var sample = new Sample(original);
        original[0] = 99; // 元配列を変更しても
        foreach (var num in sample.Numbers)
        {
            Console.WriteLine(num); // 変更は反映されない
        }
    }
}
1
2
3

このように、元の配列の変更がReadOnlyCollection<T>に影響しないようにコピーを作成することで、より安全に読み取り専用コレクションを提供できます。

まとめると、ReadOnlyCollection<T>は配列の要素更新を防ぐ簡単な方法ですが、元の配列が変更可能な場合は注意が必要です。

コピーを作成してラップすることで、より堅牢な不変コレクションとして活用できます。

ImmutableArray<T>で完全不変を実現

System.Collections.Immutable導入方法

ImmutableArray<T>は、Microsoftが提供するSystem.Collections.Immutable名前空間に含まれる不変コレクションの一つです。

ImmutableArray<T>は一度作成すると変更できない配列であり、要素の追加・削除・変更ができません。

これにより、スレッドセーフで安全に共有できる不変の配列を実現します。

ImmutableArray<T>を使うには、まずNuGetパッケージSystem.Collections.Immutableをプロジェクトに追加する必要があります。

Visual Studioのパッケージマネージャーコンソールで以下のコマンドを実行します。

Install-Package System.Collections.Immutable

または、.NET CLIを使う場合は以下のコマンドです。

dotnet add package System.Collections.Immutable

パッケージを追加したら、ソースコードでusing System.Collections.Immutable;を宣言して利用できます。

Builderパターンでの生成

ImmutableArray<T>は不変であるため、直接要素を追加・変更することはできません。

そのため、要素を効率的に追加・編集したい場合は、ImmutableArray<T>.Builderを使います。

Builderは可変のコレクションとして動作し、最終的にImmutableArray<T>に変換できます。

以下はBuilderを使ってImmutableArray<int>を生成する例です。

using System;
using System.Collections.Immutable;
class Program
{
    static void Main()
    {
        // Builderを作成
        var builder = ImmutableArray.CreateBuilder<int>();
        // 要素を追加
        builder.Add(10);
        builder.Add(20);
        builder.Add(30);
        // ImmutableArrayに変換
        ImmutableArray<int> immutableArray = builder.ToImmutable();
        foreach (var num in immutableArray)
        {
            Console.WriteLine(num);
        }
    }
}
10
20
30

Builderを使うことで、複数の要素を効率的に追加・編集し、最後に不変のImmutableArray<T>として確定できます。

Builderは内部的に可変の配列を持つため、頻繁な追加や変更がある場合にパフォーマンスが良いです。

コピーコストとメモリ効率

ImmutableArray<T>は不変であるため、要素の追加や削除を行うと新しい配列が生成されます。

これはコピーコストを伴い、特に大きな配列の場合はメモリ使用量や処理時間に影響します。

ただし、ImmutableArray<T>は内部的に配列を共有する仕組みを持っており、変更がない場合はコピーを避ける最適化も行われています。

例えば、同じ内容の配列を複数の変数で共有することが可能です。

一方で、Builderを使うと可変の状態で要素を追加できるため、複数回の変更をまとめて行い、最後に一度だけ不変配列を生成することでコピーコストを抑えられます。

操作コピーコストメモリ効率
直接ImmutableArrayに要素追加高い(新しい配列生成)低い(複数配列生成の可能性)
Builderでまとめて追加後変換低い(1回の配列生成)高い(効率的なメモリ使用)

値比較と参照比較の挙動

ImmutableArray<T>は構造体structとして実装されているため、値型としての振る舞いをします。

つまり、==演算子は参照比較ではなく、構造体のフィールドの比較を行います。

ただし、ImmutableArray<T>は内部的に配列への参照を持っているため、Equalsメソッドや==演算子の挙動には注意が必要です。

以下の例で比較の挙動を確認できます。

using System;
using System.Collections.Immutable;
class Program
{
    static void Main()
    {
        var array1 = ImmutableArray.Create(1, 2, 3);
        var array2 = ImmutableArray.Create(1, 2, 3);
        var array3 = array1;
        Console.WriteLine(array1 == array2); // false
        Console.WriteLine(array1.Equals(array2)); // false
        Console.WriteLine(array1 == array3); // true
        Console.WriteLine(array1.Equals(array3)); // true
    }
}
False
False
True
True

この結果からわかるように、array1array2は内容が同じでも異なるインスタンスなので==Equalsfalseになります。

一方、array3array1のコピーなのでtrueです。

内容の等価性を比較したい場合は、SequenceEqualなどのLINQメソッドを使う必要があります。

using System.Linq;
Console.WriteLine(array1.SequenceEqual(array2)); // true

このように、ImmutableArray<T>は参照の比較が基本であり、内容の比較は明示的に行う必要があります。

これを理解して使うことが重要です。

ReadOnlySpan<T>と不変配列の併用

高速読み取りの仕組み

ReadOnlySpan<T>は、C#で導入された軽量で高速な読み取り専用のメモリビューを提供する構造体です。

配列や文字列、メモリ領域の一部をコピーせずに参照できるため、パフォーマンスが非常に高いのが特徴です。

ReadOnlySpan<T>はスタック上に割り当てられ、ヒープ割り当てを伴わないため、ガベージコレクションの負荷を減らせます。

また、配列の一部をスライスして扱う際にコピーが発生しないため、大量のデータを扱う場合でも効率的に読み取りが可能です。

以下は、配列からReadOnlySpan<T>を作成し、高速に読み取る例です。

using System;
class Program
{
    static void Main()
    {
        int[] numbers = { 10, 20, 30, 40, 50 };
        // 配列からReadOnlySpanを作成
        ReadOnlySpan<int> span = numbers;
        // 高速に読み取り
        for (int i = 0; i < span.Length; i++)
        {
            Console.WriteLine(span[i]);
        }
    }
}
10
20
30
40
50

この例では、ReadOnlySpan<int>が配列numbersのデータをコピーせずに参照しているため、メモリ効率が良く、高速にアクセスできます。

スライス操作の安全性

ReadOnlySpan<T>はスライス操作が可能で、配列やメモリの一部を安全に切り出して扱えます。

スライスは元のデータの一部を参照するだけで、新たな配列を生成しないため、メモリの無駄遣いを防ぎます。

スライス操作は範囲外アクセスを防ぐために境界チェックが行われ、安全性が確保されています。

例えば、以下のように配列の一部をスライスして読み取れます。

using System;
class Program
{
    static void Main()
    {
        int[] numbers = { 1, 2, 3, 4, 5, 6 };
        ReadOnlySpan<int> span = numbers.AsSpan();
        // インデックス2から3要素のスライスを取得
        ReadOnlySpan<int> slice = span.Slice(2, 3);
        foreach (var num in slice)
        {
            Console.WriteLine(num);
        }
    }
}
3
4
5

このスライスは元の配列の3番目から5番目の要素を参照していますが、新しい配列は作成されていません。

もしスライスの範囲が配列の長さを超えると、実行時にArgumentOutOfRangeExceptionが発生し、安全に範囲外アクセスを防止します。

また、ReadOnlySpan<T>は読み取り専用なので、スライスを通じて元の配列の要素を変更することはできません。

これにより、不変配列と組み合わせて使う場合に、データの安全性とパフォーマンスを両立できます。

まとめると、ReadOnlySpan<T>は不変配列の高速で安全な読み取りを実現し、スライス操作によってメモリ効率の良い部分参照を可能にします。

これにより、大規模データの処理やパフォーマンスが求められる場面で有効に活用できます。

レコード型・initプロパティとの連携

不変データモデルの構築例

C# 9.0以降で導入されたレコード型recordinitプロパティは、不変データモデルを簡潔に構築するための強力な機能です。

これらを活用すると、定数的な配列やコレクションを含むオブジェクトの不変性を保ちながら、初期化時にのみ値を設定できる設計が可能になります。

以下は、レコード型とinitプロパティを使って不変のデータモデルを構築し、配列をinitプロパティで初期化する例です。

using System;
public record Person
{
    public string Name { get; init; }
    public int Age { get; init; }
    public int[] Scores { get; init; } // 配列をinitプロパティで保持
    public void PrintInfo()
    {
        Console.WriteLine($"名前: {Name}, 年齢: {Age}");
        Console.WriteLine("スコア一覧:");
        foreach (var score in Scores)
        {
            Console.WriteLine(score);
        }
    }
}
class Program
{
    static void Main()
    {
        var person = new Person
        {
            Name = "山田太郎",
            Age = 30,
            Scores = new int[] { 85, 90, 78 }
        };
        person.PrintInfo();
        // person.Name = "佐藤花子"; // コンパイルエラー: init-onlyプロパティのため変更不可
        // person.Scores[0] = 100; // 配列の要素は変更可能なので注意が必要
    }
}
名前: 山田太郎, 年齢: 30
スコア一覧:
85
90
78

この例では、PersonレコードのNameAgeScoresはすべてinitプロパティとして定義されています。

これにより、オブジェクト生成時にのみ値を設定でき、生成後の変更はできません。

ただし、Scoresは配列であるため、配列の要素自体は変更可能です。

配列の不変性を完全に保証したい場合は、ImmutableArray<T>ReadOnlyCollection<T>を使うことが推奨されます。

シリアライズ時の利点

レコード型とinitプロパティは、JSONやXMLなどのシリアライズ・デシリアライズ時に非常に便利です。

特に、initプロパティはオブジェクト初期化子と相性が良く、シリアライザーがオブジェクトを生成しつつプロパティを設定する際に柔軟に対応できます。

例えば、System.Text.Jsonを使ったJSONシリアライズ・デシリアライズの例を示します。

using System;
using System.Text.Json;
public record Person
{
    public string Name { get; init; }
    public int Age { get; init; }
    public int[] Scores { get; init; }
}
class Program
{
    static void Main()
    {
        var person = new Person
        {
            Name = "佐藤花子",
            Age = 25,
            Scores = new int[] { 88, 92, 79 }
        };
        // JSONにシリアライズ
        string json = JsonSerializer.Serialize(person);
        Console.WriteLine(json);
        // JSONからデシリアライズ
        var deserializedPerson = JsonSerializer.Deserialize<Person>(json);
        Console.WriteLine($"名前: {deserializedPerson.Name}, 年齢: {deserializedPerson.Age}");
    }
}
{"Name":"佐藤花子","Age":25,"Scores":[88,92,79]}
名前: 佐藤花子, 年齢: 25

このように、initプロパティはデシリアライズ時に値を設定できるため、読み取り専用のように見えてもシリアライザーが問題なくオブジェクトを復元できます。

これにより、不変性を保ちつつ、シリアライズ対応もスムーズに行えます。

まとめると、レコード型とinitプロパティの組み合わせは、不変データモデルの構築とシリアライズの両方に適しており、配列を含むデータ構造の安全性と利便性を高めることができます。

struct内のfixed配列でサイズを固定

unsafeコードが必要な理由

C#のstruct内で固定長の配列を定義するには、fixedキーワードを使います。

fixed配列は、メモリ上で連続した固定サイズの領域を確保し、配列のサイズをコンパイル時に決定できるため、サイズが変わらない配列を効率的に扱えます。

しかし、fixed配列は安全なマネージドコードの範囲外で動作するため、unsafeコンテキスト内でのみ使用可能です。

これは、fixed配列がポインター操作を伴い、ガベージコレクションの管理外でメモリを直接操作するためです。

以下は、struct内にfixed配列を定義する例です。

using System;
unsafe struct FixedBuffer
{
    public fixed int Numbers[5]; // サイズ5の固定長配列
}
class Program
{
    static unsafe void Main()
    {
        FixedBuffer buffer = new FixedBuffer();
        for (int i = 0; i < 5; i++)
        {
            buffer.Numbers[i] = i * 10;
        }
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine(buffer.Numbers[i]);
        }
    }
}
0
10
20
30
40

このコードでは、FixedBuffer構造体のNumbers配列は固定長で、サイズは5に決まっています。

unsafeキーワードが必要なのは、fixed配列がポインターのように扱われ、メモリの直接操作を伴うためです。

マネージドコードとの相互運用

fixed配列はアンマネージドメモリに近い形でデータを保持するため、パフォーマンスが求められる低レベル処理やネイティブコードとの相互運用に適しています。

例えば、C言語の構造体とバイナリ互換性を持たせたい場合や、ハードウェア制御、ゲーム開発などで使われます。

ただし、fixed配列はマネージドコードの安全性や柔軟性を犠牲にしているため、通常のアプリケーション開発では推奨されません。

マネージドコードからfixed配列を扱う際は、unsafeコンテキストを明示的に指定し、ポインター操作に注意が必要です。

また、fixed配列はstructの一部としてメモリ上に連続して配置されるため、アンマネージドコードに渡す際にポインターを取得しやすく、パフォーマンス面で有利です。

以下は、アンマネージドコードとの相互運用を想定した例です。

using System;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential)]
unsafe struct NativeData
{
    public fixed byte Buffer[16];
}
class Program
{
    static unsafe void Main()
    {
        NativeData data = new NativeData();
        byte* ptr = data.Buffer;
        for (int i = 0; i < 16; i++)
        {
            ptr[i] = (byte)i;
        }
        for (int i = 0; i < 16; i++)
        {
            Console.WriteLine(ptr[i]);
        }
    }
}
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

この例では、NativeData構造体のBufferは16バイトの固定長配列で、アンマネージドコードに渡すことを想定しています。

StructLayout属性でメモリレイアウトを制御し、ポインターを使って直接アクセスしています。

まとめると、fixed配列はunsafeコードが必要な理由はメモリの直接操作を伴うためであり、マネージドコードとの相互運用ではパフォーマンスやバイナリ互換性を重視する場面で有効です。

ただし、安全性や保守性の観点から、通常のアプリケーションでは慎重に使う必要があります。

C#バージョン別サポート状況

C# 3〜7で可能な選択肢

C# 3.0から7.xまでのバージョンでは、配列の不変性を直接サポートする機能は限定的でした。

constキーワードはプリミティブ型や文字列にしか使えず、配列を定数として扱うことはできませんでした。

この期間で主に使われた方法は、readonlyフィールドを使って配列の参照を固定することです。

readonlyを使うと、配列の参照先は変更できませんが、配列の要素は変更可能なままでした。

また、System.Collections.ObjectModel.ReadOnlyCollection<T>を使って配列をラップし、外部からの要素変更を防ぐ方法も一般的でした。

ただし、元の配列が変更されるとラップしたコレクションの内容も変わるため、完全な不変性は保証されません。

using System.Collections.ObjectModel;
readonly int[] numbers = { 1, 2, 3 };
readonly ReadOnlyCollection<int> readOnlyNumbers = new ReadOnlyCollection<int>(numbers);

この時期は、不変コレクションの標準的なサポートがなく、手動でコピーを作成したり、ラップしたりするのが主流でした。

C# 8の読み取り専用メンバー

C# 8.0では、readonlyメンバーが導入され、構造体のメソッドやプロパティに対して読み取り専用を指定できるようになりました。

これにより、構造体の不変性を強化し、意図しない状態変更を防止しやすくなりました。

ただし、配列自体の不変性を直接サポートする機能ではなく、あくまでメンバーの読み取り専用化が中心です。

配列の要素変更は依然として可能であり、配列の不変性を保証するには別の手段が必要でした。

struct SampleStruct
{
    private readonly int[] numbers;
    public SampleStruct(int[] nums)
    {
        numbers = nums;
    }
    public readonly int GetFirst() => numbers[0];
}

この例のように、readonlyメンバーは構造体の状態を変更しないことを保証し、パフォーマンスの最適化にも寄与します。

C# 9以降のrecordとinit

C# 9.0で導入されたrecord型とinitプロパティは、不変データモデルの構築を大きく簡素化しました。

recordは値の等価性をサポートし、initプロパティはオブジェクト初期化時のみ値を設定可能にします。

これにより、配列を含むオブジェクトの不変性を保ちながら、初期化時にのみ値を設定できる設計が可能になりました。

ただし、配列自体は参照型であり、要素の不変性は保証されないため、ImmutableArray<T>などの不変コレクションと組み合わせて使うことが推奨されます。

public record Person
{
    public string Name { get; init; }
    public ImmutableArray<int> Scores { get; init; }
}

このように、C# 9以降は不変性を意識した設計が標準的に行いやすくなりました。

C# 12の新しいコレクション初期化子

C# 12では、新しいコレクション初期化子の構文が導入され、配列やリストの初期化がより簡潔に記述できるようになりました。

例えば、従来の波括弧{}を使った初期化に加え、角括弧[]を使った初期化が可能になりました。

int[] numbers = [1, 2, 3, 4];

この構文は、配列やコレクションの初期化をより直感的にし、コードの可読性を向上させます。

特に不変配列や読み取り専用コレクションと組み合わせることで、初期化と不変性の両立がしやすくなります。

また、C# 12ではconstreadonlyとの連携も強化され、より安全で簡潔な不変コレクションの利用が期待されています。

このように、C#のバージョンアップに伴い、配列やコレクションの不変性を実現するための機能が段階的に充実してきました。

古いバージョンではreadonlyReadOnlyCollection<T>が中心でしたが、最新のバージョンではrecordImmutableArray<T>、新しい初期化子構文などを活用することで、より安全で効率的な不変配列の実装が可能です。

パフォーマンス比較

ランタイムオーバーヘッド

配列の不変性を実現する方法によって、ランタイムでのオーバーヘッドは大きく異なります。

単純にreadonlyフィールドで配列の参照を固定する場合、配列自体は通常の配列なので、ほとんどオーバーヘッドは発生しません。

要素のアクセスや列挙はネイティブな配列操作と同等の高速さを維持できます。

一方、ReadOnlyCollection<T>で配列をラップすると、ラッパークラスのメソッド呼び出しが介在するため、わずかなオーバーヘッドが発生します。

特に大量の要素を頻繁にアクセスする場合は、メソッド呼び出しの積み重ねがパフォーマンスに影響を与えることがあります。

ImmutableArray<T>は構造体であり、内部的に配列を保持しますが、要素の追加や削除時に新しい配列を生成するため、その操作時にはコピーコストが発生します。

読み取り時はほぼ配列と同等の速度ですが、変更操作が多い場合はオーバーヘッドが大きくなります。

ReadOnlySpan<T>はスタック上に割り当てられ、ヒープ割り当てがないため、非常に低いオーバーヘッドで高速に読み取りが可能です。

ただし、SpanReadOnlySpanは構造体であり、ボックス化やクロスメソッドの使用に注意が必要です。

JIT最適化への影響

JIT(Just-In-Time)コンパイラは、コードの最適化を行い実行時のパフォーマンスを向上させます。

単純な配列アクセスやreadonlyフィールドの参照はJITによって効率的に最適化され、インライン化やループアンローリングなどの最適化が適用されやすいです。

ReadOnlyCollection<T>のようなラッパークラスは、メソッド呼び出しが多くなるため、JITのインライン化の対象となるかどうかでパフォーマンスが変わります。

小さなメソッドであればインライン化されることが多いですが、複雑な処理や多層のラップがあると最適化が難しくなります。

ImmutableArray<T>は構造体であるため、JITは値型の特性を活かして効率的に最適化します。

ただし、変更操作で新しい配列を生成するコードはJITの最適化範囲外となり、コピーコストが発生します。

ReadOnlySpan<T>はJIT最適化の恩恵を大きく受ける設計で、特にループ内でのアクセスは高速に実行されます。

スタック上のデータ参照であるため、キャッシュ効率も良好です。

ガベージコレクションの観点

ガベージコレクション(GC)は、ヒープ上の不要なオブジェクトを自動的に回収します。

配列の不変性を実現する方法によって、GCへの影響は異なります。

readonlyフィールドで固定した配列はヒープ上に1つだけ存在し、GCの負荷は低いです。

配列の要素を変更しても新しいオブジェクトは生成されないため、GCの発生は最小限に抑えられます。

ReadOnlyCollection<T>は元の配列をラップするだけなので、追加のヒープ割り当ては少ないですが、ラッパークラス自体がヒープに存在するため、多少のGC負荷はあります。

ImmutableArray<T>は変更操作時に新しい配列を生成するため、頻繁に変更を行うと多くの一時オブジェクトがヒープに生成され、GCの負荷が増加します。

読み取り専用で使う場合はGC負荷は低いですが、変更が多いシナリオでは注意が必要です。

ReadOnlySpan<T>はスタック上に割り当てられるため、ヒープ割り当てが発生せず、GCの影響をほぼ受けません。

これにより、GC負荷を抑えたい高パフォーマンスな処理に適しています。

以上のように、配列の不変性を実現する方法によって、ランタイムオーバーヘッド、JIT最適化の効果、ガベージコレクションへの影響が大きく異なります。

用途やパフォーマンス要件に応じて適切な手法を選択することが重要です。

実運用での選定チェックリスト

読み取り専用設定値への適用

読み取り専用の設定値や定数的なデータを扱う場合、配列の不変性を確保することはコードの安全性と保守性を高めるうえで重要です。

実運用で適切な手法を選ぶ際は、以下のポイントをチェックしてください。

  • 変更の必要性がないか

設定値が実行時に変更される可能性がない場合は、readonlyReadOnlyCollection<T>ImmutableArray<T>などの不変コレクションを使い、参照や要素の変更を防止します。

  • パフォーマンス要件

頻繁にアクセスされる設定値であれば、readonly配列やReadOnlySpan<T>を使い、オーバーヘッドを抑えつつ高速な読み取りを実現します。

ImmutableArray<T>は読み取り専用であれば高速ですが、変更操作がある場合はコストがかかるため注意が必要です。

  • スレッドセーフ性

複数スレッドから同時にアクセスされる場合は、要素の不変性を保証することが必須です。

ReadOnlyCollection<T>は元配列の変更を防げないため、ImmutableArray<T>ReadOnlySpan<T>の利用を検討します。

  • メモリ効率

大量の設定値を扱う場合は、コピーを避けるためにreadonly配列やImmutableArray<T>のBuilderパターンを活用し、メモリ使用量を最適化します。

  • 初期化の柔軟性

設定値の初期化が静的であればstatic readonly配列が適しています。

動的に初期化する場合は、コンストラクター内でreadonlyフィールドを初期化したり、ImmutableArray<T>.Builderを使った生成を検討します。

ライブラリ公開APIでの検討ポイント

ライブラリやフレームワークの公開APIとして配列やコレクションを提供する場合、利用者に対して安全かつ使いやすいインターフェースを設計することが重要です。

以下の点を考慮してください。

  • 不変性の保証

APIで返す配列やコレクションは、利用者が変更できないように不変コレクションImmutableArray<T>ReadOnlyCollection<T>でラップするか、ReadOnlySpan<T>を返す設計が望ましいです。

これにより、内部状態の破壊を防ぎます。

  • パフォーマンスと互換性のバランス

ImmutableArray<T>は不変性が高い反面、利用者が既存の配列やリストと互換性を持たせにくい場合があります。

APIの利用シーンに応じて、IReadOnlyList<T>IEnumerable<T>などの抽象型を返すことも検討します。

  • APIの明確な意図表示

メソッド名やプロパティ名で「ReadOnly」や「Immutable」といったキーワードを使い、利用者に不変性を明示することで誤用を防ぎます。

  • バージョン互換性

将来的にAPIの戻り値の型を変更する可能性がある場合は、インターフェースや抽象クラスを使い、実装の詳細を隠蔽しておくと柔軟に対応できます。

  • ドキュメントの充実

不変コレクションの特性や利用上の注意点をドキュメントに明記し、利用者が正しく使えるようにサポートします。

  • 例外の取り扱い

不変コレクションを返す場合、要素の変更を試みると例外が発生することがあるため、API利用者にその旨を伝え、適切なエラーハンドリングを促します。

これらのポイントを踏まえ、実運用での配列の不変性確保に最適な手法を選択し、堅牢で使いやすいコード設計を心がけることが重要です。

よくあるエラーとデバッグ例

CS0191: Field is readonly

CS0191は、「readonlyフィールドに対して代入しようとした」場合に発生するコンパイルエラーです。

readonly修飾子が付いたフィールドは、宣言時かコンストラクター内でのみ代入可能であり、それ以外の場所で再代入するとこのエラーが出ます。

例えば、以下のコードはCS0191エラーになります。

class Sample
{
    private readonly int[] numbers = { 1, 2, 3 };
    public void Update()
    {
        numbers = new int[] { 4, 5, 6 }; // CS0191エラー
    }
}

このエラーを解決するには、readonlyフィールドへの代入はコンストラクター内か宣言時に限定し、メソッド内での再代入を避ける必要があります。

配列の要素は変更可能なので、要素の更新は問題ありません。

public void UpdateElement(int index, int value)
{
    numbers[index] = value; // 問題なし
}

InvalidOperationExceptionとImmutableArray

ImmutableArray<T>を操作する際に、InvalidOperationExceptionが発生することがあります。

特に、ImmutableArray<T>.Builderを使っている場合に、ビルダーの状態が不正なときや、ビルダーから不変配列に変換した後にビルダーを再利用しようとした場合に起こりやすいです。

例えば、以下のようなコードで例外が発生します。

using System.Collections.Immutable;
var builder = ImmutableArray.CreateBuilder<int>();
builder.Add(1);
var immutableArray = builder.ToImmutable();
// builderを再利用しようとすると例外が発生する可能性がある
builder.Add(2); // InvalidOperationException

ImmutableArray<T>.Builderは、一度ToImmutable()を呼び出すと内部状態が変更され、再利用が制限されます。

ビルダーを再利用したい場合は、新たにビルダーを作成するか、Clear()メソッドで状態をリセットしてから使う必要があります。

builder.Clear();
builder.Add(2);

また、ImmutableArray<T>自体は不変なので、要素の追加や削除はできません。

変更したい場合は、ビルダーを使うか、新しい配列を生成する必要があります。

タイプミスマッチ例外への対処

配列や不変コレクションを扱う際に、型の不一致による例外が発生することがあります。

特にジェネリック型の不一致や、配列のキャスト時に注意が必要です。

例えば、object[]string[]にキャストしようとするとInvalidCastExceptionが発生します。

object[] objects = { "a", "b", "c" };
string[] strings = (string[])objects; // InvalidCastException

この問題を回避するには、配列の型を正しく扱うか、LINQのCast<T>()メソッドを使って要素を変換します。

using System.Linq;
string[] strings = objects.Cast<string>().ToArray();

また、ImmutableArray<T>の型パラメーターが異なる場合も代入や比較でエラーになります。

型の整合性を保つために、宣言時やメソッドの引数で正しい型を指定し、必要に応じて型変換やジェネリック制約を見直してください。

さらに、配列の要素型が派生クラスやインターフェースの場合、共変性・反変性の制約により代入が制限されることがあります。

これも型ミスマッチの原因となるため、型階層を理解し適切にキャストや変換を行うことが重要です。

これらのエラーは、配列や不変コレクションの特性を理解し、適切な初期化や操作を行うことで回避できます。

デバッグ時はエラーメッセージをよく読み、どの操作が不正なのかを特定することが解決の近道です。

コーディングスタイルと命名規則

定数風配列の命名プレフィックス

C#において、配列を定数のように扱う場合でも、実際にはconstではなくreadonlyや不変コレクションを使うことが多いため、命名規則で「定数風」であることを明示することが望ましいです。

これにより、コードの可読性が向上し、他の開発者が意図を理解しやすくなります。

一般的に、定数や読み取り専用のフィールドには以下のような命名スタイルが推奨されます。

  • 大文字スネークケース(UPPER_SNAKE_CASE)

例:DEFAULT_VALUESERROR_CODES

これはconst定数でよく使われるスタイルですが、配列のreadonlyフィールドにも適用されることがあります。

  • PascalCase

例:DefaultValuesErrorCodes

.NETの一般的なフィールドやプロパティの命名規則に準拠しつつ、readonlyや不変コレクションであることを示す場合に使います。

  • プレフィックスの付与

配列が定数的な意味合いを持つ場合、s_(static)、k_(constantの意)などのプレフィックスを付けることもあります。

例:s_defaultValueskErrorCodes

ただし、Microsoftの公式ガイドラインでは、プレフィックスは推奨されていません。

チームのコーディング規約に合わせて統一することが重要です。

具体例:

// static readonly配列の例(PascalCase)
private static readonly int[] DefaultScores = { 100, 90, 80 };
// readonly配列の例(大文字スネークケース)
private readonly string[] ERROR_MESSAGES = { "エラー1", "エラー2" };

命名から「変更不可」「定数的」な意味合いを伝えたい場合は、コメントやXMLドキュメントで補足説明を加えるとより親切です。

アクセス修飾子と公開プロパティ整合性

配列や不変コレクションをクラスや構造体のメンバーとして定義する際、アクセス修飾子の設定と公開プロパティの整合性を保つことが重要です。

これにより、外部からの不適切な変更を防ぎ、APIの意図を明確にできます。

  • フィールドは基本的にprivateまたはprotectedにする

直接配列フィールドをpublicにすることは推奨されません。

配列は参照型であり、外部から要素の変更が可能になるため、不変性が損なわれます。

  • 公開する場合は読み取り専用のプロパティを用意する

配列や不変コレクションを外部に公開する際は、IReadOnlyList<T>ReadOnlyCollection<T>ImmutableArray<T>などの読み取り専用インターフェースや型を返すプロパティを用意します。

private readonly int[] _scores = { 100, 90, 80 };
public IReadOnlyList<int> Scores => Array.AsReadOnly(_scores);
  • readonlyフィールドとgetのみのプロパティの組み合わせ

フィールドをreadonlyにし、プロパティはgetのみで公開することで、外部からの再代入や変更を防ぎます。

  • static readonlyフィールドの場合も同様にプロパティで公開する

静的な定数的配列はprivate static readonlyで保持し、public static IReadOnlyList<T>などのプロパティで公開するのが一般的です。

  • ReadOnlySpan<T>を返す場合の注意

ReadOnlySpan<T>はスタック上の構造体であり、プロパティで返す場合はライフタイムに注意が必要です。

長期間保持する用途には向きません。

まとめると、アクセス修飾子はカプセル化の基本であり、配列や不変コレクションの公開は読み取り専用のインターフェースや型を通じて行うことで、不変性と安全性を保つことができます。

命名規則と合わせて、コードの意図を明確に伝える設計を心がけましょう。

まとめ

C#では配列をconstとして扱えないため、readonlyReadOnlyCollection<T>ImmutableArray<T>などを活用して不変性を実現します。

各手法にはパフォーマンスやスレッドセーフ性、使いやすさの違いがあり、用途に応じて選択が必要です。

最新のC#機能やコーディング規約を踏まえ、適切な命名やアクセス制御を行うことで、安全で保守性の高いコードが書けます。

関連記事

Back to top button
目次へ