Menu

C# 结构体:值类型、struct 与 class 的区别及使用场景

C# 中结构体的工作方式:值语义与赋值时复制,List<T> 修改报错,构造函数规则,相等性,装箱,readonly struct 与 record struct,以及 struct 与 class 的对比和何时该选用结构体。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

结构体(struct)是你自己定义的值类型。它看起来像类,有字段、属性、方法和构造函数,但结构体类型的变量直接包含数据,而不是对某个对象的引用。这一个区别改变了赋值、方法调用、相等性和 null 的行为。

值语义:赋值即复制

内置的数值类型、bool、char、DateTime、TimeSpan 和 Guid 都是结构体。你自己定义的结构体也一样:

输出:

struct: s1.X = 1, s2.X = 50
class:  c1.X = 50, c2.X = 50
after Move: s1.X = 1, c1.X = 150

使用结构体时,s2 是一个独立的副本,Move 操作的也是它自己的副本。要让方法修改调用方的结构体,请用 ref 按引用传递(见 ref 和 out)。为了在不允许修改的前提下避免复制大型结构体,C# 7.2 新增了 in 参数。

作为值类型的其他后果:

  • 结构体变量永远不能为 null。PointS p = null; 无法编译。当“没有值”有意义时,请使用 PointS?(可空值类型)。
  • 未初始化的结构体全部为零:数值字段为 0,bool 字段为 false,引用字段为 null。
  • 结构体不能继承其他结构体或类,任何类型也不能继承结构体。它可以实现接口。
  • 局部结构体变量通常位于栈上或包含它的对象内部,所以创建一个结构体不会在堆上分配内存。

结构体列表的陷阱:CS1612

因为读取结构体会得到一个副本,所以下面这行看似无害的代码无法编译:

var points = new List<PointS> { new PointS(1, 2) };
points[0].X = 10;
// error CS1612: Cannot modify the return value of 'List<PointS>.this[int]' because it is not a variable

List<T> 的索引器是一个返回元素副本的方法。在这个临时副本上设置 X 不会改变任何东西,所以编译器会阻止你。当属性返回结构体时也会出现同样的错误:order.Location.X = 10。复制、修改、写回:

输出:

35
7

数组是例外:array[0] 就是元素本身,而不是副本。这个反复出现的麻烦,正是“让结构体不可变”这条标准建议的原因:如果结构体无法修改,修改副本也就不会丢失任何东西。

构造函数与默认值

在 C# 7 到 9 中,结构体构造函数的规则很严格:

  • 不能声明无参构造函数。new PointS() 始终存在,并把每个字段清零。
  • 你声明的构造函数必须在返回前给每个字段(以及自动属性)赋值。
  • 实例字段不允许使用字段初始化器(public int X = 1;)。

输出:

19.90 EUR
0.00 (none)
0.00 (none)
0.00 (none)

Money 也说明了默认值为什么重要:清零的 Money 的货币是 null,你的代码必须处理这种情况,因为数组、default(T) 和未初始化的字段都会产生这样的值。

更新的版本放宽了这些规则:

// C# 10: explicit parameterless constructors and field initializers
public struct Settings
{
    public int Retries = 3;
    public Settings() { }
}

// C# 11: fields you do not assign in a constructor are zeroed automatically,
// instead of being a compile error.

C# 10 的无参构造函数会在 new Settings() 时运行,但在 default(Settings) 或数组元素中不会运行,它们仍然全部为零。这种分裂常常让人意外,所以请谨慎使用。

readonly struct(C# 7.2)

把结构体本身标记为 readonly,编译器就会强制它不可变:每个字段都必须是 readonly,每个自动属性都只能有 get。

public readonly struct Temperature
{
    public double Celsius { get; }
    public Temperature(double celsius) { Celsius = celsius; }

    public Temperature WarmerBy(double delta) => new Temperature(Celsius + delta);   // returns a new value
}

除了表明意图,它还有助于性能:当一个非只读结构体存储在 readonly 字段中或作为 in 参数传递时,编译器会在每次调用方法前复制它(因为它无法知道该方法会不会修改结构体)。readonly struct 不需要这种防御性复制。在 C# 7.0 上,你仍然可以把每个字段都设为 readonly,就像 Money 使用只读属性那样:这样能得到不可变性,但省不掉复制,因为编译器只信任声明为 readonly 的结构体。

相等性

结构体上的 Equals 默认逐个字段比较,这正是你想要的值行为。但默认实现(ValueType.Equals)可能使用反射,速度很慢,而且 == 运算符根本没有定义:对你自己的结构体写 a == b 会导致错误 CS0019。当结构体需要比较时,请把两者都实现:

输出:

True
True
True

实现 IEquatable<T> 对集合很重要:HashSet<T>、Dictionary<TKey, TValue> 和 List<T>.Contains 会直接调用 Equals(GridCell),而不是先把每个值装箱再调用 Equals(object)。记录结构体(见下文)会为你生成这一切。

装箱

把结构体转换为 object 或接口类型会对它进行装箱:运行时把值复制到一个新的堆对象中。之后装箱的对象和原始值就相互独立了:

输出:

2
0

装箱每次都要分配一次内存,这就是旧的非泛型集合(如 ArrayList)处理值类型时很慢的原因,也是 泛型 取代它们的原因。这也意味着通过接口访问的可变结构体,被修改的是装箱后的对象,而不是原始值,这是让结构体保持不可变的又一个理由。

struct 与 class 对比

structclass
种类值类型引用类型
赋值和参数复制数据复制引用
能否为 null不能(T? 可以)能
默认值所有字段为零null
继承不能继承;可以实现接口单一基类,接口
==除非重载,否则未定义除非重载,否则比较引用
Equals 默认行为比较字段比较引用
分配内联(栈或包含它的对象)堆,由垃圾回收器管理
适用于小而不可变的值实体,大型或共享的状态

什么时候用结构体

当以下条件全部满足时选择结构体:这个类型是一个逻辑上的值(坐标、金额、颜色、日期范围),它很小(微软的建议是大约 16 字节,相当于四个 int),它是不可变的,而且你不需要继承。当你要创建大量实例时,结构体的优势才会体现出来,比如数组里的数百万个点,因为它们省去了每一项的堆分配和垃圾回收开销。

对于任何有身份的东西(客户、订单)、任何大型的东西、任何会在多处被修改的东西,以及你拿不准的时候,都请选择类。一个大型的可变结构体既有复制的开销,又有修改副本带来的困惑。

record struct(C# 10)

C# 10 新增了 record struct,一行代码就能为结构体生成值相等、==、ToString 以及 with 支持:

public readonly record struct Coordinate(double Lat, double Lng);

var home = new Coordinate(38.72, -9.14);
var same = new Coordinate(38.72, -9.14);
Console.WriteLine(home == same);            // True
Console.WriteLine(home);                    // Coordinate { Lat = 38.72, Lng = -9.14 }
var north = home with { Lat = 38.80 };

普通的 record struct 拥有可变的位置属性;readonly record struct 让它们变成只能在初始化时赋值,这通常正是你想要的。记录页面介绍了类版本,并展示了编译器生成的成员。

常见错误

  • 通过副本修改结构体。 list[0].X = 1(CS1612)、foreach 变量(CS1654)、属性 getter 的结果。把修改后的副本写回去,或者让结构体不可变。
  • 大型结构体。 每次赋值和调用都会复制整个结构体。超过几个字段后,类通常更快。
  • 忘了默认值是全零。 数组和 default(T) 创建结构体值时不会运行你的构造函数,所以你在构造函数里校验的字段仍可能是零或 null。
  • 没有定义 == 就用它比较。 CS0019。请实现 IEquatable<T> 和运算符,或者使用记录结构体。
  • 接口背后的可变结构体。 装箱意味着修改发生在一个你看不到的副本上。

常见问题

C# 中的结构体是什么?

结构体是用户自定义的值类型:struct Point { public int X; public int Y; }。结构体类型的变量保存的是数据本身,而不是对某个对象的引用,所以赋值和传给方法时都会复制整个值。int、double、DateTime 和 Guid 都是结构体。

C# 中结构体和类有什么区别?

类是引用类型:多个变量共享同一个对象,并且允许为 null。结构体是值类型:每个变量都有自己的副本,它不能为 null(除非你用 Point?),既不能继承也不能被继承,默认值是所有字段都为零。结构体适合小而不可变的值;类适合有身份和行为的实体。

C# 中什么时候应该用结构体而不是类?

当这个类型表示一个单一的小值(坐标、金额、日期范围),最好是不可变的,按内容比较,并且会大量创建、需要避免堆分配的时候。微软的建议还要求大小约为 16 字节或更小。如果这个类型有身份、字段很多,或者会通过引用被修改,就用类。

为什么会出现“无法修改返回值,因为它不是变量”的错误?

这是错误 CS1612,通常来自对结构体组成的 List<Point> 写 list[0].X = 5。索引器返回的是结构体的一个副本,修改副本的结果会丢失,所以编译器拒绝这样做。把元素复制到一个变量中,修改它,再赋值回去:var p = list[0]; p.X = 5; list[0] = p;。数组没有这个问题,因为 array[0] 指向的就是元素本身。

C# 中的 readonly struct 是什么?

readonly struct(C# 7.2)声明结构体的任何成员都不会修改其状态:所有字段必须是 readonly,自动属性只能有 get。编译器会强制执行这一点,并且当结构体通过 in 传递或存储在只读字段中时,编译器可以避免防御性复制。大多数结构体都应该是只读的。

Coddy 编程语言插图

和 Coddy 一起学编程

开始