10. 枚举-Enum

212 阅读10分钟

前言

Swift中的枚举非常灵活,除了定义枚举成员,还可以定义属性,方法,可以有拓展,也可以遵循协议。

一、简单用法

import Foundation

/// 枚举值
enum Color {
    case red, white, cyan
}

/// red
print(Color.red)

/// RawValue(原始值)
enum Direction: String {
    case east, south = "南", west, north
}

/// east
print(Direction.east.rawValue)
/// 南
print(Direction.south.rawValue)

/// 隐式RawValue自增(基于类型推导)
enum Level: Int {
    case a = 1, b, c
}

/// 1
print(Level.a.rawValue)
/// 2
print(Level.b.rawValue)

/// 关联值
enum Shape {
    case circle(radius: Double)
    case rectangle(width: Double, height: Double)

    /// 面积
    func area() -> Double {
        switch self {
        case let .circle(radius):
            return Double.pi * radius * radius

        case let .rectangle(width, height):
            return width * height
        }
    }
}

/// 314.1592653589793
print(Shape.circle(radius: 10).area())
/// 50.0
print(Shape.rectangle(width: 5, height: 10).area())

枚举的用法并不复杂,熟悉语法即可。接下来,我们分析一下枚举中的原始值。

二、 原始值(RawValue)

1. 原始值的本质

这里我们先添加代码:

import Foundation

enum Test: String {
    case one, two, three
}

let a = Test.one.rawValue

直接swiftc -emit-silgen -Onone main.swift > ./main.sil查看SIL

enum Test : String {
  case one, two, three
  typealias RawValue = String
  init?(rawValue: String)
  var rawValue: String { get }
}

SIL来看,Test有了一个可失败初始化器,一个String类型的别名RawValue, 还有个rawValue属性生成了get方法。我们重点看看rawValueget方法:

image-20220111170813489

这里注意一下rawValueget方法s4main4TestO8rawValueSSvg,我们搜索这个方法:

image-20220111171904011

这里可以看到,首先声明了默认的self,这个self其实就是当前的枚举Test,然后放到%0中。接着对%0进行模式匹配,这里·对应的是bb1。我们看bb1,其实这里是创建了一个硬编码字符串one返回了,硬编码字符串我们可以借助MachOView看一下:

image-20220111172811956

硬编码字符串在__TEXT__csting中,我们进去就能看到value中枚举类型的值都在value中,所以获取rawValue其实就是获取硬编码字符串。那么可失败初始化器(init?(rawValue: String))在做什么?

2. 可失败初始化器(init?(rawValue: String)

这里先添加案例代码:

import Foundation

enum Test: String {
    case one, two, three
}

let a = Test(rawValue: "four")
print(a)

/* 执行结果
nil
Program ended with exit code: 0
*/

从这可以看到,初始化一个不存在的枚举类型返回的结果是nil。我们继续看SIL

image-20220111180149701

SIL中可以找到Test的初始化方法,这里截取片段分析一下。注意一下_allocateUninitializedArrayStaticString,这里其实是系统申请了一段连续的内存空间,然后把硬编码字符串(__cstring)放在这段连续的内存空间中。后面的Build其实是在这段内存空间中作偏移来匹配传进来的rawValue,如果匹配得上则返回对应的枚举类型,如果匹配不上,则返回nil。简单来说init?(rawValue: String)是在连续的空间匹配原始值rawValue,这样性能较高。

三、内存大小

这里需要区分几种不同的情况:

  • No-payload enums(无负载)
  • Singlepayload enums(单个负载)
  • Mutil-payload enums(多个负载)
  • 特殊情况
1. No-payload enums

这里我们直接添加案例代码:

import Foundation

enum Test {
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")

/* 执行结果
1
1
end
Program ended with exit code: 0
*/

执行之后可以发现Testsizestride都是1。为什么会是1呢?原因是在Swift中进行枚举内存布局一直是尝试用最少的空间存储枚举,对于Testcase数量来说,UInt8完全能够表示。因为UInt8能够表示256种case,这也就意味着一个默认类型且没有关联值的枚举,若它的case不超过256,该枚举类型的大小都是1字节。 我们可以验证一下:

image-20220111210311606

这里可以看到,a、b、c的地址是连续的(只差1位),再看他们存储的值,00,01,02也是连续的。也就是说对于一个默认类型且没有关联值的枚举来说它是以UInt8的方式存储枚举值,它的case是以0x00x10x2这种形式依次累加存放在内存中进行标示。那如果case数量超过256怎么办?它会由UInt8自动升级为UInt16,虽然Swift在节约内存上设计的还可以,但是如果有这么多的case还用枚举是不是不太合适了😁?

2. Singlepayload enums

这里我们直接添加案例代码:

enum Test {
    case zero(Bool)
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")

/* 执行结果
1
1
end
Program ended with exit code: 0
*/

执行之后可以发现Testsizestride还是1。为什么多了一个Bool类型的关联值后结果还是1呢?这里需要注意一下,对于单个负载类型的枚举来说,Swift并不是无脑的用关联类型大小加case的1(UInt8),而是看关联类型有没有额外的空间存储case。对Test.zero的关联类型Bool来说,它虽然是8位(1字节),但只需要1位就可以标示truefalse,所以剩下7位(common spare bits)足够存储Test的其它case。除非其它case太多(超过128),否则1个字节完全足够标示全部的case。枚举的这种内存布局方式在源码中可以看到:

image-20220112152318063

这里可以看到,单负载的枚举会判断关联类型剩余空间是不是大于或等于额外case,是的话size就是关联类型大小,否则的话size就是关联类型大小加上额外case占用的最小空间大小。这也就是说案例中的Bool和其它case是共用一段内存空间,这里我们可以验证一下:

image-20220112095440783

通过断点调试可以看出,Test.zero的关联类型Bool在内存中的标识就是0100,后面紧跟着的是其它case的标识,这也验证了我们的推论。我们再看另一种情况:

enum Test {
    case zero(Int)
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")

/* 执行结果
9
16
end
Program ended with exit code: 0
*/

执行之后可以发现Testsize是9,stride是16。stride为16是因为需要内存对齐,这个不难理解。那size为什么是9呢?这里的关联类型Int占8字节,它并没有多余的空间存储其它的case,所以其它case占1字节,加起来是9字节。

3. Mutil-payload enums

多个负载也分几种情况,我们先看一种情况:

import Foundation

enum Test {
    case zero(Bool)
    case other(Bool)
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")

/* 执行结果
1
1
end
Program ended with exit code: 0
*/

执行之后可以发现Testsizestride都是1。这是因为关联类型都是Bool,所以1字节足够存储。接着看另外一种情况:

import Foundation

enum Test {
    case zero(Bool)
    case other(Int)
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")

/* 执行结果
9
16
end
Program ended with exit code: 0
*/

执行之后可以发现Testsize是9,stride是16。这种情况跟之前Singlepayload enums中的情况其实一样,Int占8字节,其它case占1字节。再看另外一种情况:

import Foundation

enum Test {
    case zero(Int)
    case other(Int, Bool)
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")

/* 执行结果
9
16
end
Program ended with exit code: 0
*/

执行之后可以发现Testsize是9,stride是16。这种情况也不难理解,取关联类型最大的sizeInt + Bool)就是9,内存对齐stride就是16。如果我们稍作修改:

import Foundation

enum Test {
    case zero(Int)
    case other(Bool, Int)
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")

/* 执行结果
17
24
end
Program ended with exit code: 0
*/

执行之后可以发现Testsize是17,stride是24。WTF?貌似不符合我们之前的推论啊!为啥size不是16?Bool + Int内存对齐后应该是16。这里需要注意,枚举中只能存在一种状态。case zero(Int) case other(Bool, Int)第一个关联值类型不同,它们(Int 和 Bool)无法共存,这就导致必须增加1字节来存储Bool或其它case,最后计算size应该按Int + Int + Bool计算的。这里我们验证一下:

image-20220112170459567

这里可以看到,a、 b、 c的地址都是相差stride的24,它们的第一个8字节存储的有多种类型的值,第二个8字节存储的是Int值。第一个8字节没办法标识全部类型值(Int + Bool + 其它case),必须得补充一个能标识其它类型值的空间(Bool + 其它case),也就是1。所以最终它的size就是Int + Int + Bool = 17。这个案例中因为没有其它位置能标识其它类型的值所以补充了空间,那如果换种情况:

import Foundation

enum Test {
    case zero(Int)
    case other(Bool, Int, Bool, Int)
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")

/* 执行结果
32
32
end
Program ended with exit code: 0
*/

这里执行结果sizestride都是32。按照之前的推论来分析,第一个8字节没办法标识全部,后面必须补充空间,但这里注意case other第三个参数类型是Bool,它完全足够标识其它case,所以没必要补充空间,内存对齐后size就是32。同理,如果case other第三个参数类型换成Int,后面就必须补充空间了:

import Foundation

enum Test {
    case zero(Int)
    case other(Bool, Int, Int, Int)
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")

/* 执行结果
33
40
end
Program ended with exit code: 0
*/

这里我们可以总结一下,当出现多个关联类型,我们要根据最大关联类型中的参数来和其它关联类型参数进行内存占位匹配,当所有参数类型都没有额外空间存储其它case,此时需要在末尾补充空间

4. 特殊情况

这里直接添加案例代码:

import Foundation

enum Test {
    case one
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")

/* 执行结果
0
1
end
Program ended with exit code: 0
*/

从执行结果看,单个casesize为0。原因很简单,它仅有一种case one的状态,没有标记的必要。再看另一种情况:

import Foundation

indirect enum Test {
    case five(Test)
    case zero(Int)
    case other(Bool, Int, Int, Int)
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")


/* 执行结果
8
8
end
Program ended with exit code: 0
*/

这种情况size为8,注意关键字enum前的关键字indirectindirect让枚举可以递归调用,但同时也让枚举实例在堆上创建。这里可以验证一下:

image-20220113094332839

image-20220113094439264

从这里可以看到Test.five(Test.one)的地址的确在堆上。这里如果将indirect关键字换个位置:

import Foundation

enum Test {
    indirect case five(Test)
    case zero(Int)
    case other(Bool, Int, Int, Int)
    case one, two, three
}

print(MemoryLayout<Test>.size)
print(MemoryLayout<Test>.stride)

print("end")


/* 执行结果
33
40
end
Program ended with exit code: 0
*/

这种情况下size为33,stride为40。indirectcase前面仅表示该case在堆上创建,其它case还是遵守之前Mutil-payload enums的规则,所以计算枚举的size时忽略indirect修饰的case存在即可。这里可以验证一下:

image-20220113100437694

这里可以看到,Test.five(Test.one)在堆上,Test.zero(3)还是正常的存储方式。

四、总结

枚举作为Swift中的常见类型在开发中使用频率非常高。与OC相比,Swift的枚举类型灵活很多,合理的运用会让代码结构更加清晰,比如Moya中枚举和协议结合的请求方式。