一个 enum 竟比 class 还节省?聊聊 Swift 枚举关联值的存储优化

0 阅读4分钟
enum Barcode {
    case upc(Int, Int, Int, Int) //关联值定义时,只需要注明类型
    case qrCode(String)
}

枚举的 case 可以携带附加信息,这些信息在创建实例时提供。可以像这样声明使用

var productBarcode = Barcode.upc(885909512263)
productBarcode = .qrCode("ABCDEFGHIJKLMNOP")

怎么提取关联值呢?在 switch 中可以使用 let 或 var 来提取关联值:

switch productBarcode {
case .upc(let numberSystem, let manufacturer, let product, let check):
    print("UPC: \(numberSystem)\(manufacturer)\(product)\(check).")
case .qrCode(let productCode):
    print("QR code: \(productCode).")
}

Swift 在内部使用一种 tagged union(带标签的联合体) 来实现这种结构。

enum Value {
    case int(Int)
    case text(String)
}

这样的枚举相当于

struct Value {
    uint8_t tag;   // 哪一个 case
    union {
        Int intValue;
        String stringValue;
    } payload;
};

你可以把 union 理解成:多种字段共用同一块内存,同一时刻“逻辑上只应有一个有效值”。编译器会保证:这个联合体的大小足够容纳所有 case 中的最大内容。

仅有 union 不够,因为运行时不知道当前有效成员是哪一个。

所以要加  tag 标识当前是哪一个 case(0 -> int, 1 -> text)

switch  时编译器先看 tag,再按对应 case 解包 payload:

switch v {
case .int(let x):
    // 按 Int 解包 payload
case .text(let s):
    // 按 String 解包 payload
}

上述的struct代码示例是“总有一个显式 tag 字段”。
Swift 更聪明:很多时候不需要单独存这个 tag,或者 tag 很小。

这里举一些例子说明swift可能如何对上述的struct Value结构进行优化

1、对某些指针类型,某些比特模式本来就不会是有效对象地址

Optional<T> 其中的T是指针类型,就可以用union部分这样来表示

- 全 0x00...00  => .none
- 非 0          => .some(pointer)

这只适用指针类型,如果是Int,全0这种情况会真的用来标志值为0的情况,这种情况就不能省略tag了

2、multi-payload enum(多个 case 都带关联值)

这种情况比Optional难,因为要区分的不止 .none/.some
Swift 的策略是:先“借”payload里的无效位模式(extra inhabitants / spare bits)编码一部分 tag,不够再加显式 discriminator 字节。

为了说清楚这里的无效位,我们先了解一下地址宽度和内存对齐

例如地址宽度为64bit,那么可以理解指针为8字节大小

8 字节对齐,可以通过以下这个struct示例来理解:

struct S1 {
    var a: Int8   // 1字节, 对齐1
    var b: Int    // 8字节, 对齐8
}
//a 放在 offset 0(占 1 字节)
//b 必须从 8 的倍数地址开始 -> 会在中间补 7 字节 padding
//总大小常为 16(末尾还要满足整体对齐)

了解了这些还不够,因为multi-payload enum在发生无效位借用时常常存储的是指针关联值,64bit的寻址,说明地址就是64bit,8字节对齐的情况下,无效位是从哪里得到的?

enum E2 {
    case a(AnyObject)
    case b(AnyObject)
    case c(AnyObject)
    case d(AnyObject)
}

答案是由对齐带来的低位恒定带来的。对齐为什么会带来低位恒定很好理解

我们访问地址时,要么是0x1000  二进制:0000 0000 0000 1000

要么是0x1008 二进制:0001 0000 0000 1000

但不会是0x1007 二进制:0001 0000 0000 0111

可以看出针对8字节对齐的地址访问,都是可以整除2的3次方的,这就像我们要求10进制的数字要整除10的3次方。这两种情况都会导致末尾3位恒0。

既然末尾3位一定为0,编译器就可以“从中作梗”,使用这3位来表示tag,例如:想要取出原地址只需要与1111 1111 1111 1000进行与操作就好,这只是一种假设,具体编译器如何做我们可以不必在意。

如果本文对你有帮助,可以关注微信公众号iOS开发小挖,学海无涯,但学到就是赚到!