每周小结002

1 优化位枚举

1.1 原生HasFlag问题

  • 原生的HasFlag方法会将枚举装箱为Enum类型,产生性能开销
  • Enum本身是一个引用类型,所有具体的枚举类型比如MyColor都是值类型,并且继承自Enum(特殊的继承规则,就像所有C#对象都继承自object,它也是引用类型)。当调用基类Enum的方法时,自身需要装箱成Enum,参数也需要传递给Enum flag,会进行两次装箱
  • HasFlagFast 用泛型值类型参数替代 Enum 引用类型参数,从根源上避免了装箱。Unsafe.As 只是进一步优化了类型转换的性能,而泛型值类型参数才是避免装箱的关键
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 原生判断函数是Enum的实例方法,Enum本身是抽象类(引用类型)
public bool HasFlag(Enum flag){
// ...
}

// 替换 HasFlag 方法,避免装箱,仅适用于Int32枚举
public static bool HasFlagFast<T>(this T flags, T value) where T : struct, Enum
{
if (Enum.GetUnderlyingType(typeof(T)) != typeof(int))
throw new ArgumentException($"枚举类型 {typeof(T).Name} 的底层类型不是 Int32,无法使用此方法", nameof(T));
var f = Unsafe.As<T, int>(ref flags);
var v = Unsafe.As<T, int>(ref value);
return (f & v) == v;
}

1.2 内部发生了什么

1
bool result1 = myEnum.HasFlag(TestEnum.Flag2); 

通过SharpLab查看IL如下,发现进行了两次装箱

1
2
3
4
5
6
IL_0003: ldloc.0
IL_0004: box TestEnum
IL_0009: ldc.i4.2
IL_000a: box TestEnum
IL_000f: call instance bool [System.Runtime]System.Enum::HasFlag(class [System.Runtime]System.Enum)
IL_0014: stloc.1
  • 不只是Enum,所有值类型(int/struct)调用其引用类型基类(Enum/ValueType/object)的实例方法,(大部分)都会触发装箱。
    • 因为值类型存储在栈上,没有方法表;引用类型存储在堆上,有方法表
    • 要调用引用类型基类的实例方法,必须先把值类型 “包装” 成堆上的引用类型对象(装箱),才能通过方法表找到对应的方法。
  • 但是有例外:如果值类型重写了基类方法,就不会触发装箱(比如 int 重写了 Equals/ToString),调用该方法时不会装箱 —— 因为方法已经属于值类型本身,直接在栈上执行
    • 然而,如果你在重写方法里调用base.ParentFunc(),内部依旧会装箱的!
  • 调用引用基类的静态方法也不需要装箱,因为压根不依赖实例

1.3 为什么枚举是struct,但是却能继承class

  • 从语法上,枚举声明为 enum TestEnum { … },编译器会自动把它编译为 struct TestEnum : Enum;
  • 但 CLR 对枚举做了特殊处理:枚举的基类虽然是 Enum(class),但枚举实例本身是值类型,存储在栈上;
  • 当需要将枚举转换为 Enum 类型(比如传给 HasFlag 的参数),就必须装箱 —— 因为引用类型参数只能接收堆上的对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
TestEnum a = TestEnum.Flag1;
TestEnum b = TestEnum.Flag1;

// 直接比较值类型:true(栈上比较)
bool isEqual1 = a.Equals(b);

// 转换为Enum后比较:false(两次装箱产生不同的堆对象)
bool isEqual2 = ReferenceEquals((Enum)a, (Enum)b);

Console.WriteLine(isEqual1); // True
Console.WriteLine(isEqual2); // False

enum TestEnum
{
Flag1,
Flag2
}

1.4 值类型没有方法表的解释

  • 引用类型的方法调用逻辑(有方法表指针)
1
2
3
4
5
6
堆上的引用类型实例:[对象头(含方法表指针)] + [字段数据]
调用方法时:
1. 通过对象头的方法表指针找到类型的方法表;
2. 从方法表中找到要调用的方法地址;
3. 执行方法。
哪怕是空对象(new object()),对象头也会占用 8/16 字节(取决于 32/64 位),核心就是存方法表指针。
  • 值类型的方法调用逻辑(无方法表指针)

    值类型实例存储在栈上,结构是:[字段数据](无对象头、无方法表指针),调用方法分两种情况:
    • 情况 1:调用值类型自定义 / 重写的方法(比如 A.ToString())
      CLR 直接从程序集元数据中找到该方法的静态地址(编译期已确定);
      把栈上值类型实例的地址(this)传给方法,直接执行;
      全程不需要方法表指针,也不需要装箱 —— 这就是你定义的结构体方法能正常执行的原因。
    • 情况 2:调用基类(object/ValueType) 的方法(比如 base.ToString())
      基类方法需要依赖方法表执行(比如 object.ToString() 是虚方法);
      但值类型实例没有方法表指针,因此 CLR 必须先把值类型装箱(堆上创建引用类型对象,带方法表指针);
      通过装箱对象的方法表指针找到基类方法,执行调用。

总结:

  • “值类型没有方法表” → 准确表述是:值类型实例(栈上)没有方法表指针,方法表不随实例分配(引用类型实例必须带方法表指针);
  • 值类型可以定义 / 执行方法 → 因为自定义 / 重写方法的地址编译期已确定,CLR 直接通过静态地址调用,无需方法表;
  • 调用基类(引用类型)方法时 → 必须先装箱(生成带方法表指针的堆对象),才能通过方法表找到并执行基类方法。
  • 所以,struct没有方法表,不能被继承