Swift类和结构体(二)

228 阅读14分钟

Swift类和结构体(二)

异变方法

mutating关键字

Swift 中 class 和 struct 都能定义方法。但是有一点区别的是默认情况 下,值类型属性不能被自身的实例方法修改。

struct point {
    var x = 0.0
    var y = 0.0
    func movBy(x  deltaX:Double,y deltaY:Double) {
        print(self)
    }
}
let p = point()
p.movBy(x: 1.0, y: 1.0)`

首先我们来看一下self是什么类型的,运行以上代码,我们可以得到下图的的打印结果,打印的不是一个指针,而是直接打印出来值,所以self是一个值类型,而能定义多个不同类型的变量,我们可以很顺利的想到,self是一个结构体。

2691640698926_.pic

接下来我们来看一下值类型属性不能被自身的实例方法修改,请看下图

WeChatb0dad38e9bab3b12ee8911c0bcb6b57d

在自己的方法里修改自己值得属性是不被允许的,因为如果此时你在外层定义一个实例变量,这直接影响到了实例变量,这显然是不被允许的,同样编译器也会告诉你self是不可变的。

想要self能被修改我们可以再实例方法前面加上mutating这个关键字,我们来看看效果

struct point {
    var x = 0.0
    var y = 0.0
    mutating func movBy(x  deltaX:Double,y deltaY:Double) {
        print(self)
        x += deltaX
        y += deltaY
        print(self)
      }
}
var p = point()
p.movBy(x: 1.0, y: 2.0)
​

编译之后我们发现编译器不再报错了,运行以上代码,得下下图的结果WeChat41f4d98b4951edf2799a1d845f2b1d90

值类型的self竟然被修改了,我们通过SIL 中间代码我们来看一下不添加 mutating 访问和添加 mutating 两者有什么本质的区别 为了更加直观的对比我们定义两个函数,一个添加了mutating,一个不添加mutating 通过 swiftc -emit-sil main.swift >> main.sil && open main.sil 命令转换成SIL代码

struct point {
    var x = 0.0
    var y = 0.0
    func test()  {
        let tmp = x
    }
    mutating func movBy(x deltaX: Double,y deltaY: Double){
        x += deltaX
        y += deltaY
      }
}

2701640701554_.pic_hd

首先我们看到point这个结构体,结构体中的test ()和movBy ()这两个函数在定义上看起来没有什么大的区别,接下来我们来看到方法的具体实现

image-20211228224911548

image-20211228225131026

  • test函数: 我们来仔细对比图上标记的①号位置test函数表明这个函数需要传入一个point类型的参数相当于我们的也就是self,可以从②好位置看出是self
  • movBy函数: 从①我们可以看到这个函数接吼了三个参数,分别是deltaX,deltaY依旧 @inout 标记的point的self
  • test函数: ②号位置表明这里将self这个变量放到%0这个寄存上,是一个let的声明
  • movBy函数: ②好位置表明将self这个变量的地址放到2%这个寄存器上,说明@inout标记的self已经不是值的赋值,而是地址的赋值了,同事我们可以看到这个变量的声明是一个var的声明

@inout关键字

SIL 文档的解释

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

let self = Point 
var self = &Point

以上我们可以看到self的不同,可以用上述代码来表示,我们可以得出以下结论

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

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

image-20211228235236047

最后我们通过class类型的self的打印来加深印象,class类型的self是存储着一个地址,而不是直接的数值。

方法调度

oc是通过objc_msgSend来进行方法调度的,那swift是通过什么方式来进行方法调度的呢?

汇编指令:
  • bl: 跳转到某地址(有返回)
  • blr: 跳转到某地址(无返回)
  • mov: 将某一个寄存器的值复制到另一寄存器(只能用于寄存器与寄存器或者寄存器与常量之间传值,不能用于地址
  • add: 将某一寄存器的值和另一寄存器的值相加并将结果保存在另一寄存器
  • ldr: 将内存中的值读取到寄存器中
  • 函数的返回值存放在x0寄存器:x0实例对象

首先我们来探讨一下类的方法调度

class MJYTeacher {
    func teach() {
        print("teach")
    }
}
class ViewController: UIViewController {
​
    override func viewDidLoad() {
//        super.viewDidLoad()
        let t = MJYTeacher()
        t.teach()
    }
}
​

我们在t.teach函数调用的地方打个断点,使用真机调试在ARM66位的环境下:

WeChat661e9247e371f10fe0a18dd87f5ff4cc

我们可以猜出来blr无函数返回值的应该就是teach方法的调用,在blr的位置打个断点,控制台输入si进入函数

WeChatbd7b65895e368093c393613f62fb2f51

我们可以看到这个就是teach函数

class MJYTeacher {
    func teach1() {
        print("teach1")
    }
    func teach2() {
        print("teach2")
    }
    func teach3() {
        print("teach3")
    }
}
 let t = MJYTeacher()
        t.teach1()
        t.teach2()
        t.teach3()

接下来我们将代码更改一下在方法调用的地方打上断点,我们去看一下这种情况下函数具体是怎么实现的

WeChatda4fdfd6206924c4fd40c38fefa59829

  • 根据以上情况我们可以看到在函数调用之前我们读取了x8寄存器,寄存器说他是一个type为metadata的值,根据已知的class类前8个字节是metadata可以看出此时x8就是实例对象的地址。
  • [x8, #0x50]这个是x8的地址再加上偏移量,此处打印的第二条值告诉我们这个地方时teach1函数 (82行)
  • [x8, #0x58],[x8, #0x60]这里也是一样的情况(86行,90行)
teach函数的调用过程:找到metadata(就是实例对象的地址),确定函数地址(metadata+偏移量),执行函数,并且每个函数之间的地址值差是8,就是一个函数指针的大小,说明函数指针是存在一块连续的内存空间里,也就是基于函数表的调度。

以上结论或许不太具有说服力接下来我们将文件编译成sil文件去看一下

WeChat24fef90bcfabe920eb147774b1049894

我们可以再该文件的末尾找到sil_vtable,表明我们的函数都是存放在一个表中的。

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
}
​

我们关注到var typeDescriptor: UnsafeMutableRawPointer描述信息这里,这个是对我们类的详细描述

image-20220103191600419

在metadata.h的源码中我们找到desc,这个描述的类型是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 
}

但是在以上结构中我们没有看到vTable,在全局的文件中搜索TargetClassDescriptor

image-20220103194719377

我们在metadata的源码中可以看到以上一句代码,TargetClassDescriptor的别名叫做ClassDescriptor,所以我们需要搜索的是ClassDescriptor

image-20220103194955732

搜索以后我们得到13处相关的文件,快速排除掉一些不相关的文件,看到genMeta(生成metadata源数据)这个就是生成元类型的地方,我们进去查看GenMeta.cpp

Xnip2022-01-03_20-02-27

我们在其方法中找到layout方法

void layout() {
      assert(!getType()->isForeignReferenceType());
      super::layout();
      addVTable();
      addOverrideTable();
      addObjCResilientClassStubInfo();
      maybeAddCanonicalMetadataPrespecializations();
    }

首先调用super layout的方法,super是TypeContextDescriptorBuilderBase我们进去查看可以看到

  void layout() {
      asImpl().computeIdentity();
​
      super::layout();
      asImpl().addName();
      asImpl().addAccessFunction();
      asImpl().addReflectionFieldDescriptor();
      asImpl().addLayoutInfo();
      asImpl().addGenericSignature();
      asImpl().maybeAddResilientSuperclass();
      asImpl().maybeAddMetadataInitialization();
    }

这个地方跟之前找到的TargetClassDescriptor的结构体非常接近,所以这个layout实在创建我们的TargetClassDescriptor,是在做一些赋值操作。

接下来就是addVTable方法进去查看这个函数

image-20220103201254718

上图是addVTable的具体实现,可以明显看到拥有一个size和一个offset。

Mahco

Swift的源码的逻辑比较难以读懂,上方可以作为一些辅助参考,接下来我们可以看一下编译成ipa之后的Mahco文件。

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

Mahoc文件格式:

Xnip2022-01-03_22-46-03

Mahoc文件分为三个部分分别为header部分、Load commands部分和Data部分。

header

image-20220103230617052

文件头,表明该文件是 Mach-O 格式,指定目标架构,还有一些其他的文件属性信 息,文件头信息影响后续的文件结构安排

Load commands

image-20220103230925302

Load commands是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。

Data

image-20220103231444468

Data 区主要就是负责代码和数据记录的。Mach-O 是以 Segment 这种结构来组织数据 的,一个 Segment 可以包含 0 个或多个Section。根据 Segment 是映射的哪一个 Load Command,Segment 中 section 就可以被解读为是是代码,常量或者一些其他的数据类 型。在装载在内存中时,也是根据 Segment 做内存映射的。

我们的OC类存放在__objc_classlist中,那么我们Swift类存放在哪?

接下来我们来看一下我们当前程序的可执行文件,运行之后通过product -> Show Builder Folder in Finder ->procucts

image-20220104144207664

image-20220104144341620

image-20220104144434952

用MachOView来打开这个可执行文件

Swift的类和结构体存放在__swift5_types这个section中,这个section里面存放的是当前结构体或者class的desc(描述信息)的地址。

image-20220103232025597

image-20220104144905966

前四个字节是我们MJYTeacher的desc,直接拿上前四个字节加上前方的地址值就是我们desc在Mahoc的内存地址

小端模式:FFFFFBF4+0000BC58 = 0x10000B84C(0x100---)是虚拟内存的基地址减去0x100000--的地址就能得到desc的偏移量,我们点开文件看一下相关地址定位到__const文件中找到B84C的位置就是desc在MachO文件中的偏移地址

image-20220104145934861

我们再此处找到了desc这个结构体的首地址,根据文章上方找到的结构体我们可以知道偏移12个4字节就能找到desc中的size位置,B880开始就是teacher1,teacher2....的位置

接下来我们需要加上程序的随即偏移地址,在控制台使用 image list获取应用程序的各个模块地址,取到第一个地址0x0000000100c58000,这个是程序运行的基地址,也就是我们的随机偏移量

image-20220104153721155

所以我们需要用基地址加上desc的偏移量:0x0000000100560000 + B880 = 0x10056B880,这个是内存中真正的teach1这个方法的首地址

我们再源码中找到TargetMethodDescriptor这个结构体Swift方法在内存中的结构

image-20220104150803462

该结构体中有一个Flags的标识,以及一个Impl的指针点开Flags我们可以知道这个是一个32位(4字节)的数据,Impl存储的是一个相对指针,也就是偏移量

image-20220104151949545所以我们要用我们上面算好的地址0x100C63880 加上一个4字节的flags以及impl的偏移量:0x10056B880+4+FFFFB9D4= 0x200567258这个还需要减去一个程序运行的基地址得到0x100567258

image-20220104152821554

这个结果说明上文的说法都是对的,过程比较晦涩难懂,仅做记录参考而已。在结果正确的前提下,sil的vTable就能说明函数是存放在虚函数表中。

接下来我们来看一下MJYTeacher这个类变成一个结构体,此时此刻这个结构体中函数的调度方式会变成什么样子呢?

struct MJYTeacher{ //继承关系吗?没有
    func teach1() {
        print("teach1")
    }
    func teach2() {
        print("teach2")
    }
    func teach3() {
        print("teach3")
    }
}

image-20220104155611077

通过汇编我们可以看到bl方法调用直接拿到了一个地址,说明struct中的方法是直接的地址调用,也就是静态调用,函数是静态派发的。为啥呢,因为结构体是一个值类型,没有继承关系,每写一个结构体都是一个新的值,没有必要开辟一块连续的内存空间用来记录函数表。

在GenMeta.cpp中我们找StructContextDescriptorBuilder结构体的描述

image-20220104160134308

addLayoutInfo中并没有添加addVTable,也就意味着我们程序编译结束的时候结构体重的函数地址就已经确定了。直接被编译器优化成直接地址调用了。

如果我们给结构体添加一个extension 那么这个函数也应该是静态派发的

extension MJYTeacher{
    func teach4(){
        print("teache4")
    }
}

image-20220104160604514

汇编也同样证明了这一点。

那如果是Class的extension呢?,函数的派发方式是咋样呢,我们同样通过汇编查看一下

image-20220104160916276

我们会发现teach4是通过bl直接调用地址,是一个静态的派发。

方法调度方式总结:
类型调度方式extension
值类型静态派发静态派发
函数表派发静态派发
NSObject子类函数表派发静态派发

影响函数的派发方式

  • final: 添加了 final 关键字的函数无法被重写,使用静态派发,不会在 vtable 中出现,且对 objc 运行时不可见。 使用场景:实际开发中属性、方法、类不需要被重载的时候,可以加上final这个关键字
class MJYTeacher{
    //添加了final关键字
   final func teach1() {
        print("teach1")
    }
    func teach2() {
        print("teach2")
    }
    func teach3() {
        print("teach3")
    }
}

image-20220104163231797

image-20220104163403142

通过sil文件以及汇编我们可以看到VTable表中没有了teach1的身影,汇编中更是直接使用bl调用的地址;teach4是extension。

  • dynamic: 函数均可添加 dynamic 关键字,为非objc类和值类型的函数赋予动态性,但派发方式还是函数表或静态派发。 什么是动态性呢?
struct MJYTeacher{ //继承关系吗?没有
    //消息调度的机制 
   dynamic func teach1() {
        print("teach1")
    }
    func teach2() {
        print("teach2")
    }
    func teach3() {
        print("teach3")
    }
}
extension MJYTeacher{
    @_dynamicReplacement(for :teach1)
    func teach4(){
        print("teache4")
    }
}

以上函数调用teach1实际上是调用teach4,也就是意味着此时此刻编译器将teach1的impl的指向变成了teach4,这就是给函数赋予动态性,但是在函数的调度中,本来是怎么样的调度就是怎么样的调度

  • @objc: 该关键字可以将Swift函数暴露给Objc运行时,依旧是函数表派发。
  • @objc + dynamic: 消息派发的方式 ,objc_msgSend,那就意味着此时此刻可以使用method swizzling,也就是runtime的api,该类最好继承自NSObject,一般是为了OC和Swift的交互来使用的
  • static:类中static修饰的方法属于静态派发

lg:原生Swift是没有runtime的

函数的内联

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

debug模式下默认不优化代码的,但是内联函数是默认行为,在函数中代码比较少,或者编译器认为代码非常少直接将函数中的代码挪到该位置执行就好

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

image-20220104172719844

image-20220104172838486

通过上图的设置可以设置编译器的优化

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

private class MJYPerson{
    private var sex: Bool
    
    private func unpdateSex(){
        self.sex = !self.sex
    }
    init(sex innerSex: Bool) {
        self.sex = innerSex
    }
    func test(){
        self.unpdateSex()
    }
}
private class SubPerson: MJYPerson{
    
}
let p = MJYPerson(sex: true)
p.test()

以上代码是没有问题的 private 不代表没有继承只代表这个类是私有的

image-20220104180914858

在self.unpdateSex()处打上断点会发现第一处停下的地方是MJYPerson.unpdateSex()而不是test()这就是是被优化过了 callq 地址 直接调用