Swift 一、类与结构体(下)

2022-01-03  本文已影响0人  常在士心
类和结构体下.png

一、异变方法

1.1 值类型添加/不添加mutating关键字的区别

Swift语言中的类型有值类型引用类型之分,对于引用类型,在实例方法中对实例属性进行修改是没有问题的,但是对于值类型,读者需要格外注意,默认情况下,值类型属性不能被修改。示例代码如下:

///创建一个结构体
struct Point {
    var x: Double
    var y: Double
    func move(x deltaX: Double, y deltaY: Double)  {
        x += deltaX
        y += deltaY
    }
}

编译上面的代码会报如下图所示错误:


错误.png

对于值类型,使用mutating关键字修饰实例方法才能对属性进行修改,示例代码如下:

///创建一个结构体
struct Point {
    var x: Double
    var y: Double
    ///将点进行移动,因为修改了属性的值,需要用mutating修饰方法
    mutating func move(x deltaX: Double, y deltaY: Double)  {
        x += deltaX
        y += deltaY
    }
}

var point = Point(x: 3, y: 3)
///进行移动,此时位置为(6,6)
point.move(x: 3, y: 3)

实际上,在值类型实例方法中修改值类型属性的值就相当于创建了一个新的实例,上面的代码和下面的代码原理是一致的:

///创建一个结构体
struct Point {
    var x: Double
    var y: Double
    ///将点进行移动,直接创建新的实例
    mutating func move(x deltaX: Double, y deltaY: Double)  {
        self = Point(x: self.x + x, y: self.y + y)
    }
}

var point = Point(x: 3, y: 3)
///进行移动,此时位置为(6,6)
point.move(x: 3, y: 3)

1.2 SIL文档探究异变方法本质

下面我们通过SIL来对比一下,不添加mutating和添加mutating两者有什么区别:

///创建一个结构体
struct Point {
    var x: Double
    var y: Double
    ///没有用mutating修饰,和下面的move函数进行对比
    func test()  {
        let tmp = self.x
        print(tmp)
    }
    ///将点进行移动,直接创建新的实例
    mutating func move(x deltaX: Double, y deltaY: Double)  {
        self = Point(x: self.x + x, y: self.y + y)
    }
}

var point = Point(x: 3, y: 3)
///进行移动,此时位置为(6,6)
point.move(x: 3, y: 3)
// 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

分析上面的代码,可以知道test函数,有一个默认参数self,类型是Point类型。这里的test函数实际就是let self = Point,是直接取值。

// Point.move(x:y:)
sil hidden @$s4main5PointV4move1x1yySd_SdtF : $@convention(method) (Double, Double, @inout Point) -> () {
// %0 "deltaX"                                    // user: %3
// %1 "deltaY"                                    // user: %4
// %2 "self"                                      // users: %33, %23, %19, %11, %7, %5
bb0(%0 : $Double, %1 : $Double, %2 : $*Point):
  debug_value %0 : $Double, let, name "deltaX", argno 1 // id: %3
debug_value_addr %2 : $*Point, var, name "self", argno 3 // id: %5

分析上面的代码,可以知道move函数,有两个参数x,y,有一个默认参数self,类型是Point类型,一个是一个@inout关键字,那么我们先来看一下inout在官方文档的解释是什么。

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

这里的move函数实际就是var self = *Point,由原来的直接取值改变为了变量取地址。

由此我们可以得出异变方法的本质: 对于异变方法, 传入的 self被标记为 inout 参数。无论在mutating 方法内部发生什么,都会影响外部依赖类型的一切。
如果在开发中真的需要在函数内部修改传递参数的变量的值,可以将此参数声明为inout 类型。

1.3 输入输出参数inout

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

///在函数内部修改参数变量的值
func myFunc(a: inout Int)  {
    a += 1
}

var a = 15;
myFunc(a: &a)
///将打印16
print(a)

上面的代码中将参数a声明为inout 类型,在传参时需要使用‘&’ 符号,这个符号将传递参数变量的内存地址。

二、方法调度

2.1 函数调用过程

在OC中,方法调度是通过消息发送机制,也就是objc_msgsend。那么在Swift中的方法调度又是怎样的一种形式哪?我们一起通过下面的代码来求证分析一下。

import UIKit

class ZGTeacher {
    func teach()  {
        print("teach")
    }
    func teach1()  {
        print("teach1")
    }
    func teach2()  {
        print("teach2")
    }
}



class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let t = ZGTeacher()
        t.teach()
        t.teach1()
        t.teach2()
    }


}

给三个函数方法打上断点,编译时选择Xcode->Debug -> Debug Workflow ->Always Show Disassembly,我们来看一下对应的汇编打印

0x109299e8c <+108>: callq  0x109299dd0               ; ZGSwiftAPPTest.ZGTeacher.__allocating_init() -> ZGSwiftAPPTest.ZGTeacher at ViewController.swift:10
    0x109299e91 <+113>: movq   %rax, %r13
    0x109299e94 <+116>: movq   %r13, -0x30(%rbp)
    0x109299e98 <+120>: movq   %r13, -0x28(%rbp)
->  0x109299e9c <+124>: movq   (%r13), %rax
    0x109299ea0 <+128>: movq   0x50(%rax), %rax
    0x109299ea4 <+132>: callq  *%rax
    0x109299ea6 <+134>: movq   -0x30(%rbp), %r13
    0x109299eaa <+138>: movq   (%r13), %rax
    0x109299eae <+142>: movq   0x58(%rax), %rax
    0x109299eb2 <+146>: callq  *%rax
    0x109299eb4 <+148>: movq   -0x30(%rbp), %r13
    0x109299eb8 <+152>: movq   (%r13), %rax
    0x109299ebc <+156>: movq   0x60(%rax), %rax
    0x109299ec0 <+160>: callq  *%rax
    0x109299ec2 <+162>: movq   -0x30(%rbp), %rdi
    0x109299ec6 <+166>: callq  0x10929bac6               ; symbol stub for: swift_release

我们看到关键字__allocating_init(),这很明显是在开辟空间,而关键字swift_release告知我们这里是在销毁空间,而断点停在第124行,0x109299e9c <+124>: movq (%r13), %rax,很明显这里是我们的teach函数开始执行的地方,同样的,第138行和152行分别代表了teach1函数teach2函数执行。
第128行 0x50(%rax)
第142行 0x58(%rax)
第156行 0x60(%rax)
这三行地址的值,每一个相差8个字节,说明他们函数地址的值在内存里是连续的一块内存空间。

通过上面汇编指令的对应分析,可以知道函数teach的调用过程

2.2 基于函数表V-table的调度

下面我们去掉Xcode ->Debug -> Debug Workflow ->Always Show Disassembly,汇编指令打印,在项目中添加如下路径的sh文件

路径1.png
选择other->Aggregate,创建一个Script的Target
路径2.png
选择New Run Script Phase添加一个新的sh文件,并在Run Script添加以下sh代码
swiftc -emit-silgen -Onone -target x86_64-apple-ios15.2-simulator -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) ${SRCROOT}/ZGSwiftAPPTest/ViewController.swift > ./ViewController.sil && open ViewController.sil

编译运行这个Script Target,就可以生成并打开对应的sil文件。下面我们来分析一下这份sil文件。

sil_vtable ZGTeacher {
  #ZGTeacher.teach: (ZGTeacher) -> () -> () : @$s14ViewController9ZGTeacherC5teachyyF   // ZGTeacher.teach()
  #ZGTeacher.teach1: (ZGTeacher) -> () -> () : @$s14ViewController9ZGTeacherC6teach1yyF // ZGTeacher.teach1()
  #ZGTeacher.teach2: (ZGTeacher) -> () -> () : @$s14ViewController9ZGTeacherC6teach2yyF // ZGTeacher.teach2()
  #ZGTeacher.init!allocator: (ZGTeacher.Type) -> () -> ZGTeacher : @$s14ViewController9ZGTeacherCACycfC // ZGTeacher.__allocating_init()
  #ZGTeacher.deinit!deallocator: @$s14ViewController9ZGTeacherCfD   // ZGTeacher.__deallocating_deinit
}

这里就罗列了我们的 ZGTeacher函数里都有哪些函数,是以vtable存放并罗列对应函数的函数表。

2.3 typeDescriptor源码分析

之前我们在第一节课讲到了 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 ,不管是 ClassStruct , Enum 都有自己 的 Descriptor ,就是对类的一个详细描述。
我们通过查看Swift源码,找到这个Metadata.h文件

ConstTargetMetadataPointer<Runtime, TargetClassDescriptor>
  getDescription() const {
    return 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
    
}

2.4 Mach-o文件读取分析

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

Mach-O文件格式.png

MachOView 工具打开 Mach-O 文件的格式大概长这样:

Descriptor.png

前面的四个字节 80 FB FF FF 就是 ZGTeacherDescriptor 信息,那用 80 FB FF FF 加上前面的 00007CAC 得到的就是 Descriptor 在当前 Mach-O 文件的内存地址。

它们怎么相加呢,iOS 属于小端模式,所以 80 FB FF FF 要从右边往左读。也就是:

0xFFFFFB80 + 0x00007CAC = 0x10000782C

0x10000782C 这个值是我拿计算器算的,那么 0x100000000 就是 Mach-O 文件中虚拟内存的基地址,如下图所示:

虚拟内存的基地址.png

我们用0x10000782C - 0x100000000 = 0x782C 就是 ZGTeacher 在整个 Data 区的内存地址。我们找到 TEXT, const

0x782C.png

如图所示,这个0x7820是首地址,偏移12个字节就是0x782c,也是意味着,它后面的数据是 TargetClassDescriptor的数据,所以我们可以在这里拿到 ZGTeacher 的虚函数表 - ZGTeacher 方法的地址

计算 TargetClassDescriptorVTable 前面的数据大小,求得偏移量。一共 12 个 4 字节(48字节)的成员变量,12 个四字节的成员变量再加上 size(4字节)得到 52 字节,再往后的 24 字节就是teach,teach1,teach2 方法的结构地址(一个函数地址占 8 字节)。如图所示:

Teach方法地址@2x.png

如图中所示,0x7860 - 0x7867teach 结构在 Mach-O 文件的地址。那么在程序中如何找到该地址呢。

ASLR 是一个随机偏移地址,这个随机偏移地址的目的是为了给应用程序一个随机内存地址。

image list 是列出应用程序运行的模块,我们找到第一个,其内存地址为 0x000000010eaeb000,这个地址就是当前应用程序的基地址。

接下来我在Swift源码中找到这么一个结构体TargetMethodDescriptor

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

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

};

到这里,TargetMethodDescriptor 结构体的地址就可以确定了,那么要找到函数地址,还需要偏移 Flags + Impl,得到的就是函数的地址。 综合以上的逻辑开始计算:

// 应用程序的基地址:0x000000010eaeb000,teach 结构地址:0x7860,Flags:0x4,offset:1C C2 FF FF
// 注意!小端模式要从右往左,所以为 FFFFC21C
0x000000010C493000 + 7860 + 0x4 + FFFFC21C = 0x20EAEEA80

// 接下来需要减掉 Mach-O 文件的虚拟地址 0x100000000,得到的就是函数的地址。
0x20EAEEA80 - 0x100000000 = 0x10EAEEA80

打开汇编调试,读取汇编中 teach 的地址,验证 0x10EAEEA80 就是否就是 teach 的地址。到这里就完全验证了 Swift 类的方法确实是存放在 VTable - 虚函数表里面的。

2.5 方法调度方式总结

方法调度方式总结.png

三、影响函数派发方式

3.1 final

添加了final 关键字的函数无法被重写,使用静态派发,不会在vtable中出现,且对objc运行时不可见。
示例如下:

class Shape {
    final var center:(Double, Double)
    init() {
        center = (0, 0)
    }
}

3.2 dynamic

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

3.3 @objc

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

3.4 @objc + dynamic

消息派发的方式

四、函数内联

4.1 什么是函数内联

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

4.2 @inline(__always)

将确保始终内联函数。通过在函数前添加 @inline(__always) 来实现此行为。

4.3 @inline(never)

将确保永远不会内联函数。这可以通过在函数前添加 @inline(never) 来实现。 如果函数很长并且想避免增加代码段大小,请使用@inline(never)

上一篇 下一篇

猜你喜欢

热点阅读