Swift

Swift 中类与结构体(二)

2022-01-04  本文已影响0人  晨曦的简书
愿大家新的一年都能成功上岸

异变方法

在上篇文章 Swift 中类与结构体(一)中我们了解到 SwiftClassStruct 中都能定义方法,但是有一点区别的是默认情况下,值类型属性不能被自身的实例方法修改。

如上代码所示,会提示 self 不可被修改,如果我们想在结构体自己的方法中修改自己的值的话就需要加关键字 mutating

struct Point {
    var x = 0.0, y = 0.0
    mutating func moveBy(x deltaX: Double, y deltaY: Double) {
        // 这里就相当于修改 self.x 跟 self.y 的值,这里 self 是值类型,相当于在自己的方法里面修改自己本身
        x += deltaX
        y += deltaY
    }
}

如上代码,当加了 mutating 关键字之后就可以在方法中修改 self 的值了。那么具体原因我们通过以下代码转成 SIL 文件来分析一下。

struct Point {
    var x = 0.0, y = 0.0
    
    func textFunc() {
        let temp = self.x;
    }
    
    mutating func moveBy(x deltaX: Double, y deltaY: Double) {
        // 这里就相当于修改 self.x 跟 self.y 的值,这里 self 是值类型,相当于在自己的方法里面修改自己本身
        x += deltaX
        y += deltaY
    }
}
// Point.textFunc()
// 这里 textFunc 函数需要一个参数 Point,也就是 self,swift 函数中有一个默认参数 self
sil hidden @$s4main5PointV8textFuncyyF : $@convention(method) (Point) -> () {
// %0 "self"                                      // users: %2, %1
bb0(%0 : $Point):
  debug_value %0 : $Point, let, name "self", argno 1 // id: %1
  %2 = struct_extract %0 : $Point, #Point.x       // user: %3
  debug_value %2 : $Double, let, name "temp"      // id: %3
  %4 = tuple ()                                   // user: %5
  return %4 : $()                                 // id: %5
}

// Point.moveBy(x:y:)
// 这里默认参数 Point 前多了 @inout 关键字
sil hidden @$s4main5PointV6moveBy1x1yySd_SdtF : $@convention(method) (Double, Double, @inout Point) -> () {
// %0 "deltaX"                                    // users: %10, %3
// %1 "deltaY"                                    // users: %20, %4
// %2 "self"                                      // users: %16, %6, %5
bb0(%0 : $Double, %1 : $Double, %2 : $*Point):
  debug_value %0 : $Double, let, name "deltaX", argno 1 // id: %3
}

SIL 文件中我们找到 textFunc 函数与 moveBy 函数的实现,可以发现在 Swift 代码中函数前用 mutating 修饰的话,在 SIL 中的函数默认参数前会多了 @inout 关键字。以下是官方文档对 @inout 关键字的解释。

用伪代码来表示的话就是在 textFunc 函数中是 let self = Point,用的是 let 关键字修饰的,表示不可修改。在 moveBy 函数中的表示是 var self = &Point,用的是 var 关键字修饰,且取得是 Point 的地址。

如以上代码编译的时候会报错,当我们用 inout 关键修饰后就可以在函数内修改 age 的值,不过这里需要注意的是调用 modifyage 函数的时候传参要传 &age

运行输出之后可以看到 age 的值确实被修改了,函数中的 age 跟外部 age 指向的其实是同一块内存地址。

方法调度

OC 中我们是通过 obj_msgsend 这种消息机制来进行方法的调度的,那么在 swift 中又是什么样的呢,下面我们来探究一下。

通过汇编分析函数调度形式

汇编常见指令

mov x1, x0    //将寄存器 x0 的值复制到寄存器 x1
add x0, x1, x2    //将寄存器 x1 和 x2 的值相加后保存到寄存器 x0 中
 sub x0, x1, x2    //将寄存器 x1 x2 的值相减后保存到寄存器 x0 中
 and x0, x0, #0x1    //将寄存器 x0 的值和常量 1 按位与后保存到寄存器 x0
 orr x0, x0, #0x1    //将寄存器 x0 的值和常量 1 按位或后保存到寄存器 x0
 str x0, [x0, x8]    //将寄存器 x0 中的值保存到栈内存  [x0, x8]  处
 ldr x0, [x1, x2]   //将寄存器 x1 和寄存器 x2 的值相加作为地址,取该地址的值放入寄存器 x0 中

汇编调试

class CXPerson {
    func eat() {
        print("eat")
    }
    func eat1() {
        print("eat1")
    }
    func eat2() {
        print("eat2")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let p = CXPerson()
        p.eat()
        p.eat1()
        p.eat2()
    }
}

以上代码我们在 CXPerson 类中定义了 eateat1eat2 三个方法,并在三个方法调用的地方打上断点并开启汇编调试,结合上面的汇编指令可以看到在汇编代码中 33行、37行、41行分别对应的就是 eateat1eat2 这三个方法的调用指令,以下是汇编代码的注释。

// 这里返回的就是 CXPerson 对象实例,且返回结果保存在 x0 寄存器中
0x100ef2764 <+100>: bl     0x100ef26b0               ; swiftDemo.CXPerson.__allocating_init() -> swiftDemo.CXPerson at ViewController.swift:10
// 将寄存器 x0 的值复制到寄存器 x20
    0x100ef2768 <+104>: mov    x20, x0
// 将寄存器 x20 中的值保存到栈内存  [sp, #0x18]  处
    0x100ef276c <+108>: str    x20, [sp, #0x18]
// 将寄存器 x20 中的值保存到栈内存  [sp, #0x20]  处
    0x100ef2770 <+112>: str    x20, [sp, #0x20]
// 取 x20 实例对象的地址的值放入寄存器 x8  中,地址就是实例对象的前 8  个字节,x20 第一个8字节就是 metedata
    0x100ef2774 <+116>: ldr    x8, [x20]
// 将寄存器 x8(也就是 metedata 的值) 值加上 0x50 并放入寄存器 x8 中
    0x100ef2778 <+120>: ldr    x8, [x8, #0x50]
// 此时 x8 就是 eat 函数的地址,这里跳转到函数地址,也就是执行 eat  函数 
->  0x100ef277c <+124>: blr    x8
    0x100ef2780 <+128>: ldr    x20, [sp, #0x18]
    0x100ef2784 <+132>: ldr    x8, [x20]
    0x100ef2788 <+136>: ldr    x8, [x8, #0x58]
// 此处为 eat1 函数执行,函数地址为 x8 + 0x58
    0x100ef278c <+140>: blr    x8
    0x100ef2790 <+144>: ldr    x20, [sp, #0x18]
    0x100ef2794 <+148>: ldr    x8, [x20]
// 此处为 eat2 函数执行,函数地址为 x8 + 0x60
    0x100ef2798 <+152>: ldr    x8, [x8, #0x60]
    0x100ef279c <+156>: blr    x8

eat 函数的调用过程: 找到 Metadata 基于函数表的调度
,确定函数地址(metadata + 偏移量), 执行函数

通过以上汇编分析可以看到 eateat1eat2 三个函数都是相差 8 个字节,也就是函数地址的大小,且是连续的内存空间,有点类似数组,所以可以猜测 swift 中的方法调度是基于函数表的调度。例如 eat函数的调用过程就是找到 Metadata,基于函数表的调度,确定函数地址(metadata + 偏移量), 然后执行函数。下面我们再来通过 SIL 文件来验证一下。

通过 SIL 验证函数表

sil_vtable CXPerson {
  #CXPerson.eat: (CXPerson) -> () -> () : @$s4main8CXPersonC3eatyyF // CXPerson.eat()
  #CXPerson.eat1: (CXPerson) -> () -> () : @$s4main8CXPersonC4eat1yyF   // CXPerson.eat1()
  #CXPerson.eat2: (CXPerson) -> () -> () : @$s4main8CXPersonC4eat2yyF   // CXPerson.eat2()
  #CXPerson.init!allocator: (CXPerson.Type) -> () -> CXPerson : @$s4main8CXPersonCACycfC    // CXPerson.__allocating_init()
  #CXPerson.deinit!deallocator: @$s4main8CXPersonCfD    // CXPerson.__deallocating_deinit
}

swift 代码生成 SIL 文件之后,可以看到 sil_vtable,里面包含了 CXPerson 类的所有函数,所以函数表在 SIL 文件中的表现形式就是 sil_vtablesil_vtable 是每个类自己的函数表。

通过源码分析函数表

以上我们通过汇编跟 SIL 文件证明了函数表的调度形式,但是源码更有说服力,下面我们通过源码来验证一下。

在上篇文章 Swift 中类与结构体(一)中我们讲到了 Metdata 的数据结构,那么 V-Table 是存放在什么地方呢?

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,就是对类或者结构体等类型的一个详细描述。下面我们打开源码来分析一下。

TargetSignedPointer<Runtime, const TargetClassDescriptor<Runtime> * __ptrauth_swift_type_descriptor> Description;
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
}
using ClassDescriptor = TargetClassDescriptor<InProcess>;
void layout() {
      assert(!getType()->isForeignReferenceType());
      super::layout();
      addVTable();
      addOverrideTable();
      addObjCResilientClassStubInfo();
      maybeAddCanonicalMetadataPrespecializations();
    }
    void layout() {
      asImpl().computeIdentity();

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

父类的 layout 方法中我们可以看到 addNameaddAccessFunction 等方法调用跟我们上面分析的 TargetClassDescriptor 类的结构中的属性存在对应关系,这里其实就是在创建 Descriptorlayout 中是做一些赋值的操作。

void addVTable() {
      // 计算偏移量      
      auto offset = MetadataLayout->hasResilientSuperclass()
                      ? MetadataLayout->getRelativeVTableOffset()
                      : MetadataLayout->getStaticVTableOffset();
      // 将偏移量添加到 B 中,这里 B 是一个结构体 ,就是 Descriptor
      B.addInt32(offset / IGM.getPointerSize());
      B.addInt32(VTableEntries.size());
      
      // 遍历数组,添加函数的指针 
      for (auto fn : VTableEntries)
        emitMethodDescriptor(fn);
    }

所以向 B 中添加就是向 Descriptor 结构体中添加 method,在 Descriptor 中的最后一个属性是 v-table,下面我们通过 Mach-O 文件来验证一下。

通过 Mach-O 文件验证源码分析结果

Mach-O 介绍

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

Mach-O 文件格式

可执行文件分析

class CXPerson {
    func eat() {
        print("eat")
    }
    func eat1() {
        print("eat1")
    }
    func eat2() {
        print("eat2")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let p = CXPerson()
        p.eat()
        p.eat1()
        p.eat2()
    }
}

以上代码生成可执行文件之后我们借助 MachOView 工具来查看一下。

通过 MachOView 工具打开代码运行后的可执行文件如上所示,其中 swift5_types 中存储的是 ClassStructEnum 等数据类型的 Descriptor 信息,且是以 4 字节为单位,这里 0XFFFFFBA8 + BBCC = 0X10000B774 就是 CXPerson 类的 DescriptorMach-O 中的地址。

PAGEZERO 中可以看到虚拟内存的基地址是从 0000000100000000,开始, 这里 0X10000B774 - 0000000100000000 = B774 就是 DescriptorData 区的地址。

如上图所示,红色圈选的部分就是 CXPerson 类的 Descriptor 的首地址,而圈选部分后面的数据应该是跟 TargetClassDescriptor 中的属性一一对应的,前面我们推断 v-table 应该在所有的属性后面,所以 Descriptor 的首地址加上所有属性占用的内存大小,就是 v-table 的内容位置,在上图中我们也标记出了 size 属性的位置,在 size 之后就分别是 eateat1eat2 三个函数的内容。而 B7A8 就是 eat 函数在 MachO 中的偏移量。那么我们如果要找到 eat 函数在运行程序内存中的真实地址的话就需要用 B7A8 加上程序的随机偏移地址(ASLR)。

我们通过 LLDB 命令可以看到,0x00000001000ac000 就是当前程序的基地址,0x102238000 + B7A8 = 0x1000B77A8 就是 eat 函数对应的数据结构 TargetMethodDescriptor 在内存中的首地址,下面我们再来看下函数的数据结构。

struct TargetMethodDescriptor {
  //  Flags 为 4 字节
  MethodDescriptorFlags Flags;

  // 这里存储的是相对指针,就是 offset
  TargetRelativeDirectPointer<Runtime, void> Impl;
};

所以 eat 函数的指针地址需要用 0x1000B77A8 + 4字节 + offset(FFFFC028)- 虚拟内存的起始地址(0000000100000000) = 0x1000B37D4,那么我们算的对不对呢,我们通过断点调试来验证一下。

通过断点调试,输出 eat 函数的地址跟我们计算出的一样。

结构体的方法调度

以上我们分析了类的方法调度方式,那么结构体的方法调度方式又是什么样的呢?下面我们继续来分析一下。

struct CXPerson {
    func eat() {
        print("eat")
    }
    func eat1() {
        print("eat1")
    }
    func eat2() {
        print("eat2")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let p = CXPerson()
        p.eat()
        p.eat1()
        p.eat2()
    }
}

通过汇编调试可以看到,结构体的方法调用就是 bl 指令直接拿到地址进行跳转,也就是静态派发, 意味着在编译链接之后,结构体中的方法地址就已经确定了, 这是因为结构体是值类型,没有继承关系,所以没必要再开辟连续的空间来记录结构体中的方法, 编译器会把方法执行直接优化成地址调用,类似于静态函数。

通过源码也可以看到,在建立结构体的描述的时候也没有 v-table

extension 下方法的调度

struct CXPerson {
    func eat() {
        print("eat")
    }
    func eat1() {
        print("eat1")
    }
    func eat2() {
        print("eat2")
    }
}

extension CXPerson {
    func eat3() {
        print("eat3")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let p = CXPerson()
        p.eat()
        p.eat1()
        p.eat2()
        p.eat3()
    }
}

CXPerson 为结构体的时候,给 CXPersonextension 中添加方法,新添加的方法调度方式不变,也是静态派发。

class CXPerson {
    func eat() {
        print("eat")
    }
    func eat1() {
        print("eat1")
    }
    func eat2() {
        print("eat2")
    }
}

extension CXPerson {
    func eat3() {
        print("eat3")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let p = CXPerson()
        p.eat()
        p.eat1()
        p.eat2()
        p.eat3()
    }
}

CXPersonclass 的时候,给 CXPersonextension 中添加方法,类中的方法调度依然是通过函数表派发,但是 extension 中的方法是通过静态派发的方式调用的。个人理解的原因是,如果 extension 中的方法依然通过函数表派发的话,会影响类中 v-table 的内存结构,需要插入新的方法到函数表中,对内存的开销是非常大的,所以编译器对 extension 中的方法调用会直接优化成静态调用。

方法调度方式的总结

影响函数派发方式的因素

如上图可以看到 eat 函数被替换成了 eat3

class CXPerson {
    @objc dynamic func eat() {
        print("eat")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let p = CXPerson()
        p.eat()
    }
}

这里 eat 会通过消息派发的方式进行调用,可以使用 runtimeapi,也可以进行方法的交换,但是 eat 不能被 NSObject 使用,如果想让 NSObject 使用 eat 函数的话需要让 CXPerson 继承于 NSObject

函数内联

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

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

class LGPerson{
    private var sex: Bool
    private func unpdateSex(){
        self.sex = !self.sex
    }
    init(sex innerSex: Bool) {
        self.sex = innerSex
    }
}
上一篇下一篇

猜你喜欢

热点阅读