C#基础(八):结构体(Struct)
结构体是 C# 中用户自定义的值类型,用于封装轻量的相关数据组。与作为引用类型的 class 不同,struct 直接存储数据本身,通常分配在线程栈上,无 GC 开销,但也因此有诸多限制与特殊行为。它是 C# 高性能编程的核心基础之一。
一、核心本质与内存特性
核心定义
结构体使用 struct 关键字声明,属于值类型,隐式继承自 System.ValueType(该类又继承自 object),并且天生是密封的(无法被继承)。
其最核心的特征是:变量直接存储数据本身,赋值时会创建完整的数据副本,两个变量相互独立。
内存分配
结构体的内存位置并非绝对在栈上,取决于它的声明场景:
- 局部变量:分配在线程栈上,随方法执行结束自动释放,无 GC 压力
- 类的字段:随所属类对象一起分配在托管堆上
- 数组元素:直接存储在数组的连续内存中,而非单独的堆对象
- 装箱后:被包装到托管堆上的对象中
日常开发中所说 “结构体在栈上”,指的是其作为局部变量时的典型场景,这也是结构体性能优势的主要来源。
赋值行为:值拷贝
这是结构体与类最本质的区别:赋值 = 完整复制所有字段数据,两个变量完全独立。
public struct Point { public int X; public int Y; public Point(int x, int y) { X = x; Y = y; } } // 演示值拷贝 Point p1 = new Point(3, 4); Point p2 = p1; // 创建完整副本,p1 和 p2 是两个独立的数据 p2.X = 100; Console.WriteLine(p1.X); // 输出 3,p1 不受 p2 修改影响 Console.WriteLine(p2.X); // 输出 100
对比引用类型的类:赋值仅复制内存地址,多个变量指向同一个堆对象,修改会相互影响。
默认值
结构体变量的默认值是「所有字段均为对应类型的零值」,无需手动初始化即可使用。
Point p; // 声明即可用,X 和 Y 默认都是 0 Console.WriteLine(p.X); // 输出 0,不会出现“未赋值变量”编译错误
二、结构体的成员与语法规则
支持的成员
结构体可以包含绝大多数类型成员,核心包括:
- 字段、常量、只读字段
- 属性、索引器
- 方法、运算符重载、静态构造函数
- 事件
- 可以实现一个或多个接口
不支持的成员(核心限制)
- 不能继承其他类 / 结构体:天生密封,不能指定基类
- 不能被其他类 / 结构体继承:无法作为父类
- 不能声明
protected成员:因为没有继承体系 - 不能声明实例析构函数:值类型由栈自动释放,不需要 GC 终结
- 不能声明虚方法 / 抽象方法:不支持多态(可以重写
object的虚方法如ToString()) - C# 10 之前不能自定义无参构造函数
// 一个标准的二维向量结构体 public struct Vector2 { // 字段 public float X; public float Y; // 常量 public const float Epsilon = 0.00001f; // 有参构造函数 public Vector2(float x, float y) { X = x; Y = y; } // 只读计算属性 public float Length => MathF.Sqrt(X * X + Y * Y); // 实例方法 public void Normalize() { float len = Length; if (len > Epsilon) { X /= len; Y /= len; } } // 运算符重载 public static Vector2 operator +(Vector2 a, Vector2 b) { return new Vector2(a.X + b.X, a.Y + b.Y); } // 重写 object 的方法 public override string ToString() { return $"({X}, {Y})"; } }
三、构造函数详解
结构体的构造函数规则是最容易混淆的知识点,随着 C# 版本升级发生了重要变化。
1. 默认无参构造函数
- C# 9 及以前:编译器始终生成默认无参构造函数,将所有字段初始化为零值,开发者不能自定义无参构造函数。
- C# 10 及以后:允许开发者自定义无参构造函数,但编译器仍会在你未定义时自动生成默认无参构造。
public struct Size { public int Width; public int Height; // C# 10+ 支持自定义无参构造函数 public Size() { Width = 100; Height = 100; } } // 注意:new 调用自定义无参构造 Size s1 = new Size(); // Width=100, Height=100 // default 关键字永远返回零值,不会调用构造函数 Size s2 = default; // Width=0, Height=0
高频坑点:
default(Struct)永远返回全零值,与构造函数无关。
2. 有参构造函数的强制规则
结构体的有参构造函数必须为所有实例字段赋值,不允许字段保持未初始化状态。
public struct Point { public int X; public int Y; // 编译报错:构造函数退出前,字段 Y 未被赋值 // public Point(int x) // { // X = x; // } }
3. 构造函数链:this 调用
和类一样,结构体也可以通过 this() 调用自身的其他构造函数。
public struct Point { public int X; public int Y; public Point() : this(0, 0) { } public Point(int x, int y) { X = x; Y = y; } }
4. 主构造函数(C# 12+)
结构体支持主构造函数语法,简化初始化逻辑。
// 主构造函数参数直接作为结构体的状态 public struct Point(int x, int y) { public int X { get; set; } = x; public int Y { get; set; } = y; }
四、只读结构体与只读成员(性能优化核心)
问题背景:防御性拷贝
当结构体作为 readonly 字段、in 参数使用时,如果调用它的普通方法,编译器无法确认方法是否会修改结构体状态,因此会创建一个临时副本再调用方法,避免原数据被修改,这就是「防御性拷贝」。对于大结构体,这会带来显著的性能损耗。
只读结构体(readonly struct,C# 7.2+)
使用 readonly 修饰结构体,强制所有实例字段都是只读的,所有实例方法都不能修改状态。编译器可以确认不会修改数据,从而跳过防御性拷贝,提升性能。
// 只读结构体:所有实例成员都不能修改状态 public readonly struct ReadonlyPoint { // 字段必须是只读的 public readonly int X; public readonly int Y; public ReadonlyPoint(int x, int y) { X = x; Y = y; } // 计算属性,不能修改值 public double Length => Math.Sqrt(X * X + Y * Y); // 方法隐式只读,不能修改字段 public override string ToString() { return $"({X}, {Y})"; } }
最佳实践:如果结构体是不可变的数据载体,务必加上
readonly修饰,这是结构体性能优化的最重要手段之一。
只读实例方法(readonly 方法,C# 8.0+)
如果不想把整个结构体设为只读,可以单独给不修改状态的方法加上 readonly 修饰,调用该方法时不会产生防御性拷贝。
public struct Point { public int X; public int Y; // 标记该方法不会修改结构体状态 public readonly double GetLength() { // X = 10; // 编译报错,readonly 方法内不能修改字段 return Math.Sqrt(X * X + Y * Y); } }
五、ref 结构体(栈 - only 类型,C# 7.2+)
核心本质
ref struct 是一种特殊的值类型,只能分配在线程栈上,绝对不能逃逸到托管堆。它从语言层面保证了类型的栈 - only 约束,是高性能内存操作的基础。
.NET 内置的 Span<T>、ReadOnlySpan<T> 就是典型的 ref 结构体。
强制限制
为了保证不逃逸到堆,编译器施加了严格限制:
- 不能被装箱(不能转成
object、ValueType、接口类型) - 不能作为普通类 / 普通结构体的字段
- 不能在
async方法、迭代器方法中使用 - 不能作为泛型类型参数
- 不能被 Lambda 表达式捕获
// ref 结构体:只能在栈上使用 public ref struct BufferWrapper { public Span<byte> Data; public int Length => Data.Length; public BufferWrapper(Span<byte> data) { Data = data; } } // 使用场景:栈上分配内存并包装 Span<byte> buffer = stackalloc byte[1024]; var wrapper = new BufferWrapper(buffer);
适用场景:高性能底层开发、临时内存包装、避免 GC 分配的热路径代码。普通业务开发极少需要自定义 ref struct。
六、record struct 记录结构体(C# 10+)
C# 9 引入了引用类型的 record,C# 10 补充了值类型版本的 record struct,专门用于不可变的值数据模型。
核心特性
- 值相等性:默认按字段值比较两个实例是否相等
- 非破坏性修改:支持
with表达式,基于现有对象创建修改了部分属性的新对象 - 默认不可变:主构造参数默认生成
{ get; init; }属性 - 值类型:分配在栈上,无 GC 开销
// 记录结构体:位置语法定义主构造函数 public record struct Student(string Name, int Age); // 使用 var s1 = new Student("Alice", 20); var s2 = new Student("Alice", 20); // 值比较:内容相同即相等 Console.WriteLine(s1 == s2); // True // with 表达式:非破坏性修改 var s3 = s1 with { Age = 21 }; Console.WriteLine(s3.Name); // Alice Console.WriteLine(s3.Age); // 21
record class vs record struct
| 特性 | record class | record struct |
|---|---|---|
| 类型分类 | 引用类型 | 值类型 |
| 内存分配 | 托管堆 | 栈(局部变量) |
| 相等性 | 重写为值比较 | 默认值比较 |
| 默认可变性 | 不可变(init) | 不可变(init) |
| 适用场景 | 复杂数据模型、需要继承 | 轻量数据、值语义 |
七、结构体与接口
结构体不能继承类,但可以实现接口。但需要注意:将结构体转换为接口类型时,会发生装箱,将值类型包装到堆上的对象中,带来性能损耗。
public interface IComparable<T> { int CompareTo(T other); } public struct Point : IComparable<Point> { public int X; public int Y; public int CompareTo(Point other) { return X.CompareTo(other.X); } } // 装箱:结构体转接口 Point p = new Point(1, 2); IComparable<Point> iface = p; // 发生装箱,分配到堆上
避免装箱的技巧
使用泛型 + 接口约束,编译器会直接调用结构体的方法,不会发生装箱。
// 泛型方法:无装箱调用 int Compare<T>(T a, T b) where T : IComparable<T> { return a.CompareTo(b); } Compare(p, new Point(3, 4)); // 无装箱,性能更高
八、经典坑点与避坑指南
1. 可变结构体 + 集合索引器:修改无效
这是新手最高频踩坑点:List<T> 的索引器是属性,get 访问器会返回结构体的副本,直接修改副本不会影响集合内的原始数据,编译器会直接报错。
List<Point> points = new List<Point>(); points.Add(new Point(1, 2)); // ❌ 编译报错:无法修改返回值,因为它不是变量 // points[0].X = 100; // ✅ 正确做法:取出副本修改后重新赋值 Point temp = points[0]; temp.X = 100; points[0] = temp; // ✅ 数组索引可以直接修改,因为数组元素是直接存储的连续内存 Point[] arr = { new Point(1, 2) }; arr[0].X = 100; // 正常生效
2. 方法 / 属性返回结构体,修改不生效
方法返回的结构体是副本,直接修改副本没有意义。
Point GetPoint() => new Point(1, 2); // ❌ 无意义,修改的是临时副本 GetPoint().X = 100;
3. 大结构体拷贝开销大
结构体赋值、传参都会完整拷贝所有字段,如果结构体超过 32 字节,拷贝开销会显著超过引用类型。
经验法则:结构体大小建议控制在 16 字节以内,超过 64 字节且频繁传递时,优先考虑用类或者
readonly ref struct。
4. 不要在结构体中写引用类型字段
结构体本身是值语义,如果内部包含引用类型字段,赋值时只拷贝引用地址,会出现 “值类型里套引用类型” 的混合语义,极易产生 bug。
5. 作为方法参数时,默认是值传递
结构体作为参数默认是值传递,方法内修改不会影响原对象;需要修改原对象时要加 ref 或 in(只读传引用)。
// 传引用,避免大结构体拷贝,同时可修改原对象 void Transform(ref Point p) { p.X *= 2; } // in 修饰:只读传引用,不拷贝也不允许修改 void Print(in Point p) { Console.WriteLine(p.X); }
九、选型原则:struct vs class
优先使用 struct 的场景
- 类型的核心职责是存储数据,行为很少或没有
- 实例体积小(通常 ≤ 16 字节)、生命周期短
- 具有明确的值语义,相等判断基于内容而非引用
- 不需要继承、不需要多态
- 高性能热路径,希望避免 GC 压力
优先使用 class 的场景
- 类型有复杂的行为和状态变化
- 实例体积较大,频繁传递
- 需要继承、多态、面向抽象编程
- 需要标识同一性(同一个对象的引用)
- 生命周期较长,需要在多个地方共享同一个实例
总结:struct 是「数据的载体」,class 是「对象的模板」。不要为了 “性能” 盲目使用 struct,不合适的结构体反而会因为拷贝、装箱带来更多问题。

浙公网安备 33010602011771号