Swift进阶(二) —— 方法探究

7,437 阅读16分钟

一、异变方法mutating

Swift相对OC有一个巨大的改变,那就是结构体(struct)和枚举(enum)里面可以定义方法。但是有一点要注意,由于结构体和枚举属于值类型,因此在默认情况下,结构体和枚举里面的属性不能被自身的实例方法所修改。

截屏2022-01-05 下午7.34.41.png 如上代码所示,当在结构体中定义一个修改属性的实例方法时,系统编译器就会提示自身self无法进行修改。

Swift提供了mutating关键字来解决这个问题,在func前面加上mutating修饰,就可以把这个方法变成异变方法,然后就可以对属性值进行修改。这里面的原理是什么,我们通过代码分析来探寻原理。

截屏2022-01-05 下午11.32.26.png

1.SIL文件分析

我们先通过生成SIL文件,然后查看一下这两个函数之间有什么不同

moveBy1函数 截屏2022-01-05 下午11.37.35.png

moveBy函数 截屏2022-01-05 下午11.42.06.png

通过查看这两个函数的中间语言,我们可以发现方法里面默认带了Point参数,也可以说是self 而这里也与OC有所不同,在OC中,如果方法里面访问或者修改属性值,需要在属性名前带上self,而在Swift里面可以不用带self

我们比较一下这两个函数的参数,发现moveBy方法在添加了mutating关键字后,Point后面加了inout的关键字。

//moveBy1
debug_value %0 : $Point, let, name "self", argno 1 // id: %1
//moveBy
debug_value_addr %2 : $*Point, var, name "self", argno 3 // id: %5

同时,我们对上面两个声明语言进行伪代码转换,可以得到下面两个伪代码

//moveBy1
let self = Point 
//moveBy
var self = &Point

我们可以看到,值类型的属性都是存在实例本身中,因此在方法内部修改属性值相当于在修改实例本身。在moveBy1函数里面,传入self参数的是值本身,而且是用let声明成一个常量,因此无法对方法属性进行修改。而使用mutating修饰的moveBy函数里面,self传入的是实例对象的内存地址,而且是用var声明成一个变量,因此我们可以对方法里面的属性进行修改。

2.inout关键字

在Swift中,函数参数默认是常量。试图在函数体中更改参数值将会导致编译错误。这意味着你不能错误地更改参数值。如果你想要一个函数可以修改参数的值,并且想要在这些修改在函数调用结束后仍然存在,那么就应该把这个参数定义为输入输出参数(In-Out Parameters)

定义一个输入输出参数时,在参数定义前加 inout 关键字。一个 输入输出参数有传入函数的值,这个值被函数修改,然后被传出函数,替换原来的值。

你只能传递变量给输入输出参数。你不能传入常量或者字面量,因为这些量是不能被修改的。当传入的参数作为输入输出参数时,需要在参数名前加 & 符,表示这个值可以被函数修改。

二、方法调度

1、常用的一些汇编指令

分析Swift方法调度需要看汇编代码,我这里先准备一些常用到的汇编指令,有助于我们更好地理解。

  • mov: 将某一寄存器的值复制到另一寄存器(只能用于寄存器与寄存器或者寄存器 与常量之间传值,不能用于内存地址),如:
 mov x1, x0 将寄存器 x0 赋值到寄存器 x1 ˙中
  • add: 将某一寄存器的值和另一寄存器的值 相加 并将结果保存在另一寄存器中, 如:
 add x0, x1, x2 将寄存器 x1 加 x2 的值赋值到 x0 
  • sub: 将某一寄存器的值和另一寄存器的值 相减 并将结果保存在另一寄存器中:
  sub x0, x1, x2 将寄存器 x1 减 x2 的值赋值到 x0 
  • and: 将某一寄存器的值和另一寄存器的值 按位与 并将结果保存到另一寄存器中, 如:
and x0, x0, #0x1 将寄存器 x0 的值和常量 1 按位与后赋值到 x0 
  • orr: 将某一寄存器的值和另一寄存器的值 按位或 并将结果保存到另一寄存器中, 如:
 orr 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: 比较指令
  • br: (branch)跳转到某地址(无返回)
  • blr: 跳转到某地址(有返回)
  • ret: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器 lr (x30) 中

2.类的实例方法

首先,我们先定义一个类和类的实例方法。

class LGStudent {
    func study() {
        print("study")
    }
}

class ViewController: UIViewController{
    override func viewDidLoad() {
        let t = LGStudent()
        t.study()
    }
}

然后,通过真机调试,拿到这个类的arm64的汇编源码

截屏2022-01-06 下午8.20.36.png

经过分析,我们可以看到LGStudent类创建的时候会调用到__allocating_init方法,以及释放LGStudent类的swift_release方法。所以我们可以推测study方法应该在这两个方法中间。

通过上面的汇编指令,我们可以猜测,方法调用可能通过blr指令来调用,我们可以看到,在__allocating_init方法和swift_release方法之间,刚好有一个blr汇编指令,我们进入到这个指令的函数,结果证实是study()方法。

截屏2022-01-06 下午8.53.13.png

  • swift函数的调用 在OC中,函数的调用通过objc_mgsend来实现,那么在swift中函数是如何调用的呢?

截屏2022-01-06 下午9.05.15.png 我们通过对上面的汇编源码分析,发现blr是对x8寄存器里面的地址进行调用,而x8先是读取x20的地址,然后再加上0x50偏移量再存入到x8寄存器。往上我们可以看到x20寄存器的地址是由x0寄存器赋值的。我们可以看出x8最终是通过x0存入x20,然后存入x8,之后再通过x8加上一个0x50的偏移量获取的。 截屏2022-01-07 上午10.08.17.png 由于x0寄存器一般是用来存放函数的返回值,我们可以知道x0寄存器里面存放的是LGStudent的实例对象。通过register read方法读取x0寄存器的值,我们得到了LGStudent实例的metadata,所以我们可以得出一个结论:

类的实例方法是先找到实例的metadata地址,然后通过一个偏移量找到方法地址进行调用
  • swift函数的存储结构 由于类的函数地址是通过metadata地址加上偏移量得到的,由此可以猜测函数是否会存放到一个函数表里面。我们可以通过新增多个函数方法,然后对汇编源码进行分分析。

截屏2022-01-07 上午10.20.44.png

汇编源码情况如下 截屏2022-01-07 上午10.22.35.png 图里面三个方框标识出来的blr方法,就是三个实例方法的调用,我们可以看到,这三个方法的偏移量分别是0x500x580x60,三个连续的内存空间,各自相差8个字节,因此我们可以证实这个猜测,函数是存放到一个函数表里面的,函数的调用是基于函数表的调用。

另一个方面,我们通过sil文件分析,也可以发现函数储存在一个函数表内。

截屏2022-01-07 上午10.42.37.png

源码分析函数表

我们知道,实例函数是通过Metadata加上偏移量获取v_table,那么v_table可能放在Metadata的数据结构中。Swift的Metadata的数据结构如下:

struct Metadata{ 
    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
}

这里我们有一个东西需要关注typeDescriptor,不管是ClassStructEnum都有自己的Descriptor,就是对类的一个详细描述。

TargetClassMetadata中有一个TargetClassDescriptor类的私有属性Description

截屏2022-01-07 上午11.27.30.png

我们可以通过 TargetClassDescriptor 结构的源码得知其继承关系 TargetClassDescriptorTargetTypeContextDescriptor :TargetContextDescriptor 根据继承关系可以获取 Descriptor 的大致结构如下:

class 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

Descriptor的结构中,我们没有找到v_Table的结构,所以我们接着寻找,在Metadata.h文件中全局搜索TargetClassDescriptor后,发现它有一个别名ClassDescriptor

截屏2022-01-07 上午11.41.25.png 我们全局文件查找ClassDescriptor,然后发现跟Metadata相关文件里面还有一个GenMata.cpp文件使用到,进入这个文件,我们可以看到有一个ClassContextDescriptorBuilder的类,这个就是创建MetadataDescriptor的类。

截屏2022-01-07 上午11.49.29.pngClassContextDescriptorBuilder类中,我们找到了一个layout()的方法

截屏2022-01-07 上午11.50.01.png 查看super::layout(),可以看到它的父类方法实现

截屏2022-01-07 上午11.51.47.png

截屏2022-01-07 上午11.51.56.png

可以看到,这个layout()方法就是在创建TargetClassDescriptor类,当调用完super::layout()后,就开始进行addVTable()方法,而这个就是添加函数表的方法。我们看下这个方法的具体实现

截屏2022-01-07 上午11.58.12.png

在代码中,变量offset就是计算出偏移量,然后把这个偏移量加入到变量B中,把VTableEntriessize也加入到变量B中。最后去遍历这个VTableEntries,添加VTableEntries的指针,也就是当前函数的指针。

截屏2022-01-07 下午12.04.41.png 我们再去查找这个变量B,它定义在这个CotextDescriptorBuildBase类中,而这个类是TargetClassDescriptor的最终父类,因此我们可以确定,这个变量B应该是TargetClassDescriptor类。

截屏2022-01-07 下午2.33.29.pnglayout()方法中,我们还看到了addOverrideTable方法,这个是把父类中可以继承的实例方法加入到子类的函数表中。

类的实例方法的数据结构

在Swift源码Metadata.h的文件中,我们找到了TargetMethodDescriptor结构体,这个就是Swift的实例方法的内存结构。

截屏2022-01-07 下午4.18.21.png

  • Flags 用来标识方法类型,比如setter方法、getter方法、init方法等
  • Impl 用来指向相对位置的指针。

Mach-o文件分析函数表

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

Mach-o文件格式:

16412758701925.jpg

  • 首先是文件头,表明该文件是Mach-O格式,指定目标架构,还有一些其他的文件属性信息文件头信息影响后续的文件结构安排 
  • Load commands是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。 Load commands相关命令如下
LC_SEGMENT_64          //将文件中(32位或64位)的段映射到进程地 址空间中
LC_DYLD_INFO_ONLY      //动态链接相关信息
LC_SYMTAB              //符号地址
LC_DYSYMTAB            //动态符号表地址
LC_LOAD_DYLINKER       //dyld加载
LC_UUID                //文件的UUID
LC_VERSION_MIN_MACOSX  //支持最低的操作系统版本
LC_SOURCE_VERSION      //源代码版本
LC_MAIN                //设置程序主线程的入口地址和栈大小
LC_LOAD_DYLIB          //依赖库的路径,包含三方库
LC_FUNCTION_STARTS     //函数起始地址表
LC_CODE_SIGNATURE      //代码签名
  • Data区主要就是负责代码和数据记录的。Mach-O 是以 Segment 这种结构来组织数据的,一个Segment可以包含0个或多个Section。根据Segment是映射的哪一个Load Command,Segment中section就可以被解读为是是代码,常量或者一些其他的数据类 型。在装载在内存中时,也是根据Segment做内存映射的。

读取Mach-o文件

首先,在Xcode->Product->Show Build Folder in Finder,获取这个工程的Build文件夹,然后找到.app文件,显示包内容,可以看到有一个以工程名命名的可执行文件,把这个可执行文件拖入到MachoView程序中,就可以读取到Mach-o文件。

截屏2022-01-08 下午5.52.33.png

打开Mach-o文件后,我们先去寻找__swift5_types的文件,这里存放的是Swift 结构体、枚举、类的 Descriptor,那么我们可以在这里找到类的 Descriptor 的地址信息

截屏2022-01-08 下午9.36.32.png 因为我们的代码里面只有一个LGStudent类,所以这里的前四个字节就是我们的LGStudentDescriptor信息。

截屏2022-01-08 下午10.02.09.png 那么用前面的 BC58 + F4 FB FF FF 就是Descriptor 在当前 Mach-O 文件的内存地址。

//由于iOS属于小端模式,所以 前四个字节是 FFFFFBF4
0xFFFFFBF4 + 0x0000BC58 = 0x10000B84C

在每个Mach-O 文件中有虚拟内存的基地址,当前得到的地址还需要减去这个虚拟内存的基地址才是LGStudent的 Descriptor在Data区的首地址0xB698

截屏2022-01-08 下午10.08.37.png

0x10000B84C - 0x10000000000 = 0xB84C

通过这个地址 我们找到了 Section64(_TEXT,__const) 这个区 截屏2022-01-08 下午10.30.48.png 这里也就是LGStudent的 Descriptor类的内存首地址。从上面的Descriptor类的内存结构我们可以知道,vtable的位置是在Descriptor类里面,Descriptor中有13个UInt32,也就是需要后移 13个四字节,所以我们的study方法就应该在红框位置往后数13个四字节,且方法的指针应该是8个字节,所以后面红框标记的的8个字节,B880就是study方法 Mach-O 文件中的偏移量。

截屏2022-01-08 下午10.40.29.png

在上面的源码分析中我们知道了方法在swift底层中的结构是TargetMethodDescriptor,所以这里存储的值前四个字节0x1就是flag的地址,后面四个存储的 FFFFB9D4就是impl的地址。

在iOS中每个应用程序都有一个ASLR(随机偏移地址)study的真实调用地址应该是study方法在 Mach-O 文件中的偏移量加上ASLR,然后偏移TargetMethodDescriptorflag的4个字节,加上impl中的偏移量,最后再减去 Mach-O 的虚拟基地址。

我们可以在真机调试工程的时候,在lldb打上image list 命令,来获取这个ASLR。通过image list命令得到ASLR程序运行的基地址 0x0000000100194000 截屏2022-01-08 下午10.49.08.png

//应用程序的基地址:0x0000000100194000,study函数结构地址:B880,Flags:0x4,offset:FFFFB9D4
0x0000000100194000 + B880 + 4 + FFFFB9D4 = 0x20019B258 
//减掉 Mach-O 文件的虚拟地址 0x100000000,得到的就是函数的地址
0x20019B258 - 0x100000000 =  0x10019B258

前面我们通过汇编分析知道实例方法如何被调用的,我们只需要在汇编代码中找到 调用该方法的位置查看寄存器的内容是否和计算结果相同,就可以验证Mach-O分析是否正确 截屏2022-01-08 下午10.58.58.png 由此,我们Mach-o文件分析函数地址无误。

结构体的函数调用

我们把类改成结构体,然后查看结构体的函数如何调用。

截屏2022-01-08 下午11.27.08.png 真机调试后,查看它的汇编代码:

截屏2022-01-08 下午11.28.39.png 我们可以看到,在Swift中,调用一个结构体的方法是直接拿到函数的地址直接调用,包括初始化方法,Swift 是一门静态语言,许多东西在运行的时候就可以确定了,所以才可以直接拿到函数的地址进行调用,这个调用的形式也可以称作静态派发

类和结构体的extension扩展

我们对类和结构体使用extension关键字扩展,去了解一下在extension里面的函数如何调用。

截屏2022-01-08 下午11.39.27.png

然后查看汇编源码

截屏2022-01-08 下午11.41.23.png 我们可以看到,无论是class或者是struct在extension 中的的方法都是通过静态调用的方式。

继承自NSObject的子类

截屏2022-01-08 下午11.49.35.png

截屏2022-01-08 下午11.50.33.png 我们可以看到,继承自NSObject的子类和Swift的类一样,都是通过函数表派发,扩展方法也是直接调用,静态派发。

方法调度方式总结

截屏2022-01-08 下午11.52.47.png

关键字影响函数的派发方式

  • final关键字

截屏2022-01-08 下午11.58.13.png 通过对sil文件进行分析,我们发现在这两个类的sil_vtable中,都没有找到final关键字修饰的study3方法。 截屏2022-01-08 下午11.58.00.png 通过对汇编源码的分析,我们发现直接调用了study3的函数地址。

截屏2022-01-09 上午12.01.11.png 因此,我们可以得出结论:添加了final关键字的函数无法被重写,使用静态派发,不会在vtable中出现,且对objc运行时不可见。

  • dynamic关键字 函数均可添加dynamic关键字,为非objc类和值类型的函数赋予动态性,可以进行一些方法替换,如果是非objc类,派发方式还是函数表派发。如果是值类型,派发方式是静态调用。 截屏2022-01-09 上午12.05.47.png 截屏2022-01-09 上午12.06.15.png

  • @objc关键字 @objc关键字可以将Swift函数暴露给Objc运行时,依旧是函数表派发。

截屏2022-01-09 上午12.14.47.png 截屏2022-01-09 上午12.15.17.png

  • @objc + dynamic 截屏2022-01-09 上午12.17.21.png 截屏2022-01-09 上午12.17.15.png 我们可以看到,使用@objc + dynamic 修饰,函数调用方式就变成了消息派发方式,我们就可以使用run-time里面的API对这个方法进行交换等操作。

  • static关键字 static 方法不会存在vTable中,也是通过静态派发 截屏2022-01-09 上午12.21.50.png 截屏2022-01-09 上午12.22.02.png

函数内联

函数内联是一种编译器优化技术,它通过使用方法的内容替换直接调用该方法,从而优化性能。 

  • Swift 中的内联函数是默认行为,我们无需执行任何操作. Swift编译器可能会自动内联函数作为优化
  • always - 将确保始终内联函数。通过在函数前添加 @inline(__always) 来实现此行为
  • never - 将确保永远不会内联函数。这可以通过在函数前添加 @inline(never) 来实现。
  • 如果函数很⻓并且想避免增加代码段大小,请使用@inline(never)(使用@inline(never))

private 的优化操作

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