※筆者は C#初心者なので、この記事には何かしら誤った情報や断定が含まれている可能性が高いです。
背景
C# においていわゆる配列やリストを表す型 (Array, List, ...) は参照型であるため、基本的に代入や等価性評価の操作では参照の代入、比較が行われる。
int[] a = new int[]{ 1, 2, 3 };
var b = a;
Asseert.IsTrue(ReferenceEquals(a, b)); // PASS
b[0] = b[0] + 1;
Asseert.IsTrue(a == b); // PASS
この振る舞いは実行パフォーマンスを考えると自然だが、一方でパフォーマンスに注意する必要がない(要素数、操作量が少ないなど)場面では、配列を値型のように代入、比較したいことが多々ある。
配列単体であれば IStructuralEquatable.Equals(Object, IEqualityComparer)
や Enumerable.SequenceEqual
で比較したり、 Array.Copy
他多数でコピーすることもできる。
しかし配列をメンバーに持つ record 型を使いたい場合、上記の処理をクラスごとに逐一実装する必要がある(必要あるよね?)。その場合でも with
式の振る舞いは変更することができない(C#12では)ため、with式による代入演算が参照コピーになってしまう。
そのため以下のざっくりとした要求を満たす、配列をラップした型 ValueTypeArray<T>
を実装する。
- 固定長の配列
- 要素数が変更されるようなメソッドは持たないか、NotSupportedException を投げる
- 配列の要素型 T に追加の要件を持たない
- 組み込みの配列と同様、どのような型でも受け入れる(要素 T 自身の制約や要件はユーザの責任で扱う)
- ミュータブル
- 添字アクセスで set/get 可能
- 代入操作で参照コピーせず、値コピーする
- 代入前後のインスタンスは異なる参照を持つが、等価と判定される
- 配列の要素 T が参照型ならシャローコピーになる
- 等価性比較で、要素全てが等しいかどうかを判定する
- 今回は1次元配列のみ考え、多次元配列やジャグ配列は扱わない
- 配列のコピーによるパフォーマンスの悪化は許容する
こうした需要が無いはずはないので、より良い実装が既に存在するだろう。とはいえ同様の実装はすぐには見つからなかったし、初学者には C#クラス設計の練習として良い経験が得られたので記録しておく。
今回概ね欲しい振る舞いの型が実装できたが、以下のような制約ができてしまった。
-
a.b[0] = 1
のように別の型のメンバのときは添字アクセスができない
record DemoRecord(ValueTypeArray<int> Bytes);
var record = new DemoRecord([1, 2, 3]);
record.Bytes[0] = 1; // CS1612 Error
添字アクセスを含むプロパティは値渡しなのが原因で、直接配列をメンバーにしない限り避けられないようだ。
[小ネタ] リストと配列のあいだ (Qiita)
検証環境
2024/02/21 時点での以下の最新バージョンで検証した。
- C# 12
- .NET 8
Notes
設計する型に必要な機能と、その実装を記載していく。
実装コードはあくまで要件を満たす一例で、パフォーマンスや機能は十分に検証されていない。
実装コードのほとんどは VisualStudio や GitHub Copilot の補完機能の支援を受けて作成しているが、要件を満たすかどうかの検証は行っている。
代入操作時の振る舞い
実装の上で一番問題なのは、C#では 代入演算 (=
) はオーバーロードできない点。そのためユーザ定義クラスを =
演算子で代入すると参照コピーされてしまう。
代入演算子 (Microsoft Learn)
C#では値型か、イミュータブルな参照型は代入操作時に値コピー(クローン)されるが、それ以外の参照型は参照コピーされる。
そのため前者に含まれる型を利用することで、代入操作時に値コピーされる型を実装できる。
型定義の実装
値コピーされるように、 struct
で型を定義する。
(インターフェースは後述)
public struct ValueTypeArray<T>() :
IEnumerable<T>,
IList<T>,
IStructuralEquatable,
IStructuralComparable,
IComparable<ValueTypeArray<T>>,
IEquatable<ValueTypeArray<T>>
フィールドの実装
型自体だけでなくフィールドも代入演算時に値コピー(クローン)されるようにするため、データ本体を持つフィールドを通常の配列や List<T>
ではなく、イミュータブル性を持つ ImmutableArray<T>
で定義する。
ImmutableArray Class (Microsoft Learn)
今回は固定長の配列を扱う意図を示すため、 ImmutableList<T>
ではなく ImmutableArray<T>
を使用するが、実装詳細であるためどちらでもよい。
ImmutableList と ImmutableArray (Qiita)
private ImmutableArray<T> Data { get; set; } = [];
以降の必要なメソッドは全てこの Data
フィールドに移譲する形で実装する。
struct & ImmutableArray 以外に検討したアイデア
- ImmutableArray 等に拡張メソッドを追加:
- -> 既存のメソッドの振る舞いを上書きすることはできないので不可
- ImmutableArray のサブクラスを定義:
- ImmutableArray, ImmutableList は
sealed
クラスなため継承できず不可
- ImmutableArray, ImmutableList は
(参考)String クラスの振る舞い
「C#の string は参照型だが値型のように振る舞う」ようなので、その実装が参考になるかもしれない。
イミュータブル性を上手く活用することで代入操作時の振る舞いを変更できるようだ。
- 文字列オブジェクトはイミュータブル性を持つ。つまり代入やコピーコンストラクタ、文字列を変更する操作では、新たなオブジェクトを生成して返している
- string 自体はイミュータブルだが、 配列の要素 (char) を頻繁に変更する際は、
StringBuilder
を使えばいいようだ(今回は未実装)
配列としての要件
配列に関する公式ドキュメントを読むと、以下のように書いてある。
配列 (Microsoft Learn)
配列型は、抽象基本型 Array から派生した参照型です。 すべての配列は IList および IEnumerable を実装します。 配列を反復処理するための foreach ステートメントを追加することができます。 1 次元配列でも IList および IEnumerable が実装されています。
更に Array
型は以下のインターフェースを実装している。
Array Class (Microsoft Learn)
- ICloneable
- System.Collections.IList
- System.Collections.IStructuralComparable
- System.Collections.IStructuralEquatable
他にも実装すべきメソッドがあるかもしれない(リファレンスのメソッド一覧を見ても、どれが静的メソッドでないのか分からない..。)が、ひとまずこれらのインターフェースを実装すれば、基本的な配列の要件は満たせそうだ。
ただし:
- Because callers of Clone() cannot depend on the method performing a predictable cloning operation, we recommend that ICloneable not be implemented in public APIs.
- ICloneable Interface (Microsoft Learn)
- =>
ICloneable
は実装しない
- 配列が
IList<T>
のメソッドを呼び出せるのはIList<T>
に明示的にキャストした場合のみで、Add
など要素数を変更するメソッドは例外を発生させる- e.g.,
((IList<T>)array).Add(1); // NotSupportedException
- => 「明示的なインターフェースの実装」を行う
- e.g.,
補足:
-
IList
やIEnumerable
と型パラメータのないインターフェースが記載されていることがあるが、これはIList<object>
やIEnumerable<object>
と等価で object 型引数のオーバーライドを提供するようだ。
IEnumerable<T>
の実装
public IEnumerator<T> GetEnumerator() => ((IEnumerable<T>)Data).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => Data.GetEnumerator();
IList<T>
の実装
配列の振る舞いに合わせ、添字アクセス以外のメソッドは明示的なインターフェースで実装し、特に要素数を変更するようなものは NotSupportedException
を投げるようにする(コード省略)。
添字 set アクセスの度に新しい ImmutableArray
が生成されるので、効率は悪そう。
public T this[int index]
{
set => Data = Data.SetItem(index, value);
readonly get => Data[index];
}
IStructuralComparable
の実装
public int CompareTo(object other, IComparer comparer) => ((IStructuralComparable)Data).CompareTo(other, comparer);
IStructuralEquatable
の実装
readonly bool IStructuralEquatable.Equals(object other, IEqualityComparer comparer) => ((IStructuralEquatable)Data).Equals(other, comparer);
readonly int IStructuralEquatable.GetHashCode(IEqualityComparer comparer) => ((IStructuralEquatable)Data).GetHashCode(comparer);
コンストラクタの要件
配列か自分自身の型を受け取った際、配列をクローンしてフィールドに格納するようにコンストラクタを実装する。
これ以外にも IEnumerable<T>
を受け取れるようにするなど拡張はいくらかできそうだが、ひとまず現状での実装に留める。
ただし、以下のように注意書きが記載されている。
コピー コンストラクターを記述する方法 (C# プログラミング ガイド)
クラス階層内のすべての派生型に対して機能するコピー コンストラクターを記述するのは難しい場合があります。 クラスが sealed でない場合は、コンパイラによって合成されたコピー コンストラクターを使う record class 型の階層を作ることを検討することを強くお勧めします。
つまり、ユーザ実装したコピーコンストラクタを持つクラス A のサブクラス B が存在する場合、サブクラス B でもコピーコンストラクタを実装しなければならないということだと思われる。
今回は struct で実装するので、この問題は発生しない。
コンストラクタの実装
static public implicit operator ValueTypeArray<T>(ReadOnlySpan<T> array) => new ValueTypeArray<T>(array);
public ValueTypeArray(T[] array) => Data = [.. array];
public ValueTypeArray(ValueTypeArray<T> other) => Data = [.. other.Data];
等価性比較の要件
==
演算子の振る舞いを実装するためには、 IEquatable<T>
インターフェースを実装する必要がある。
クラスまたは構造体の値の等価性を定義する方法 (C# プログラミング ガイド)
IEquatable<T>
の実装
先に IStruucturalEquatable
インターフェースを実装しているため、 それをラップするだけでよい。
public override readonly bool Equals(object? obj) => obj is ValueTypeArray<T> other && Equals(other);
public readonly bool Equals(ValueTypeArray<T> other) => ((IStructuralEquatable)this).Equals(other.Data, EqualityComparer<T>.Default);
public static bool operator ==(ValueTypeArray<T> left, ValueTypeArray<T> right) => left.Equals(right);
public static bool operator !=(ValueTypeArray<T> left, ValueTypeArray<T> right) => !left.Equals(right);
public override readonly int GetHashCode() => ((IStructuralEquatable)this).GetHashCode(EqualityComparer<T>.Default);
コレクション式への対応
コレクション式が対応するのは以下に挙げる型のみ:
-
Span<T>
,ReadOnlySpan<T>
, インライン配列, 配列 - "コレクションビルダー" を実装するクラス
- コレクション初期化子をサポートする型, つまり
IEnumerable<T>
とAdd
メソッドを実装するクラス
独自型の場合はコレクションビルダーを実装するか、 IEnumerable<T>
と Add
メソッドを実装する必要がある。
今回はコレクションビルダーを実装する。
コレクションビルダーの実装
internal static class ValueTypeArrayBuilder
{
internal static ValueTypeArray<T> Create<T>(ReadOnlySpan<T> values) => new ValueTypeArray<T>(values);
}
クラス宣言に以下を追加する
[CollectionBuilder(typeof(ValueTypeArrayBuilder), "Create")]
ToString()
への対応
ToString メソッドをオーバーライドする方法 (C# プログラミング ガイド)
標準出力で配列要素を確認できるように、ToString()
メソッドをオーバーライドする。
public override string ToString() => $"[{string.Join(", ", Data)}]";
拡張案
今回は最低限の機能のみ実装したため、拡張・改善案はいくつも考えられる。
例えば以下のような案を考えている。
C#実装リファレンスの調査
今回同様イミュータブルな配列を実装するものとして、 string
型がある。
Array
クラス等含めて、関連するC#組み込み型の実装を調査することで、実装を改善できるかもしれない。
イミュータブル配列への拡張
いわゆる「値オブジェクト」として扱うためには、添字アクセスによる要素の変更を削除し、イミュータブルな配列に修正する必要がある。
今回は要素アクセス可能なリストを実装するためイミュータブルではないので、 record 型のメンバとして誤って要素が変更されないよう注意が必要。
実践 DDD 本 第 6 章「値オブジェクト」~振る舞いを持つ不変オブジェクト~
シリアライズの要件(未検証)
.NET で JSON シリアル化 (マーシャリング) のためのカスタム コンバーターを作成する方法
Summary
-
ImmutableArray<T>
をラップし、値型のように代入、比較が実行される型を実装した。ただし他の型のメンバとして使用する場合、添字アクセスによる要素の変更はできなくなった - イミュータブルな参照型は、値型と同様に、代入操作時に値コピーされる
- C#ではインターフェースを実装することで、ユーザ定義型を既存の構文や型へ容易に接続できる