Menu

C#のレコード:値の等価性、with式、record struct

C#のレコード(C# 9以降)とは何かを解説します。位置指定の構文、コンパイラが生成するメンバー、値に基づく等価性、withによる非破壊的なコピー、組み込みのToString、C# 10のrecord struct、レコード間の継承、そして手書きした同等のクラスを扱います。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

レコードは、データを保持することが主な役割で、そのデータによって等価性が定義される型です。レコードはC# 9で導入されました。1行書くだけで、コンパイラがデータクラスに必要なメンバーを生成します。プロパティ、コンストラクター、値に基づく Equals と ==、GetHashCode、読みやすい ToString、Deconstruct、そして with によるコピーへの対応です。

レコードにはC# 9以降(.NET 5以降)が必要なので、このページのレコードのコードは普通のC#として、出力をコメントで示しています。最後の節では同じメンバーをC# 7で手書きしていて、そちらは実行できます。

位置指定のレコード

最も短い形は、名前の後のかっこの中にプロパティを並べるものです。各引数は、同じ名前の公開のinit専用プロパティになります。

public record Product(string Sku, string Name, decimal Price);

var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name);        // Mug
Console.WriteLine(mug);             // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m;                  // error CS8852: init-only property

var (sku, name, price) = mug;       // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50

その1行から、コンパイラは次のものを生成します。

  • (string Sku, string Name, decimal Price) を受け取るコンストラクター。
  • 3つの public ... { get; init; } プロパティ。
  • 3つのプロパティを比較する Equals(object)、Equals(Product)、GetHashCode()、== と != 演算子。
  • 型名とすべての公開プロパティを表示する ToString()。
  • Deconstruct(out string Sku, out string Name, out decimal Price)。
  • コピーコンストラクター(protected、sealedなレコードではprivate)と、with が使う隠れた複製メソッド。

レコードは普通の本体を持つ形でも書け、プロパティに既定値や検証が必要な場合に便利です。

public record Customer
{
    public required string Email { get; init; }   // required: C# 11
    public string Name { get; init; } = "";
}

var c = new Customer { Email = "ana@example.com" };

そして両方の形を組み合わせることもできます。位置指定の引数に加えて、波かっこの中に追加のメンバーを書きます。

public record Order(string Id, decimal Subtotal)
{
    public decimal Tax => Subtotal * 0.23m;
    public decimal Total => Subtotal + Tax;
}

値の等価性

普通のクラスでは、== は「これらは同じオブジェクトか」を尋ねます。レコードでは「これらは同じ値を持っているか」を尋ねます。

var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);

Console.WriteLine(a == b);                  // True
Console.WriteLine(a.Equals(b));             // True
Console.WriteLine(ReferenceEquals(a, b));   // False: still two objects

GetHashCode もそれに合わせて生成されるので、レコードは辞書のキーや HashSet<T> で正しく動きます。同じ値を持つ2つ目のレコードが、1つ目のエントリを見つけます。

等価性は各フィールド(位置指定のレコードでは各プロパティの背後にあるフィールド)を、その型自身の Equals を呼ぶ EqualityComparer<T>.Default で比較します。コレクションのプロパティではそれが参照の等価性になり、多くの人が驚きます。

public record Basket(string Owner, List<string> Items);

var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y);   // False: two different List objects

コレクションを保持するレコードをその内容で比較すべきなら、Equals(Basket other) と GetHashCode() をオーバーライドするか、値のセマンティクスを持つ独自の不変のコレクションを使います。

with式:非破壊的な変更

レコードは通常不変なので、変更したコピーを作ることで「変更」します。with はすべてのプロパティをコピーしてから、波かっこの中の代入を適用します。

var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };

Console.WriteLine(sale);        // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price);   // 8.50: the original is untouched

コピーは浅いです。参照型のプロパティは参照としてコピーされるので、両方のレコードがそのオブジェクトを共有します。

public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }

var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");

Console.WriteLine(string.Join(",", c1.Tags));   // vip,newsletter

レコードのプロパティを最後まで不変に保つ(一度だけ埋める IReadOnlyList<T> や ImmutableList<T>)か、with の中で新しいリストを作ります:c1 with { Tags = new List<string>(c1.Tags) }。

ToString

生成される ToString は型名とすべての公開プロパティを表示するので、レコードはログやデバッガーで扱いやすくなります。

Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }

コレクションは型名(System.Collections.Generic.List`1[System.String])として表示され、入れ子のレコードは再帰的に表示されます。ToString をオーバーライドすれば、出力全体を置き換えられます。

public record Money(decimal Amount, string Currency)
{
    public override string ToString() => $"{Amount:F2} {Currency}";
}

record struct(C# 10)

単に record と書くと record class、つまり参照型を意味します。C# 10では、同じ生成されるメンバーを持つ値型の record struct が追加されました。

public readonly record struct Point(int X, int Y);

var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4));   // True
Console.WriteLine(q);                      // Point { X = 3, Y = 10 }

既定の違いは覚えておく価値があります。位置指定の record struct のプロパティは、構造体の通常の振る舞いに合わせて変更可能({ get; set; })ですが、readonly record struct と record class はinit専用です。構造体とクラスを選ぶのと同じように選びます。自由にコピーされる小さな値には readonly record struct が、それ以外には record が向いています。

継承

レコードは別のレコードを継承できます(クラスは継承できず、クラスもレコードを継承できません)。位置指定の引数は、コンストラクターの引数のように基底に渡します。

public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);

Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);

Console.WriteLine(a == b);   // True
Console.WriteLine(a == c);   // False: different runtime types are never equal
Console.WriteLine(a);        // Circle { Color = red, Radius = 2 }

等価性には、生成される EqualityContract プロパティを通して実行時の型も含まれます。だから、どちらも Shape を通して比較されても、Circle は同じ Color の Square と決して等しくならず、変数の型が基底であっても ToString と with は派生型に対して動作します。

C# 7での同じもの:値の等価性を持つクラス

レコードが生成するのは自分でも書けるコードで、それを見るとレコードの振る舞いがわかります。これは public record Point(int X, int Y); と同等のC# 7のクラスです。読み取り専用のプロパティ、コンストラクター、Deconstruct、値の等価性、それに合ったハッシュコード、==、ToString、そして with 式の代わりになる With メソッドを持ちます。

出力:

True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False

2つのプロパティのために約30行が必要で、プロパティを1つ増やすたびに、コンストラクター、Deconstruct、Equals、GetHashCode、ToString にもう一度手を入れることになります。そのどれかを忘れるのは典型的なバグです(== なのにハッシュが異なる2つの点ができ、HashSet がそれらを見失います)。この保守の手間こそ、レコードが取り除くものです。

クラスは意図的に sealed にしています。値の等価性と継承を組み合わせるには、レコードが EqualityContract を通して生成する追加の型のチェックが必要で、sealedにすれば問題を避けられます。

レコードを使うべきとき

レコードが向いているのは、値によって定義され、作成後に変わらないデータです。

  • Web APIのリクエストとレスポンスのモデル。
  • システムの部分間でやり取りされるメッセージ、コマンド、イベント。
  • 設定やオプションのオブジェクト。
  • 複合的な辞書のキー(record CacheKey(string Region, int Year))。
  • 計算の結果(record PriceQuote(decimal Net, decimal Tax))。

値より同一性が重要な場面には向いていません。Entity Frameworkのエンティティは、名前が変わった後も「顧客42」であり、EF Coreの変更追跡は参照の同一性に依存しています。そこではクラスを使います。

よくある間違い

  • コレクションの深い等価性を期待する。 List<T> のプロパティは参照で比較されます。等しく見えるリストを持つ2つのレコードは等しくありません。
  • with が深いコピーをすると思う。 入れ子のオブジェクトやコレクションは、元のものとコピーで共有されます。
  • うっかり変更可能な位置指定の record struct。 設定可能なプロパティが欲しいのでない限り、readonly を付けます。
  • レコードをEF Coreのエンティティとして使う。 値の等価性とコピーが変更追跡と衝突します。
  • C# 8のプロジェクトにレコードを追加する。 レコードにはC# 9(.NET 5以降の既定)が必要です。古いターゲットでは、上に示したようにクラスを手書きします。

よくある質問

C#のレコードとは何ですか?

レコード(C# 9)はクラスで、record struct(C# 10)なら構造体です。コンパイラが、値に基づく等価性、読みやすい ToString()、Deconstruct メソッド、with によるコピーへの対応を生成します。public record Product(string Sku, decimal Price); は、init専用の2つのプロパティを持つ完全な型です。プロパティの値が等しい2つのレコードは、別々のオブジェクトであっても等しくなります。

C#のレコードとクラスの違いは何ですか?

record の中身はクラスなので、参照型であり、他のレコードを継承できます。違いは生成されるメンバーです。レコードは値で比較し(== と Equals がすべてのフィールドを確認)、ToString() でプロパティを表示し、with に対応します。普通のクラスは、それらのメンバーを自分で書かない限り、参照で比較して型名を表示します。

C#のwith式は何をしますか?

var sale = product with { Price = 6.00m }; は、product のすべてのプロパティをコピーしてから、列挙したものを設定した新しいレコードを作ります。元のレコードは変わりません。コピーは浅いので、List<T> のプロパティは両方のレコードで共有され、一方を通して追加するともう一方からも見えます。

C#のrecord structとは何ですか?

record struct(C# 10)は、レコードクラスと同じ生成されるメンバー(値の等価性、ToString、Deconstruct、with)を持つ値型です。レコードクラスと違い、位置指定のプロパティは既定で変更可能です。init専用にするには readonly record struct と宣言します。座標や金額のような小さな値に使います。

C#ではいつレコードを使うべきですか?

同一性がその値であるデータに使います。DTO、APIのリクエストとレスポンスのモデル、メッセージとイベント、設定、辞書のキーです。Entity Frameworkのエンティティのように、時間とともに変化し、IDで識別されるエンティティには使いません。値の等価性や with によるコピーが変更追跡と相性が悪いからです。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める