Zenn
#️⃣

【C#】コレクション式について

に公開
1

今回はC#のコレクション式について。C#12で追加されたコレクション式は非常に強力な構文で、コレクションの初期化を簡潔かつ最適なパフォーマンスで記述できます。(本当に便利なので早くUnityでも使えるようになって欲しい...)

しかし、このコレクション式がコンパイル時にどのような形で展開されるかは型によって異なるため、動作が読みづらい部分があるかもしれません。そこで今回はコレクション式について、概要から実際の動作までを詳しく見ていきましょう。

従来の書き方 (コレクション初期化子)

従来のC#ではコレクション初期化子という構文がありましたが、型によってnew[]new()を使い分ける必要があり、やや書きづらい構文でした。

int[] array = new[] { 1, 2, 3 };
List<int> list = new() { 1, 2, 3 };

また、stackallocSpan<T>を確保する場合はさらに別の書き方(stackalloc[])が必要になります。

さらに、.NET 7以降のReadOnlySpan<T>には静的な値を代入すると配列が消える特殊な最適化が存在していますが、これは一見配列を確保するように見えるため混乱を招くという問題もありました。

// stackalloc
Span<int> span = stackalloc[] { 1, 2, 3 };

// ReadOnlySpan<byte> + 定数のbyte配列
// これはコンパイラによってdllに埋め込まれたアドレスの参照に置き換えられる
// (実際に配列は作成されない)
ReadOnlySpan<byte> data = new byte[] { 1, 2, 3 };

最も厄介なのは悪名高きImmuetableArray<T>でしょう。これはImmutableArray.Create()を用いて作成する必要があるのですが、困ったことにコレクション初期化子を記述可能な条件を満たしているため、実行時例外が起きるにも関わらず初期化子を記述できてしまいます。

using System.Collections.Immutable;

// 正しい初期化処理
ImmutableArray<int> immutable = ImmutableArray.Create(1, 2, 3);

// これは実行すると例外が発生する (コンパイルは通過してしまう...)
ImmutableArray<int> immutable = new() { 1, 2, 3 };

コレクション式

そこでC#12からコレクション式という新たな構文が追加されました。これを用いることで、ほとんどのコレクション型に対して[1, 2, 3]のような構文で初期化が行えるようになります。

他の言語でも配列の初期化等で多く採用されている構文であるため馴染みやすいのではないでしょうか。

int[] array = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];
ReadOnlySpan<byte> data = [1, 2, 3];
ImmutableArray<int> immutable = [1, 2, 3];

コレクション初期化子とは異なり、コレクション式は初期化時でなくとも普通に使用できます。

int[] array;

// 初期化じゃなくてもOK
array = [1, 2, 3];

// 引数に直接書いたりも勿論OK
Foo([1, 2, 3]);

public void Foo(int[] array) { ... }

また ..(スプレッド演算子) を使うことで、他のコレクションをコレクション式に埋め込むことも可能です。

int[] array1 = [1, 2, 3];

// [1, 2, 1, 2, 3, 3]
int[] array2 = [1, 2, ..array1, 3];

ただし、現状ではコレクション式と型推論(var)は併用できません。 コレクション式を使う際は型を明示する必要があります。(これは不便なので後のバージョンで改善が検討されています。)

// これは型を決定できずコンパイルエラー
var error = [1, 2, 3];

コレクション式の動作

さて、ここからがこの記事の本題です。便利な構文であるコレクション式ですが、実際にはどのような動作になっているのでしょうか。

適用可能な条件

コレクション式を適用可能な型の条件は以下のようになっています。

  • 配列
  • Span<T> / ReadOnlySpan<T>
  • IEnumerable<T>, IReadOnlyList<T>, IList<T>などの配列が実装するインターフェイス
  • [CollectionBuilder]属性を持つ型
  • コレクション初期化子の条件を満たす型 (IEnumerableを実装しAdd()メソッドを持つ)

配列

コレクション式が受け取る型が配列の場合は、普通に配列を初期化するのと同じ処理に展開されます。

int[] array = [1, 2, 3];

// 上と同じ
int[] array = new[] { 1, 2, 3 };

ただし、要素が空の場合はArray.Empty<T>()が利用されます。

int[] empty = [];

// 上と同じ
int[] empty = Array.Empty<int>();

Span<T> / ReadOnlySpan<T>

Span<T>ReadOnlySpan<T>の場合は、InlineArrayを用いた型をコンパイラが生成する形に展開されます。そのためstackallocとは異なり参照型にも利用可能で、かつ配列を作成するよりもパフォーマンスが良いコードになります。

Span<int> span = [1, 2, 3];

// 以下のコードはコンパイル結果を読みやすいように書き直したもの
var buffer = default(InlineArray3<int>);
Unsafe.Add(ref Unsafe.As<InlineArray3<int>, int>(ref buffer), 0) = 1;
Unsafe.Add(ref Unsafe.As<InlineArray3<int>, int>(ref buffer), 1) = 2;
Unsafe.Add(ref Unsafe.As<InlineArray3<int>, int>(ref buffer), 2) = 3;
Span<int> span = MemoryMarshal.CreateSpan(ref Unsafe.As<InlineArray3, int>(ref buffer), 3);

// 要素数に応じて以下のような構造体が生成される
[StructLayout(LayoutKind.Auto)]
[InlineArray(3)]
internal struct InlineArray3<T>
{
    [CompilerGenerated]
    private T _element0;
}

また、前述のReadOnlySpan<T>と静的データに対する最適化が適用可能な場合はそちらが利用されます。

// 静的なデータには最適化が適用される
ReadOnlySpan<int> span = [1, 2, 3];

ただし、InlineArrayは.NET8以降の機能であるため、それ以前の環境では新しい配列が作成されます。

// InlineArrayが利用できない環境では新しい配列が確保される
int[] array = [1, 2, 3];
Span<int> span = array;

コレクション型

HashSet<T>などのコレクション初期化子の条件を満たすコレクション型については、通常のコレクション初期化子と同じようにAdd()を利用する形に展開されます。

var hashSet = [1, 2, 3];

// 上と同じ
var hashSet = new HashSet<int>();
hashSet.Add(1);
hashSet.Add(2);
hashSet.Add(3);

ただし、List<T>に関してはCollectionsMarshalを用いたより高速なコードに展開されます。

List<int> list = [1, 2, 3]

// 上と同じ
List<int> list = new List<int>(3);
CollectionsMarshal.SetCount(list, 3);
Span<int> span = CollectionsMarshal.AsSpan(list);
span[0] = 1;
span[1] = 2;
span[2] = 3;

ImmutableArray<T>

ImmutableArray<T>の場合はImmutableCollectionsMarshal.AsImmutableArray()を利用したコードに展開されます。

ImmutableArray<int> immutable = [1, 2, 3];

int[] array = { 1, 2, 3 };
ImmutableArray<int> immutable = ImmutableCollectionsMarshal.AsImmutableArray(array);

CollectionBuilder

対象の型が [CollectionBuilder]属性 を持つ場合は、この属性で指定された初期化メソッドを利用する形に展開されます。(これはコレクション初期化子よりも優先度が高いため、両方を満たす場合はCollectionBuilderの方が利用されます)

例として[CollectionBuilder]を持つMyCollectionを実装すると以下のようになります。

using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.CompilerServices;

// 配列をラップするだけの型
// 第一引数でコレクションの作成につかう型、第二引数でメソッド名を指定する
[CollectionBuilder(typeof(MyCollection), nameof(MyCollection.Create))]
class MyCollection<T> : IEnumerable<T>
{
    T[] array;

    public MyCollection(T[] array)
    {
        this.array = array;
    }

    public IEnumerator<T> GetEnumerator() => ((IEnumerable<T>)array).GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

// CollectionBuilderではジェネリクス型を指定できない
// そのため作成自体は非ジェネリックなstatic classに分離
static class MyCollection
{
    // 作成用のメソッドはReadOnlySpan<T>を受け取る必要がある
    public static MyCollection<T> Create<T>(ReadOnlySpan<T> span)
    {
        return new MyCollection<T>(span.ToArray());
    }
}

これでカスタム型でもコレクション式が利用可能になります。

// カスタム型に対するコレクション式
MyCollection<int> myCollection = [1, 2, 3];

// 上と同じ
// ReadOnlySpan<int>を作成してからCreateに渡す
// そのため静的データに対する最適化もちゃんとかかる
ReadOnlySpan<int> span = [1, 2, 3];
MyCollection<int> myCollection = MyCollection.Create(span);

また、このCollectionBuilderはinterfaceに対しても適用可能です。

// interfaceに対するコレクション式もCollectionBuilderで定義できる
[CollectionBuilder(typeof(MyCollection), nameof(MyCollection.Create))]
public interface IMyCollection<T> { ... }

IEnumerable<T>IList<T>などのインターフェース

IEnumerable<T>などの読み取り専用なインターフェースかつ要素数が既知のものに対しては、ReadOnlyArrayのような型をコンパイラが生成して使うコードに展開されます。

IEnumerable<int> enumerable = [1, 2, 3];

// 上と同じ
int[] array = new[] { 1, 2, 3 };
IEnumerable<int> enumerable = new <>z__ReadOnlyArray<int>(array);

// 読み取り専用の配列をラップするクラスが生成される
[CompilerGenerated]
internal sealed class <>z__ReadOnlyArray<T> : IEnumerable, ICollection, IList, IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, ICollection<T>, IList<T>
{
    [CompilerGenerated]
    private readonly T[] _items;

    ...
}

ただし、要素が空の場合はArray.Empty<T>()になります。

IEnumerable<int> enumerable = [];

// 上と同じ
IEnumerable<int> enumerable = Array.Empty<int>();

また、要素数が不定の場合や可変なインターフェースに対してはList<T>が利用されます。

ICollection<int> collection = [1, 2, 3];

// 上と同じ
List<int> list = new List<int>(3);
CollectionsMarshal.SetCount(list, 3);
Span<int> span = CollectionsMarshal.AsSpan(list);
span[0] = 1;
span[1] = 2;
span[2] = 3;
ICollection<int> collection = list;

まとめ

コレクション式は便利な構文というだけでなく、パフォーマンス的にも最適な動作になっていることが確認できました。非常に強力なので、C#12以上が使える環境であれば積極的に使っていきましょう。

1

Discussion

ログインするとコメントできます