Swift中Enum枚举底层探索

2,648 阅读6分钟

前言

枚举值的定义与用法这里就不多提了,不过不得不说,swift中的Enum非常强大,它不但有传统语言的原始值,还可以放关联类型的值,甚至是定义类属性,计数属性等,那么在这么强大的功能的背后,底层是如何实现的呢?

枚举在源码中的实现EnumMetadata

EnumMetadataStructMetadata基本上一样,这里就不在细说了,不清楚的可以看我这篇文章:Swift结构体底层StructMetadata。而且老样子,我也把EnumMetadata源码翻译成swift代码实现了,GitHub地址在这。

EnumMetadataStructMetadata唯一的区别在于,Description中的属性NumFieldsFieldOffsetVectorOffset变成了NumPayloadCasesAndPayloadSizeOffsetNumEmptyCases

NumPayloadCasesAndPayloadSizeOffset是个UInt32,低24位存放了带有关联值枚举的个数,高8位放了最大有效负荷数。NumEmptyCases放了非关联值枚举的个数,也就是带有原始值或者啥也不带的枚举的个数。NumPayloadCasesAndPayloadSizeOffset取低24位加上NumEmptyCases后,能够得到枚举的总个数,上面翻译的代码有实现。

枚举在内存中的结构

先来一段最简单的代码:

enum Animal {
    case bird
    case cat
    case dog
}
var obj = Animal.dog
withUnsafePointer(to: &obj) {
    print($0)
}

看下内存中存了什么:

我们看到Animal.dog在内存中存放的是2,测下来Animal.bird0Animal.cat1。不过这些应该都在我们的意料之中,因为在OC中或者C语言中也是这样设定的,如果你不给枚举值赋值的话,他们的枚举从上到下,从0开始,依次递增。

枚举原始值的底层实现

如果我们给刚才的枚举添加原始值rawValue,情况会是怎么样呢?我们来看下:

这回有点出乎意料了,Animal.dog在内存中存放的依然是2,而不是我们赋值的7,那么我们赋值的rawValue去哪了呢?

我们把如下代码转成sil文件看下:

enum Animal: Int {
    case bird  = 10
    case cat   = 27
    case dog   = 7
}
let value = Animal.dog.rawValue

Enumsil文件中的定义:

enum Animal : Int {
  case bird
  case cat
  case dog
  typealias RawValue = Int
  init?(rawValue: Int)
  var rawValue: Int { get }
}

我们可以看到,编译器自动帮我们添加了计算属性rawValue,因为rawValue是计算属性,是不会放在内存里的,所以我们在打印内存的时候,看不到rawValue的值的。那么是如何得到rawValue的值的呢?因为在sil文件中都是判断语句的代码,太长了,所以我转成swift翻译下:

enum Animal: Int {
    case bird  = 10
    case cat   = 27
    case dog   = 7
    
    var rawValue: Int {
        switch self {
        case .bird:
            return 10
        case .cat:
            return 27
        case .dog:
            return 7
        }
    }
    
    init?(rawValue: Int) {
        if rawValue == 10 {
            self = .bird
        } else if rawValue == 27 {
            self = .cat
        } else if rawValue == 7 {
            self = .dog
        } else {
            return nil
        }
    }
}

底层逻辑就是这样的,有趣的是,如果我们自己实现了这些方法,那么编译器就不会自动生成这些方法。比如说,你在匹配到.dog时,返回的是8,那么你获取rawValue的时候,获得的是8,并不是你在定义中的7,就像这样:

CaseIterable协议

我们有时候有需求,遍历整个枚举或者要知道枚举的个数。不知道你们在OC中怎么解决的,我是用的宏。不过在Swift中就简单了,只要让枚举遵守CaseIterable协议,就能自动实现。我们看下CaseIterable协议定义:

public protocol CaseIterable {

    /// A type that can represent a collection of all values of this type.
    associatedtype AllCases : Collection where Self == Self.AllCases.Element

    /// A collection of all values of this type.
    static var allCases: Self.AllCases { get }
}

遵守协议后,就能出现类属性allCases,而且这个属性遵守Collection协议,那么就可以遍历自己所有的枚举值了:

extension Animal: CaseIterable {

}

let x = Animal.allCases
Animal.allCases.forEach {
    print($0)
}

至于原理,emmmm,和上面一样,是编译器帮你完成的,我们看下sil文件

image.png

先生成了一个数组,然后把所有的枚举生成放在数组里,然后返回。不过这个过程是由编译器生成的,就不会存在以后加了枚举值,而在业务代码里忘记添加新枚举的情况了。

枚举的关联值

我们写一个简单的枚举值:

enum Animal {
    case bird(Int)
    case cat(Int, Double)
    case dog(String, Double, Int)
}
var obj = Animal.cat(22, 1.5)

sil文件看下:

image.png

我们可以看到,枚举里保持了原有的定义,并没有为我们实现其它额外的功能,在生成枚举的时候,先创建了一个元祖,然后把元祖给了枚举用来初始化,说明所有枚举的关联值都是元祖类型。

既然sil文件中看不出什么,我们就探索下它的内存吧

枚举的内存大小

没有原始值没有关联值的枚举

enum Animal {
    case bird
    case cat
    case dog
}

print("size: \(MemoryLayout<Animal>.size)")
print("stride: \(MemoryLayout<Animal>.stride)")
print("alignment: \(MemoryLayout<Animal>.alignment)")

/**
 size: 1
 stride: 1
 alignment: 1
 */

上面我们也说过,枚举值在内存中存的就是从0开始,依次往上涨的值,而一个字节能表示256个数,所以当枚举值的个数在256以下时,他的sizestride都是1。当个数超过256个时,sizestride都会变成2,这个我测过,就不展示出来了。

带有原始值的枚举

enum Animal: String {
    case bird
    case cat
    case dog
}

print("size: \(MemoryLayout<Animal>.size)")
print("stride: \(MemoryLayout<Animal>.stride)")
print("alignment: \(MemoryLayout<Animal>.alignment)")

/**
 size: 1
 stride: 1
 alignment: 1
 */

我们看到结果和上面的一样,其实也不难理解,有原始值的枚举带有rawValue属性,但是rawValue属性是一个计算属性,不会占用内存,所以和没有原始值的枚举内存模型一模一样。

关联值的枚举

enum Animal {
    case bird(Int)
    case cat(Int, Double)
    case dog(String, Double, Int)
}

print("size: \(MemoryLayout<Animal>.size)")
print("stride: \(MemoryLayout<Animal>.stride)")
print("alignment: \(MemoryLayout<Animal>.alignment)")

/**
 size: 33
 stride: 40
 alignment: 8
 */

我们看到对齐大小alignment8,那么stride会取比size稍微大一点的,并且是alignment倍数的数,也就是40了,这些都没有问题。关键就在于size怎么得到的33。 我们整理下基础类型大小,String16字节,Int8字节,Double8字节,枚举本身占1字节,加起来正好是33个字节。那我们是不是能猜测,关联值枚举的大小取决于size最大的元祖类型大小及枚举本身大小。

我们先看下内存中的值:

image.png

被红色方框圈起来的依次放的:String类型的abcDouble类型的55Int类型的42及最后的枚举值2,正好是33个字节。

关联值枚举的内存结构是前面放元祖的数据,后面跟上枚举值,所以关联值枚举的大小基本上等于关联值元祖的大小加上枚举本身的大小。当然可能会有一点出入,和内存对齐有关,这个探索起来情况比较多,感兴趣的可以自己测试下。

indirect关键字底层分析

有时候,我们会碰到枚举嵌套自己本身,这个时候需要用到我们的indirect关键字。

例如,我们定义一个链表结构的枚举:

enum List<T>{
    case end
    indirect case node(T, next: List<T>)
}

也可以写成这样:

indirect enum List<T>{
    case end
    case node(T, next: List<T>)
}

我们知道,枚举是一个值类型,那么在编译之前就能确认大小,但是想这样无限嵌套自己的根本无法确定固定的大小,怎么办呢?

我们先查看下该枚举的大小:

print("size: \(MemoryLayout<List<String>>.size)")
print("stride: \(MemoryLayout<List<String>>.stride)")
print("alignment: \(MemoryLayout<List<String>>.alignment)")

/**
 size: 8
 stride: 8
 alignment: 8
 */

我们把范型定义成了StringString本身就有16个字节,那么枚举至少要比16个字节大吧,结果却是8个字节,怎么回事呢?我们看下内存情况:

image.png

很明显,内存中存放了一个指针,所以被indirect关键字修饰过的枚举会变成指针类型。

我们在看一个神奇的对比:

image.png 和:

image.png

就因为indirect关键字的位置不一样,枚举的size也不一样,原因是indirect关键字放在enum前面,那么枚举的每一个选项都变成了指针,也就是引用类型,而如果指放在单个的case前面,那么只会把单独的case变成引用类型。

我们接下来看下indirect关键字如何把值类型变成引用类型的,下面是源码中Mirror解析enum关键代码:

image.png

我们发现包装起来调用的是swift_allocBox,看下swift_allocBox的实现:

struct BoxPair {
  HeapObject *object;
  OpaqueValue *buffer;
};

BoxPair swift::swift_allocBox(const Metadata *type) {
  // Get the heap metadata for the box.
  auto metadata = &Boxes.getOrInsert(type).first->Data;

  // Allocate and project the box.
  auto allocation = swift_allocObject(metadata, metadata->getAllocSize(),
                                      metadata->getAllocAlignMask());
  auto projection = metadata->project(allocation);

  return BoxPair{allocation, projection};
}

我们看到swift_allocBox在堆上开辟了一块空间,然后会把值放在HeapObject后面,具体要看的话,可以源码搜索TargetGenericBoxHeapMetadata,就不一起看了,有点扯远了。

结语

枚举还有好多功能没有讲,但是真的非常强大。

不会结语了,喜欢本文的点个赞 = =