Swift探索

Swift探索(二): 类与结构体(下)

2022-01-05  本文已影响0人  Lee_Toto

上一篇中我们探讨了类与结构体的本质,以及他们的相同点和不同点。接下来我们将对 Swift 的方法进行深入了解。

1.异变方法

默认情况下,值类型属性不能被自身的实例方法修改。

结构体在方法中修改属性.png
因为 xy是属于 self 的,修改它们就是修改 self本身,在自己的方法里修改自己。
解决方式:使用mutating 关键字进行修饰方法
struct Point {
    var x = 0.0, y = 0.0
    mutating func moveBy(x deltaX: Double, y deltaY: Double) {
    //self --- x, y
        x += deltaX
        y += deltaY
    }
}

通过SIL文件来分析mutating关键字的作用

struct Point {
    var x = 0.0, y = 0.0
    func test(){
        let tmp = self.x
    }
    mutating func mutatingTest(){
        x = self.x + 1
    }
}

通过swiftc main.swift -emit-sil > ./main.sil生成.sil文件

struct Point {
  @_hasStorage @_hasInitialValue var x: Double { get set }
  @_hasStorage @_hasInitialValue var y: Double { get set }
  func test()
  mutating func mutatingTest()
  init()
  init(x: Double = 0.0, y: Double = 0.0)
}

结构体PointmutatingTest()函数有mutating关键字修饰

// Point.test()
sil hidden @$s4main5PointV4testyyF : $@convention(method) (Point) -> () {
// %0 "self"                                      // users: %2, %1
bb0(%0 : $Point):
  debug_value %0 : $Point, let, name "self", argno 1 // id: %1
  ···
} // end sil function '$s4main5PointV4testyyF'
// Point.mutatingTest()
sil hidden @$s4main5PointV12mutatingTestyyF : $@convention(method) (@inout Point) -> () {
// %0 "self"                                      // users: %10, %2, %1
bb0(%0 : $*Point):
  debug_value_addr %0 : $*Point, var, name "self", argno 1 // id: %1
 ···
} // end sil function '$s4main5PointV12mutatingTestyyF'

可以发现test()函数和mutatingTest()函数在调用是都传入了一个参数Point,不同的是
mutatingTest()传入的Point参数是有@inout修饰的,在SIL官方文档@inout的解释为

An @inout parameter is indirect. The address must be of an initialized object.(当前参数类型是间接的,传递的是已经初始化过的地址)

注意看两个函数执行的第一行代码

// test函数
debug_value %0 : $Point, let, name "self", argno 1 // id: %1 
// mutatingTest函数
debug_value_addr %0 : $*Point, var, name "self", argno 1 // id: %1

可以看出

  • 没有添加mutating关键字的函数,传入的是self的值 相当于 let self = Point
  • 添加了mutating关键字的函数,传入的是self的地址 相当于 var self = &Point

因此对于变异方法,传入的 self 被标记为 inout 参数。无论在mutating方法内部发生什么,都会影响外部依赖类型的一切。

inout关键字还有一个用处:

如果我们想函数能够修改一个形式参数的值,而且希望这些改变在函数结束之后依然生效,那么就需要将形式参数定义为 输入输出形式参数 。在形式参数定义开始的时候在前边 添加一个inout关键字可以定义一个输入输出形式参数

比如我们想在changeAge函数中修改age

函数的形式参数都是let类型的.png

那么我们只需要在参数age1加上inout关键字

var age = 18

func changeInoutAge (_ age1 : inout Int) {
    age1 += 1
}

changeInoutAge(&age);

print(age)
// 输出结果 19

注意在调用changeInoutAge()函数的时候传入的一定是age的地址

2.方法调度

2.1 使用汇编进行分析

OC 中,调用一个方法的本质是消息传递,底层通过objc_msgSend函数去查找方法并调用。那么Swift中又是怎么调度的呢?这里我们通过汇编代码来分析

ARM64(真机)常用汇编指令

  • mov: 将某一寄存器的值复制到另一寄存器(只能用于寄存器与寄存器或者寄存器 与常量之间传值,不能用于内存地址),如:
    mov x1, x0 //将寄存器 x0的值复制到寄存器x1
  • add: 将某一寄存器的值和另一寄存器的值 相加 并将结果保存在另一寄存器中, 如:
    add x0, x1, x2 //将寄存器 x1x2的值相加后保存到寄存器x1
  • sub: 将某一寄存器的值和另一寄存器的值 相减 并将结果保存在另一寄存器中:
    sub x0, x1, x2 //将寄存器 x1x2的值相减后保存到寄存器x1
  • and: 将某一寄存器的值和另一寄存器的值 按位与 并将结果保存到另一寄存器中, 如:
    and x0, x0, #0x1 //将寄存器x0的值和常量1按位与后保存到寄存器x0
  • orr: 将某一寄存器的值和另一寄存器的值 按位或 并将结果保存到另一寄存器中, 如:
    prr x0, x0, #0x1 //将寄存器x0的值和常量1按位或后保存到寄存器x0
  • str : 将寄存器中的值写入到内存中,如:
    str x0, [x0, x8] // 将寄存器x0中的值保存到栈内存[x0 + x8]处
  • ldr: 将内存中的值读取到寄存器中,如:
    ldr x0, [x1, x2] // 将寄存器x1和寄存器x2的值相加作为地址,取该内存地址的值放入寄存器x0
  • cbz: 和 0 比较,如果结果为零就转移(只能跳到后面的指令)
  • cbnz: 和非 0 比较,如果结果非零就转移(只能跳到后面的指令)
  • cmp: 比较指令
  • bl: 带返回的跳转指令, 返回地址保存到lr(X30)
  • blr: 带返回的跳转指令,跳转到指令后边跟随寄存器中保存的地址
    blr x8 //跳转到x8保存的地址中去执行
  • ret: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器 lr (x30)

将如下代码运行,并进入汇编

class Person {
    func play(){
        print("play")
    }
    
    func play1(){
        print("play1")
    }
    
    func play2(){
        print("play2")
    }
}

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let t = Person()
        t.play()
        t.play1()
        t.play2()
        // Do any additional setup after loading the view.
    }
}
image.png

通过汇编代码我们可以看到在allocating_initrelesae中间有三个blr指令

// 函数的返回值是放在x0寄存器中的 x0的第一个8字节:metadata
mov x20, x0  // 将x0的值复制到x20中  
str x20, [sp, #0x18]  // 将寄存器x20中的值保存到栈内存[sp + #0x18]处 #0x18偏移量
str x20, [sp, #0x20] // 将寄存器x20中的值保存到栈内存[x0 + #0x20]处
ldr x8, [x20]  // 将寄存器x20的值作为地址,取该内存地址的值放入寄存器x8中
ldr x8, [x8, #0x50] // 将寄存器x8的值+#0x50(偏移量)相加作为地址,取该内存地址的值放入寄存器x8中
blr x8 //跳转到x8保存的地址中去执行

由此可以得出Swift中函数的调用过程:

在汇编代码中我们发现三个函数的地址的偏移量(0X50#0x580x60)相差8个字节(函数指针的大小),并且在内存中是连续的内存空间,因此Swift函数的调度是基于函数表的调度

上一篇中我们了解到Metdata的结构那么函数表在什么地方呢?

struct HeapMataData {
  var kind: Int
  var superClass: Any.Type
  var cacheData: (Int, Int)
  var data: Int
  var classFlags: Int32
  var instanceAddressPoint: UInt32
  var instanceSize: UInt32
  var instanceAlignmentMask: UInt16
  var reserved: UInt16
  var classSize: UInt32
  var classAddressPoint: UInt32
  var typeDescriptor: UnsafeMutableRawPointer
  var iVarDestroyer: UnsafeRawPointer
}

我们知道不管是 ClassStructEnum都有自己的 typeDescriptor,就是对类的一个详细描述。通过对Swift源码的分析最后能够得到TargetClassDescriptor的结构体

struct TargetClassDescriptor {
    var flags: UInt32
    var parent: UInt32
    var name: Int32
    var accessFunctionPointer: Int32
    var fieldDescriptor: Int32
    var superClassType: Int32
    var metadataNegativeSizeInWords: UInt32
    var metadataPositiveSizeInWords: UInt32
    var numImmediateMembers: UInt32
    var numFields: UInt32
    var fieldOffsetVectorOffset: UInt32
    var Offset: UInt32
    var size: UInt32
    //V-Table
}

2.2 使用Mach-O进行分析

2.2.1 Mach-O简介

MachoMach-O其实是Mach Object文件格式的缩写,是 mac 以及 iOS 上可执行文件的格 式, 类似于 windows上的 PE格式 (Portable Executable ), linux 上的 elf格式 (Executable and Linking Format) 。常⻅的有.o.a.dylibFrameworkdyld.dsym

Mach-O文件格式

Mach-O文件格式.png

分析Macho必备工具

必备工具显示的Mach-O文件格式.png

LC_SEGMENT_64 :将文件中(32位64位)的段映射到进程地址空间中
LC_DYLD_INFO_ONLY: 动态链接相关信息
LC_SYMTAB :符号地址
LC_DYSYMTAB :动态符号表地址
LC_LOAD_DYLINKERdyld加载
LC_UUID : 文件的UUID
LC_VERSION_MIN_MACOSX :支持最低的操作系统版本
LC_SOURCE_VERSION : 源代码版本
LC_MAIN :设置程序主线程的入口地址和栈大小
LC_LOAD_DYLIB :依赖库的路径,包含三方库
LC_FUNCTION_STARTS : 函数起始地址表
LC_CODE_SIGNATURE:代码签名

2.2.2 验证流程

Section64(_TEXT,__swift5_types)中存放的就是 Descriptor

Descriptor的地址.png
因此得到Descriptor的地址为:
0xFFFFFB7C + 0xBBCC = 0x10000B748
image.png
Load CommandsLC_SEGMENT_64(__PAGEZERO)中可以看到虚拟地址的首地址和大小,因此上一步得到的地址0x10000B748减去虚拟内存的首地址0x100000000就是当前Descriptor在虚拟内存中的偏移量(offset)。
0x10000B748 - 0x100000000 = 0xB748

定位到0xB748

0xB748.png

0xB748就是TargetClassDescriptor这个结构体类的首地址,后面存储的就是相应成员变量的内容,根据前面对源码的分析我们得到了TargetClassDescriptor结构体中有13Int32类型,也就是134字节,于是我们向后偏移134字节

向后偏移13个4字节.png
因此我们可以得到play1()这个函数地址在Mach-O 文件中的偏移量0xB77C,由于这里得到的只是偏移量,要想得到play1()的地址我们就还要加上ASLR(随机偏移地址) 此时程序运行时的基地址加上方法在macho的偏移量,可以得到方法在真机运行时下的首地址
通过命令image list 可以查看到程序运行时的基地址0x0000000104834000 image.png
所以play1()函数首地址为基地址0x0000000104834000 + 偏移量0xB77C
0x0000000104834000 + 0xB77C = 0x10483F77C

0x10483F77C是函数play1()在运行内存当中的地址,在Swift源码中我们找到内存对于函数的存储是一个结构体TargetMethodDescriptor

struct TargetMethodDescriptor {

  /// Flags describing the method.
  MethodDescriptorFlags Flags; // 4字节
  /// The method implementation. // offset
  TargetRelativeDirectPointer<Runtime, void> Impl; 
};
TargetMethodDescriptor.png

所以0x10483F77C指向的就是结构体TargetMethodDescriptor的首地址。
因此Impl 的地址则要偏移4个字节,而Impl的偏移量是0xFFFFC250

0x10483F77C + 4 = 0x10483F780
0x10483F780 + 0xFFFFC250 = 0x20483B9D0
0x20483B9D0 - 0x100000000 = 0x10483B9D0  // 减去虚拟内存的首地址0x100000000

得到play1()函数的地址为0x10483B9D0
对比汇编断点读取寄存器x8play1()方法的值,可以得到:

汇编play1()函数的地址.png
得到最后两个地址是一致的,因此我们可以得出结论V-Table就是在 Descriptor 结构的后面。

2.3 函数派发方式

2.3.1 Struct函数

上面我们已经知道了Class的函数派发方式是函数表,那么Struct又怎么怎样的呢?
我们将上面的代码中的Class换成Struct并在汇编中看一看

struct Person {
    func play(){
        print("play")
    }
    
    func play1(){
        print("play1")
    }
    
    func play2(){
        print("play2")
    }
}

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let t = Person()
        t.play()
        t.play1()
        t.play2()
    }
}
sturct汇编.png
我们可以看到play()play1()play2()函数调用就是直接的地址调用,也就意味着是静态派发
2.3.1 Struct 的 extension中的函数

Person添加一个扩展

extension Person {
    func play3() {
        print("play3")
    }
}
struct中的extension的函数调用.png
可以看到extension中的函数也是静态派发
2.3.2 Class 的 extension中的函数

struct改回class,并在汇编中调试

class Person {
    func play(){
        print("play")
    }
    
    func play1(){
        print("play1")
    }
    
    func play2(){
        print("play2")
    }
}

extension Person {
    func play3() {
        print("play3")
    }
}

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let t = Person()
        t.play()
        t.play1()
        t.play2()
        t.play3()
    }
}
class中的extension的函数调用.png
我们可以看到class中的extension的函数也是静态派发的方式。那么为什么类里面的extension也是静态派发呢?
我认为应该是这样的:创建类的时候,V-table的大小已经确认好了,那么extension里面的方法需要插入到之前创建V-table里面,那么V-table的大小就不够了,并且这样的操作也比较消耗性能,对应用程序的设计这样是不可取的。并且如果这个类作为其他类的父类的话,那么子类就需要记住父类V-Table的位置,然后插入到父类的V-Table中,这样的操作是比较复杂的。
所以为了优化,直接把 extension 独立于虚函数表之外,采用静态调用的方式。
2.3.3 函数调度方式

由上述我们可以总结出函数的调度方式如下图:


函数调度方式总结.png

2.4 影响函数派发方式

2.4.1 final
class Person {
    final func play(){
        print("play")
    }
}
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let t = Person()
        t.play()
    }
}
final关键字.png
添加了 final 关键字的函数无法被重写,使用静态派发,不会在 v-table 中出现,且对objc 运行时不可⻅。
实际开发过程中当属性,方法,类不需要被重载时使用final关键字
2.4.2 dynamic
class Person {
    dynamic func play(){
        print("play")
    }
}

extension Person {
    @_dynamicReplacement(for: play) // 用play1()替代play()函数
    func play1() {
        print("play1")
    }
}

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let t = Person()
        t.play()
        print("end")
        t.play1()
    }
}
打印结果.png dynamic关键字.png

函数均可添加 dynamic 关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发。

2.4.3 @objc
class Person : NSObject {
    @objc func play(){
        print("play")
    }
}
查看方式.png
Swift类暴露给OC.png
@objc可以将Swift函数暴露给Objc运行时,依旧是函数表派发。
2.4.4@objc + dynamic
class Person {
    @objc dynamic func play(){
        print("play")
    }
}

extension Person {
    @objc dynamic func play1() {
        print("play1")
    }
}
@objc + dynamic.png

添加了@objc + dynamic关键字,那么这里的函数的派发方式就变成了消息派发objc_msgSend的方式,并且可以使用OCruntimeapi

2.4.5 private和fileprivate
class Person {
    private var gender: Bool

    private func updateGender(){
        self.gender = !self.gender
    }
    
    init(_ gender: Bool) {
        self.gender = gender
    }
    
    func play(){
        self.updateGender()
    }
}


class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let t = Person(true)
        t.play()
    }
}
updateGender()函数的调用.png

如果对象只在声明的文件中可⻅,可以用 privatefileprivate 进行修饰。编译器会对 privatefileprivate 对象进行检查,确保没有其他继承关系的情形下,自动打上 final标记,进而使得对象获得静态派发的特性(fileprivate: 只允许在定义的源文件中访问,private : 定义的声明中访问)

3.函数内联

函数内联是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优化性能。
如果开启了编译器优化(Release模式默认会开启优化),编译器会自动将某些函数变成内联函数-将函数调用展开成函数体。
手动修改的方式如图所示:

编译器优化.png
// 永远不会被内联(即使开启了编译器优化)
@inline(never) func play() {
    print("play")
}

// 开启编译器优化后,即使代码很长,也会被内联(递归调用函数、动态派发的函数除外)
@inline(__always) func play1() {
    print("play1")
}

Debug 模式下默认不优化代码的,但是内联函数是默认行为,在函数中代码比较少,或者编译器认为代码非常少直接将函数中的代码挪到该位置执行就好
Release 模式下,编译器已经开启优化,会自动决定哪些函数需要内联,因此没必要使用@inline

上一篇下一篇

猜你喜欢

热点阅读