在C#里,struct和class都可以用来封装字段、属性与方法,但它们在内存分配、赋值语义、继承能力和性能特征上有着本质区别。选错类型通常不会立刻导致编译错误,却可能带来难以察觉的拷贝逻辑错误或额外的垃圾回收压力。要做出合理选择,需要先理解值类型与引用类型在运行时的行为差异。本文从内存分配、赋值与传参、面向对象能力以及性能开销几个维度展开,并给出可操作的选型检查清单。

一、内存分配与赋值语义:栈与堆的分野
struct是值类型,它的实例通常分配在当前线程栈上,但这不是绝对的:当struct作为类的字段、数组元素或装箱对象时,它的数据会随宿主对象一起存储在托管堆中。class是引用类型,实例本身始终分配在托管堆上,栈或寄存器中保存的只是指向堆内存的引用。理解这一点对性能分析有帮助:栈分配和释放速度快,不需要垃圾回收;堆分配则统一由GC管理。
赋值行为是两者最容易混淆的地方。值类型赋值会复制整个数据块:把struct变量A赋给B后,A和B拥有两份完全独立的数据。引用类型赋值只复制引用:把class变量C赋给D后,C和D指向同一个堆对象,通过任意一个引用修改字段,另一个引用都能观察到变化。方法传参默认也遵循同样的语义:struct参数会按值复制一份,方法内部修改形参不会影响调用方变量;class参数传递的是引用的副本,修改对象字段会影响原对象,但如果对形参重新赋值,调用方变量不会改变。
下面的示例直观展示了二者的赋值区别:
struct PointStruct
{
public int X;
public int Y;
}
class PointClass
{
public int X;
public int Y;
}
var s1 = new PointStruct { X = 1, Y = 2 };
var s2 = s1;
s2.X = 100;
var c1 = new PointClass { X = 1, Y = 2 };
var c2 = c1;
c2.X = 100;
// s1.X 仍然为 1,因为发生了完整复制
// c1.X 变为 100,因为 c1 和 c2 指向同一对象
还要注意默认值和可空性差异。值类型变量不能为null,除非使用可空值类型 Nullable<PointStruct> 或 PointStruct?;引用类型变量默认可以为null。未显式初始化的struct字段会得到其默认值,例如int为0、bool为false,而class的字段在对象创建时也会被初始化为默认值,但class引用本身默认是null。
二、继承、多态与构造限制
class是面向对象设计的核心载体,支持单继承、接口实现、虚方法、抽象类以及protected访问修饰符。struct则被设计为轻量数据容器,隐式密封且不能继承其他类或struct,也不能作为基类被继承。struct只能实现接口,这限制了它在需要多态扩展场景中的使用。
构造函数方面,class可以自定义无参构造函数、带参构造函数、静态构造函数和析构函数;struct在较新的C#语言版本中已经放宽了一些限制,但整体上对无参构造函数、字段初始化器和析构函数仍有较多约束。传统上struct不能声明显式无参构造函数,实例字段也不能在声明时直接初始化。因此定义一个struct时,通常需要把初始化逻辑集中在带参构造函数或属性赋值中。
多态行为也明显不同。class可以通过虚方法和重写实现动态分派,例如基类引用指向子类对象时调用子类重写的方法。struct虽然可以重写 ToString、Equals、GetHashCode 等来自 object 的虚方法,但自身不能定义新的虚方法,也没有继承层次。默认相等性方面,class使用引用相等,只有两个引用指向同一对象时才相等;struct继承自 ValueType,默认使用反射逐字段比较,虽然语义上更接近值相等,但性能较差。因此只要把struct用作字典键或需要比较相等性,强烈建议重写 Equals 和 GetHashCode。
下面是一个实现接口的struct示例,展示struct也能参与一定程度的抽象,但没有继承能力:
interface IMeasurable
{
double GetArea();
}
struct Rectangle : IMeasurable
{
public double Width;
public double Height;
public double GetArea()
{
return Width * Height;
}
}
IMeasurable shape = new Rectangle { Width = 3, Height = 4 };
double area = shape.GetArea(); // 这里会发生装箱,因为接口类型是引用类型
代码中 shape 变量类型是接口 IMeasurable,把struct赋值给接口变量时发生装箱,这是性能分析中需要特别留意的点。如果只是局部调用 GetArea,直接使用 Rectangle 类型可以避免装箱。
三、性能影响:装箱、复制成本与垃圾回收压力
选择struct还是class,性能往往是决定性因素之一。struct在局部变量场景下分配在栈上,栈的分配只是移动栈指针,几乎零成本,方法返回后自动清理,不产生垃圾回收压力。class对象在堆上分配,当对象不再被引用时需要GC介入回收。如果程序高频创建大量小对象,例如在循环中生成坐标点、向量、颜色值,使用class可能导致频繁的第0代垃圾回收,影响吞吐和延迟。
但struct也并非没有代价。值类型赋值和传参会复制整个结构体,如果struct体积很大,拷贝成本可能超过堆分配和GC开销。官方设计指南建议将struct保持为不可变且大小不超过16字节。超过这个规模时,应更倾向于使用class,或者使用 readonly struct 和 ref 参数传递来减少复制。
装箱是另一个隐藏开销。每当把一个struct赋值给 object、接口或传递到需要 object 的方法时,都会生成一个堆上的装箱副本。频繁装箱不仅产生垃圾,还会造成额外的内存拷贝。泛型可以有效避免装箱,例如 List<PointStruct> 在存储和遍历时不会对元素装箱,而 ArrayList 之类的非泛型集合则会。因此在现代C#中,优先使用泛型集合存放struct。
下面的代码展示了大量小对象场景下,struct和class在数组遍历时的性能差异:
struct Vector3
{
public float X;
public float Y;
public float Z;
}
class Vector3Class
{
public float X;
public float Y;
public float Z;
}
var structArray = new Vector3[100000];
for (int i = 0; i < structArray.Length; i++)
{
structArray[i].X = i;
}
var classArray = new Vector3Class[100000];
for (int i = 0; i < classArray.Length; i++)
{
classArray[i] = new Vector3Class();
classArray[i].X = i;
}
上面的 for 循环中,structArray 的元素是内联在数组中的连续内存,赋值时不需要额外的堆分配;而 classArray 的每个元素需要单独 new 一个对象,数组只保存引用,堆上会多出10万个独立对象。这个差异在高频更新场景中会非常明显。
四、选型检查清单与常见误区
判断逻辑上是否具有值语义是第一步。如果两个对象在数据完全相同的情况下应当被视为相等,例如坐标、复数、颜色、日期范围,那么struct更符合直觉。如果对象代表一个独立的实体,有自己的生命周期和身份,即使所有字段都相同也不应视为同一个对象,例如用户、订单、数据库连接,那么class更合适。
可以从下面几个问题入手:是否需要继承或多态?是则用class;对象是否小于16字节且不可变?是则优先struct;是否需要在方法间共享同一份状态?是则用class;是否频繁在泛型集合中存储与遍历?是则struct可能更高效;是否经常赋值给object或接口?是则struct可能带来装箱开销,需要重新评估。