03-📝Swift5常用核心语法|面向对象【闭包、结构体、类、枚举、值类型和引用类型】

1,689 阅读37分钟

image.png

一、概述

本系列文章旨在复习Swift5核心语法且适当进行底层原理探索,属于阶段性复习和巩固,以供日后进一步探索Swift语言的底层原理做铺垫。

整个系列文章如下,每一文章知识点独立成篇,欢迎各位按需或按兴趣点击阅读:

二、 闭包

1. 闭包表达式(Closure Expression)

    1. 在Swift中,可以通过func定义一个函数,也可以通过闭包表达式定义一个函数
    1. 闭包表达式格式如下:
    {
        (参数列表) -> 返回值类型 in
        函数体代码
    }
    
    var fn = {
        (v1: Int, v2: Int) -> Int in
        return v1 + v2
    }
    fn(10, 20)
    
    {
        (v1: Int, v2: Int) -> Int in
        return v1 + v2
    }(10, 20)
    
    1. 闭包表达式的简写如下:
    • case1
    var fn = {
        (v1: Int, v2: Int) -> Int in
        return v1 + v2
    }
    
    var fn: (Int, Int) -> Int = { $0 + $1 }
    
    • case2
    func exec(v1: Int, v2: Int, fn: (Int, Int) -> Int) {
        print(fn(v1, v2))
    }
    
    
    exec(v1: 10, v2: 20) {
        (v1, v2) -> Int in
        return v1 + v2
    }
    
    exec(v1: 10, v2: 20, fn: {
        (v1, v2) -> Int in
        return v1 + v2
    })
    
    exec(v1: 10, v2: 20, fn: {
        (v1, v2) -> Int in
        v1 + v2
    })
    
    exec(v1: 10, v2: 20, fn: {
        v1, v2 in return v1 + v2
    })
    
    exec(v1: 10, v2: 20, fn: {
        v1, v2 in v1 + v2
    })
    
    exec(v1: 10, v2: 20, fn: { $0 + $1 })
    
    exec(v1: 10, v2: 20, fn: +)
    

2. 尾随闭包

    1. 如果将一个很长的闭包表达式作为函数的最后一个实参,使用尾随闭包可以增强函数的可读性
    1. 尾随闭包是一个被书写在函数调用括号外面(后面)的闭包表达式
    func exec(v1: Int, v2: Int, fn: (Int, Int) -> Int) {
        print(fn(v1, v2))
    }
    
    exec(v1: 10, v2: 20) {
        $0 + $1
    }
    
    1. 如果闭包表达式是函数的唯一实参,而且使用了尾随闭包的语法,那就不需要在函数名后边写圆括号
    func exec(fn: (Int, Int) -> Int) {
        print(fn(1, 2))
    }
    
    exec(fn: { $0 + $1 })
    exec() { $0 + $1 }
    exec { $0 + $1 }
    exec { _, _ in 10 }
    

Swift中的sort函数用来排序的,使用的就是闭包的写法 -w449 -w597

var nums = [11, 2, 18, 6, 5, 68, 45]

//1.
nums.sort()

//2.
nums.sort { (i1, i2) -> Bool in
    i1 < i2
}

//3.
nums.sort(by: { (i1, i2) in return i1 < i2 })

//4.
nums.sort(by: { (i1, i2) in return i1 < i2 })

//5.
nums.sort(by: { (i1, i2) in i1 < i2 })

//6.
nums.sort(by: { $0 < $1 })

//7.
nums.sort(by: <)

//8.
nums.sort() { $0 < $1 }

//9.
nums.sort { $0 < $1 }
 
//10.
print(nums) // [2, 5, 6, 11, 18, 45, 68]

3. 闭包的定义(Closure)

严谨的定义是:

  • 一个函数和它所捕获的变量/常量环境组合起来,称为闭包
    • 一般指定义在函数内部的函数
    • 一般它捕获的是外层函数的局部变量\常量
    • 全局变量,全局都可以访问,内存只有一份,且只要程序不停止运行,其内存就不会回收
    typealias Fn = (Int) -> Int
    
    func getFn() -> Fn {
        var num = 0
        func plus(_ i: Int) -> Int {
            num += i
            return num
        }
        return plus
    }
    
    func getFn() -> Fn {
        var num = 0
        return {
            num += $0
            return num
        }
    }
    
     var fn1 = getFn()
     var fn2 = getFn() 
     
     fn1(1) // 1
     fn2(2) // 2
     fn1(3) // 4 
     fn2(4) // 6
     fn1(5) // 9 
     fn2(6) // 12
    

通过汇编分析闭包的实现 看下面示例代码,分别打印为多少

func getFn() -> Fn {
    var num = 0
    func plus(_ i: Int) -> Int {
        num += i
        return num
    }
    return plus
}

var fn = getFn()

print(fn(1)) // 1
print(fn(2)) // 3
print(fn(3)) // 6
print(fn(4)) // 10

我们通过反汇编来观察: -w1012 通过这句调用可以看出:

  • return plus之前,闭包(表层通过allicObject)底层会调用malloc函数进行堆内存的分配,也就是将拷贝num的值到堆上来持有不被释放
  • 而栈里的num由于getFn调用完毕就随着栈释放了,plus函数里操作的都是堆上的num
  • 调用malloc函数之前需要告诉系统要分配多少内存,需要24个字节来存储内存
    • (因为在iOS系统中,分配堆内存的底层算法有内存对齐的概念,内存对齐的参数是16)而通过malloc函数分配的内存都是大于或等于其本身数据结构所需内存的16的最小倍数,所以会分配32个字节内存

-w1014 -w596

我们打印rax寄存器的值可以知道:

  • 系统分配的32个字节,前16个字节用来存储其他信息
  • 而且从图上的圈起来的地方也可以看到,将0移动16个字节
  • 所以16个字节之后的8个字节才用来存储num的值

-w532

调用fn(1),将断点打在这里,然后查看反汇编指令

-w1009 -w575

然后调用到plus函数内部,再次打印rax寄存器的值,发现num的值已经变为1了

-w575

然后继续往下执行调用fn(2),发现num的值已经变为3了

-w606

然后继续往下执行调用fn(3),发现num的值已经变为6了

-w596

然后继续往下执行调用fn(4),发现num的值已经变为10了

闭包和类的相似之处

我们可以把闭包想像成是一个类的实例对象

  • 内存在堆空间
  • 捕获的局部变量\常量就是对象的成员(存储属性)
  • 组成闭包的函数就是类内部定义的方法

类似如下示例

class Closure {
    var num = 0
    
    func plus(_ i: Int) -> Int {
        num += i
        return num
    }
}

var cs = Closure()
cs.plus(1)
cs.plus(2)
cs.plus(3)
cs.plus(4)

而且通过反汇编也能看出类和闭包的共同之处:

  • 分配的堆内存空间前16个字节都是用来存储数据类型信息引用计数

再看下面的示例

如果把num变成全局变量呢,还会不会分配堆内存

typealias Fn = (Int) -> Int

var num = 0

func getFn() -> Fn {

    func plus(_ i: Int) -> Int {
        num += i
        return num
    }
    return plus
}

var fn = getFn()

print(fn(1)) // 1
print(fn(2)) // 3
print(fn(3)) // 6
print(fn(4)) // 10

我们通过反汇编可以看到,系统不再分配堆内存空间了

-w717

注意: 如果返回值是函数类型,那么参数的修饰要保持统一

func add(_ num: Int) -> (inout Int) -> Void {
    func plus(v: inout Int) {
        v += num
    }
    
    return plus
}

var num = 5
add(20)(&num)

print(num)

4. 自动闭包

我们先看下面的示例代码

如果调用getFirstPositive并传入两个参数,第一个参数符合条件,但是还需要调用plus来得到第二个参数,这种设计相比就稍许有些浪费了

// 如果第1个数大于0,返回第一个数。否则返回第2个数
func getFirstPositive(_ v1: Int, _ v2: Int) -> Int {
    v1 > 0 ? v1 : v2
}

func plus(_ num1: Int, _ num2: Int) -> Int {
    print("haha")
    return num1 + num2
}

getFirstPositive(10, plus(2, 4))

我们进行了一些优化,将第二个参数的类型变为函数,只有条件成立的时候才会去调用

func getFirstPositive(_ v1: Int, _ v2: () -> Int) -> Int {
    v1 > 0 ? v1 : v2()
}

func plus(_ num1: Int, _ num2: Int) -> Int {
    print("haha")
    return num1 + num2
}

getFirstPositive(10, { plus(2, 4)} )

这样确定能够满足条件避免多余的调用,但是可读性就会差一些

我们可以使用自动闭包@autoclosure来修饰形参

    1. @autoclosure会将传进来的类型包装成闭包表达式,这是编译器特性
    1. @autoclosure只支持() -> T格式的参数
    func getFirstPositive(_ v1: Int, _ v2: @autoclosure () -> Int) -> Int {
        v1 > 0 ? v1 : v2()
    }
    
    func plus(_ num1: Int, _ num2: Int) -> Int {
        print("haha")
        return num1 + num2
    }
    
    getFirstPositive(10, plus(2, 4))
    
    1. @autoclosure并非只支持最后一个参数
    func getFirstPositive(_ v1: Int, _ v2: @autoclosure () -> Int, _ v3: Int) -> Int 
    {
        v1 > 0 ? v1 : v2()
    }
    
    1. 空合并运算符??中就使用了@autoclosure来将??后面的参数进行了包装 -w860
    1. @autoclosure和无@autoclosure会构成函数重载,不会报错
    func getFirstPositive(_ v1: Int, _ v2: () -> Int) -> Int {
        v1 > 0 ? v1 : v2()
    }
    
    func getFirstPositive(_ v1: Int, _ v2: @autoclosure () -> Int) -> Int {
        v1 > 0 ? v1 : v2()
    }
    

注意:为了避免与期望冲突,使用了@autoclosure的地方最好明确注释清楚:这个值会被推迟执行

5. 通过汇编进行底层分析

1.分析下面这个函数的内存布局

func sum(_ v1: Int, _ v2: Int) -> Int { v1 + v2 }

var fn = sum
print(MemoryLayout.stride(ofValue: fn)) // 16

反汇编之后 -w717 可以看到:

  • 底层会先计算sum的值,然后移动到fn的前8个字节
  • 再将0移动到fn的后8个字节,总共占用16个字节 -w716

两个地址相差8个字节,所以是连续的,都表示fn的前后8个字节的地址值

2.分析下面这个函数的内存布局

typealias Fn = (Int) -> Int

func getFn() -> Fn {
    var num = 0
    
    func plus(_ i: Int) -> Int {
        return i
    }
    return plus
}

var fn = getFn()

print(Mems.size(ofVal: &fn)) // 16

反汇编之后 -w716

我们能看到:

    1. 先调用getFn
    1. 之后raxrdx会给fn分配16个字节

然后我们进入getFn看看raxrdx存储的值分别是什么

-w715

可以看到会将plus的返回值放到rax-w949

可以看到ecx和自己进行异或运算,并把结果0存储到rdx

所以回过头看第一张图就知道了,fn的16个字节中,前8个字节存储的是plus的返回值,后8个字节存储的是0
等同于将plus函数赋值给fn

var fn = plus()

3.分析下面这个函数的内存布局

我们将上面示例里的plus函数内部对num进行捕获,看看其内存布局有什么变化

typealias Fn = (Int) -> Int

func getFn() -> Fn {
    var num = 0
    
    func plus(_ i: Int) -> Int {
        num += i
        return num
    }
    return plus
}

var fn = getFn()

fn(1)
fn(2)
fn(3)

print(Mems.size(ofVal: &fn)) // 16

反汇编之后

-w945

我们可以看到,调用完getFn之后,会分别将raxrdx的值移动到rip+内存地址,也就是给全局变量fn进行赋值操作

我们通过打印获取fn的内存占用知道是16个字节,fn的前8个字节就是rax里存储的值,而后8个字节存储的是rdx里的值

我们只需要找到raxrdx里分别存储的是什么就可以了

-w947

可以看到在堆空间分配完内存之后的rax给上面几个都进行了赋值,最后的rdx里存储的就是堆空间的地址值

-w944

从这句看rax里存储的应该是和plus函数相关,下面我们就要找到rax里存储的是什么

-w947 -w946

而且我们调用fn(1)时也可以推导出是调用的全局变量fn的前八个字节

-w947

参数1会存储到edi

而经过上面的推导我们知道-0xf8(%rbp)中存储的是fn的前8个字节,那么往后8位就是-0x100(%rbp),里面放的肯定就是堆空间的地址值了,存储到了r13

我们在这里打断点,来观察rax里到底存储的是什么

-w947 -w946 -w947 -w947 -w949

经过一系列的跳转,重要来到了plus真正的函数地址

而且r13最后给了rsirdi中存储的还是参数1

-w947 -w946

进到plus函数中,然后找到进行相加计算的地方,因为传进来的参数是变化的,所以不可能是和固定地址值进行相加

-w947 -w946

通过推导得知rcx里存储的值就是rdi中的参数1

-w945 -w945

通过推导得知rdx里存储的值就是rsi中的堆内存的num地址

所以可以得知0x10(%rdx)也就是rdx跳过16个字节的值就是num的值

-w741

通过打印也可以证明我们的分析是正确的

-w947 -w946

通过推导可以发现rax中存储的是rsi的num的地址值

然后将rcx中的值覆盖掉rax中的num地址值

而且真正进行捕获变量的时机是在getFn即将return之前做的事

4.分析下面这个函数的内存布局

我们来看下面这个闭包里的变量会被捕获几次

typealias Fn = (Int) -> (Int, Int)

func getFns() -> (Fn, Fn) {
    var num1 = 0
    var num2 = 0

    func plus(_ i: Int) -> (Int, Int) {
        num1 += i // 6 + 0 = 6, 1 + 4 = 5,
        num2 += i << 1 // 1100 = 12 + 0 = 12, 1000 = 8 + 2 = 10
        return (num1, num2)
    }

    func minus(_ i: Int) -> (Int, Int) {
        num1 -= i // 6 - 5 = 1, 5 - 3 = 2
        num2 -= i << 1 // 1010 = 12 - 10 = 2, 0110 = 10 - 6 = 4
        return (num1, num2)
    }

    return (plus, minus)
}

let (p, m) = getFns()
print(p(6)) // 6, 12
print(m(5)) // 1, 2
print(p(4)) // 5, 10
print(m(3)) // 2, 4

反汇编之后

-w946

发现其底层分别会分配两个堆空间,但是num1、num2也只是分别捕获一次,然后两个函数plus、minus共有

三、结构体

1. 基本概念

  • 在Swift标准库中,绝大多数的公开类型都是结构体,而枚举和类只占很小一部分
    • 比如Bool、Int、String、Double、Array、Dictionary等常见类型都是结构体
          struct Date {
              var year: Int
              var month: Int
              var day: Int
          }
          var date = Date(year: 2019, month: 6, day: 23)
      
  • 所有的结构体都有一个编译器自动生成的初始化器initializer,初始化方法、构造器、构造方法)
    • 通过默认生成的初始化器初始化:传入所有成员值,用以初始化所有成员(存储属性Stored Property
         var date = Date(year: 2019, month: 6, day: 23)
      

2. 结构体的初始化器

  • 编译器会根据情况,可能会为结构体生成多个初始化器,宗旨是:保证所有成员都有初始值
  • 如果结构体的成员定义的时候都有默认值了,那么生成的初始化器不会报错 -w569
    • 如果是下面这几种情况就会报错 -w642 -w645 -w640
    • 如果是可选类型的初始化器也不会报错,因为可选类型默认的值就是nil -w457

3. 自定义初始化器

我们也可以自定义初始化器

struct Point {
    var x: Int = 0
    var y: Int = 0
    
    init(x: Int, y: Int) {
        self.x = x
        self.y = y
    }
} 
var p1 = Point(x: 10, y: 10)

下面对变量p2p3p4初始化报错的原因是 因为我们 已经自定义初始化器了,编译器就不会再帮我们生成默认的初始化器了 -w643

4. 初始化器的本质

下面这两种写法是完全等效的

struct Point {
    var x: Int = 0
    var y: Int = 0
} 
等效于 
struct Point {
    var x: Int
    var y: Int
    
    init() {
        x = 0
        y = 0
    }
} 
var p4 = Point()
  • 我们通过反汇编分别对比一下两种写法的实现,发现也是一样的:
  • 因此,我们不难得出结论:
    默认初始化器的本质,就是给存储属性做了默认赋值工作(比如这里给Int类型的两个属性默认赋值为0) -w713 -w715

5.结构体的内存结构

  1. 我们通过打印,了解下结构体占用的内存大小 和 其 内存布局
struct Point {
    var x: Int = 10
    var y: Int = 20
} 
var p4 = Point() 
print(MemoryLayout<Point>.stride) // 16
print(MemoryLayout<Point>.size) // 16
print(MemoryLayout<Point>.alignment) // 8

print(Mems.memStr(ofVal: &p4)) // 0x000000000000000a 0x0000000000000014

通过控制台,我们可以看到:

  • 系统一共分配了16个字节的内存空间
  • 前8个字节存储的是10,后8个字节存储的是20
  1. 我们再看下面这个结构体
struct Point {
    var x: Int = 10
    var y: Int = 20
    var origin: Bool = false
}

var p4 = Point()

print(MemoryLayout<Point>.stride) // 24
print(MemoryLayout<Point>.size) // 17
print(MemoryLayout<Point>.alignment) // 8

print(Mems.memStr(ofVal: &p4)) // 0x000000000000000a 0x0000000000000014 0x0000000000000000

可以看到:

  • 结构体实际只用了17个字节,而因为系统分配有内存对齐的概念,所以分配了24个字节
  • 前8个字节存储的是10,中间8个字节存储的是20,最后1个字节存储的是false,也就是0

四、类

  • 类的定义和结构体类似,但编译器并没有为类自动生成可以传入成员值的初始化器 -w646
  • 如果成员没有初始值,所有的初始化器都会报错 -w648

1. 类的初始化器

  • 如果类的所有成员都在定义的时候指定了初始值,编译器会为类生成 无参的初始化器
  • 成员的初始化是在这个初始化器中完成的
    class Point {
        var x: Int = 0
        var y: Int = 0
    }
    
    let p1 = Point()
    
    • 下面这两种写法是完全等效的
      class Point {
          var x: Int = 0
          var y: Int = 0
      }
      
      等效于
      
      class Point {
          var x: Int
          var y: Int
      
          init() {
              x = 0
              y = 0
          }
      }
      
      let p1 = Point()
      

2. 结构体与类的本质区别

  • 结构体是值类型(枚举也是值类型),类是引用类型(指针类型)
    下面我们分析函数内的局部变量分别都在内存的什么位置
class Size {
    var width = 1
    var height = 2
} 
struct Point {
    var x: Int = 3
    var y: Int = 4
}

func test() {
    var size = Size()
    var point = Point()
} 
  • 变量size和point都是在栈空间
  • 不同的是局部变量point是一个结构体类型。结构体是值类型,结构体变量会在栈空间中分配内存,它里面的两个成员x、y按顺序的排布
  • 局部指针变量size是一个类的实例,类是引用类型,所以size指针指向的已初始化的变量的存储空间,是在堆中分配的,size指针内部存储的是Size类的实例内存地址 -w1020

3. 分析类的内存布局

    1. 我们先来看一下类的占用内存大小是多少
        class Size {
            var width = 1
            var height = 2
        }
        print(MemoryLayout<Size>.stride) // 8
    

    通过打印我们可以发现MemoryLayout获取的8个字节实际上是指针变量占用多少存储空间,并不是对象在堆中的占用大小

    1. 然后我们再看类的内存布局是怎样的
        var size = Size()
    
        print(Mems.ptr(ofVal: &size)) // 0x000000010000c388
        print(Mems.memStr(ofVal: &size)) // 0x000000010072dba0
    

    通过打印我们可以看到变量里面存储的值也是一个地址

    1. 我们再打印该变量所指向的对象的内存布局是什么
        print(Mems.size(ofRef: size)) // 32
        print(Mems.ptr(ofRef: size)) // 0x000000010072dba0
        print(Mems.memStr(ofRef: size)) // 0x000000010000c278 0x0000000200000003 0x0000000000000001 0x0000000000000002
    

    通过打印可以看到在堆中存储的对象的地址和上面的指针变量里存储的值是一样的

    内存布局里一共占用32个字节:

    • 前16个字节分别用来存储一些类信息引用计数
    • 后面16个字节存储着类的成员变量的值

下面我们再从反汇编的角度来分析

  • 我们要想确定类是否在堆空间中分配空间,通过反汇编来查看是否有调用malloc函数 -w708 -w716
  • 然后就一直跟进直到这里最好调用了swift_slowAlloc -w714
  • 发现函数内部调用了系统的malloc在堆空间分配内存 -w709

注意:

  • 结构体枚举 存储在哪里取决于它们是在哪里分配
    • 如果是在 函数中 分配的那就是在
    • 如果是在 全局中 分配的那就是在 数据段
  • 无论是在哪里分配的,对象都是在 堆空间
    • 指向对象内存的指针的存储位置是不确定的,可能在栈中也可能在数据段

我们再看下面的类型占用内存大小是多少

class Size {
    var width: Int = 0
    var height: Int = 0
    var test = true
}

let s = Size()

print(Mems.size(ofRef: s)) // 48
  • Mac、iOS中的malloc函数分配的内存大小总是16的倍数
  • 类最前面会有16个字节用来存储类的信息引用计数,所以实际占用内存是33个字节,但由于malloc函数分配的内存都是刚好大于或等于其所需内存的16最小倍数,所以分配48个字节
  • 我们还可以通过class_getInstanceSize函数来获取类对象的内存大小
// 获取的是经过内存对齐后的内存大小,不是malloc函数分配的内存大小
print(class_getInstanceSize(type(of: s))) // 40
print(class_getInstanceSize(Size.self)) // 40

五、枚举

1. 枚举的基本用法

enum Direction {
    case north
    case south
    case east
    case west
}

// 简便写法
enum Direction {
    case north, south, east, west
}

var dir = Direction.west
dir = Direction.east
dir = .north
print(dir) // north

switch dir {
case .north:
    print("north")
case .south:
    print("south")
case .east:
    print("east")
case .west:
    print("west")
}

2. 关联值(Associated Values)

有时会将枚举的成员值其他类型的值关联 存储在一起 ,会非常有用

enum Score {
     case points(Int)
     case grade(Character)
}

var score = Score.points(96)
score = .grade("A")

switch score {
case let .points(i):
  debugPrint(i)
case let .grade(i):
  debugPrint(i)
}
enum Date {
    case digit(year: Int, month: Int, day: Int)
    case string(String)
}

var date = Date.digit(year: 2020, month: 12, day: 5)
date = .string("2022-07-10")
//必要时【let】也可以改为【var】
switch date {
case .digit(let year, let month, let day):
  debugPrint(year, month, day)
case let .string(value):
  debugPrint(value)
}

关联值举例 image.png image.png

enum Password {
    case number(Int, Int, Int, Int)
    case gesture(String)
}

var pwd = Password.number(5, 6, 4, 7)
pwd = .gesture("12369")

switch pwd {
case let .number(n1, n2, n3, n4):
    print("number is ", n1, n2, n3, n4)
case let .gesture(str):
    print("gesture is ", str)
}

必要时,使用了枚举关联值的switch-case语句 里面的 let也可以改成var

3. 原始值(Raw Values)

枚举成员可以使用相同类型的默认值预先关联,这个默认值叫做原始值

enum PokerSuit: String {
   case spade = "♠"
   case heart = "♥"
   case diamond = "♦" 
   case club = "♣"
}

let suit = PokerSuit.heart
debugPrint(suit)// heart
debugPrint(suit.rawValue)// ♥
debugPrint(PokerSuit.spade.rawValue)// ♠ 
enum Grade : String { 
    case perfect = "A" 
    case great = "B" 
    case good = "C" 
    case bad = "D" 
} 
print(Grade.perfect.rawValue) // A 
print(Grade.great.rawValue) // B 
print(Grade.good.rawValue) // C
print(Grade.bad.rawValue) // D

注意:

  • 原始值不占用枚举变量的内存
  • 原始值只是关联上了枚举变量,所以原始值占用内存的大小并不是枚举变量的大小
  • 底层实现是通过计算属性/函数来获取原始值的

4. 隐式原始值(Implicitly Assigned Raw Values)

如果枚举的原始值类型是IntString,Swift会自动分配原始值

字符串默认分配的原始值就是其变量名

enum Direction: String {
    case north = "north"
    case south = "south"
    case east = "east"
    case west = "west"
}

// 等价于上面
enum Direction: String {
     case north, south, east, west
}
print(Direction.north) // north
print(Direction.north.rawValue) // north

Int类型默认分配的原始值是从0开始递增的数字

enum Season: Int {
    case spring, summer, autumn, winter
}

print(Season.spring.rawValue) // 0
print(Season.summer.rawValue) // 1
print(Season.autumn.rawValue) // 2
print(Season.winter.rawValue) // 3

如果有指定原始值的,下一个就会按照已经指定的值递增分配

enum Season: Int {
    case spring = 1, summer, autumn = 4, winter
} 
print(Season.spring.rawValue) // 1
print(Season.summer.rawValue) // 2
print(Season.autumn.rawValue) // 4
print(Season.winter.rawValue) // 5

5. 递归枚举(Recursive Enumeration)

    1. 递归枚举要用indirect关键字来修饰enum,不然会报错
    indirect enum ArithExpr {
        case number(Int)
        case sum(ArithExpr, ArithExpr)
        case difference(ArithExpr, ArithExpr)
    }
    
    或者
    
    enum ArithExpr {
        case number(Int)
        indirect case sum(ArithExpr, ArithExpr)
        indirect case difference(ArithExpr, ArithExpr)
    }
    
    let five = ArithExpr.number(5)
    let four = ArithExpr.number(4)
    let sum = ArithExpr.sum(five, four)
    let two = ArithExpr.number(2)
    let difference = ArithExpr.difference(sum, two)
    
    func calculate(_ expr: ArithExpr) -> Int {
        switch expr {
        case let .number(value):
            return value
        case let .sum(left, right):
            return calculate(left) + calculate(right)
        case let .difference(left, right):
            return calculate(left) - calculate(right)
        }
    }
    
    calculate(difference)
    

6. MemoryLayout

    1. 可以使用MemoryLayout获取数据类型占用的内存大小
      64bitInt类型8个字节
      let age = 10
      
      MemoryLayout<Int>.stride // 8, 分配占用的空间大小
      MemoryLayout<Int>.size // 8, 实际用到的空间大小
      MemoryLayout<Int>.alignment // 8, 内存对齐参数
      
      等同于
      
      MemoryLayout.size(ofValue: age)
      MemoryLayout.stride(ofValue: age)
      MemoryLayout.alignment(ofValue: age)
      

关联值和原始值的区别:

  • 关联值类型会存储到枚举变量里面

  • 原始值因为一开始就会知道默认值是多少,所以只做记录,不会存储

    enum Password {
        case number(Int, Int, Int, Int)
        case other
    }
    
    MemoryLayout<Password>.stride // 40,分配占用的空间大小
    MemoryLayout<Password>.size // 33,实际用到的空间大小
    MemoryLayout<Password>.alignment // 8,对齐参数
    
    enum Session: Int {
         case spring, summer, autnmn, winter
    }
    
    MemoryLayout<Session>.stride // 1,分配占用的空间大小
    MemoryLayout<Session>.size // 1,实际用到的空间大小
    MemoryLayout<Session>.alignment // 1,对齐参数
    

思考下面枚举变量的内存布局: 案例1:

enum TestEnum { 
    case test1, test2, test3 
} 
var t = TestEnum.test1
t = .test2 
t = .test3
MemoryLayout<TestEnum>.stride // 1,分配占用的空间大小
MemoryLayout<TestEnum>.size // 1,实际用到的空间大小
MemoryLayout<TestEnum>.alignment // 1,对齐参数

案例2:

enum TestEnum : Int {
    case test1 = 1, test2 = 2, test3 = 3 
}
var t = TestEnum.test1 
t = .test2 
t = .test3
MemoryLayout<TestEnum>.stride // 1,分配占用的空间大小
MemoryLayout<TestEnum>.size // 1,实际用到的空间大小
MemoryLayout<TestEnum>.alignment // 1,对齐参数

案例3:

enum TestEnum {
    case test1(Int, Int, Int)
    case test2(Int, Int)
    case test3(Int) 
    case test4(Bool) 
    case test5
} 
var e = TestEnum.test1(1, 2, 3)
e = .test2(4, 5)
e = .test3(6) 
e = .test4(true)
e = .test5
MemoryLayout<TestEnum>.stride // 32,分配占用的空间大小
MemoryLayout<TestEnum>.size // 25,实际用到的空间大小
MemoryLayout<TestEnum>.alignment // 8,对齐参数

案例4:

//注意!!!!   枚举选项只有一个,所以实际用到的内存空间 为0,但是要存储一个成员值 所以对其参数为1,给其分配一个字节
enum TestEnum { 
    case test
} 
var t = TestEnum.test
MemoryLayout<TestEnum>.stride // 1,分配占用的空间大小
MemoryLayout<TestEnum>.size // 0,实际用到的空间大小
MemoryLayout<TestEnum>.alignment // 1,对齐参数

案例5:

enum TestEnum { 
    case test(Int)
} 
var t = TestEnum.test(10)
MemoryLayout<TestEnum>.stride // 8,分配占用的空间大小
MemoryLayout<TestEnum>.size // 8,实际用到的空间大小
MemoryLayout<TestEnum>.alignment // 8,对齐参数

案例6:

enum TestEnum { 
    case test(Int)
} 
var t = TestEnum.test(10)
MemoryLayout<TestEnum>.stride // 8,分配占用的空间大小
MemoryLayout<TestEnum>.size // 8,实际用到的空间大小
MemoryLayout<TestEnum>.alignment // 8,对齐参数

案例7:

enum TestEnum { 
    case test0 
    case test1 
    case test2 
    case test4(Int) 
    case test5(Int, Int)
    case test6(Int, Int, Int, Bool)
} 
var t = TestEnum.test(10)
MemoryLayout<TestEnum>.stride // 32,分配占用的空间大小
MemoryLayout<TestEnum>.size // 25,实际用到的空间大小
MemoryLayout<TestEnum>.alignment // 8,对齐参数

案例8:

enum TestEnum { 
    case test0 
    case test1 
    case test2 
    case test4(Int) 
    case test5(Int, Int)
    case test6(Int, Int, Bool, Int)
} 
var t = TestEnum.test(10)
MemoryLayout<TestEnum>.stride // 32,分配占用的空间大小
MemoryLayout<TestEnum>.size // 32,实际用到的空间大小
MemoryLayout<TestEnum>.alignment // 8,对齐参数

案例9:

enum TestEnum { 
    case test0 
    case test1 
    case test2 
    case test4(Int) 
    case test5(Int, Int)
    case test6(Int, Bool, Int)
} 
var t = TestEnum.test(10)
MemoryLayout<TestEnum>.stride //32,分配占用的空间大小
MemoryLayout<TestEnum>.size //25,实际用到的空间大小
MemoryLayout<TestEnum>.alignment //8,对齐参数

7. 枚举变量的内存布局

我们知道通过MemoryLayout可以获取到枚举占用内存的大小,那枚举变量分别占用多少内存呢?

要想知道枚举变量的大小,我们需要通过查看枚举变量的内存布局来进行分析

枚举变量的分析准备

我们可以通过Xcode里的View Memory选项来查看详细的内存布局

  1. 可以在运行程序时,通过控制台打印的枚举变量右键选择View Memory of *进入到内存布局的页面 -w440
  2. 还可以从Xcode标题栏中选择Debug -> Debug Workflow -> View Memory进入到内存布局的页面 -w569
  3. 进入到该页面,然后通过输入变量的内存地址来查看 -w1129
  4. 我们可以下载一个小工具来获取到变量的内存地址
    下载地址:github.com/CoderMJLee/…
  5. 然后将下载好的这个文件Mems.swift拖到自己的Xcode中 调用这个函数就可以了
print(Mems.ptr(ofVal: &t))

我们来分析下面的枚举变量的情况

enum TestEnum {
    case test1, test2, test3
}

var t = TestEnum.test1
print(Mems.ptr(ofVal: &t))

t = TestEnum.test2
t = TestEnum.test3

print(MemoryLayout<TestEnum>.stride) // 1
print(MemoryLayout<TestEnum>.size) // 1
print(MemoryLayout<TestEnum>.alignment) // 1

分别将断点打在给枚举变量t赋值的三句代码上,然后运行程序观察每次断点之后的内存布局有什么变化

-w1127

-w1124

-w1124

通过上图可以看到,每个枚举变量都分配了一个字节的大小,并且存储的值分别是0、1、2,我们可以知道这一个字节的大小就是用来存储枚举成员值

我们再来分析一个枚举变量的情况

enum TestEnum: Int {
    case test1 = 1, test2 = 2, test3 = 3
}

var t = TestEnum.test1
print(Mems.ptr(ofVal: &t))

t = TestEnum.test2
t = TestEnum.test3

print(MemoryLayout<TestEnum>.stride) // 1
print(MemoryLayout<TestEnum>.size) // 1
print(MemoryLayout<TestEnum>.alignment) // 1

-w1131

-w1126

-w1125

通过上图可以看到,每个枚举变量存储的值也是0、1、2,并且分配了一个字节的大小

可以证明枚举变量的内存大小和原始值类型无关,而且枚举变量里存储的值和原始值也无关

我们再来分析一个枚举变量的情况

enum TestEnum {
    case test1(Int, Int, Int) // 24
    case test2(Int, Int) // 16
    case test3(Int) // 8
    case test4(Bool) // 1
    case test5 // 1
}

var t = TestEnum.test1(1, 2, 3)
print(Mems.ptr(ofVal: &t))

t = TestEnum.test2(4, 5)
t = TestEnum.test3(6)
t = TestEnum.test4(true)
t = TestEnum.test5

MemoryLayout<TestEnum>.size // 25
MemoryLayout<TestEnum>.stride // 32
MemoryLayout<TestEnum>.alignment // 8

我们先通过打印了解到枚举类型总共分配了32个字节,然后我们通过断点分别来观察枚举变量的内存布局

-w773 -w1124

执行完第一句我们可以看到,前面24个字节分别用来存储关联值1、2、3,第25个字节用来存储成员值0,之所以分配32个字节是因为内存对齐的原因

// 调整排版后的内存布局如下所示
01 00 00 00 00 00 00 00
02 00 00 00 00 00 00 00
03 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00

-w719 -w1193

执行完第二句我们可以看到,前面16个字节分半用来存储关联值4、5,然后第25个字节用来存储成员值1

// 调整排版后的内存布局如下所示
04 00 00 00 00 00 00 00
05 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
01 00 00 00 00 00 00 00

-w563 -w1196

执行完第三句我们可以看到,前面8个字节分半用来存储关联值6,然后第25个字节用来存储成员值2

// 调整排版后的内存布局如下所示
06 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
02 00 00 00 00 00 00 00

-w665 -w1192

执行完第四句我们可以看到,由于是Bool类型,那么只用了第一个字节来存储关联值1,然后第25个字节用来存储成员值3

// 调整排版后的内存布局如下所示
01 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
03 00 00 00 00 00 00 00

-w676 -w1191

执行完最后一句我们可以看到,由于没有关联值,那么只用了第25个字节存储成员值4

// 调整排版后的内存布局如下所示
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
04 00 00 00 00 00 00 00

总结:内存分配情况:一个字节存储成员值,n个字节存储关联值(n取占用内存最大的关联值),任何一个case的关联值都共有这n个字节

我们再来看几个情况

enum TestEnum {
    case test
}

MemoryLayout<Session>.stride // 1,分配占用的空间大小
MemoryLayout<Session>.size // 0,实际用到的空间大小
MemoryLayout<Session>.alignment // 1,对齐参数

如果枚举里只有一个case,那么实际用到的空间为0,都不用特别分配内存来进行存储

enum TestEnum {
    case test(Int)
}

MemoryLayout<Session>.stride // 8,分配占用的空间大小
MemoryLayout<Session>.size // 8,实际用到的空间大小
MemoryLayout<Session>.alignment // 8,对齐参数

可以看到分配的内存大小就是关联值类型决定的,因为只有一个case,所以都不需要再额外分配内存来存储是哪个case

六、值类型和引用类型

1. 值类型

  • 值类型赋值给var、let或者给函数传参,是直接将所有内容拷贝一份
  • 类似于对文件进行copy、paste操作,产生了全新的文件副本,属于深拷贝(deep copy)

值类型进行拷贝的内存布局如下所示

struct Point {
    var x: Int = 3
    var y: Int = 4
}

func test() {
    var p1 = Point(x: 10, y: 20)
    var p2 = p1

    p2.x = 4
    p2.y = 5

    print(p1.x, p1.y)
}

test()

-w536

我们通过反汇编来进行分析

-w712 -w947 -w713 -w1048

通过上述分析可以发现,值类型的赋值内部会先将p1的成员值保存起来,再给p2进行赋值,所以不会影响到p1

2. 值类型的赋值操作

  • 在Swift标准库中,为了提升性能,Array、String、Dictionary、Set采用了Copy On Write的技术
  • 如果只是将赋值操作,那么只会进行浅拷贝,两个变量使用的还是同一块存储空间
  • 只有当进行了”写“的操作时,才会进行深拷贝操作
  • 对于标准库值类型的赋值操作,Swift能确保最佳性能,所以没必要为了保证最佳性能来避免赋值
  • 建议:不需要修改值的,尽量定义成let
var s1 = "Jack"
var s2 = s1
s2.append("_Rose")

print(s1) // Jack
print(s2) // Jack_Rose
var a1 = [1, 2, 3]
var a2 = a1
a2.append(4)
a1[0] = 2

print(a1) // [2, 2, 3]
print(a2) // [1, 2, 3, 4]
var d1 = ["max" : 10, "min" : 2]
var d2 = d1
d1["other"] = 7
d2["max"] = 12

print(d1) // ["other" : 7, "max" : 10, "min" : 2]
print(d2) // ["max" : 12, "min" : 2]

我们再看下面这段代码
对于p1来说,再次赋值也只是覆盖了成员x、y的值而已,都是同一个结构体变量

struct Point {
    var x: Int
    var y: Int
}
var p1 = Point(x: 10, y: 20)
p1 = Point(x: 11, y: 22)

用let定义的赋值操作

  • 如果用let定义的常量赋值结构体类型会报错,并且修改结构体里的成员值也会报错
  • let定义就意味着常量里存储的值不可更改,而结构体是由x和y这16个字节组成的,所以更改x和y就意味着结构体的值要被覆盖,所以报错

-w645

3. 引用类型

  • 引用赋值给var、let或者给函数传参,是将内存地址拷贝一份
  • 类似于制作一个文件的替身(快捷方式、链接),指向的是同一个文件,属于 浅拷贝(shallow copy)
    class Size {
        var width = 0
        var height = 0
    
        init(width: Int, height: Int) {
            self.width = width
            self.height = height
        }
    }
    
    func test() {
        var s1 = Size(width: 10, height: 20)
        var s2 = s1
    
        s2.width = 11
        s2.height = 22
    
        print(s1.height, s1.width)
    }
    
    test() 
    

由于s1和s2都指向的同一块存储空间,所以s2修改了成员变量,s1再调用成员变量也已经是改变后的了 -w1124

我们通过反汇编来进行分析 -w1049 -w1052 -w1052

  • 堆空间分配完内存之后,我们拿到rax的值查看内存布局
  • 发现rax里和对象的结构一样,证明rax里存储的就是对象的地址 -w1051 -w1187
  • 将新的值11和22分别覆盖掉堆空间对象的成员值 -w1223 -w1224 -w1220 -w1225

通过上面的分析可以发现,修改的成员值都是改的同一个地址的对象,所以修改了p2的成员值,会影响到p1

4. 对象的堆空间申请过程

  • 在Swift中,创建类的实例对象,要向堆空间申请内存,大概流程如下
    • Class.__allocating_init()
    • libswiftCore.dylib:_swift_allocObject_
    • libswiftCore.dylib:swift_slowAlloc
    • libsystem_malloc.dylib:malloc
  • 在Mac、iOS中的malloc函数分配的内存大小总是16的倍数
  • 通过class_getInstanceSize可以得知:类的对象至少需要占用多少内存
    class Point{
        var x = 11
        var test = true
        var y = 22
    } 
    var p = Point() 
    class_getInstanceSize(type(of: p)) // 40 
    class_getInstanceSize(Point.self) // 40
    

5. 引用类型的赋值操作

    1. 将引用类型初始化对象赋值给同一个指针变量,指针变量会指向另一块存储空间
    class Size {
        var width: Int
        var height: Int
    
        init(width: Int, height: Int) {
            self.width = width
            self.height = height
        }
    }
    
    var s1 = Size(width: 10, height: 20)
    s1 = Size(width: 11, height: 22)
    

用let定义的赋值操作

    1. 如果用let定义的常量赋值引用类型会报错,因为会改变指针常量里存储的8个字节的地址值
    1. 但修改类里的属性值不会报错,因为修改属性值并不是修改的指针常量的内存,只是通过指针常量找到类所存储的堆空间的内存地址去修改类的属性 -w643

6. 嵌套类型

struct Poker {
    enum Suit: Character {
        case spades = "♠️",
             hearts = "♥️",
             diamonds = "♦️",
             clubs = "♣️"
    }
    
    enum Rank: Int {
        case two = 2, three, four, five, six, seven, eight, nine, ten
        case jack, queen, king, ace
    }
}

print(Poker.Suit.hearts.rawValue)

var suit = Poker.Suit.spades
suit = .diamonds

var rank = Poker.Rank.five
rank = .king 

7. 枚举、结构体、类都可以定义方法

    1. 一般把定义在枚举、结构体、类内部的函数,叫做方法
    struct Point {
        var x: Int = 10
        var y: Int = 10
    
        func show() {
            print("show")
        }
    }
    
    let p = Point()
    p.show()
    
    
    class Size {
        var width: Int = 10
        var height: Int = 10
    
        func show() {
            print("show")
        }
    }
    
    let s = Size()
    s.show()
    
    
    enum PokerFace: Character {
        case spades = "♠️",
             hearts = "♥️",
             diamonds = "♦️",
             clubs = "♣️"
    
        func show() {
            print("show")
        }
    }
    
    let pf = PokerFace.hearts
    pf.show()
    
    
    1. 方法不管放在哪里,其内存都是放在代码段中
    1. 枚举、结构体、类里的方法其实会有隐式参数
    class Size {
        var width: Int = 10
        var height: Int = 10
    
        // 默认会有隐式参数,该参数类型为当前枚举、结构体、类
        func show(self: Size) {
            print(self.width, self.height)
        }
    }
    

专题系列文章

1. 前知识

2. 基于OC语言探索iOS底层原理

3. 基于Swift语言探索iOS底层原理

关于函数枚举可选项结构体闭包属性方法swift多态原理StringArrayDictionary引用计数MetaData等Swift基本语法和相关的底层原理文章有如下几篇:

其它底层原理专题

1. 底层原理相关专题

2. iOS相关专题

3. webApp相关专题

4. 跨平台开发方案相关专题

5. 阶段性总结:Native、WebApp、跨平台开发三种方案性能比较

6. Android、HarmonyOS页面渲染专题

7. 小程序页面渲染专题