值类型与引用类型
00:00
C#值类型与引用类型详解:struct vs class。
概述
C# 中的类型分为值类型和引用类型,两者的内存分配、赋值行为和 GC 影响截然不同。理解这些差异是编写高性能 C# 代码的基础,也是避免常见 Bug 的关键。
基础概念
对比
| 特性 | struct(值类型) | class(引用类型) |
|---|---|---|
| 存储 | 栈(通常) | 堆 |
| 赋值 | 复制值 | 复制引用 |
| 继承 | 不支持 | 支持 |
| 默认值 | 零值 | null |
| 装箱 | 是 | 否 |
| 适合 | 小型数据 | 复杂对象 |
值类型分类
- 简单类型:int, double, bool, char, byte 等
- 枚举:enum
- 结构体:struct(包括 Nullable
、DateTime、Guid 等) - 元组:ValueTuple
引用类型分类
- 类:class
- 字符串:string
- 数组:T[]
- 委托:delegate
- 接口:interface(实现可以是值类型或引用类型)
快速上手
赋值行为差异
// 值类型:赋值时复制整个值
var p1 = new Point(1, 2);
var p2 = p1; // 复制值,p2 是独立的副本
p2.X = 10; // 只修改 p2,p1 不受影响
Console.WriteLine(p1.X); // 1
// 引用类型:赋值时复制引用
var u1 = new User("张三");
var u2 = u1; // 复制引用,指向同一个对象
u2.Name = "李四"; // 修改了同一个对象
Console.WriteLine(u1.Name); // "李四"
struct Point { public int X; public int Y; public Point(int x, int y) => (X, Y) = (x, y); }
class User { public string Name; public User(string name) => Name = name; }
装箱与拆箱
// 装箱:值类型转为引用类型(在堆上创建副本)
int x = 42;
object obj = x; // 装箱:值类型 -> 堆上的引用类型
// 拆箱:引用类型转回值类型
int y = (int)obj; // 拆箱:引用类型 -> 值类型
// 装箱的性能影响
var list = new ArrayList();
for (int i = 0; i < 1000000; i++) {
list.Add(i); // 每次都装箱!产生大量 GC 压力
}
// 使用泛型避免装箱
var list2 = new List<int>();
for (int i = 0; i < 1000000; i++) {
list2.Add(i); // 无装箱,值类型直接存储
}
详细用法
何时使用 struct
// 适合使用 struct 的条件:
// 1. 小于 16 字节
// 2. 生命周期短
// 3. 不可变
// 4. 频繁分配/释放
// 好的 struct 示例
public readonly struct Point {
public double X { get; }
public double Y { get; }
public Point(double x, double y) => (X, Y) = (x, y);
public double DistanceTo(Point other) =>
Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
}
// 不好的 struct 示例(太大,应该用 class)
public struct Order { // 超过 16 字节,应该用 class
public long Id;
public string CustomerName; // string 是引用类型,struct 中仍存引用
public List<OrderItem> Items; // 同上
public decimal TotalAmount;
}
ref struct
// ref struct 只能存在于栈上,不能逃逸到堆
public ref struct StackBuffer {
private readonly Span<byte> _buffer;
public StackBuffer(int size) {
_buffer = stackalloc byte[size];
}
public void Write(int offset, byte value) => _buffer[offset] = value;
public byte Read(int offset) => _buffer[offset];
}
// ref struct 的限制:
// - 不能作为类字段
// - 不能装箱
// - 不能在 Lambda 中捕获
// - 不能在 async 方法中使用
record struct(C# 10+)
// record struct:值类型的记录,自动生成相等性比较
public record struct Point(double X, double Y);
var p1 = new Point(1, 2);
var p2 = new Point(1, 2);
Console.WriteLine(p1 == p2); // True(值相等)
Console.WriteLine(p1.Equals(p2)); // True
// record class:引用类型的记录
public record User(string Name, int Age);
var u1 = new User("张三", 25);
var u2 = u1 with { Age = 26 }; // 使用 with 创建副本并修改
常见场景
避免不必要的装箱
// 错误:接口导致值类型装箱
interface IProcessor {
void Process(); // 值类型实现此接口时,调用会装箱
}
struct MyProcessor : IProcessor {
public void Process() { /* ... */ }
}
MyProcessor processor = new();
processor.Process(); // 无装箱(直接调用)
IProcessor boxed = processor; // 装箱!
boxed.Process(); // 通过虚方法表调用,有额外开销
// 解决:使用泛型约束
void Process<T>(T processor) where T : IProcessor {
processor.Process(); // 无装箱
}
传递参数优化
// 大 struct 使用 ref 传递,避免复制
void ProcessLargeStruct(ref LargeData data) {
// 通过引用传递,不复制
data.Value = 42;
}
// 只读引用传递
void ReadLargeStruct(in LargeData data) {
// 通过引用传递,且不允许修改
Console.WriteLine(data.Value);
}
// 返回引用
ref int FindMax(int[] array) {
int maxIndex = 0;
for (int i = 1; i < array.Length; i++) {
if (array[i] > array[maxIndex]) maxIndex = i;
}
return ref array[maxIndex]; // 返回引用,调用者可直接修改
}
注意事项
- struct 默认是可变的,建议使用 readonly struct 确保不可变性
- struct 中的 string 和数组字段仍然是引用,修改引用指向的对象会影响所有副本
- struct 作为字典键时,必须是不可变的,否则哈希值会变化
- 频繁装箱的值类型应考虑使用泛型替代
- struct 不能继承其他类/结构体,但可以实现接口
- struct 有隐式无参构造器(C# 10 之前不能自定义),所有字段初始化为零值
进阶用法
结构体布局控制
// 使用 StructLayout 控制内存布局(与 C/C++ 互操作)
[StructLayout(LayoutKind.Sequential)]
public struct NativeHeader {
public int Magic;
public short Version;
public short Flags;
public int DataLength;
}
// 显式布局:精确控制字段偏移
[StructLayout(LayoutKind.Explicit)]
public struct UnionValue {
[FieldOffset(0)] public int IntValue;
[FieldOffset(0)] public float FloatValue;
[FieldOffset(0)] public double DoubleValue;
}
var v = new UnionValue { IntValue = 42 };
Console.WriteLine(v.FloatValue); // 42 的浮点表示
MemoryMarshal 高级操作
// 将 byte 数组重新解释为 int 数组(零拷贝)
byte[] bytes = new byte[16];
Span<int> ints = MemoryMarshal.Cast<byte, int>(bytes.AsSpan());
ints[0] = 42; // 直接修改 byte 数组的前4个字节
// 将结构体转为字节数组
var header = new NativeHeader { Magic = 0x4D42, Version = 1 };
ReadOnlySpan<byte> headerBytes = MemoryMarshal.AsBytes(
MemoryMarshal.CreateReadOnlySpan(ref header, 1));