配列入りのレコードってどうだろう
はじめに
この記事では、レコードと配列の共存を図る方法について書いていきます。
「レコードの中に配列を入れたい」という好奇心と「レコードが不変型であることを守りたい」という頑固な心が葛藤する人のための記事ですが、単にレコードのカスタマイズに興味がある人にも参考になるかもしれません。
C#9.0の
record
を想定しております。
C#10.0で予定されているrecord struct
についてはまた違った考え方が必要になると思います。
using System;
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Linq;
using System.Text;
位置指定パラメータの型の選択
データ列を扱う型がC#(.NET)にはいくつかあります。
まずは今回位置指定パラメータに指定する型を考えていきます。
制限がないのだから通常の配列でも良い?
record MyArrayRecord(string Name, int[] Values);
単純に考えれば、配列を使ってはいけない理由はないですし、
何も気にしない場合は上のコードのまま使っても構わないのですが、
「不変型」を指向しているレコードに、配列という「可変型」を含めるのは気が引けるのです。
var rec = new MyArrayRecord("田中", new int[] { 1, 3, 5, 7 });
// rec.Values = new int[] { 2, 4, 6 }; /* プロパティ自体は書き換えられない */
rec.Values[0] = 100; /* プロパティの中身は書き換えられる */
だったらIEnumerableでいいのでは?
record MyEnumerableRecord(string Name, IEnumerable<int> Values);
これでValuesプロパティが書き換えられる心配は無くなりました。
大きな改善です。
ちなみに、Microsoftのレコード紹介記事では
IEnumerable<T>
を使う方法が書かれています。
Create record types
これで十分な状況であれば、間違った対応ではないと思います。
めでたしめでたし……と言いたいところですが、まだ少しだけ問題があります。
var nums = new int[] { 1, 3, 5, 7 };
var rec = new MyEnumerableRecord("田中", nums);
// rec.Values[0] = 100; /* プロパティを経由した値の変更はできない。 */
nums[0] = 123; /* ←元の配列の中身を書き換えることはできる。 */
この方法では、元の配列の中身を書き換えることはできてしまいます。
IEnumerableを使う場合の緊急回避策
record MyEnumerableRecord(string Name, IEnumerable<int> Values)
{
public IEnumerable<int> Values { get; } = Values.ToArray();
}
一回コピーしてしまうのが作戦の一つです。
もしIEnumerable<T>
型で良ければ、この対応策で問題ないでしょう。
var nums = new int[] { 1, 3, 5, 7 };
var rec = new MyEnumerableRecord("田中", nums);
Console.WriteLine(rec.Values[1]); /* ←これを書きたい */
ですが、今回は配列のようにインデクサを使って値を取り出したいとします。
ImmutableArrayの出番
こういう時のためのSystem.Collections.Immutable
名前空間です。
ImmutableArray<T>
が「不変型」を作るためのカギになります。
record MyImmutableArrayRecord(string Name, ImmutableArray<int> Values);
System.Collections.Immutable
名前空間のクラスは、一度作成したインスタンスの値を変更したい場合、新たなインスタンスを作成するような仕組みになっています。
つまり、レコードと同じような考え方のクラスになっています。
//ImmutableArray<int> array = ImmutableArray.Create(1, 3, 5); C#11以前
ImmutableArray<int> array = [1, 3, 5];
//Builderを使って操作する方法
ImmutableArray<int>.Builder builder = array.ToBuilder();
for (int i = 7; i <= 13; i+=2)
{
builder.Add(i);
}
array = builder.ToImmutable();
//Addメソッドを使って操作する方法
array = array.Add(15);
今回はこのImmutableArray<int>
を選ぶことにして、
レコードの本体に必要な本体を追加していきます。
不変配列レコードの本体の肉付け
以下の順で本体にメンバーを追加します。
コンストラクタをオーバーロードする- ToString()の結果に配列の中身も反映する
- 等値比較を整備する
コンストラクタをオーバーロードする
C#12より、「コレクション式」が導入され、ImmutableArrayを含むコレクション型のインスタンス生成が容易に書けるようになりました。
この項で扱うようなコンストラクタのオーバーロードは、初めからC#12を利用できる環境では、不要どころか邪魔な記述となると考えます。
var rec = new MyImmutableArrayRecord("田中", ImmutableArray.Create(1, 3, 5, 7));
// コンストラクタを追加しなければ、これで呼び出せる。
var rec = new MyImmutableArrayRecord("田中", [1, 3, 5, 7]);
// 元のコレクションの型が異なっていても、これで良い。
var rec2 = new MyImmutableArrayRecord("名前", [..array]);
// コンストラクタを追加してしまうと、
// オーバーロード解決のためにキャストが必要。
var rec = new MyImmutableArrayRecord("田中", (ImmutableArray<int>)[1, 3, 5, 7]);
このままではインスタンス生成が多少面倒なので、int[]
を受け付けるコンストラクタも用意しておきましょう。
C#12以降は、以下のコンストラクタを作成するのは(この記事の目的では) 非推奨です。
record MyImmutableArrayRecord(string Name, ImmutableArray<int> Values)
{
public MyImmutableArrayRecord(string name, params int[] values)
: this(name, ImmutableArray.Create(values)) { }
}
C#12以降は、以下の書き方よりも、コレクション式に統一しましょう。
var rec1 = new MyImmutableArrayRecord("田中", ImmutableArray.Create(1, 3, 5, 7));
var rec2 = new MyImmutableArrayRecord("田中", new int[] { 1, 3, 5, 7 });
var rec3 = new MyImmutableArrayRecord("田中", 1, 3, 5, 7);
レコードにコンストラクタを追加する場合、
this
によるプライマリコンストラクタによる初期化が必須となります。
※位置指定パラメータ有りのレコードに限る。
個人的にはこの制約はお気に入りで、下記のQiitaの意見交換(クローズ済)を思い出しました。
コンストラクタのオーバーロードが複数ある場合のメンバーの初期化について
ToString()の結果に配列の中身も反映する
インスタンスの中身を表示
Console.WriteLine(rec1)
してみると、こんな結果になってしまいます。
MyImmutableArrayRecord { Name = 田中, Values = System.Collections.Immutable.ImmutableArray`1[System.Int32] }
配列の中身まではToString()
されない、あるある話ですね。
ここでは、配列の中身まで表示できるように手を加えます。
レコード型ではToString()
のoverride
より先に、PrintMembers()
メソッドを書くことを優先します。
/// 内容を出力する
protected virtual bool PrintMembers(StringBuilder builder)
{
builder.Append(nameof(Name));
builder.Append(" = ");
builder.Append(Name);
builder.Append(", ");
builder.Append(nameof(Values));
if (!Values.IsDefaultOrEmpty)
{
builder.Append(" = { ");
builder.Append(string.Join(", ", Values));
builder.Append(" }");
}
else
{
builder.Append(" = { }");
}
return true;
}
MyImmutableArrayRecord { Name = 田中, Values = { 1, 3, 5, 7 } }
このPrintMembers()
メソッドはToString()
から呼び出されています。
表示すべき中身が無いときには以下のように書きましょう。
protected virtual bool PrintMembers(StringBuilder builder)
{
return false;
}
MyImmutableArrayRecord { }
型名すら表示させたくないのであれば、
ToString()
をoverride
します。
この場合も、ToString()
からPrintMembers()
の呼び出しの流れを意識して書くとより良いと思います。
等値比較を整備する
//var rec1 = new MyImmutableArrayRecord("田中", 1, 3, 5, 7); C#11以前
//var rec2 = new MyImmutableArrayRecord("田中", 1, 3, 5, 7); C#11以前
var rec1 = new MyImmutableArrayRecord("田中", [1, 3, 5, 7]);
var rec2 = new MyImmutableArrayRecord("田中", [1, 3, 5, 7]);
Console.WriteLine(rec1 == rec2);
False
現状の実装だと、配列内の値は比較されません。
内部的にはName
プロパティの比較とValues
プロパティの比較はしてくれているのですが、Values
の部分の比較が常にFalse
を返してしまいます。
等値性関連メソッド Equals()
, GetHashCode()
を追加します。
// 全ての要素について等値かどうか確認する
public virtual bool Equals(MyImmutableArrayRecord? other)
{
if (ReferenceEquals(this, other))
{ return true; }
if (other is null)
{ return false; }
if (Values.IsDefaultOrEmpty && other.Values.IsDefaultOrEmpty)
{ return true; }
if (Values.IsDefaultOrEmpty ^ other.Values.IsDefaultOrEmpty)
{ return false; }
return EqualityContract == other.EqualityContract &&
Name == other.Name &&
Values.SequenceEqual(other.Values);
}
public override int GetHashCode()
{
HashCode hash = new();
hash.Add(EqualityContract);
hash.Add(Name);
if (!Values.IsDefaultOrEmpty)
{
//要素が多い場合、末尾8つの要素を用いて生成する
int start = Values.Length > 8 ? Values.Length - 8 : 0;
for (int i = start; i < Values.Length; i++)
{
hash.Add(Values[i]);
}
}
return hash.ToHashCode();
}
True
ここで Equals(object? o)
, ==
, !=
は実装しなくていいの? と思った方は察しが良いです。
レコード型では、上記のメソッドや演算子はEquals(T? o)
の実装を利用するようになるため、これらを手書きする必要はありません。(上書きできない)
余談:EqualityContract
プロパティ
EqualityContract
という見慣れないプロパティは、等値比較に「型」の一致を要求するためのものです。
派生型と基本型、派生型同士の比較は常にFalse
になるように設計されています。
そうです。レコードにも継承があるのです。
レコードの継承については色々考えなければないのですが、この記事では扱いません。
この問題を考えたくない場合は、初めから
sealed
にしてしまうのも手かもしれません。
まとめ
当初の要求を満たす不変型配列レコードができました。
同じようなコードをclass
で書くよりコードの量も減っていて、
record
の機能を生かしているのではないでしょうか。
ご意見、ご感想、ご質問などあれば、是非コメントお願いします。
この実装が実用的かどうかは、状況次第だと思っています。
「不変」にこだわったレコードを実装する時に何をすればいいのか、
少しでも参考になる情報が書き残せていれば幸いです。