7
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C#9.0 配列入りのレコードってどうだろう 【不変型配列レコード】

Last updated at Posted at 2021-10-31

配列入りのレコードってどうだろう

はじめに

この記事では、レコードと配列の共存を図る方法について書いていきます。

「レコードの中に配列を入れたい」という好奇心と「レコードが不変型であることを守りたい」という頑固な心が葛藤する人のための記事ですが、単にレコードのカスタマイズに興味がある人にも参考になるかもしれません。

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)にはいくつかあります。
まずは今回位置指定パラメータに指定する型を考えていきます。


制限がないのだから通常の配列でも良い?

案:Arrayバージョン
record MyArrayRecord(string Name, int[] Values);

単純に考えれば、配列を使ってはいけない理由はないですし、
何も気にしない場合は上のコードのまま使っても構わないのですが、
「不変型」を指向しているレコードに、配列という「可変型」を含めるのは気が引けるのです。

問題点:Arrayは不変ではない
var rec = new MyArrayRecord("田中", new int[] { 1, 3, 5, 7 });

// rec.Values = new int[] { 2, 4, 6 }; /* プロパティ自体は書き換えられない */
rec.Values[0] = 100; /* プロパティの中身は書き換えられる */

だったらIEnumerableでいいのでは?

案:IEnumerableバージョン
record MyEnumerableRecord(string Name, IEnumerable<int> Values);

これでValuesプロパティが書き換えられる心配は無くなりました。
大きな改善です。

ちなみに、Microsoftのレコード紹介記事ではIEnumerable<T>を使う方法が書かれています。
Create record types
これで十分な状況であれば、間違った対応ではないと思います。

めでたしめでたし……と言いたいところですが、まだ少しだけ問題があります。

問題点:IEnumerableでも、渡した側は書き換えられる
var nums = new int[] { 1, 3, 5, 7 };
var rec = new MyEnumerableRecord("田中", nums);

// rec.Values[0] = 100; /* プロパティを経由した値の変更はできない。 */
nums[0] = 123; /* ←元の配列の中身を書き換えることはできる。 */

この方法では、元の配列の中身を書き換えることはできてしまいます。


IEnumerableを使う場合の緊急回避策

案: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>が「不変型」を作るためのカギになります。

案:Immutableバージョン
record MyImmutableArrayRecord(string Name, ImmutableArray<int> Values);

System.Collections.Immutable名前空間のクラスは、一度作成したインスタンスの値を変更したい場合、新たなインスタンスを作成するような仕組みになっています。
つまり、レコードと同じような考え方のクラスになっています。

参考:ImmutableArrayの使い方(値の操作)
//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を利用できる環境では、不要どころか邪魔な記述となると考えます。

C#11以前 (不満:いちいちImmutableArrayと書きたくない)
var rec = new MyImmutableArrayRecord("田中", ImmutableArray.Create(1, 3, 5, 7));
C#12では簡潔に記述できる。
// コンストラクタを追加しなければ、これで呼び出せる。
var rec = new MyImmutableArrayRecord("田中", [1, 3, 5, 7]);

// 元のコレクションの型が異なっていても、これで良い。
var rec2 = new MyImmutableArrayRecord("名前", [..array]);
C#12 既にコンストラクタを追加してしまった場合
// コンストラクタを追加してしまうと、
// オーバーロード解決のためにキャストが必要。
var rec = new MyImmutableArrayRecord("田中", (ImmutableArray<int>)[1, 3, 5, 7]);

このままではインスタンス生成が多少面倒なので、int[]を受け付けるコンストラクタも用意しておきましょう。
C#12以降は、以下のコンストラクタを作成するのは(この記事の目的では) 非推奨です。

C#11以前(実装: 配列を受け入れるコンストラクタ)
record MyImmutableArrayRecord(string Name, ImmutableArray<int> Values)
{
    public MyImmutableArrayRecord(string name, params int[] values) 
    : this(name, ImmutableArray.Create(values)) { }
}

C#12以降は、以下の書き方よりも、コレクション式に統一しましょう。

C#11以前(実装後:書きなれた方法でインスタンス生成できる)
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)
してみると、こんな結果になってしまいます。

不満:ToStringの出力が不便
MyImmutableArrayRecord { Name = 田中, Values = System.Collections.Immutable.ImmutableArray`1[System.Int32] }

配列の中身まではToString()されない、あるある話ですね。

ここでは、配列の中身まで表示できるように手を加えます。
レコード型ではToString()overrideより先に、PrintMembers()メソッドを書くことを優先します。

実装: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() を追加します。

実装: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の機能を生かしているのではないでしょうか。

ご意見、ご感想、ご質問などあれば、是非コメントお願いします。

この実装が実用的かどうかは、状況次第だと思っています。
「不変」にこだわったレコードを実装する時に何をすればいいのか、
少しでも参考になる情報が書き残せていれば幸いです。

7
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?