前回は、継承やインターフェースなど「クラスの応用的な使い方」について学びました。
今回は、C# 9.0 で導入された record
(レコード)型について、クラスとの違いや基本的な使い方を解説します。
record型は実務でもよく使われる機能で、特にWebアプリケーション開発などで「変更しないデータ」を扱う場面において重宝されます。
record型とは?
record
は、データ型の一つで、見た目や書き方はクラスによく似ています。
実際、レコードもクラスと同じように、プロパティやメソッドを定義したり、継承やインターフェースを使った設計が可能です。
まずは、クラスと同じような書き方で定義する例を見てみましょう。
public record User
{
public string Name { get; init; }
public int Age { get; init; }
public string Greet()
{
return $"Hello, {Name}!";
}
}
record キーワード以外はクラスと見た目は同じです。
※プロパティのinit
アクセサについては後ほど説明します。
レコードには「プライマリコンストラクタ」と呼ばれる省略記法があり、プロパティだけを持つシンプルな構造の場合は、1行で書くことができます。
public record User(string Name, int Age);
この一行で、Name
と Age
という2つのプロパティやコンストラクタが自動的に定義されます。
インスタンス生成も通常のクラスと同じようにできます。(ただし必ず全プロパティの初期化が必要)
var user = new User("Alice", 30);
Console.WriteLine(user); // 出力: User { Name = Alice, Age = 30 }
この省略スタイルは、主にデータの中身だけを扱いたい場面で使われることが多く、表示用のモデルや設定情報などに最適です。
イミュータブルとは?
次に、レコードの大きな特徴であるイミュータブル(不変・immutable)について説明します。
「一度作ったら中身を変更できない」データのことをイミュータブルと呼びます。
例えば、よく使う string
もイミュータブルな型です。
例:string型のイミュータブルな動作
string name1 = "Alice";
string name2 = name1.ToUpper();
Console.WriteLine(name1); // Alice(元の文字列は変わらない)
Console.WriteLine(name2); // ALICE(新しい文字列が作られる)
ToUpper()
メソッドは、name1
の中身を直接変更するのではなく、すべて大文字に変換した新しい文字列を返します。
name1
という変数の中身はそのままで、別の新しい文字列が作られ、それが name2
に代入されるという仕組みです。
このように、元のデータを変更せずに、新しいデータとして扱う設計を「イミュータブル」と呼びます。
レコードもイミュータブル
public record Person(string Name, int Age);
var person1 = new Person("Alice", 30);
person1.Age = 31 // エラー。インスタンス生成後に値を変更することはできない
var person2 = new Person("Alice", 31); // 値が違うものは別の新しいインスタンスを生成する
Console.WriteLine(person1); // Person { Name = Alice, Age = 30 }
Console.WriteLine(person2); // Person { Name = Alice, Age = 31 }
このようにレコードでは元のデータを変更するのではなく、必要に応じて新しいインスタンスを作ることで、イミュータブルな設計を実現します。
string
と同じように、「元のデータが変更されない」というのが大きな利点です。
イミュータブルの何が嬉しいのか?
イミュータブルの一度作ったら中身が変わらないという特徴は、「予期しないバグ」を防いだり、「安心してデータを共有できる」ようになるという大きなメリットがあります。
public class Person
{
public string Name { get; set; }
}
void Greet(Person person)
{
person.Name = "Bob"; // 呼び出し元のオブジェクトを変更してしまう
Console.WriteLine($"Hello, {person.Name}!");
}
var p = new Person { Name = "Alice" };
Greet(p);
Console.WriteLine(p.Name); // Bob になっている(意図しない変更)
この例では、Greet
メソッドがPerson
のName
を書き換えており、呼び出し元のデータが変わってしまっています。
意図しない場所でデータが変更されると、後からバグの原因を特定するのが難しくなります。
一方でレコードは、先ほどの例にあったようにインスタンス生成後は値を変更できなくなるので、このような心配がなくなります。
一人で開発するアプリケーションであればあまり気にしなくても大丈夫ですが、複数人で開発する複雑なアプリケーションではイミュータブルな設計が重要になってきます。
比較方法の違い
ここからはインスタンス同士を比較する際の挙動について見ていきます。
C#では、オブジェクトを比較する方法として「参照の比較」「中身の値の比較」の2種類があります。
その違いを理解するために、まずは値型と参照型の違いを簡単に見ておきましょう。
値型と参照型の比較ルール
- 値型(int, double, bool など) は、変数そのものにデータ(値)を持ちます。そのため、比較するときは値同士を直接比べます
- 参照型(class, record, string など) は、変数にデータそのものではなく「データがある場所(アドレス)」を持ちます。そのため、通常の比較では「同じ場所を参照しているかどうか」が基準になります
クラス同士の比較
以下はUserクラスから生成した2つのインスタンス同士を比較する例です。
public class User { public string Name { get; set; } }
var u1 = new User { Name = "Bob" };
var u2 = new User { Name = "Bob" };
Console.WriteLine(u1 == u2); // false(中身が同じでも違う参照)
C#のクラスは参照型なので、変数u1とu2に代入されているのはオブジェクトの「中身」ではなく「場所(アドレス)」です。
そのため、==
演算子で比較したときには、中身が同じでも異なるインスタンスであればfalseになります。
レコード同士の比較
今度はUserレコードを比較してみます。
public record User(string Name);
var r1 = new User("Bob");
var r2 = new User("Bob");
Console.WriteLine(r1 == r2); // true(値が同じなら等価とみなされる)
クラスと違ってレコードは、自動的に比較を「中身(値)ベース」で行うように設計されています。
これにより、インスタンスの中身が等しければ比較結果はtrueとなります。
レコードの便利な機能
レコードには、便利で実用的な機能がいくつか備わっています。
まず、ToString()
や Equals()
、GetHashCode()
などの基本的なメソッドを自動で用意してくれるという機能です。
これらのメソッドは、クラスでは自分で定義する必要がありますが、レコードでは何もしなくても最初から使えるようになっています。
以下は、クラスとレコードでToString()
メソッドの違いを表したサンプルコードです。
// クラスの定義(ToString()を定義していない)
public class UserClass
{
public string Name { get; set; }
public int Age { get; set; }
}
// レコードの定義(ToString()が自動で使える)
public record UserRecord(string Name, int Age);
var classUser = new UserClass { Name = "Alice", Age = 30 };
var recordUser = new UserRecord("Alice", 30);
// Console.WriteLineにオブジェクトを渡すと自動でToString()が呼ばれる
Console.WriteLine(classUser); // 出力: UserClass (中身は表示されない)
Console.WriteLine(recordUser); // 出力: UserRecord { Name = Alice, Age = 30 }
このように、クラスでToString()
メソッドを定義していない場合はクラス名がそのまま表示されますが、レコードは定義していなくても値の中身が表示されます。
次に便利なのが with
式です。これは、元のデータをコピーしつつ、一部のプロパティだけ変更した新しいオブジェクトを生成する構文です。
var original = new User("Alice", 30);
var updated = original with { Age = 31 };
Console.WriteLine(original); // User { Name = Alice, Age = 30 }
Console.WriteLine(updated); // User { Name = Alice, Age = 31 }
このように、元のオブジェクトはそのままに、必要な部分だけを変更した新しいオブジェクトを簡単に作ることができます。
さらに、レコードでは init
アクセサを使ってプロパティを定義できます。これは、値を「初期化時にだけ設定できる」ように制限するための書き方です。※クラスでもinit
アクセサの使用は可能
public record Product
{
public string Name { get; init; }
public int Price { get; init; }
}
var p = new Product { Name = "Note", Price = 500 };
// p.Price = 600; // エラー! init プロパティは初期化後に変更できません
init
アクセサは、値の変更を禁止しつつも、オブジェクト初期化構文で値を設定する柔軟さを持っています。
このように、レコードは短く書けて、かつイミュータブルなデータモデルを構築しやすい設計がされています。
クラスとレコードの使い分け
クラスとレコードは、どちらもオブジェクトを表現するための手段ですが、目的や使い方によって使い分けることが重要です。
クラスが向いている場面
クラスは、「このオブジェクトはどのような振る舞いを持つか」や「このオブジェクトはいつ、どこで作られたか」といった個別性や状態の変化を大事にする場面で活躍します。
例えば、User
クラスのオブジェクトが2つあったとして、たとえ名前と年齢が同じでも「別人」とみなすような場面ではクラスが適しています。
主な使いどころ
- ユーザー(同姓同名でも別人として区別したい)、注文(同じ注文内容でも別の注文として区別したい)など、それぞれを「別の存在」として扱う必要があるとき
- 「状態を変更する」「外部のサービスを呼び出す」など複雑な処理や振る舞い(メソッド)を持たせたいとき(コントローラー、サービス、リポジトリ、DAO、APIクライアントなど)
レコードが向いている場面
レコードは、「このオブジェクトが何者か」ではなく、「このオブジェクトの中身が何であるか」を重視する設計に向いています。
例えば、同じ名前と年齢を持つUser
が2つあったとして、それらが事実上「同じ」とみなせるような場面ではレコードが適しています。
主な使いどころ
- 値そのものに意味があり、中身(値)が同じなら同じものとみなしたいとき(バリューオブジェクト)
- 画面・データベース・APIなどと受け渡すデータを表現したいとき(ViewModelやDTOなど)
まとめ
レコードは、データの中身を重視する場面に適しており、シンプルに定義できて比較やコピーがしやすいという特徴があります。
特に、変更されるべきでない情報や、値としての意味が重要なデータを扱う際に有効です。
一方で、状態を持ち振る舞いを管理するような場面では、従来どおりのクラスの方が適しています。
次回は、C#における「null」の扱い方について解説します。nullを適切に扱うことは、バグを防ぐうえでも非常に重要です。