dotnet-csharp-type-design-performance

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

dotnet-csharp-type-design-performance

.NET C#类型设计与性能优化

Upfront type design choices that affect performance throughout an application's lifetime. Covers the struct vs class decision matrix, sealed by default for library types, readonly struct for defensive copy elimination, ref struct and Span<T>/Memory<T> selection, and collection type selection including FrozenDictionary. This skill focuses on designing types correctly from the start, not on optimizing existing code.
Version assumptions: .NET 8.0+ baseline. FrozenDictionary (requires .NET 8+) is in-scope by default.
提前做好的类型设计选择会影响应用程序整个生命周期的性能。内容涵盖struct与class的决策矩阵、库类型默认密封、使用readonly struct消除防御性复制、ref struct与Span<T>/Memory<T>的选择,以及包括FrozenDictionary在内的集合类型选择。本技能专注于从一开始就正确设计类型,而非优化现有代码。
版本假设: 以.NET 8.0+为基准。默认包含FrozenDictionary(需要.NET 8+)。

Scope

适用范围

  • struct vs class decision matrix
  • sealed by default for library types
  • readonly struct for defensive copy elimination
  • ref struct and Span<T>/Memory<T> selection
  • Collection type selection (FrozenDictionary, ImmutableArray)
  • struct与class的决策矩阵
  • 库类型默认密封
  • 使用readonly struct消除防御性复制
  • ref struct与Span<T>/Memory<T>的选择
  • 集合类型选择(FrozenDictionary、ImmutableArray)

Out of scope

不适用范围

  • Runtime optimization techniques (pooling, caching, stackalloc) -- see [skill:dotnet-performance-patterns]
  • Language syntax for records and collection expressions -- see [skill:dotnet-csharp-modern-patterns]
  • GC behavior and memory management -- see [skill:dotnet-gc-memory]
Cross-references: [skill:dotnet-performance-patterns] for optimization techniques, [skill:dotnet-csharp-modern-patterns] for language syntax, [skill:dotnet-gc-memory] for GC behavior and memory management.

  • 运行时优化技术(对象池、缓存、stackalloc)——详见[skill:dotnet-performance-patterns]
  • 记录类型与集合表达式的语法——详见[skill:dotnet-csharp-modern-patterns]
  • GC行为与内存管理——详见[skill:dotnet-gc-memory]
交叉引用:优化技术请参考[skill:dotnet-performance-patterns],语法请参考[skill:dotnet-csharp-modern-patterns],GC行为与内存管理请参考[skill:dotnet-gc-memory]。

Struct vs Class Decision Matrix

Struct与Class决策矩阵

Choosing between
struct
and
class
at design time has cascading effects on allocation, GC pressure, copying cost, and API shape. Make the decision once, correctly.
在设计阶段选择
struct
还是
class
会对内存分配、GC压力、复制成本和API形态产生连锁影响。请一次性做出正确的选择。

Decision Criteria

判定标准

CriterionFavors
struct
Favors
class
SizeSmall (<= 16 bytes ideal, <= 64 bytes acceptable)Large or variable size
LifetimeShort-lived, method-scopedLong-lived, shared across scopes
IdentityValue equality (two instances with same data are equal)Reference identity matters
MutabilityImmutable (
readonly struct
)
Mutable or complex state transitions
InheritanceNot neededRequires polymorphism or base class
Nullable semantics
default
is a valid zero state
Needs explicit null to signal absence
Collection usageStored in arrays/spans (contiguous memory, cache-friendly)Stored via references (indirection on every access)
判定标准倾向于
struct
倾向于
class
大小较小(理想<=16字节,可接受<=64字节)较大或可变大小
生命周期短生命周期,方法作用域内长生命周期,跨作用域共享
标识值相等(数据相同的两个实例相等)引用标识重要
可变性不可变(
readonly struct
可变或有复杂状态转换
继承不需要需要多态或基类
可空语义
default
是有效的零状态
需要显式null表示缺失
集合使用存储在数组/span中(连续内存,缓存友好)通过引用存储(每次访问都有间接开销)

Size Guidelines

大小指南

<= 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
The 16-byte threshold comes from x64 calling conventions: two register-sized values can be passed in registers without stack spilling. Beyond that, the struct is passed by reference on the stack, and copying becomes the dominant cost.
<= 16字节:  理想的struct——可放入两个寄存器,传递高效
17-64字节:  可接受的struct——需衡量复制成本与分配成本
> 64字节:   优先选择class——复制成本超过了避免分配的收益
16字节阈值来自x64调用约定:两个寄存器大小的值可以直接在寄存器中传递,无需栈溢出。超过该值后,struct会在栈上按引用传递,复制成为主要成本。

Common Types and Their Correct Design

常见类型及其正确设计

TypeCorrect ChoiceWhy
Point2D (8 bytes: two floats)
readonly struct
Small, immutable, value semantics
Money (16 bytes: decimal + currency enum)
readonly struct
Small, immutable, value equality
DateRange (16 bytes: two DateOnly)
readonly struct
Small, immutable, value semantics
Matrix4x4 (64 bytes: 16 floats)
struct
(with
in
parameters)
Performance-critical math, contiguous arrays
CustomerDto (variable: strings, lists)
class
or
record
Contains references, variable size
HttpRequest context
class
Long-lived, shared across middleware

类型正确选择原因
Point2D(8字节:两个float)
readonly struct
体积小、不可变、值语义
Money(16字节:decimal + 货币枚举)
readonly struct
体积小、不可变、值相等
DateRange(16字节:两个DateOnly)
readonly struct
体积小、不可变、值语义
Matrix4x4(64字节:16个float)
struct
(配合
in
参数)
性能关键的数学运算,连续数组
CustomerDto(可变大小:字符串、列表)
class
record
包含引用,大小可变
HttpRequest上下文
class
长生命周期,跨中间件共享

Sealed by Default

默认密封类

Why Seal Library Types

为何要密封库类型

For library types (code consumed by other assemblies), seal classes by default:
  1. JIT devirtualization -- sealed classes enable the JIT to replace virtual calls with direct calls, enabling inlining. See [skill:dotnet-performance-patterns] for benchmarking this effect.
  2. Simpler contracts -- unsealed classes imply a promise to support inheritance, which constrains future changes.
  3. Fewer breaking changes -- sealing a class later is a binary-breaking change. Starting sealed and unsealing later is safe.
csharp
// 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);

    // Template method pattern -- intentional extension point
    protected virtual void OnValidationComplete(Widget widget) { }
}
对于库类型(供其他程序集使用的代码),默认密封类:
  1. JIT去虚拟化——密封类使JIT能够将虚调用替换为直接调用,从而支持内联。有关基准测试请参考[skill:dotnet-performance-patterns]。
  2. 更简洁的契约——未密封的类意味着承诺支持继承,这会限制未来的变更。
  3. 更少的破坏性变更——后续密封类是二进制破坏性变更。从密封开始,之后再取消密封是安全的。
csharp
// 良好实践——库类型默认密封
public sealed class WidgetService
{
    public Widget GetWidget(int id) => new(id, "Default");
}

// 仅当继承是有意的设计决策时才取消密封
public abstract class WidgetValidatorBase
{
    public abstract bool Validate(Widget widget);

    // 模板方法模式——有意设计的扩展点
    protected virtual void OnValidationComplete(Widget widget) { }
}

When NOT to Seal

何时不密封

ScenarioReason
Abstract base classesInheritance is the purpose
Framework extensibility pointsConsumers need to subclass
Test doubles in non-mockable designsMocking frameworks need to subclass (prefer interfaces)
Application-internal classes with no consumersSealing adds no value (no external callers to devirtualize)

场景原因
抽象基类继承是其设计目的
框架扩展点使用者需要子类化
不可模拟设计中的测试替身模拟框架需要子类化(优先使用接口)
无外部使用者的应用内部类密封无价值(无外部调用者可去虚拟化)

readonly struct

readonly struct

Mark structs
readonly
when all fields are immutable. This eliminates defensive copies the JIT creates when accessing structs through
in
parameters or
readonly
fields.
当所有字段都是不可变的时,将struct标记为
readonly
。这可以消除JIT在通过
in
参数或
readonly
字段访问struct时创建的防御性复制。

The Defensive Copy Problem

防御性复制问题

csharp
// 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);
}

// In a readonly context, JIT copies the struct before calling Length()
// because Length() MIGHT mutate X or Y
public double GetLength(in MutablePoint point)
{
    return point.Length(); // Hidden copy here!
}
csharp
// 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);
}

// No defensive copy -- JIT can call Length() directly on the reference
public double GetLength(in ImmutablePoint point)
{
    return point.Length(); // No copy, direct call
}
csharp
// 非readonly struct——JIT在每次方法调用时必须进行防御性复制
public struct MutablePoint
{
    public double X;
    public double Y;
    public double Length() => Math.Sqrt(X * X + Y * Y);
}

// 在readonly上下文中,JIT会在调用Length()前复制struct
// 因为Length()可能会修改X或Y
public double GetLength(in MutablePoint point)
{
    return point.Length(); // 此处存在隐藏的复制!
}
csharp
// 良好实践——readonly struct:JIT知道不会有状态变更
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);
}

// 无防御性复制——JIT可以直接对引用调用Length()
public double GetLength(in ImmutablePoint point)
{
    return point.Length(); // 无复制,直接调用
}

readonly struct Checklist

readonly struct检查清单

  • All fields are
    readonly
    or
    { get; }
    /
    { get; init; }
    properties
  • No methods mutate state
  • Constructor initializes all fields
  • Consider
    IEquatable<T>
    for value comparison without boxing

  • 所有字段都是
    readonly
    { get; }
    /
    { get; init; }
    属性
  • 没有方法会修改状态
  • 构造函数初始化所有字段
  • 考虑实现
    IEquatable<T>
    以避免装箱的情况下进行值比较

ref struct and Span/Memory Selection

ref struct与Span/Memory选择

ref struct Constraints

ref struct的限制

ref struct
types are stack-only: they cannot be boxed, stored in fields of non-ref-struct types, or used in async methods. This constraint enables safe wrapping of
Span<T>
.
ref struct
类型仅存在于栈上:它们不能被装箱、存储在非ref-struct类型的字段中,也不能在异步方法中使用。这种限制使得安全包装
Span<T>
成为可能。

Span<T> vs Memory<T> Decision

Span<T> vs Memory<T>决策

CriterionUse
Span<T>
Use
Memory<T>
Synchronous methodYesYes (but Span is lower overhead)
Async methodNo (ref struct)Yes
Store in field/collectionNo (ref struct)Yes
Pass to callback/delegateNoYes
Slice without allocationYesYes
Wrap stackalloc bufferYesNo
判定标准使用
Span<T>
使用
Memory<T>
同步方法是(但Span的开销更低)
异步方法否(ref struct限制)
存储在字段/集合中否(ref struct限制)
传递给回调/委托
无分配切片
包装stackalloc缓冲区

Selection Flowchart

选择流程图

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; Memory<T> is also acceptable
是否会在异步方法中使用缓冲区或存储在字段中?
  是 -> 使用Memory<T>(同步处理时通过.Span转换为Span<T>)
  否 -> 是否需要包装stackalloc缓冲区?
           是 -> 使用Span<T>
           否 -> 优先选择Span<T>以获得最低开销;Memory<T>也是可接受的

Practical Pattern

实用模式

csharp
// Public API uses Memory<T> for maximum flexibility
public async Task<int> ProcessAsync(ReadOnlyMemory<byte> data,
    CancellationToken ct = default)
{
    // Hand off to awaitable I/O
    await _stream.WriteAsync(data, ct);

    // Convert to Span for synchronous processing
    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;
}

csharp
// 公共API使用Memory<T>以获得最大灵活性
public async Task<int> ProcessAsync(ReadOnlyMemory<byte> data,
    CancellationToken ct = default)
{
    // 交给可等待的I/O操作
    await _stream.WriteAsync(data, ct);

    // 转换为Span进行同步处理
    return CountNonZero(data.Span);
}

// 内部热点路径方法使用Span<T>以实现零开销
private static int CountNonZero(ReadOnlySpan<byte> data)
{
    var count = 0;
    foreach (var b in data)
    {
        if (b != 0) count++;
    }
    return count;
}

Collection Type Selection

集合类型选择

Decision Matrix

决策矩阵

ScenarioRecommended TypeRationale
Build once, read many
FrozenDictionary<K,V>
/
FrozenSet<T>
Optimized read layout, immutable after creation (.NET 8+)
Build once, read many (pre-.NET 8)
ImmutableDictionary<K,V>
Thread-safe, immutable
Concurrent read/write
ConcurrentDictionary<K,V>
Thread-safe without external locking
Frequent modifications
Dictionary<K,V>
Lowest per-operation overhead for single-threaded access
Ordered data
SortedDictionary<K,V>
O(log n) lookup with sorted enumeration
Return from public API
IReadOnlyList<T>
/
IReadOnlyDictionary<K,V>
Immutable interface communicates intent
Stack-allocated small collection
Span<T>
with stackalloc
Zero GC pressure for small, known-size buffers
场景推荐类型理由
一次构建、多次读取
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(.NET 8+)

FrozenDictionary<K,V>
optimizes the internal layout at creation time for maximum read performance. The creation cost is higher than
Dictionary
, but subsequent lookups are faster because the hash table layout is optimized for the specific keys:
csharp
using System.Collections.Frozen;

// One-time creation cost during initialization
private static readonly FrozenDictionary<string, int> StatusCodes =
    new Dictionary<string, int>
    {
        ["OK"] = 200,
        ["NotFound"] = 404,
        ["InternalServerError"] = 500
    }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);

// Optimized lookups on every request
public int GetStatusCode(string name) =>
    StatusCodes.TryGetValue(name, out var code) ? code : -1;
When to use FrozenDictionary:
  • Configuration lookup tables populated at startup
  • Static mappings (enum-to-string, error codes, feature flags)
  • Any dictionary that is populated once and read many times
When NOT to use FrozenDictionary:
  • Data that changes at runtime (use
    Dictionary
    or
    ConcurrentDictionary
    )
  • Small lookups (< 10 items) where the optimization overhead is not recouped
FrozenDictionary<K,V>
在创建时优化内部布局,以实现最大的读取性能。其创建成本高于
Dictionary
,但后续查找速度更快,因为哈希表布局针对特定键进行了优化:
csharp
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:
  • 启动时填充的配置查找表
  • 静态映射(枚举转字符串、错误码、功能标志)
  • 任何一次构建、多次读取的字典
何时不使用FrozenDictionary:
  • 运行时会变更的数据(使用
    Dictionary
    ConcurrentDictionary
  • 小型查找(<10个条目),优化开销无法抵消收益

ImmutableDictionary vs FrozenDictionary

ImmutableDictionary vs FrozenDictionary

Characteristic
ImmutableDictionary
FrozenDictionary
MutabilityImmutable (returns new instance on "Add")Immutable (no add/remove APIs)
Read performanceGoodExcellent (optimized layout)
CreationFast (incremental building)Slower (one-time optimization)
EvolutionSupports Add/Remove (new instance)No mutation -- rebuild from scratch
Thread safetyInherently thread-safeInherently thread-safe

特性
ImmutableDictionary
FrozenDictionary
可变性不可变("添加"时返回新实例)不可变(无添加/移除API)
读取性能良好极佳(优化布局)
创建速度快(增量构建)慢(一次性优化)
扩展能力支持添加/移除(返回新实例)无变更能力——需从头重建
线程安全天生线程安全天生线程安全

Record Types for Data Transfer

用于数据传输的记录类型

record class vs record struct

record class vs record struct

Characteristic
record class
record struct
AllocationHeapStack (or inline in arrays)
EqualityReference type with value equalityValue type with value equality
with
expression
Creates new heap objectCreates new stack copy
Nullable
null
represents absence
default
represents empty state
SizeReference (8 bytes on x64) + heapFull size on stack
csharp
// record class -- heap allocated, good for DTOs passed through layers
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);

// Use readonly record struct when:
// 1. Size <= ~64 bytes
// 2. Value semantics are desired
// 3. High-throughput scenarios where allocation matters

特性
record class
record struct
内存分配栈(或内联在数组中)
相等性引用类型,值相等语义值类型,值相等语义
with
表达式
创建新的堆对象创建新的栈副本
可空性
null
表示缺失
default
表示空状态
大小引用(x64上8字节)+ 堆栈上的完整大小
csharp
// record class——堆分配,适用于跨层传递的DTO
public record CustomerDto(string Name, string Email, DateOnly JoinDate);

// readonly record struct——栈分配,适用于小型值对象
public readonly record struct Money(decimal Amount, string Currency);

// 在以下情况使用readonly record struct:
// 1. 大小<=约64字节
// 2. 需要值语义
// 3. 分配影响显著的高吞吐量场景

Agent Gotchas

注意事项

  1. Do not default to
    class
    for every type
    -- evaluate the struct vs class decision matrix. Small, immutable value objects (coordinates, money, date ranges) should be
    readonly struct
    to avoid unnecessary heap allocations.
  2. Do not create non-readonly structs -- mutable structs cause subtle bugs (defensive copies, lost mutations on copy). If a struct needs mutation, reconsider whether it should be a class. Always mark structs
    readonly
    .
  3. Do not use
    Span<T>
    in async methods
    --
    Span<T>
    is a
    ref struct
    and cannot cross
    await
    boundaries. Use
    Memory<T>
    for async code and convert to
    Span<T>
    via
    .Span
    for synchronous processing sections.
  4. Do not use
    FrozenDictionary
    for mutable data
    -- it has no add/remove APIs. It is designed for build-once-read-many scenarios. Use
    Dictionary<K,V>
    or
    ConcurrentDictionary<K,V>
    for data that changes at runtime.
  5. Do not seal abstract classes or classes designed as extension points -- sealing is a design-time decision for concrete types. Abstract classes and intentional base classes must remain unsealed.
  6. Do not make large structs (> 64 bytes) without measuring -- large structs are expensive to copy. If passed by value (no
    in
    modifier), they may be slower than a heap-allocated class. Benchmark with [skill:dotnet-performance-patterns].
  7. Do not use
    Dictionary<K,V>
    for static lookup tables in hot paths
    -- if the dictionary is populated at startup and never modified, use
    FrozenDictionary
    for optimized read performance. Requires .NET 8+.
  8. Do not forget
    in
    parameter for large readonly structs
    -- without
    in
    , the struct is copied on every method call. With
    in
    on a
    readonly struct
    , the JIT passes by reference with no defensive copy.

  1. 不要默认对所有类型使用
    class
    ——请评估struct与class的决策矩阵。小型、不可变的值对象(坐标、金额、日期范围)应使用
    readonly struct
    ,以避免不必要的堆分配。
  2. 不要创建非readonly的struct——可变struct会导致微妙的bug(防御性复制、复制时丢失变更)。如果struct需要变更,请重新考虑是否应该是class。始终将struct标记为
    readonly
  3. 不要在异步方法中使用
    Span<T>
    ——
    Span<T>
    ref struct
    ,无法跨越
    await
    边界。异步代码使用
    Memory<T>
    ,同步处理部分通过
    .Span
    转换为
    Span<T>
  4. 不要对可变数据使用
    FrozenDictionary
    ——它没有添加/移除API。它专为一次构建、多次读取的场景设计。运行时变更的数据请使用
    Dictionary<K,V>
    ConcurrentDictionary<K,V>
  5. 不要密封抽象类或设计为扩展点的类——密封是针对具体类型的设计决策。抽象类和有意设计的基类必须保持未密封。
  6. 不要未经测试就创建大型struct(>64字节)——大型struct复制成本高。如果按值传递(无
    in
    修饰符),可能比堆分配的class更慢。请使用[skill:dotnet-performance-patterns]进行基准测试。
  7. 不要在热点路径中对静态查找表使用
    Dictionary<K,V>
    ——如果字典在启动时填充且永不修改,请使用
    FrozenDictionary
    以获得优化的读取性能。需要.NET 8+。
  8. 不要忘记对大型readonly struct使用
    in
    参数
    ——没有
    in
    的话,每次方法调用都会复制struct。对
    readonly struct
    使用
    in
    ,JIT会按引用传递且无防御性复制。

Prerequisites

前置条件

  • .NET 8.0+ SDK (required for
    FrozenDictionary
    ,
    FrozenSet
    )
  • Understanding of GC generations and heap behavior (see [skill:dotnet-gc-memory])
  • Familiarity with performance measurement (see [skill:dotnet-performance-patterns])
  • System.Collections.Frozen
    namespace (.NET 8+)
  • System.Collections.Immutable
    namespace

  • .NET 8.0+ SDK(
    FrozenDictionary
    FrozenSet
    所需)
  • 了解GC代与堆行为(详见[skill:dotnet-gc-memory])
  • 熟悉性能测量(详见[skill:dotnet-performance-patterns])
  • System.Collections.Frozen
    命名空间(.NET 8+)
  • System.Collections.Immutable
    命名空间

References

参考资料