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 の設計ガイドラインに沿った判断の流れです。
| こういうとき | 選ぶもの |
|---|---|
保存した値や軽い計算(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 ではフィールドと自動実装プロパティは同じでした。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 になりません。属性はコンパイラへの約束で、中身は検査されない点に注意します。