レコードは、データを保持することが主な役割で、そのデータによって等価性が定義される型です。レコードは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 によるコピーが変更追跡と相性が悪いからです。