C#プロパティ応用:fieldキーワード・required・変更通知

C# 14のfieldキーワードを.NET 10でコンパイルした結果と、既存コードの動作が黙って変わるCS9258の罠、required+init、計算とキャッシュ、INotifyPropertyChanged、速度実測。

2026年11月改訂: 2021年に韓国語で書いた記事を、日本語向けに書き直しました。例を .NET 10.0.14(C# 14)と Apple clang 21 でコンパイルし直したところ、C++ の例は8個のエラーで通らず(Private が大文字、クラス末尾の ; 抜け、GetBum のタイプミス、std:: なしの cout)、C# の例も3つのうち2つがコンパイルできませんでした(CS1514、CS1002、CS1597、CS1729)。また「自動実装プロパティは .NET Framework 3.0 から」という記述は、正しくは C# 3(一緒に出たのは .NET Framework 3.5)です。例に入っていた個人名も削除しています。

この記事は C#プロパティの基本(get/set、自動実装、init、セッターの検証)を読んだ人向けの応用編です。2026年のコードを書く・レビューするときに実際に出会う話題を扱います。C# 14 の field キーワード(と、アップグレード時に既存コードが警告1つだけで動作を変える罠)、required と init、計算プロパティとキャッシュ、INotifyPropertyChanged、プロパティとメソッドの使い分け、そして「プロパティはフィールドより遅いのか」の実測です。

2021年の例を直す:C++ の Get/Set からオブジェクト初期化子まで

2021年版は C++ の Get/Set 関数と C# のプロパティを比べるところから始めていました。比較自体は今でも役に立ちますが、C++ のコードはそのままではコンパイルできません。

orig.cpp:2:5: error: unknown type name 'Private'
orig.cpp:9:2: error: expected ';' after class
orig.cpp:13:5: error: use of undeclared identifier 'cout'
orig.cpp:13:24: error: no member named 'GetBum' in 'TestClass'
8 errors generated.

private: は小文字、クラス定義の末尾に ;、エントリポイントは int main()、cout には <iostream> と std:: が要ります。直したコードは 10 を出力しました。

#include <iostream>
class TestClass {
private:
    int num = 0;
public:
    void SetNum(int n) { num = n; }
    int GetNum() const { return num; }
};
int main() {
    TestClass t;
    t.SetNum(10);
    std::cout << t.GetNum() << std::endl;
}

C# の最初の例はプロパティ名の後ろにセミコロンが付いていて(public int Number;)、error CS1514: { が必要です になりました。セミコロンを消すと 10 が出ます。自動実装プロパティの例はそのまま動きました。

問題は最後の「コンストラクターなしでプロパティを初期化する」例です。まず文法エラーが2つ出ます。

Program.cs(13,31): error CS1002: ; が必要です
Program.cs(15,6): error CS1597: メソッドまたはアクセサー ブロックの後のセミコロンの使用が正しくありません

return num1 + num2 の後ろの ; 抜けと、プロパティのブロックの後ろの }; です。ここを直しても次のエラーが残ります。

Program.cs(19,32): error CS1729: 'TestClass' には、引数 2 を指定するコンストラクターは含まれていません
Program.cs(19,42): error CS0103: 現在のコンテキストに 'num1' という名前は存在しません

本文が説明していた機能は、丸かっこ () ではなく波かっこ {} のオブジェクト初期化子です。ついでに2点。value は「予約された変数」ではなくセッターの暗黙の引数で、プロパティ自体も変数ではなくメソッドの組です。修正版です。

var t = new TestClass { Num1 = 10, Name = "sample" };
t.Num2 = 20;
Console.WriteLine(t.Sum);   // 30

class TestClass
{
    public int Num1 { get; set; }
    public int Num2 { get; set; }
    public string Name { get; set; } = "";
    public int Sum => Num1 + Num2;   // 読み取り専用、読むたびに計算
}

この記事の C# の例はすべて、トップレベルステートメントを使う Program.cs 1つで実行しました。実際のファイルでは実行文をクラス宣言より上に置きます(逆だと error CS8803)。

C# 14 の field キーワードを .NET 10 でコンパイル

セッターに検証を1行入れたいだけなのに、その瞬間に自動実装プロパティをやめて private フィールドを自分で宣言しなければならない。これが長年の不満でした。C# 14 の field キーワードは、アクセサーの中でコンパイラが生成したバッキングフィールドを指します。get; は自動実装のまま、set だけを書けます。

var u = new User { Email = "  Dev@Example.com ", Name = "dev" };
Console.WriteLine($"[{u.Email}]");
try { u.Email = "no-at-sign"; } catch (ArgumentException e) { Console.WriteLine(e.Message); }

public class User
{
    public required string Name { get; init; }
    public required string Email
    {
        get;
        set
        {
            var v = value.Trim().ToLowerInvariant();
            if (!v.Contains('@')) throw new ArgumentException($"Invalid email: {v}", nameof(value));
            field = v;
        }
    }
}
[dev@example.com]
Invalid email: no-at-sign (Parameter 'value')

.NET SDK 10.0.403 の素の net10.0 プロジェクト(既定で C# 14)で、追加設定なしにコンパイル・実行できました。-p:LangVersion=13 に下げると field はただの名前になり、error CS0103: 現在のコンテキストに 'field' という名前は存在しません で失敗します。

アップグレードの罠:もともと field というメンバーがあるコード

C# 13 までは field は普通に使える名前だったので、private フィールドにそのまま field と名付けた古いコードがありえます。同じファイルを C# 13 と C# 14 で実行しました。

var c = new Counter();
c.Value = 5;
Console.WriteLine($"Value={c.Value}, Dump()={c.Dump()}");

public class Counter
{
    private int field;                                   // 何年も前に書いたコード
    public int Value { get => field; set => field = value; }
    public int Dump() => this.field;
}
-- LangVersion 13 --
Value=5, Dump()=5
-- LangVersion 14(net10.0 の既定値) --
Program.cs(7,17): warning CS0649: フィールド 'Counter.field' は割り当てられません。常に既定値 0 を使用します
Program.cs(8,31): warning CS9258: 言語バージョン 14.0 では、'field' キーワードがプロパティの合成バッキング フィールドにバインドされます。合成されたバッキング フィールドが生成されないようにし、既存のメンバーを参照するには、代わりに 'this.field' または '@field' を使用します。
Value=5, Dump()=0

エラーにはなりません。プロパティは新しくできた隠れフィールドに書き込み、古いフィールドを読む Dump() は 0 を返します。ターゲットを net10.0 に上げるときはビルドログで CS9258 を検索し、既存メンバーのつもりなら this.field か @field に書き換えます。プロジェクトに <WarningsAsErrors>CS9258</WarningsAsErrors> を入れておけば、確実に止められます。

もう1つ、初期値なしで public string Name { get; set => field = value.Trim(); } と書くと warning CS9264(null 非許容プロパティ)が出ます。= ""; を付けるか required にします。

required と init:「必ず、一度だけ」

基本編 の init は「生成時に一度だけ書ける」を保証しますが、書かずに済ませることは防げません。C# 11 の required を付けると、オブジェクト初期化子で書き忘れたときにコンパイルエラーになります。

var c = new Config();                 // Url を書き忘れ
var d = new Config { Url = "x" };
d.Url = "y";                          // 生成後の変更

public class Config
{
    public required string Url { get; init; }
}
error CS9035: 必要なメンバー 'Config.Url' は、オブジェクト初期化子または属性コンストラクターに設定する必要があります。
error CS8852: init 専用プロパティまたはインデクサー 'Config.Url' を割り当てることができるのは、オブジェクト初期化子の中か、インスタンス コンストラクターまたは 'init' アクセサーの 'this' か 'base' 上のみです。

required + init で「必ず、一度だけ」です。値を埋めるコンストラクターも用意したいなら、そのコンストラクターに [SetsRequiredMembers] を付けます。付けないと、コンストラクターを呼んだ側でも CS9035 が出ます。

var a = new AppConfig("https://example.com");   // 初期化子なしで OK

public class AppConfig
{
    public required string BaseUrl { get; init; }
    public int TimeoutSeconds { get; init; } = 30;

    [SetsRequiredMembers]
    public AppConfig(string baseUrl) => BaseUrl = baseUrl;
    public AppConfig() { }
}

この属性はコンパイラへの約束で、検査ではありません。コンストラクターがメンバーを埋め忘れても、何も教えてくれません。

計算プロパティとキャッシュプロパティ

public long Total => prices.Sum(); のように読むたびに計算するプロパティは常に最新ですが、毎回コストがかかります。データが変わる場所(ここでは Add)でキャッシュを捨てる方式と比べました。

class Cart
{
    private readonly List<int> prices = [];
    private long? cachedTotal;
    public void Add(int price) { prices.Add(price); cachedTotal = null; }   // 変更時に無効化
    public long TotalComputed => prices.Sum(p => (long)p);
    public long TotalCached => cachedTotal ??= prices.Sum(p => (long)p);
}

1,000品目のカートの合計を10万回読んだ時間です(Release、ウォームアップ1回のあと3回)。

プロパティ 1回目 2回目 3回目
TotalComputed 220.2ms 219.4ms 225.3ms
TotalCached 0.1ms 0.1ms 0.1ms

計算プロパティは1回あたり約2.2µsです。画面に一度出すだけなら計算プロパティで十分で、ループの中やデータバインディングで頻繁に読まれるならキャッシュにします。ただしキャッシュは、無効化の場所を1つでも見落とすと古い値を返すという新しいバグの種になります。リストを公開すると(public List<int> Prices)Add を迂回されるので、コレクションを private にするところまでがセットです。

INotifyPropertyChanged と field キーワード

WPF、.NET MAUI、Avalonia などの UI フレームワークは、PropertyChanged イベントで画面を更新します。以前はプロパティごとにバッキングフィールドが必要でしたが、field と小さなヘルパーで短くなります。

public class ProfileViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    public string FirstName
    {
        get;
        set { if (SetField(ref field, value)) OnPropertyChanged(nameof(FullName)); }
    } = "";

    public string LastName
    {
        get;
        set { if (SetField(ref field, value)) OnPropertyChanged(nameof(FullName)); }
    } = "";

    public string FullName => $"{FirstName} {LastName}".Trim();   // 計算プロパティ:フィールドなし

    private bool SetField<T>(ref T storage, T value, [CallerMemberName] string? name = null)
    {
        if (EqualityComparer<T>.Default.Equals(storage, value)) return false;
        storage = value;
        OnPropertyChanged(name);
        return true;
    }

    private void OnPropertyChanged(string? name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

イベントを購読し、FirstName に同じ値を2回、続けて LastName を設定した結果です。

changed: FirstName
changed: FullName
changed: LastName
changed: FullName
Ada Lovelace

ref field で隠れフィールドをヘルパーに渡せること、[CallerMemberName] でプロパティ名の文字列を書かずに済むこと、同じ値なら通知しないので無駄な再描画が起きないこと、の3点がポイントです。FullName のような計算プロパティは、元の値が変わったときに一緒に通知しないと画面が追従しません。イベントの仕組みそのものは C#のデリゲートとイベント にまとめています。

プロパティにするか、メソッドにするか

プロパティの中には何でも書けますが、使う側はフィールドを読む程度のコストと振る舞いを期待します。.NET の設計ガイドラインに沿った判断の流れです。

値を公開するときの判断フロー。1つ目の質問:速く、副作用がないか。いいえなら ComputeTotal() や LoadAsync() のようなメソッドにする。はいなら2つ目の質問:呼ぶたびに新しい配列やコピーを返すか。はいならメソッド(コード分析ルール CA1819 が配列を返すプロパティを警告する)。いいえならプロパティにする。
.NET の設計ガイドラインに沿った目安。プロパティはフィールドを読むような感覚で使えるべきで、遅いもの・副作用があるもの・毎回コピーを返すものはメソッドにする。
こういうとき 選ぶもの
保存した値や軽い計算(A + B)を返す プロパティ
DB・ファイル・ネットワークに触れる、目に見えて遅い メソッド(LoadAsync()、ComputeTotal())
呼ぶたびに新しい配列やコピーを返す メソッド(GetItems()、コード分析 CA1819)
同じオブジェクトなのに読むたびに違う値 メソッド(Guid.NewGuid() のように)
呼ぶ順番で結果が変わる副作用がある メソッド

非同期プロパティは書けません(アクセサーに async は付かない)。待つ必要がある値なら、それだけでメソッドにすべき合図です。

実測:自動実装プロパティはフィールドより遅いか

「プロパティはメソッド呼び出しだからフィールドより遅い」という話を確かめました。1,000個のオブジェクトの配列を20万周して2億回の読み取り・書き込み、変種ごとに [MethodImpl(NoInlining)] のメソッドに分け、ウォームアップ1回のあと3回、Stopwatch、Apple Silicon Mac です。

class WithField { public int X; }
class WithAuto { public int X { get; set; } }
class WithValidated { public int X { get; set { ArgumentOutOfRangeException.ThrowIfNegative(value); field = value; } } }

[MethodImpl(MethodImplOptions.NoInlining)]
static long ReadAuto(WithAuto[] xs) { long s = 0; for (int r = 0; r < Reps; r++) foreach (var x in xs) s += x.X; return s; }
// ReadField、ReadValidated、Write… も同じ形
2億回 1回目 2回目 3回目
Release:public フィールド読み取り 126.6ms 129.1ms 128.1ms
Release:自動実装プロパティ読み取り 127.3ms 128.1ms 128.2ms
Release:field キーワードのプロパティ読み取り 129.4ms 129.1ms 129.1ms
Release:public フィールド書き込み 74.8ms 74.4ms 74.7ms
Release:自動実装プロパティ書き込み 74.4ms 75.8ms 74.2ms
Release:範囲チェック付きセッター 109.6ms 108.9ms 108.7ms
Debug:public フィールド読み取り 301.6ms 293.5ms 292.9ms
Debug:自動実装プロパティ読み取り 625.1ms 606.0ms 670.2ms
棒グラフ。Release ビルド:public フィールドの読み取り 127.9ms、自動実装プロパティの読み取り 127.9ms、field キーワードのプロパティの読み取り 129.2ms、自動実装プロパティへの書き込み 74.8ms、範囲チェック付き field キーワードのセッター 109.1ms。Debug ビルド:public フィールドの読み取り 296.0ms、自動実装プロパティの読み取り 633.8ms。
1,000個のオブジェクトに対する2億回の読み取り・書き込み。ウォームアップ後の計測3回の平均。.NET 10.0.14、Apple Silicon Mac。Release ではpublic フィールドと自動実装プロパティは同じ速さで、差が出るのは Debug ビルドだけ。

Release ではフィールドと自動実装プロパティは同じでした。JIT が単純なアクセサーをインライン化し、フィールドアクセスと同じ機械語にするためです。差が出たのは2つだけです。1つは Debug ビルドで、インライン化されないためプロパティの読み取りが約2.1倍遅くなりました。Debug の数字で設計を決めてはいけない理由です。もう1つはセッターに本当のチェックを入れた場合で、書き込みが74.6msから109.1msに増えました。2億回で35ms、1回あたり0.2ns未満なので、検証を省く理由にはなりません。

補足すると、このベンチマークの最初の実行では field キーワードの読み取りが147.7ms、142.0msと跳ねた回がありましたが、その後の2回の実行では128〜131msで他と同じでした。生成されるゲッターは自動実装と同一なので、階層型コンパイルのタイミングによる揺れと判断し、安定した実行の値を載せています。

確認環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14(net10.0 の既定値)、Apple clang 21、macOS(Apple Silicon Mac)。

よくある質問

C# 14 の field キーワードは .NET 10 ですぐ使える?

使えます。net10.0 プロジェクトの既定の言語バージョンが C# 14 なので、設定なしでコンパイルできます。LangVersion を13に下げると field はただの識別子になり error CS0103 です。ただし、クラスにもともと field という名前のメンバーがあると、C# 14 ではそれではなく新しい隠れフィールドを指すようになり、警告 CS9258 しか出ないので注意が必要です。

自動実装プロパティは public フィールドより遅い?

Release ビルドでは差がありませんでした。2億回の読み取りで、public フィールドも自動実装プロパティも平均127.9msです。Debug ビルドではプロパティが633.8ms、フィールドが296.0msと約2倍遅くなりましたが、これは Debug では JIT がゲッターをインライン化しないためです。

required とコンストラクターを併用すると CS9035 になる。どうすればいい?

そのコンストラクターが required メンバーをすべて設定するなら、[SetsRequiredMembers] 属性を付けます。これで new AppConfig("https://example.com") のようにオブジェクト初期化子なしで呼んでも CS9035 になりません。属性はコンパイラへの約束で、中身は検査されない点に注意します。

  • #C#
  • #プロパティ
  • #fieldキーワード
  • #required
  • #INotifyPropertyChanged

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑