【C#】init専用プロパティ完全ガイド|IsExternalInit・required連携・with式・深い不変性・スレッドセーフまで

【C#】init専用プロパティの使い方|イミュータブルなオブジェクト設計 C#

C# 9.0 で導入された init アクセサは、「オブジェクト初期化子の中でだけ値を設定でき、それ以降は変更不可」という不変オブジェクト設計の基本部品です。コンストラクタ引数を増やさずに任意数のプロパティを初期化でき、かつ生成後の変更を防げるため、DTO・値オブジェクト・設定クラスで中心的な役割を担います。

本記事では init の内部実装(IsExternalInit)、required(C# 11)との組み合わせ、with 式によるコピー生成、継承時の振る舞い、浅い不変性と深い不変性の違い、スレッドセーフ性、System.Text.Json との連携、実践的な設計パターンまで体系的に解説します。

スポンサーリンク

init アクセサの基本と set との違い

init と set の書き分け
// set: 生成後もいつでも書き換え可能(ミュータブル)
public class MutablePerson
{
    public string Name { get; set; } = "";
    public int    Age  { get; set; }
}

var mp = new MutablePerson { Name = "Alice", Age = 30 };
mp.Age = 31;                         // OK(いつでも変更できる)

// init: 初期化子とコンストラクタの中でのみ書ける
public class ImmutablePerson
{
    public string Name { get; init; } = "";
    public int    Age  { get; init; }
}

var ip = new ImmutablePerson { Name = "Alice", Age = 30 };
// ip.Age = 31;                      // NG: CS8852 オブジェクト初期化の外では書けない

// コンストラクタ引数と併用 — 必須はコンストラクタ、オプションは初期化子
public class User
{
    public int Id { get; }                      // 生成時のみ設定可(set すらない)
    public string Name { get; init; } = "";
    public string? Email { get; init; }

    public User(int id) => Id = id;
}

var u = new User(id: 1) { Name = "Bob", Email = "bob@example.com" };
アクセサ 初期化可能なタイミング 特徴
set いつでも 完全にミュータブル。バリデーションは setter に書ける
init コンストラクタ+オブジェクト初期化子+with 生成後は不変。オブジェクト初期化子との相性◎
private set クラス内のみ 外部からは不変・内部でのみ状態遷移
{ get; }(読み取り専用) コンストラクタのみ(readonly 自動バッキングフィールド) 最も厳格だが初期化子構文が使えない

内部実装 — IsExternalInit というトリック

コンパイラは init アクセサを、特殊な修飾子(modreq)付きの setter メソッドとして生成します。具体的には System.Runtime.CompilerServices.IsExternalInit を「戻り値型の modreq」として付けた set_Prop(...) メソッドです。これにより古いコンパイラから呼び出されると型不一致になり、新しいコンパイラだけが「オブジェクト初期化子と with 式の中でのみ呼べる」と解釈します。

.NET Standard 2.0 / .NET Framework で init を使う
// .NET 5+ には IsExternalInit が標準搭載されているが
// .NET Standard 2.0 や .NET Framework では存在しないため自分で定義する必要がある
// (C# 9 のコンパイラが要求する「型」として認識される)

// この1ファイルを追加するだけで .NET Framework 4.x でも init が使える
namespace System.Runtime.CompilerServices
{
    internal static class IsExternalInit { }
}

// これを追加した後、ふつうに init が書ける
public class LegacyDto
{
    public string Name { get; init; } = "";
    public int Age { get; init; }
}
init はコンパイラの契約であり、実行時ガードではない
IsExternalInitmodreq(required modifier)で「このメソッドを呼ぶには特別な知識が必要」と示すだけで、実行時に「初期化中かどうか」をチェックしているわけではありません。リフレクション(PropertyInfo.SetValue)や古い C# コンパイラ(C# 8 以前)からは呼び出し自体を弾くコンパイル時チェックで守られているにすぎません。

required との組み合わせ(C# 11 / .NET 7+)

required(必須)修飾子は「オブジェクト生成時に必ず初期化しなければならない」事を強制する機能で、init と組み合わせると不変 × 必須の理想的な組み合わせが完成します。コンストラクタを書かずに全プロパティを必須かつ不変にできます。

required × init で最もシンプルな値オブジェクト
// C# 11+ / .NET 7+
public class UserProfile
{
    public required string Name  { get; init; }
    public required string Email { get; init; }
    public int Age { get; init; } = 0;              // デフォルト値があるので任意
}

// Name と Email を省略するとコンパイルエラー(CS9035)
var ok = new UserProfile { Name = "Alice", Email = "a@x" };
// var ng = new UserProfile { Name = "Alice" };   // NG: Email が必須

// コンストラクタの代わりに required を使うと、任意の順序で書けて可読性が上がる
public sealed class Order
{
    public required int      Id       { get; init; }
    public required string   Customer { get; init; }
    public required decimal  Total    { get; init; }
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
    public string? Memo { get; init; }
}

// required は継承時にも引き継がれる
public class Admin : UserProfile
{
    public required string Role { get; init; }
}
// var a = new Admin { Name = "X", Email = "y", Role = "SuperAdmin" };
用途 推奨する書き方 理由
必須+不変(C# 11+) public required string Name { get; init; } コンストラクタ不要で宣言がシンプル
必須+不変(C# 10以前) コンストラクタ引数で受ける required が使えない
任意+不変 public string? Memo { get; init; } 省略可能なプロパティ
必須+可変 public required string Status { get; set; } 状態遷移が必要な場合

with 式 — 不変オブジェクトの「部分変更コピー」

with 式で init プロパティを書き換えた新インスタンスを作る
// record は最初から with 式に対応している
public sealed record Employee(string Name, int Age, string Dept);

var original = new Employee("Alice", 30, "Eng");
var moved    = original with { Dept = "Sales" }; // Dept だけ変更した新インスタンス

Console.WriteLine(original.Dept); // "Eng"(元は変わらない)
Console.WriteLine(moved.Dept);    // "Sales"

// 通常の class + init でも with 式は使える(C# 10+ でコピー可能条件を満たせば)
// ただし class の場合は record と違って自分で「コピーコンストラクタ」が必要
public sealed class Config
{
    public required string Host { get; init; }
    public required int    Port { get; init; }
    public string? ApiKey { get; init; }
}

// class で with 式を使いたい場合は手動でコピーヘルパーを書く
// (class の with 式は現時点で record / record struct / struct のみサポート)
public static Config CopyWith(this Config c,
    string? host = null, int? port = null, string? apiKey = null)
    => new() { Host = host ?? c.Host, Port = port ?? c.Port, ApiKey = apiKey ?? c.ApiKey };

// struct + init + with(C# 10+)
public readonly record struct Point(int X, int Y);
var p1 = new Point(0, 0);
var p2 = p1 with { X = 10 };
Console.WriteLine(p2); // Point { X = 10, Y = 0 }
class の with 式は record 限定
C# の with 式は recordrecord structstruct(C# 10+)のみが対応しています。通常の class + init のみでは with 式を使えないため、「部分変更コピー」を多用する場合は record にする、または手動で CopyWith 拡張メソッドを用意してください。コピーセマンティクスが重要な DDD の値オブジェクトでは record が第一選択です。

浅い不変性と深い不変性の違い

init が保証するのは「プロパティ参照の変更不可」だけで、参照先オブジェクトの内部状態は変更できます(浅い不変性)。完全な不変性(深い不変性)を得るには、参照先の型も不変にする必要があります。

浅い不変性の落とし穴
// NG: List<string> は init にしてもリスト内容を書き換えられる(浅い不変)
public class Team
{
    public string Name { get; init; } = "";
    public List<string> Members { get; init; } = new(); // ← リスト自体は変更可能!
}

var t = new Team { Name = "A", Members = new() { "Alice" } };
t.Members.Add("Bob");       // ← 変更できてしまう
// t.Members = new();       // NG: 参照は差し替えられない(init で守られている)

// OK ①: 読み取り専用インターフェースに変える(意図を明示)
public class TeamReadonly
{
    public string Name { get; init; } = "";
    public IReadOnlyList<string> Members { get; init; } = Array.Empty<string>();
}
// t.Members.Add(...) はコンパイルエラー(IReadOnlyList に Add がない)

// OK ②: ImmutableArray / ImmutableList で完全な不変にする
public class TeamImmutable
{
    public string Name { get; init; } = "";
    public ImmutableArray<string> Members { get; init; } = ImmutableArray<string>.Empty;
}
// キャストでダウンキャストもできず、真の不変性が得られる
「init にした = 不変」と思い込まない
参照型プロパティを init にしても、参照先オブジェクトのミュータビリティはそのまま残ります。スレッド間で共有する値オブジェクトや公開 API の DTO では、List<T> の代わりに IReadOnlyList<T> を公開するか、ImmutableArray<T> / ImmutableList<T> / FrozenSet<T> を使って深い不変性を保証してください。

継承と init アクセサ

init の override と派生クラスでの振る舞い
// 派生クラスでも init アクセサの契約は引き継がれる
public class Base
{
    public virtual string Name { get; init; } = "";
}

public class Derived : Base
{
    // set → init の変更や逆はできない(同じアクセサを override する必要あり)
    public override string Name { get; init; } = "";
}

// set/init の整合性: 基底が init なら派生も init にする必要がある
// public override string Name { get; set; }  // NG: 整合しない

// 抽象 init
public abstract class Entity
{
    public abstract int Id { get; init; }
}
public sealed class Product : Entity
{
    public override int Id { get; init; }
}

// コンストラクタチェーンの中で init プロパティを設定できる
public class Animal
{
    public string Name { get; init; } = "";
    public Animal(string name) => Name = name; // OK: コンストラクタ内なら書ける
}

public class Dog : Animal
{
    public string Breed { get; init; } = "";
    public Dog(string name, string breed) : base(name) => Breed = breed;
}

インターフェースでの init 宣言

インターフェースに init を含められる
// インターフェース側で init を宣言すると、実装クラスも init でなければならない
public interface IHasName
{
    string Name { get; init; }
}

// OK: init として実装
public class Person : IHasName
{
    public string Name { get; init; } = "";
}

// NG: set として実装すると契約違反
// public class Bad : IHasName { public string Name { get; set; } = ""; }

// ジェネリックファクトリーパターン
public static T CreateWithName<T>(string name) where T : IHasName, new()
    => new T { Name = name };

var p = CreateWithName<Person>("Alice");
interface + init は「初期化契約」を表現できる
インターフェースで init を宣言すると、「生成時に必ず設定される不変プロパティ」をインターフェースレベルで要求できます。DI コンテナやファクトリーの汎用関数で「任意の型を生成し、名前を設定して返す」という処理が型安全に書けます。ジェネリック制約 where T : IHasName, new() との相性が抜群です。

init とスレッドセーフ性

不変オブジェクトは「フリーズ後の共有」が安全
// init 済みオブジェクトは「初期化完了後は読み取りのみ」なのでスレッド間共有が安全
public sealed class ApiKey
{
    public required string Value { get; init; }
    public DateTime ExpiresAt    { get; init; }
}

// シングルトンとして複数スレッドに公開しても問題ない
private static readonly ApiKey GlobalKey = new()
{
    Value = Environment.GetEnvironmentVariable("API_KEY")!,
    ExpiresAt = DateTime.UtcNow.AddHours(24),
};

Parallel.For(0, 100, i =>
{
    // 各スレッドが同じインスタンスを参照しても読み取りのみなので安全
    Console.WriteLine(GlobalKey.Value);
});

// NG: init プロパティでも参照先が mutable なら競合は残る
public class UnsafeCache
{
    public Dictionary<string, string> Map { get; init; } = new(); // 中身は競合する
}
// Map.Add を複数スレッドから呼ぶと内部状態が壊れる → ConcurrentDictionary か ImmutableDictionary
不変性は並列処理の最強の味方
「生成した後は変わらない」オブジェクトは、ロックなしで複数スレッドから参照できます。設定オブジェクト・API レスポンス・値オブジェクトを init + ImmutableXxx で組むと、スレッドセーフ設計が「意識せずとも達成される」という強力な効果が得られます。関数型プログラミングが並列処理で有利なのはこの性質のためです。

System.Text.Json との連携

init プロパティを JSON シリアライズ / デシリアライズ
using System.Text.Json;

// init プロパティは System.Text.Json でそのまま読み書きできる(.NET 5+)
public class Config
{
    public required string Host { get; init; }
    public int Port { get; init; } = 5432;
    public string? ApiKey { get; init; }
}

var c = new Config { Host = "db.example.com", Port = 5433, ApiKey = "secret" };

// シリアライズ
string json = JsonSerializer.Serialize(c);
// → {"Host":"db.example.com","Port":5433,"ApiKey":"secret"}

// デシリアライズ(.NET 5+ では init アクセサを認識して設定してくれる)
var back = JsonSerializer.Deserialize<Config>(json);
Console.WriteLine(back!.Host); // "db.example.com"

// .NET 7+ では required プロパティが不足していると JsonException を投げる
// → 設定ファイルの必須項目チェックが自動化できる
try
{
    var bad = JsonSerializer.Deserialize<Config>("""{"Port":5433}""");
}
catch (JsonException ex)
{
    Console.WriteLine(ex.Message); // Host が必須なのに欠けている
}

実践パターン集

パターン① — DTO / API レスポンス
// 外部から「生成される」オブジェクトは常に init + required
public sealed class OrderResponse
{
    public required long   Id         { get; init; }
    public required string Status     { get; init; }
    public required decimal Total     { get; init; }
    public required DateTime CreatedAt { get; init; }
    public string? CancellationReason { get; init; }
}
パターン② — 値オブジェクト(DDD)
// 金額・通貨・住所などの値オブジェクトは不変性が不可欠
public sealed record Money
{
    public required decimal Amount   { get; init; }
    public required string  Currency { get; init; }

    public Money Add(Money other)
    {
        if (other.Currency != Currency)
            throw new InvalidOperationException("通貨単位が異なる");
        return this with { Amount = Amount + other.Amount };
    }
}

var a = new Money { Amount = 100, Currency = "JPY" };
var b = new Money { Amount = 200, Currency = "JPY" };
var c = a.Add(b); // 新しいインスタンス { 300, JPY }
パターン③ — Options パターン
// ASP.NET Core の Options パターン: appsettings.json から init プロパティに束縛される
public sealed class SmtpOptions
{
    public required string Host { get; init; }
    public int Port { get; init; } = 587;
    public bool UseSsl { get; init; } = true;
    public string? Username { get; init; }
    public string? Password { get; init; }
}

// Program.cs
// builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
// 使用側
// public class MailService(IOptions<SmtpOptions> opt) { ... }
パターン④ — Builder / Fluent API は init では不要
// 従来の Builder パターン
public class EmailBuilder
{
    private string? _to, _subject, _body;
    public EmailBuilder To(string s)      { _to = s; return this; }
    public EmailBuilder Subject(string s) { _subject = s; return this; }
    public EmailBuilder Body(string s)    { _body = s; return this; }
    public Email Build() => new() { To = _to!, Subject = _subject!, Body = _body! };
}

// init + required があれば Builder は不要になることが多い
public sealed class Email
{
    public required string To      { get; init; }
    public required string Subject { get; init; }
    public required string Body    { get; init; }
}

// 呼び出し側は自然な名前付き初期化
var email = new Email
{
    To      = "user@example.com",
    Subject = "通知",
    Body    = "本文",
};

よくある落とし穴と対処法

落とし穴① — リフレクションで書き換えられる
public class Dto
{
    public string Name { get; init; } = "";
}

var d = new Dto { Name = "original" };

// 実行時に init アクセサを呼ぶとセット自体はできてしまう
var prop = typeof(Dto).GetProperty("Name")!;
prop.SetValue(d, "modified"); // 例外なく成功してしまう
Console.WriteLine(d.Name);    // "modified"

// init はコンパイル時の保護なので、リフレクションに対するガードにはならない
// → シリアライザ・ORM・テストヘルパーなどでは「書ける」のを前提に設計する
落とし穴② — 派生クラスのコンストラクタで基底の init を上書き
public class Animal
{
    public string Name { get; init; } = "";
}

public class Cat : Animal
{
    public Cat(string name)
    {
        Name = name; // OK: コンストラクタ内では init を呼べる
    }
}

// ただしコンストラクタが終わった後は書き換え不可
var c = new Cat("Tama");
// c.Name = "Mike"; // NG

// 落とし穴: 派生クラスが基底のコンストラクタで「先に設定された値」を上書きする
public class Dog : Animal
{
    public Dog() : base()
    {
        Name = "default-dog"; // 引数なしコンストラクタで上書き
    }
}

// new Dog { Name = "Pochi" } と書くと
// コンストラクタ: Name = "default-dog" → 初期化子: Name = "Pochi"
// の順で実行される(初期化子の方が後勝ち)
Console.WriteLine(new Dog { Name = "Pochi" }.Name); // "Pochi"
落とし穴③ — 構造体(readonly struct)では init が readonly 扱い
// readonly struct のフィールド / プロパティはすべて読み取り専用になる
public readonly struct Coord
{
    public int X { get; init; }  // OK: init は readonly と整合する
    public int Y { get; init; }
}

// 一方 readonly struct の init でミュータブルなフィールドを持とうとするとエラー
// public readonly struct Bad
// {
//     public int X { get; set; } // CS8341: readonly struct 内で set 可能プロパティはエラー
// }

// 値型は「コピー」されるので init が通常型ほど厳密には見えないことに注意
var c1 = new Coord { X = 1, Y = 2 };
var c2 = c1;
// c2.X = 99; // NG(readonly struct + init)

// readonly record struct は record の不変性 + 構造体の値型特性を両方持つ
public readonly record struct PointRec(int X, int Y);

よくある質問

Qinit と readonly フィールドはどう使い分けますか?
A「外部に公開する値」なら init プロパティ、「クラス内部でのみ使う値」なら readonly フィールドが基本です。init はオブジェクト初期化子で自然に書けるうえインターフェース・with 式・リフレクション・シリアライザから扱える点が強みです。readonly フィールドは最も厳格で、コンストラクタ内でのみ設定可能、リフレクションからも書き換えが困難(セキュリティ上も有利)です。DTO や値オブジェクトの公開プロパティには init、内部状態の基盤になる不変値は readonly フィールドを使い分けてください。
Qinit と required はどちらも「初期化の制御」ですが、関係は?
A役割が直交します。init は「いつ書けるか」(初期化時のみ)、required は「書かなければならないか」(省略不可)を制御します。組み合わせると「必須かつ不変」になり、DTO や値オブジェクトの理想形になります。required のみの場合は「必須だが初期化後に書き換え可能」、init のみの場合は「書き換え不可だが省略可能(デフォルト値になる)」になります。
Qrecord と class + init はどちらを使うべきですか?
A「値として比較される」オブジェクト(通貨・座標・API レスポンスなど)なら record、「同じ属性でも別の存在として区別される」オブジェクト(顧客・注文などのエンティティ)なら class + init が定石です。record は構造的等価性(EqualsGetHashCodeToStringDeconstruct を自動生成)と with 式に対応します。DDD の値オブジェクトは record、エンティティは class + init という分類が一般的です。
Qinit プロパティをコンストラクタで設定できますか?
Aはい、できます。init は「コンストラクタ+オブジェクト初期化子+with 式」の中で書けるアクセサです。コンストラクタ引数で受けた値を init プロパティに代入するパターンも有効です。ただし C# 11+ で required と組み合わせられるなら、コンストラクタを書かずに初期化子だけで済ませた方がシンプルになります。
Qinit でバリデーションは書けますか?
Ainit は単純プロパティ(auto-implemented)でも手動アクセサでも書けるので、アクセサ内にバリデーションを書けます。例: public int Age { get; init { if (value < 0) throw new ArgumentException(); field = value; } }(C# 13+ の field キーワードを使う場合)。または従来のバッキングフィールドを明示して書きます。より重要なバリデーションはコンストラクタで一元化し、その中で init プロパティへ代入するのが一般的です。

まとめ

ポイント 推奨
基本用途 DTO・値オブジェクト・設定クラスで init を第一選択に
必須プロパティ C# 11+ なら required + init でシンプルに
内部実装 IsExternalInitmodreq。.NET Standard 2.0 では自作定義で対応
with 式 record / record struct / struct のみ対応。class は手動コピー
浅い不変性 List<T> は中身を変更可能。IReadOnlyList / ImmutableArray を使う
継承 init は override 先でも init。set/init の混在は不可
インターフェース init 宣言で「初期化時必須」の契約を表現できる
スレッドセーフ 不変オブジェクトはロックなしで共有可能。中身も不変化するのが鍵
シリアライズ System.Text.Json は .NET 5+ で init、.NET 7+ で required に対応
リフレクション init はコンパイル時保護のみ。SetValue は通ってしまう

関連機能は以下を参照してください。record型完全ガイドで値オブジェクトと with 式、プロパティとフィールド完全ガイドで他のアクセサパターン、値型と参照型完全ガイドで不変性と共有の関係、コンストラクタ完全ガイドで初期化戦略全体を解説しています。