C# でクラスを設計するとき「プロパティにすべきか、フィールドにすべきか」という判断は思った以上に奥が深いです。基本的な違いは「フィールドは変数、プロパティはメソッドのシンタックスシュガー」ですが、それだけでは実務で困る場面があります。
本記事では基本の整理から始め、バッキングフィールドの仕組み・required 修飾子(C# 11)・INotifyPropertyChanged・インデクサー・JSON シリアライゼーションとの関係・リフレクション・スレッドセーフ設計まで、一歩踏み込んだ内容を体系的に解説します。
フィールドとプロパティ — 根本的な違い
まず最も重要な違いを一言で言えば:フィールドは変数、プロパティはコンパイラが生成する get/set メソッドのペアです。
| 観点 | フィールド | プロパティ |
|---|---|---|
| 実体 | メモリ上の変数 | get_Xxx()/set_Xxx() メソッド(IL レベル) |
| バリデーション | できない(直接代入) | setter 内で自由に実装できる |
| インターフェース | 定義できない | 定義できる |
| 抽象・仮想化 | できない | abstract / virtual / override 可 |
| データバインディング | 非対応(WPF など) | 対応(INotifyPropertyChanged と組み合わせ) |
| JSON シリアライゼーション | デフォルト非対象 | デフォルト対象(public プロパティ) |
| リフレクション取得 | Type.GetFields() |
Type.GetProperties() |
| インデクサー | できない | this[T key] で実装可 |
| スレッドセーフ | volatile/Interlocked が直接使用可 |
ロジックを setter 内に集約できる |
フィールドの種類と使いどころ
フィールドは単なる変数ですが、修飾子によって用途が変わります。
class FieldExamples
{
// 通常フィールド(インスタンスごとに独立)
private int _count;
// readonly: コンストラクタ内でのみ設定可(以後変更不可)
private readonly string _id;
// const: コンパイル時定数(static かつ readonly 相当)
private const double Pi = 3.14159265358979;
// static: すべてのインスタンスで共有
private static int _instanceCount = 0;
// static readonly: 実行時定数(const と異なりオブジェクトも可)
private static readonly DateTime AppStarted = DateTime.UtcNow;
// volatile: マルチスレッド時に最新値を読む(CPU キャッシュを経由しない)
private volatile bool _isRunning;
public FieldExamples(string id)
{
_id = id; // readonly はコンストラクタで設定
_instanceCount++;
}
}
| 修飾子 | 変更タイミング | 典型的な用途 |
|---|---|---|
| (なし) | いつでも | インスタンスの内部状態 |
readonly |
コンストラクタのみ | ID・設定値など「作ったら変えない」データ |
const |
変更不可(コンパイル時定数) | 数学定数・固定コード値 |
static |
インスタンスに依存しない | 共有カウンター・キャッシュ |
volatile |
いつでも(ただし単純型のみ) | マルチスレッドのフラグ変数 |
public にするのは原則禁止外部から任意の値が代入でき、バリデーションができません。外部へ公開するデータは必ずプロパティ経由にし、フィールドは
private に保ちましょう。バッキングフィールドの仕組み
自動実装プロパティ { get; set; } を書いたとき、コンパイラは内部的に「バッキングフィールド」を自動生成します。IL(中間言語)レベルでは次のように展開されます。
// 書いたコード
public class Person
{
public string Name { get; set; } = "";
}
// コンパイラが生成する(擬似コード)
public class Person
{
[CompilerGenerated]
private string <Name>k__BackingField = ""; // バッキングフィールド
public string Name
{
[CompilerGenerated]
get => <Name>k__BackingField; // get メソッド
[CompilerGenerated]
set => <Name>k__BackingField = value; // set メソッド
}
}
バッキングフィールドを手書きするとき
バリデーション・変換・副作用が必要な場合は、自動生成に頼らずバッキングフィールドを手書きします。慣例として名前は _camelCase です。
public class Temperature
{
private double _celsius;
public double Celsius
{
get => _celsius;
set
{
if (value < -273.15)
throw new ArgumentOutOfRangeException(nameof(value), "絶対零度より低い値は無効です");
_celsius = value;
}
}
// 計算プロパティ: バッキングフィールドなしで算出
public double Fahrenheit => _celsius * 9 / 5 + 32;
public double Kelvin => _celsius + 273.15;
}
var t = new Temperature { Celsius = 100.0 };
Console.WriteLine(t.Fahrenheit); // 212
Console.WriteLine(t.Kelvin); // 373.15
計算プロパティのキャッシュパターン
コストが高い計算は遅延初期化でキャッシュします。
public class Document
{
private readonly string _text;
private int? _wordCount; // null = 未計算
public Document(string text) => _text = text;
// 初回アクセス時にのみ計算してキャッシュ
public int WordCount => _wordCount ??= CountWords(_text);
private static int CountWords(string text)
=> text.Split(new[] { ' ', '
', ' ' }, StringSplitOptions.RemoveEmptyEntries).Length;
}
var doc = new Document("Hello World C# is great");
Console.WriteLine(doc.WordCount); // 5(ここで初めて計算)
Console.WriteLine(doc.WordCount); // 5(キャッシュから返る)
プロパティの種類と使い分け
| 種類 | 構文例 | 読み取り | 書き込み | 用途 |
|---|---|---|---|---|
| 自動実装 | { get; set; } |
○ | ○ | バリデーション不要のシンプルなデータ |
| 読み取り専用(コンストラクタ設定) | { get; } |
○ | コンストラクタのみ | 一度設定したら変えない値 |
| init専用 | { get; init; } |
○ | オブジェクト初期化子のみ | イミュータブル設計・DTO |
| private set | { get; private set; } |
○ | クラス内のみ | 外から読める・内部で更新する状態 |
| フルプロパティ | get { } set { } |
○ | ○(バリデーション付き) | 値の検証・変換・副作用 |
| 計算プロパティ | { get => 式; } |
○ | ✗ | 他フィールドから算出する値 |
| 非対称アクセス修飾子 | public get; protected set; |
○ | 継承先のみ | 基底クラスで管理する状態 |
// init: オブジェクト初期化子でのみ設定可(以後変更不可)
public class Order
{
public Guid Id { get; init; } = Guid.NewGuid();
public string Customer { get; init; } = "";
public decimal Amount { get; init; }
}
var order = new Order { Customer = "Taro", Amount = 9800m };
// order.Customer = "Hanako"; // CS8852: init専用プロパティへの代入エラー
// 非対称アクセス修飾子: 外部からは読み取り専用、継承先からは書き込み可
public class BaseEntity
{
public DateTime CreatedAt { get; protected set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; private set; } = DateTime.UtcNow;
protected void Touch() => UpdatedAt = DateTime.UtcNow;
}
required 修飾子(C# 11+)
C# 11 で導入された required は「このプロパティはオブジェクト初期化子で必ず設定すること」をコンパイル時に強制します。コンストラクタを作らなくても必須項目を保証できます。
public class UserDto
{
public required string Name { get; init; } // 必須
public required string Email { get; init; } // 必須
public string Role { get; init; } = "User"; // 任意(デフォルトあり)
}
// OK: required プロパティをすべて設定
var user = new UserDto
{
Name = "Taro",
Email = "taro@example.com"
// Role は省略可(デフォルト値 "User" が使われる)
};
// コンパイルエラー: CS9035 — Name と Email が必要です
// var invalid = new UserDto { Name = "Taro" };
コンストラクタ引数は「順番に渡す必要がある」のに対し、
required プロパティは「初期化子で名前指定」するため、項目が多い DTO・設定クラスで読みやすい初期化コードを書けます。両者を組み合わせることも可能です。インターフェース・抽象クラスのプロパティ
プロパティはインターフェースで定義でき、実装クラスに実装を強制できます。フィールドではこれができません。
// インターフェースでプロパティを定義
public interface IShape
{
double Area { get; } // 読み取り専用プロパティ(実装必須)
double Perimeter { get; }
string Color { get; set; } // 読み書き両方(実装必須)
}
public class Circle : IShape
{
private readonly double _radius;
public Circle(double radius) => _radius = radius;
public double Area => Math.PI * _radius * _radius;
public double Perimeter => 2 * Math.PI * _radius;
public string Color { get; set; } = "Red";
}
// 抽象クラスで抽象プロパティ
public abstract class Animal
{
public abstract string Sound { get; } // 派生クラスで必ず実装
public void Speak() => Console.WriteLine($"{GetType().Name} says {Sound}");
}
public class Dog : Animal
{
public override string Sound => "Woof";
}
public class Cat : Animal
{
public override string Sound => "Meow";
}
IShape c = new Circle(5);
Console.WriteLine(c.Area.ToString("F2")); // 78.54
Animal[] animals = { new Dog(), new Cat() };
foreach (var a in animals) a.Speak();
// Dog says Woof
// Cat says Meow
INotifyPropertyChanged — プロパティ変更通知
WPF・MAUI・Blazor のデータバインディングでは、プロパティの値が変わったときに UI を自動更新する仕組みが必要です。INotifyPropertyChanged インターフェースがその標準手段です。フィールドでは使えません。
using System.ComponentModel;
using System.Runtime.CompilerServices;
// 基底クラスに共通実装をまとめる
public abstract class ObservableBase : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(name);
return true;
}
}
// ViewModel クラス(WPF / MAUI で使う例)
public class PersonViewModel : ObservableBase
{
private string _name = "";
private int _age;
public string Name
{
get => _name;
set => SetField(ref _name, value); // 変更があれば PropertyChanged を発火
}
public int Age
{
get => _age;
set
{
if (SetField(ref _age, value))
OnPropertyChanged(nameof(IsAdult)); // 連鎖通知
}
}
public bool IsAdult => _age >= 18; // 計算プロパティも通知できる
}
// 使用例(UI フレームワーク外でのテスト)
var vm = new PersonViewModel();
vm.PropertyChanged += (sender, e)
=> Console.WriteLine($"変更: {e.PropertyName}");
vm.Name = "Taro"; // → 変更: Name
vm.Age = 20; // → 変更: Age / 変更: IsAdult
vm.Age = 20; // 値が同じ → 発火しない(SetField が判定)
[CallerMemberName] を使うと、SetField(ref _name, value) と書くだけでコンパイラが呼び出し元プロパティ名("Name")を自動挿入します。マジックストリングを使わずに済み、リファクタリング時の取りこぼしもありません。インデクサー(this[T] プロパティ)
インデクサーは this[キー型 key] という特殊なプロパティで、クラスを配列やディクショナリのように添字アクセスできるようにします。フィールドにはない機能です。
// 整数インデックスのインデクサー
public class CircularBuffer<T>
{
private readonly T[] _data;
private int _size;
public CircularBuffer(int capacity) => _data = new T[capacity];
// インデクサー: obj[i] で読み書きできる
public T this[int index]
{
get => _data[index % _data.Length];
set
{
_data[index % _data.Length] = value;
_size = Math.Min(_size + 1, _data.Length);
}
}
public int Count => _size;
}
var buf = new CircularBuffer<int>(3);
buf[0] = 10;
buf[1] = 20;
buf[2] = 30;
buf[3] = 40; // 0番地に上書き(循環)
Console.WriteLine(buf[0]); // 40
Console.WriteLine(buf[1]); // 20
// 文字列キーで設定値を取り出す(Dictionary のラッパー例)
public class AppSettings
{
private readonly Dictionary<string, string> _store = new();
public string this[string key]
{
get => _store.TryGetValue(key, out var v) ? v : "";
set => _store[key] = value;
}
// 複数インデクサー(引数の型が違えば定義可)
public string this[string section, string key]
{
get => _store.TryGetValue($"{section}:{key}", out var v) ? v : "";
set => _store[$"{section}:{key}"] = value;
}
}
var settings = new AppSettings();
settings["Theme"] = "Dark";
settings["Database", "Host"] = "localhost";
Console.WriteLine(settings["Theme"]); // Dark
Console.WriteLine(settings["Database", "Host"]); // localhost
Console.WriteLine(settings["Missing"]); // ""(キーなし → 空文字)
・カスタムコレクションクラス(循環バッファ・行列など)
・ラッパークラス(設定ストア・キャッシュ)
・DSL/Fluent インターフェース
読み取り専用インデクサー(
get のみ)も作れます。JSON シリアライゼーションとの関係
System.Text.Json(.NET 組み込み)も Newtonsoft.Json も、デフォルトでは public プロパティのみをシリアライゼーション対象とします。フィールドはデフォルトで無視されます。
using System.Text.Json;
using System.Text.Json.Serialization;
public class SampleData
{
public string PublicProp { get; set; } = "プロパティ"; // ✓ 対象
public string PublicField = "フィールド"; // ✗ デフォルト非対象
private string _privateField = "プライベート"; // ✗ 非対象
}
var data = new SampleData();
string json = JsonSerializer.Serialize(data);
// → {"PublicProp":"プロパティ"}
// PublicField は含まれない!
// フィールドも対象にしたい場合: JsonSerializerOptions で設定
var options = new JsonSerializerOptions { IncludeFields = true };
string json2 = JsonSerializer.Serialize(data, options);
// → {"PublicProp":"プロパティ","PublicField":"フィールド"}
// 個別に [JsonInclude] でフィールドを対象にできる
public class WithAttribute
{
[JsonInclude]
public string IncludedField = "含む"; // [JsonInclude] で明示的に対象化
[JsonIgnore]
public string IgnoredProp { get; set; } = "除外"; // プロパティでも除外可
}
フィールドをうっかり
public にすると、シリアライゼーションで意図しない除外が起きます。API のレスポンスクラスは必ずプロパティを使いましょう。リフレクションでのプロパティとフィールドの違い
リフレクションでプロパティとフィールドを取得するメソッドは別々です。混同すると「値が取れない」バグの原因になります。
using System.Reflection;
public class Product
{
public string Name { get; set; } = "Widget"; // プロパティ
public decimal Price { get; set; } = 9.99m; // プロパティ
public string Category = "Electronics"; // フィールド(public)
}
var product = new Product();
var type = typeof(Product);
// プロパティの一覧を取得
PropertyInfo[] props = type.GetProperties();
foreach (var p in props)
Console.WriteLine($"[Prop] {p.Name} = {p.GetValue(product)}");
// [Prop] Name = Widget
// [Prop] Price = 9.99
// フィールドの一覧を取得
FieldInfo[] fields = type.GetFields();
foreach (var f in fields)
Console.WriteLine($"[Field] {f.Name} = {f.GetValue(product)}");
// [Field] Category = Electronics
// 値のセット
type.GetProperty("Name")?.SetValue(product, "Gadget");
type.GetField("Category")?.SetValue(product, "Tools");
Console.WriteLine(product.Name); // Gadget
Console.WriteLine(product.Category); // Tools
スレッドセーフなプロパティ設計
マルチスレッド環境では、プロパティの get/set がアトミックでない場合に競合状態(race condition)が起きます。
using System.Threading;
public class ThreadSafeCounter
{
// パターン1: Interlocked — int/long の加算・交換に最適
private int _count;
public int Count => _count; // 読み取り
public void Increment() => Interlocked.Increment(ref _count);
public void Add(int n) => Interlocked.Add(ref _count, n);
// パターン2: lock — 複合操作や参照型に使う
private readonly object _lock = new();
private string _status = "Idle";
public string Status
{
get { lock (_lock) return _status; }
set { lock (_lock) _status = value; }
}
// パターン3: volatile — 読み取り/書き込みのみ(加算は非アトミック)
private volatile bool _isRunning;
public bool IsRunning
{
get => _isRunning;
set => _isRunning = value;
}
}
| パターン | 使いどころ | 注意点 |
|---|---|---|
Interlocked |
整数の加算・比較交換 | int/long/nint のみ。複合操作には使えない |
lock |
複合操作・参照型・複数フィールドの一貫更新 | デッドロックに注意。ロック対象は専用オブジェクト |
volatile |
単一の bool/int 読み書きフラグ | 加算などの複合操作は非アトミック |
ReaderWriterLockSlim |
読み多・書き少のシナリオ | lock より高スループット |
フィールド vs プロパティ — 実践的な判断基準
| ケース | 選択 | 理由 |
|---|---|---|
| 外部に公開するデータ | プロパティ | バリデーション可・インターフェース定義可・シリアライゼーション対象 |
| クラス内部のみで使う作業変数 | プライベートフィールド | 余分な API を公開しない・シンプル |
| 初期化後に変えない値 | readonly フィールド or { get; } |
意図を明示 |
| コンパイル時定数 | const |
static かつ変更不可。API に公開するなら static readonly を検討 |
| 変更通知が必要 | プロパティ(setter に通知) | フィールドは PropertyChanged を発火できない |
| インターフェースで定義 | プロパティ | フィールドはインターフェースに定義不可 |
| 高速ループの内部カウンター | フィールド | JIT がプロパティをインライン化するため差はほぼゼロだが、意図を明示したい場合はフィールド |
| スレッドセーフに複合更新 | フィールド + lock or Interlocked | volatile フィールドは単純な読み書きのみに限定 |
迷ったら 自動実装プロパティ を使う。フィールドを直接公開しない。バリデーションが必要になったときに自動実装→フルプロパティへ変更してもAPI は変わらない(フィールドを直接公開していると変更で破壊的変更になる)。
よくある落とし穴と注意点
フィールドを public にしてから後悔する
// NG: フィールドを直接公開
public class BadDesign
{
public int Count; // 直接変更できてしまう
}
// 後でバリデーションを追加しようとしても…
// 呼び出し側のコードを書き換えなくて済む方法はない(API 破壊)
// Count を読んでいる既存コードがすべて影響を受ける
// OK: 最初からプロパティにしておく
public class GoodDesign
{
private int _count;
public int Count
{
get => _count;
set
{
if (value < 0) throw new ArgumentOutOfRangeException(nameof(value));
_count = value;
}
}
}
// → 後でバリデーション追加しても呼び出し側コードは変更不要
構造体のプロパティと readonly
// struct のプロパティを変更すると暗黙のコピーが発生する
public struct MutablePoint
{
public int X { get; set; }
public int Y { get; set; }
}
var point = new MutablePoint { X = 1, Y = 2 };
// OK: 変数に直接代入
point.X = 10;
// NG: コレクション内の struct 変更は元に反映されない
var list = new List<MutablePoint> { point };
// list[0].X = 99; // CS1612: コピーに対する変更のためエラー
// 解決策1: ローカル変数に取り出して変更して戻す
var tmp = list[0];
tmp.X = 99;
list[0] = tmp;
// 解決策2: struct を readonly にして不変設計にする
public readonly struct ImmutablePoint
{
public int X { get; init; }
public int Y { get; init; }
public ImmutablePoint Move(int dx, int dy) => new() { X = X + dx, Y = Y + dy };
}
よくある質問
public にする必要はありません。ただし、volatile や Interlocked を使うためにフィールドが必要な場面はあります。required プロパティが読みやすい初期化コードを書けます。「必ず渡さなければ意味をなさない依存関係」や「不変性を徹底したい」なら コンストラクタ引数が適切です。DTOクラスは required { get; init; }、サービスクラスの依存関係はコンストラクタが一般的な使い分けです。Dictionary<K,V> で済む場合はインデクサーを独自実装する必要はありません。INotifyPropertyChanged を利用しています。WPF 専用の仕組みではなく、「モデルの変更を UI に伝える」標準インターフェースです。Type.GetProperties() はプロパティのみ、Type.GetFields() はフィールドのみを返します。両方取得したい場合は GetMembers() を使い、MemberTypes.Property / MemberTypes.Field でフィルタします。JSON シリアライザーが「フィールドが出力されない」という問題の多くは、このリフレクション動作の違いに起因します。まとめ
| 機能・概念 | 対象 | ポイント |
|---|---|---|
| バッキングフィールド | プロパティ内部 | 自動実装では自動生成。バリデーション必要時は手書き |
required(C# 11) |
プロパティ | コンパイル時に初期化を強制。DTO クラスに最適 |
abstract / インターフェース |
プロパティ | フィールドは定義不可。プロパティでポリモーフィズムを実現 |
INotifyPropertyChanged |
プロパティ | setter に通知ロジックを追加。WPF/MAUI/Blazor で必須 |
| インデクサー | プロパティ(特殊) | this[T key] でコレクション的な添字アクセスを提供 |
| JSON シリアライゼーション | プロパティ優先 | デフォルトは public プロパティのみ対象。フィールドは要設定 |
| リフレクション | 用途で分ける | GetProperties() と GetFields() は別々のメソッド |
| スレッドセーフ | フィールド + ロック | 単純フラグは volatile、加算は Interlocked、複合操作は lock |
プロパティとフィールドの使い分けは「外部に公開するか否か」から始まり、変更通知・シリアライゼーション・リフレクション・スレッドセーフなど多くの側面に影響します。迷ったらまず自動実装プロパティを選び、要件が増えた時点でフルプロパティに移行する設計が保守性の高いコードへの近道です。
クラス設計の全体像はクラスとオブジェクト完全ガイドを、アクセス修飾子の詳細はカプセル化とアクセス修飾子完全ガイドを参照してください。