ここまで Dispose パターンを通じて「リソースをいつ解放するか」を扱ってきました。今回は反対側、「リソースをいつ生成するか」 に関するテクニック——Lazy Object Instantiation(遅延初期化) を整理します。
Lazy Object Instantiation とは
直訳すると「遅延オブジェクト生成」。オブジェクトを宣言した瞬間ではなく、実際に必要になったタイミングで初めて生成する 技法です。
通常、フィールドはコンストラクタで初期化します。
public class Service
{
private readonly HeavyResource _heavy = new HeavyResource(); // 即時生成
}
Service のインスタンスを作った瞬間、HeavyResource も生成されます。これを「最初に使われたとき」まで遅らせるのが遅延初期化です。
なぜ遅らせるのか
遅延初期化が嬉しいのは次のような場面です。
1. 起動コストの削減
アプリケーション起動時にすべての依存を初期化すると、起動が遅くなります。実際に使われるか分からないものは、使われたときに生成 するのが合理的です。
2. 使われない可能性のあるリソース
たとえば、エラー時にだけ使うデバッグ用ロガー、特定の条件下でしか走らないキャッシュ、レアな機能のためのクライアント——使われない可能性のあるものを毎回初期化するのは無駄です。
3. 重い計算の結果のキャッシュ
「最初の 1 回だけ重い計算をして、以降はその結果を返す」というパターンも遅延初期化で表現できます。
4. 循環依存の回避
A が B を持ち、B が A を必要とする……という状況で、片方を遅延化することで初期化順序の問題を回避できる場合があります。
素朴な実装と問題点
最も単純な遅延初期化は、null チェック+代入です。
public class Service
{
private HeavyResource? _heavy;
public HeavyResource Heavy => _heavy ??= new HeavyResource();
}
シングルスレッドではこれで十分ですが、マルチスレッドでは問題があります。
- 2 つのスレッドが同時に
Heavyを読むと、両方ともnullを見て、HeavyResourceが 2 回作られる 可能性 - 片方が破棄されないまま使い続けられる
ロックを足すと安全ですが、毎回ロックを取るのはコスト高です。
private readonly object _lock = new();
private HeavyResource? _heavy;
public HeavyResource Heavy
{
get
{
if (_heavy != null) return _heavy;
lock (_lock)
{
return _heavy ??= new HeavyResource();
}
}
}
これ(ダブルチェックロッキング)を毎回手書きするのは非効率です。そこで標準ライブラリが用意している Lazy<T> を使います。
Lazy<T> クラス
System.Lazy<T> は、遅延初期化のベストプラクティスをラップしたクラスです。
public class Service
{
private readonly Lazy<HeavyResource> _heavy =
new Lazy<HeavyResource>(() => new HeavyResource());
public HeavyResource Heavy => _heavy.Value;
}
ポイント
- コンストラクタには 「値を作るファクトリ」 を渡す
Valueプロパティを 初めて読んだ瞬間 にファクトリが実行される- 2 回目以降は キャッシュされた値 が返る
- 既定で スレッドセーフ
HeavyResource がどんなに重くても、誰も Heavy を読まなければ生成されません。
IsValueCreated プロパティ
「もう生成されたか?」を確認したい場合に使います。
if (_heavy.IsValueCreated)
{
Console.WriteLine("HeavyResource は既に生成されています");
}
例えば「生成されていれば Dispose、未生成ならスキップ」という後始末で便利です。
スレッドセーフティモード
Lazy<T> は LazyThreadSafetyMode で挙動を細かく制御できます。
new Lazy<HeavyResource>(factory, LazyThreadSafetyMode.ExecutionAndPublication);
| モード | 挙動 |
|---|---|
None |
スレッドセーフでない。シングルスレッドで使うと最速 |
PublicationOnly |
ファクトリは複数スレッドが同時実行する可能性あり。最初に完了したものを採用 |
ExecutionAndPublication(既定) |
ファクトリは 1 回しか実行されない。完全にスレッドセーフ |
ファクトリが副作用を持つ(ログを書く、ファイルを開くなど)場合は ExecutionAndPublication にしておくのが安全です。逆に、純粋な計算でスレッド競合が起きてもよいなら PublicationOnly の方が速いことがあります。
// シングルスレッド前提なら None で十分
private readonly Lazy<Config> _config =
new(LoadConfig, LazyThreadSafetyMode.None);
例外の扱い
ファクトリが例外を投げた場合、Lazy<T> の挙動は重要です。
- 既定(
ExecutionAndPublication)では、例外がキャッシュされ、以降のValueアクセスでも同じ例外が再スロー される - リトライしたい場合は、自前で再生成するか、
PublicationOnlyを使う
外部依存(ネットワーク等)の初期化を Lazy<T> でくるむときは、「失敗したら次もずっと失敗扱い」 で困らないかを考える必要があります。
使いどころ別サンプル
サンプル 1:シングルトン
スレッドセーフなシングルトンを書く際、Lazy<T> は最も簡潔です。
public sealed class AppContext
{
private static readonly Lazy<AppContext> _instance =
new(() => new AppContext());
public static AppContext Instance => _instance.Value;
private AppContext() { /* 重い初期化 */ }
}
Lazy<T> が初期化のスレッドセーフ性を担保してくれるので、ロック文を書く必要がありません。
サンプル 2:高コストなプロパティのキャッシュ
「初回アクセス時に計算して、その後はキャッシュ」というパターン。
public class Catalog
{
private readonly Lazy<IReadOnlyList<Product>> _products;
public Catalog(string path)
{
_products = new Lazy<IReadOnlyList<Product>>(
() => LoadFromFile(path));
}
public IReadOnlyList<Product> Products => _products.Value;
private static IReadOnlyList<Product> LoadFromFile(string path)
{
// 重い読み込み処理
return new List<Product>();
}
}
Catalog をインスタンス化するだけならファイルは読まれず、Products を参照したときに初めて読み込まれます。
サンプル 3:オプショナルな依存
「使わないかもしれない依存」を遅延化することで、無駄な初期化を回避します。
public class OrderService
{
private readonly Lazy<IDebugReporter> _debugReporter;
public OrderService(Func<IDebugReporter> reporterFactory)
{
_debugReporter = new Lazy<IDebugReporter>(reporterFactory);
}
public void Process(Order order)
{
try
{
// 通常処理
}
catch (Exception ex)
{
_debugReporter.Value.Report(ex); // 例外時にだけ生成される
throw;
}
}
}
DI コンテナでも、Func<T> や Lazy<T> を直接注入できる仕組みがあるものが多く(例:Autofac の Lazy<T> サポート)、相性が良いパターンです。
サンプル 4:循環依存の回避
A と B が相互参照する場合に、片方を Lazy<T> 化することで初期化を後回しにできます。
public class A
{
private readonly Lazy<B> _b;
public A(Lazy<B> b) { _b = b; }
public void Ping() => _b.Value.Pong(this);
}
public class B
{
private readonly A _a;
public B(A a) { _a = a; }
public void Pong(A from) { /* ... */ }
}
設計上の臭いではあるので、可能なら循環自体を解消すべきですが、緊急避難として有効です。
非同期版:AsyncLazy<T> パターン
Lazy<T> のファクトリは同期メソッドのため、非同期初期化 には不向きです。Lazy<Task<T>> を使うのが定番のイディオムです。
public class RemoteConfig
{
private readonly Lazy<Task<Config>> _config =
new(() => Task.Run(LoadAsync));
public Task<Config> GetAsync() => _config.Value;
private static async Task<Config> LoadAsync()
{
await Task.Delay(1000);
return new Config();
}
}
呼び出し側はこう書きます。
var cfg = await remote.GetAsync();
Task<T> 自体がキャッシュされるため、複数回 await しても 初期化は 1 回だけ。これが非同期版 Lazy の本質です。
ライブラリによっては AsyncLazy<T> という名前のラッパー(Microsoft.VisualStudio.Threading の AsyncLazy<T> など)が提供されていることもあります。
遅延初期化を使うべきでない場面
便利な道具ですが、適用しない方がよいケースもあります。
- すぐ必ず使うもの:遅延化のオーバーヘッドだけが残る
- 軽量なオブジェクト:
Lazy<T>のラップコストの方が高い場合あり - 生成順序を制御したい初期化:起動時に明示的に走らせた方がデバッグしやすい
- Dispose の責務が曖昧になる場合:誰が
_heavy.ValueをDisposeするかを設計しておく
特に最後は重要です。Lazy<T> 自体は IDisposable を実装していないため、Value が IDisposable の場合、IsValueCreated をチェックして手動で破棄 する必要があります。
public void Dispose()
{
if (_heavy.IsValueCreated)
{
_heavy.Value.Dispose();
}
}
まとめ
| 項目 | 内容 |
|---|---|
| 概念 | 必要になった瞬間まで生成を遅らせる技法 |
| 中核クラス | System.Lazy<T> |
| 使いどころ | 起動時間短縮、オプショナル依存、シングルトン、循環依存回避、重い計算のキャッシュ |
| スレッドセーフ | 既定でスレッドセーフ。LazyThreadSafetyMode で調整 |
| 非同期版 | Lazy<Task<T>> で実現 |
| 注意点 | 例外キャッシュ、Dispose の責務、軽量オブジェクトでの適用は逆効果 |
遅延初期化は、「リソースを最小限にする」 ための地味だが効果の大きい設計テクニックです。Dispose パターンと組み合わせて、「使うときに生成、使い終わったら確実に解放」というライフサイクル設計の基本ツールとして押さえておきましょう。