type-design-performance について
.NET 開発者向けの型設計スキルで、パフォーマンス、メモリ割り当て、API の形に関する指針を提供します。構造体、コレクション、列挙、非同期データ処理の選択を扱います。
主な機能
- 01構造体とクラスの選択指針
- 02型を原則 sealed にする方針
- 03readonly 構造体の設計要点
- 04コレクション型の選択指針
- 05遅延列挙と非同期ストリーム
- 06Span、Memory、ValueTask の使い方
活用シーン
- 01新しい型や API の設計
- 02コードのパフォーマンスレビュー
- 03class、struct、record の選択
- 04コレクションや列挙データの設計
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill type-design-performanceインストール後、Claude Code で/type-design-performanceを入力して読み込まれたか確認します。
詳細
パフォーマンスを考慮した型設計
このスキルを使用する場面
次のような場合に、このスキルを使用します。
- 新しい型や API を設計するとき
- パフォーマンス上の問題がないかコードをレビューするとき
- クラス、構造体、レコードのどれを選ぶか検討するとき
- コレクションや列挙可能なオブジェクトを扱うとき
基本原則
- 型はシールする - 継承を明示的に想定して設計する場合を除きます
- 読み取り専用構造体を優先する - 小さく、変更不可能な値型に適しています
- 静的な純粋関数を優先する - パフォーマンスとテスト容易性が向上します
- 列挙を遅延させる - 必要になるまで具体化しません
- 不変コレクションを返す - API の境界では不変コレクションを返します
構造体とクラスの選択基準
設計時に struct と class のどちらを選ぶかによって、メモリ割り当て、GC の負荷、API の形状に連鎖的な影響が生じます。
判断基準
| 基準 | struct が適する場合 | class が適する場合 |
|---|---|---|
| サイズ | 小さい(理想的には 16 バイト以下、許容範囲は 64 バイト以下) | 大きい、または可変サイズ |
| 寿命 | 短命で、メソッド内に限定される | 長命で、スコープをまたいで共有される |
| 同一性 | 値の等価性(同じデータを持つ 2 つのインスタンスは等しい) | 参照の同一性が重要 |
| 可変性 | 不変(readonly struct) | 可変、または複雑な状態遷移がある |
| 継承 | 不要 | ポリモーフィズムや基底クラスが必要 |
| null 許容の意味 | default が有効なゼロ状態 | 不在を示すために明示的な null が必要 |
| コレクションでの使用 | 配列やスパンに格納(連続したメモリ) | 参照を介して格納(間接参照) |
サイズの指針
<= 16 bytes: Ideal struct -- fits in two registers, passed efficiently
17-64 bytes: Acceptable struct -- measure copy cost vs allocation cost
> 64 bytes: Prefer class -- copying cost outweighs allocation avoidance
一般的な型と適切な設計
| 型 | 適切な選択 | 理由 |
|---|---|---|
| Point2D(8 バイト:2 つの float) | readonly struct | 小さく、不変で、値の意味論を持つ |
| Money(16 バイト:decimal と通貨) | readonly struct | 小さく、不変で、値の等価性を持つ |
| DateRange(16 バイト:2 つの DateOnly) | readonly struct | 小さく、不変で、値の意味論を持つ |
| Matrix4x4(64 バイト:16 個の float) | struct(in パラメーターを使用) | パフォーマンスが重要な数学処理 |
| CustomerDto(可変:文字列、リスト) | class または record | 参照を含み、サイズが可変 |
| HttpRequest のコンテキスト | class | 長命で、ミドルウェア間で共有される |
デフォルトでシールする
ライブラリ型をシールする理由
ライブラリ型(他のアセンブリから利用されるコード)では、クラスをデフォルトでシールします。
- JIT の逆仮想化 -- シールクラスを使うと、JIT は仮想呼び出しを直接呼び出しに置き換えられるため、インライン化が可能になります
- 契約の簡素化 -- シールされていないクラスは、継承をサポートするという約束を意味します
- 破壊的変更の削減 -- 後からクラスをシールすると、バイナリ互換性を破る変更になります
// GOOD -- sealed by default for library types
public sealed class WidgetService
{
public Widget GetWidget(int id) => new(id, "Default");
}
// Only unseal when inheritance is an intentional design decision
public abstract class WidgetValidatorBase
{
public abstract bool Validate(Widget widget);
protected virtual void OnValidationComplete(Widget widget) { }
}
シールしない場合
| 状況 | 理由 |
|---|---|
| 抽象基底クラス | 継承が目的です |
| フレームワークの拡張ポイント | 利用者がサブクラス化する必要があります |
| モックに対応していない設計でのテスト用ダブル | モックフレームワークにはサブクラス化が必要です |
| アプリケーション内部のクラス | シールしても利点がありません |
読み取り専用構造体
すべてのフィールドが不変の場合は、構造体に readonly を指定します。これにより、in パラメーターや readonly フィールドを介して構造体にアクセスする際に JIT が生成する防御的コピーをなくせます。
防御的コピーの問題
// NON-readonly struct -- JIT must defensively copy on every method call
public struct MutablePoint
{
public double X;
public double Y;
public double Length() => Math.Sqrt(X * X + Y * Y);
}
public double GetLength(in MutablePoint point)
{
return point.Length(); // Hidden copy here!
}
// GOOD -- readonly struct: JIT knows no mutation is possible
public readonly struct ImmutablePoint
{
public double X { get; }
public double Y { get; }
public ImmutablePoint(double x, double y) => (X, Y) = (x, y);
public double Length() => Math.Sqrt(X * X + Y * Y);
}
public double GetLength(in ImmutablePoint point)
{
return point.Length(); // No copy, direct call
}
読み取り専用構造体のチェックリスト
- すべてのフィールドが
readonlyであるか、プロパティが{ get; }/{ get; init; }である - 状態を変更するメソッドがない
- コンストラクターですべてのフィールドを初期化する
- ボックス化せずに値を比較するため、
IEquatable<T>の実装を検討する
データ転送におけるレコード型
record class と record struct
| 特徴 | record class | record struct |
|---|---|---|
| メモリ割り当て | ヒープ | スタック(または配列内にインラインで格納) |
| 等価性 | 値の等価性を持つ参照型 | 値の等価性を持つ値型 |
with 式 | 新しいヒープオブジェクトを作成 | 新しいスタック上のコピーを作成 |
| null 許容 | 不在を null で表す | 空の状態を default で表す |
| サイズ | 参照(x64 では 8 バイト)+ヒープ | スタック上で完全なサイズ |
// record class -- heap allocated, good for DTOs
public record CustomerDto(string Name, string Email, DateOnly JoinDate);
// readonly record struct -- stack allocated, good for small value objects
public readonly record struct Money(decimal Amount, string Currency);
静的な純粋関数を優先する
副作用のない静的メソッドは、より高速でテストしやすくなります。
// DO: Static pure function
public static class OrderCalculator
{
public static Money CalculateTotal(IReadOnlyList<OrderItem> items)
{
var total = items.Sum(i => i.Price * i.Quantity);
return new Money(total, "USD");
}
}
// Usage - predictable, testable
var total = OrderCalculator.CalculateTotal(items);
利点:
- vtable の検索が不要(より高速)
- 隠れた状態がない
- テストしやすい(純粋な入力 → 出力)
- 設計上スレッドセーフ
- 依存関係の明示を促す
列挙を遅延する
必要になるまで列挙可能なオブジェクトを実体化しないでください。過剰な LINQ チェーンは避けてください。
// BAD: Premature materialization
public IReadOnlyList<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.ToList() // Materialized!
.OrderBy(o => o.CreatedAt) // Another iteration
.ToList(); // Materialized again!
}
// GOOD: Defer until the end
public IReadOnlyList<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.OrderBy(o => o.CreatedAt)
.ToList(); // Single materialization
}
// GOOD: Return IEnumerable if caller might not need all items
public IEnumerable<Order> GetActiveOrders()
{
return _orders
.Where(o => o.IsActive)
.OrderBy(o => o.CreatedAt);
}
非同期列挙
// GOOD: Use IAsyncEnumerable for streaming
public async IAsyncEnumerable<OrderResult> ProcessOrdersAsync(
IEnumerable<Order> orders,
[EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var order in orders)
{
ct.ThrowIfCancellationRequested();
yield return await ProcessOrderAsync(order, ct);
}
}
// GOOD: Batch processing for parallelism
var results = await Task.WhenAll(
orders.Select(o => ProcessOrderAsync(o)));
ValueTask と Task
同期的に完了することが多いホットパスでは ValueTask を使用してください。実際の I/O には Task を使用してください。
// DO: ValueTask for cached/synchronous paths
public ValueTask<User?> GetUserAsync(UserId id)
{
if (_cache.TryGetValue(id, out var user))
{
return ValueTask.FromResult<User?>(user); // No allocation
}
return new ValueTask<User?>(FetchUserAsync(id));
}
// DO: Task for real I/O (simpler, no footguns)
public Task<Order> CreateOrderAsync(CreateOrderCommand cmd)
{
return _repository.CreateAsync(cmd);
}
ValueTask のルール:
- ValueTask を複数回 await しないでください
- 完了前に
.Resultや.GetAwaiter().GetResult()を使用しないでください - 迷った場合は Task を使用してください
ref struct と Span/Memory の選択
ref struct の制約
ref struct 型はスタック専用です。ボックス化できず、ref struct 以外の型のフィールドに格納できず、非同期メソッドで使用することもできません。
Span と Memory の選択
| 基準 | Span<T> を使用 | Memory<T> を使用 |
|---|---|---|
| 同期メソッド | はい | はい(ただし Span のほうがオーバーヘッドが小さい) |
| 非同期メソッド | いいえ(ref struct) | はい |
| フィールド/コレクションに格納 | いいえ(ref struct) | はい |
| コールバック/デリゲートに渡す | いいえ | はい |
| 割り当てなしでスライスする | はい | はい |
| stackalloc バッファーをラップする | はい | いいえ |
選択フローチャート
Will the buffer be used in an async method or stored in a field?
YES -> Use Memory<T> (convert to Span<T> with .Span for synchronous processing)
NO -> Do you need to wrap a stackalloc buffer?
YES -> Use Span<T>
NO -> Prefer Span<T> for lowest overhead
実用的なパターン
// Public API uses Memory<T> for maximum flexibility
public async Task<int> ProcessAsync(ReadOnlyMemory<byte> data,
CancellationToken ct = default)
{
await _stream.WriteAsync(data, ct);
return CountNonZero(data.Span);
}
// Internal hot-path method uses Span<T> for zero overhead
private static int CountNonZero(ReadOnlySpan<byte> data)
{
var count = 0;
foreach (var b in data)
{
if (b != 0) count++;
}
return count;
}
Span の一般的なパターン
// Slice without allocation
ReadOnlySpan<char> span = "Hello, World!".AsSpan();
var hello = span[..5]; // No allocation
// Stack allocation for small buffers
Span<byte> buffer = stackalloc byte[256];
// Use ArrayPool for larger buffers
var buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
// Use buffer...
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
コレクション型の選択
判断マトリックス
| シナリオ | 推奨型 | 理由 |
|---|---|---|
| 一度構築し、何度も読み取る | FrozenDictionary<K,V> / FrozenSet<T> | 読み取り向けに最適化されたレイアウト(.NET 8 以降) |
| 一度構築し、何度も読み取る(.NET 8 より前) | ImmutableDictionary<K,V> | スレッドセーフで不変 |
| 並行して読み書きする | ConcurrentDictionary<K,V> | 外部ロックなしでスレッドセーフ |
| 頻繁に変更する | Dictionary<K,V> | 操作ごとのオーバーヘッドが最小 |
| 順序付きデータ | SortedDictionary<K,V> | ソートされた列挙で O(log n) の検索 |
| パブリック API から返す | IReadOnlyList<T> / IReadOnlyDictionary<K,V> | 不変のインターフェイス |
| スタックに割り当てる小規模なコレクション | stackalloc を使った Span<T> | GC の負荷ゼロ |
FrozenDictionary(.NET 8 以降)
FrozenDictionary<K,V> は、最大限の読み取りパフォーマンスを実現するため、作成時に内部レイアウトを最適化します。
using System.Collections.Frozen;
private static readonly FrozenDictionary<string, int> StatusCodes =
new Dictionary<string, int>
{
["OK"] = 200,
["NotFound"] = 404,
["InternalServerError"] = 500
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
public int GetStatusCode(string name) =>
StatusCodes.TryGetValue(name, out var code) ? code : -1;
FrozenDictionary を使用する場合:
- 起動時に設定値を格納するルックアップテーブル
- 静的マッピング(enum から文字列への変換、エラーコード、機能フラグ)
- 一度だけ値を設定し、何度も読み取る辞書
使用しない場合:
- 実行時に変更されるデータ
- 最適化のオーバーヘッドを回収できない小規模なルックアップ(10 項目未満)
コレクションの戻り値の型
// DO: Return immutable collection
public IReadOnlyList<Order> GetOrders()
{
return _orders.ToList();
}
// DO: Use frozen collections for static data
private static readonly FrozenDictionary<string, Handler> _handlers =
new Dictionary<string, Handler>
{
["create"] = new CreateHandler(),
["update"] = new UpdateHandler(),
}.ToFrozenDictionary();
// DON'T: Return mutable collection
public List<Order> GetOrders()
{
return _orders; // Caller can modify!
}
クイックリファレンス
| パターン | 利点 |
|---|---|
sealed class | 非仮想化、明確な API |
readonly record struct | 防御的コピーなし、値セマンティクス |
| 静的な純粋関数 | vtable なし、テスト可能、スレッドセーフ |
.ToList() を遅延する | 実体化を一度にする |
ホットパスでの ValueTask | Task の割り当てを回避 |
バイト列に Span<T> を使用 | スタック割り当て、コピーなし |
IReadOnlyList<T> を返す | 不変の API 契約 |
FrozenDictionary | 静的データの最速の検索 |
アンチパターン
// DON'T: Unsealed class without reason
public class OrderService { } // Seal it!
// DON'T: Mutable struct
public struct Point { public int X; public int Y; } // Make readonly
// DON'T: Instance method that could be static
public int Add(int a, int b) => a + b; // Make static
// DON'T: Multiple ToList() calls
items.Where(...).ToList().OrderBy(...).ToList(); // One ToList at end
// DON'T: Return List<T> from public API
public List<Order> GetOrders(); // Return IReadOnlyList<T>
// DON'T: ValueTask for always-async operations
public ValueTask<Order> CreateOrderAsync(); // Just use Task
// DON'T: Use `Span<T>` in async methods
public async Task ProcessAsync(Span<byte> data); // Use Memory<T>
// DON'T: Use `FrozenDictionary` for mutable data
// It has no add/remove APIs
エージェントで注意すべき点
- すべての型で安易に
classを使わないでください -- struct と class の選択基準を確認してください。 - readonly ではない struct を作成しないでください -- 可変 struct は、原因の特定が難しいバグを引き起こします。
- 非同期メソッドで
Span<T>を使わないでください -- 非同期コードではMemory<T>を使用してください。 - 変更可能なデータに
FrozenDictionaryを使わないでください -- 追加や削除の API がありません。 - 抽象クラスや拡張ポイントとして設計されたクラスを sealed にしないでください。
- 測定せずに大きな struct(> 64 bytes)を作成しないでください -- コピーのコストをベンチマークしてください。
- ホットパスの静的ルックアップテーブルに
Dictionary<K,V>を使わないでください --FrozenDictionaryを使用してください。 - 大きな readonly struct の
inパラメーターを忘れないでください --inがないと、struct がコピーされます。
リソース
- パフォーマンスのベストプラクティス: https://learn.microsoft.com/en-us/dotnet/standard/performance/
- Span のガイダンス: https://learn.microsoft.com/en-us/dotnet/standard/memory-and-spans/
- Frozen コレクション: https://learn.microsoft.com/en-us/dotnet/api/system.collections.frozen
- フレームワーク設計ガイドライン:型の設計: https://learn.microsoft.com/dotnet/standard/design-guidelines/type
- class と struct の選択: https://learn.microsoft.com/dotnet/standard/design-guidelines/choosing-between-class-and-struct
- ref struct 型: https://learn.microsoft.com/dotnet/csharp/language-reference/builtin-types/ref-struct
- レコード(C# リファレンス): https://learn.microsoft.com/dotnet/csharp/language-reference/builtin-types/record