Swift类与结构体(下)

类与结构体方法上的区别
一. 异变方法
  • Swift中类(class)结构体(struct)都能定义方法.但是有一点区别的是默认情况下,值类型属性不能被自身的实例方法修改.
struct Point {
    var x = 0.0, y = 0.0
    func moveBy(x deltaX: Double, y deltaY: Double) {
        x += deltaX
        y += deltaY
    }
}
  • 上面代码编译会报错, 报错原因是:Left side of mutating operator isn't mutable: 'self' is immutable

    在moveBy()函数中修改x,y, 其实就是修改self.x 和 self.y, 而此时的self是结构体,属于值类型, self里面存的就是x,y的值.此时修改x,y,即使不加self.来调用x,y, 其实就是在修改self本身. 因为当前的self指向的就是x,y. 此时就是在自己的方法里修改自己,

    var p = Point()
    
    p.moveBy(x: , y: )
    

    如上代码, 创建实例变量p, 当p调用了moveBy()函数的时候, 相当于p把自己给修改了! 因为moveBy()函数内部的修改,影响了外部的p.这是不被允许的.

  • 对于最上面的代码,在func关键字之前加 mutating 修饰符, 编译就不会报错, 也就是可以在函数内部对值属性进行修改.

struct Point {
 var x = 0.0, y = 0.0
 mutating func moveBy(x deltaX: Double, y deltaY: Double) {
     x += deltaX
     y += deltaY
 }
} 
  • 下面通过方法举例说明方法前加mutating和不加的区别
struct Point {
  var x = 0.0, y = 0.0
  
  func test() {
      let tmp = self.x
  }
  
  mutating func moveBy(x deltaX: Double, y deltaY: Double) {
      x += deltaX
      y += deltaY
  }
}

通过把该代码编译成SIL的语句:

WechatIMG9.jpeg
/**
* 参数: self,类型为Point类型
* 对比过SIL官方文档可以了解到,此时的self,也就是Point接受的是一个结构体的实例,也就是值.
*/
sil hidden @$s4main5PointV4testyyF : $@convention(method) (Point) -> () // test() 有一个参数self, 类型为Point类型
/**
* test()函数中的第一行代码,声明了self的类型为let
* 也就是此时此刻当前Point的值.
* let self = Point
*/
debug_value %0 : $Point, let, name " ", argno 1 // id: %1
// =====================================================================================
/**
* 第一个参数:x
* 第二个参数:y
* 第三个参数:也是self, 相比较上面的self多了一个关键字@inout, 看过SIL官方文档对inout的解释可知,inout Point(默认的参数)接收的是一个地址.而上面的Point接收的是一个结构体的实例,也就是self,也就是值.
*/
sil hidden @$s4main5PointV6moveBy1x1yySd_SdtF : $@convention(method) (Double, Double, > @inout Point) // 第一个参数是x,第二个参数是y, 第三个参数是self,多了inout的关键字! 
/**
* var self = &Point
*/
debug_value_addr %2 : $*Point, var, name "self", argno 3 // id: %5 // 

SIL 官方文档对inout的解释:

  • An @inout parameter is indirect. The address must be of an initialized object.(当前参数类型是间接的,传递的是已经初始化过的地址)
// 通过代码说明:对于没有加mutating的self其实就是值
// 而加了mutating的self就是当前值的地址,也就是指针.添加了mutating之后,SIL就多了一个inout的关键字,而这个关键字的目的就是传入的是当前值的地址.传地址也就意味着此时此刻修改self是合法的.
struct Point {
    var x = 0.0, y = 0.0
    func test() {
        let tmp = self.x
    }
    mutating func moveBy(x deltaX: Double, y deltaY: Double) {
        x += deltaX
        y += deltaY
    }
}
var p = Point()
let x1 = p // x1 是let修饰 , 无法修改
var x2 = withUnsafePointer(to: &p){return $0} // 取地址方法
var x3 = p// x3 和 p 是两个独立的结构体实例,互不影响,所以修改了p.x对x3没有影响,x3.x = 0.0
p.x = 30.0// 此时对p.x进行修改
print(x1.x)
print(x2.pointee.x) // x2是p的指针 要通过这种方式打印值
print(x3.x)

// 此时控制台输出
0.0
30.0
0.0

重要结论:

  • 异变方法的本质: 对于异变方法,传入的 self 被标记为 inout 参数. 无论在 mutating 方法内部发生什么,都会影响外部依赖类型的一切.(相当于直接把当前的self以地址的方式传入函数内部,所以修改self.x对于外部变量是能产生影响的)
  • inout : 输入输出参数 , 如果我们想函数能够修改一个形式参数的值,而且希望这些改变在函数结束之后依然生效,那么就需要将形式参数定义为输入输出形式参数.在形式参数定义开始的时候再前边添加一个inout 关键字可以定义一个输入输出形式参数.
var age = 10
// 函数的形式参数都是let类型的 age是let 类型 所以age+=1 报错Left side of mutating operator isn't mutable: 'age' is a 'let' constant
func modifyAge(_ age: Int) {
    age += 1
}
// 如果想在函数内部修改age, 并且在函数外部产生影响, 需要添加 inout 关键字
func modifyAge(_ age: inout Int) {
    age += 1
}
// 函数调用, 注意此时就要传入age的地址
modifyAge(&age) 

同理, 在SIL中实现为下图:

WechatIMG10.jpeg
二.方法调度

对于OC来说都是通过objc_msgSend来进行消息发送来调度方法.
项目跑真机就是arm64的汇编.

常见汇编指令:

  • 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:比较指令

  • bl:(branch)跳转到某地址(无返回)

  • blr:跳转到某地址(有返回)

  • ret:子程序(函数调用)返回指令,返回地址已默认保存在寄存器 lr (x30) 中

汇编中,判断方法的调用一般就是看bl,blr这两个指令. 这是一个小技巧

class Teacher {
    func teach() {
        print("teach")
    }
}

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let t = Teacher()
        t.teach()
    }
}

// Xcode --> DEBUG --> DEBUG WorkFlow --> Always Show Disassembly 可以查看汇编过程代码
WechatIMG11.jpeg

通过上图猜测, teach函数就在blr x8 这句代码上调用!因为在_allocating_init() 和 release 中 没有其他的bl或者blr了.
验证: 先在 blr x8 打一个断点 , 然后按住ctrl , 点击 step into.


WechatIMG14.jpeg

可以看到, 正是teach()函数的调用.
探究swift中方法的调用, 首先对Teacher类中的函数进行扩充,如下:

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

class ViewController: UIViewController {

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

查看汇编代码如下:

WechatIMG16.jpeg

x8汇编代码里面,我们称之为寄存器.函数调用的参数,包括一些运算都是放在寄存器中完成的.

分析:
Teacher.__allocating_init()返回的是实例对象
函数的返回值是放在 x0 这个寄存器中的, 所以 x0 中放的就是allocating_init()生成的对象.
例: ldr x8, [x0] 取x0这个地址里面的值放入寄存器x8中. 取地址取的是x0这个对象的前8个字节.因为寄存器是64位的,存放8个字节.x0地址的第一个8字节: metadata!(isa也是一样的).所以此时此刻,x8寄存器存放的实际上是metadata.
此时键入 register read x8 可以读出寄存器 x8 的内容, 如下图:

WechatIMG18.jpeg

ldr x8, [x8, #0x50] 的意思就是: metadataAddress + 0x50 , 所得到的结果再赋值给 x8 寄存器.

总结:teach函数的调用过程, step1.找到Metadata,step2.确定函数地址(metadata + 偏移量),step3.执行函数.

WechatIMG19.jpeg

由上图,可知swift中,函数的第一种调用方式,就是基于函数表的调度.
通过查看文件SIL
WechatIMG20.jpeg

vtable就是所谓的函数表.这个函数表里面罗列了当前Teacher都有哪些函数.所以在SIL中, 它的表现形式就是sil_vtable.这个sil_vtable就是每个类自己的函数表.

之前在类与结构体(上)中讲到了MetaData的数据结构,那么,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,不管是Class,Struct,Enum都有自己的Descriptor,就是对类的一个详细描述.
在swift code源码中可以从Metadata文件中找到关于Descriptor的类型:

WechatIMG21.jpeg

从源码可以得到TargetClassDescriptor的数据结构:

struct TargetClassDescriptor{
    var flags: UInt32
    var parent: UInt32
    var name: Int32 // name即为当前类/结构体/Enum的名称
    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
}

TargetClassDescriptor 有个别名叫: ClassDescriptor

WechatIMG22.png

源码中全局搜索ClassDescriptor,可以看到
WechatIMG25.jpeg

打开该文件, 可以定位到一个类 ClassContextDescriptorBuilder:
WechatIMG26.jpeg

ClassContextDescriptorBuilder:就是Metadata和Descriptor的创建者.

接下来看layout()方法

WechatIMG27.jpeg

首先调用了 super.layout() , 按照继承关系找到父类,下图可以看到父类:
WechatIMG28.jpeg

点击进入父类, 可以看到父类中的layout:
WechatIMG29.jpeg

上图中的各种创建与TargetClassDescriptor的数据结构比较即可知就是在创建name,Descriptor,Metadata等.

再回头看子类ClassContextDescriptorBuilder.在layout()方法中,有一个addVTable();VTable();

分析addVTable()函数:


WechatIMG30.jpeg

上图中的B其实就是TargetClassDescriptor.所以如果我们把TargetClassDescriptor数据结构补全, 就是:

struct TargetClassDescriptor{
    var flags: UInt32
    var parent: UInt32
    var name: Int32 // name即为当前类/结构体/Enum的名称
    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 (methods) 
}

验证上面的结论:(此时需要引入 Mach-O 文件)

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

Mach-O文件格式:


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

  • Load commands 是一张包含很多内容的表. 内容包括区域的位置,符号表,动态符号表等.
    WechatIMG32.jpeg
  • Data区主要就是负责代码和数据记录的.Mach-O 是以 Segment 这种结构来组织数据的,一个 Segment 可以包含0个或多个 Section. 根据 Segment 是映射的哪一个 Load Command, Segment 中 section 就可以解读为是代码, 常量或者一些其他的数据类型. 在装载在内存中时, 也是根据 Segment 做内存映射的.
    利用MachOView软件打开项目的可执行文件,可以看到如下图:


    WechatIMG33.jpeg

    对于swift, 上图中的 __TEXT,__swift5_types存放的就是(结构体,Enum,或者类)的TargetClassDescriptor. types存放的就是TargetClassDescriptor的地址信息. 右边的地址信息是以每4个字节作为一个区分.

    0xFFFFFBA4 + BBCC = 0x10000B770

    0x10000B770 - 0x100000000 = 0xB770 // 就是当前TargetClassDescriptor在整个Data数据区的内存地址.在内存中的偏移量.

下图中,在 TEXT,const 中找到0xB770的地址:


WechatIMG36.jpeg

解析:0xB770即为TargetClassDescriptor的地址, 它的数据结构应该月TargetClassDescriptor的数据结构相对应,其实它就是TargetClassDescriptor. 在size之前前面有包括 flags , parent, name, 等12个4字节的成员变量. 所以从 [50 00 00 80] 开始数12个4字节, 即[04 00 00 00]就是size的内存地址, [10 00 00 00 0C C0 FF FF]就是Teacher类第一个teach()函数的地址,紧接着就是teach1(), teach2()的地址.

已知函数在Mach-O的偏移量是0xB7A0, 我们如何找到函数在运行内存中的地址呢 ?

答 : 加上 ASLR (随机偏移地址)

那么什么是 ASLR 呢?

解析: 运行程序, 在t.teach()函数调用出断点, 程序走到断点处, 在控制台用命令 image list命令,列出当前运行程序的模块.只需要找到它最开始的模块

WechatIMG37.jpeg

0x0000000102698000 即程序运行的基地址,可以理解为当前SwiftMethod程序加载进内存的起始地址就是0x0000000102698000.

此时 函数的基地址 + 函数地址的偏移量 就是 函数teach(TargetMethodDescriptor数据结构)的首地址.

0x0000000102698000 + 0xB7A0 = 0x1026A37A0

0x1026A37A0 地址 指向的就是teach()函数. 那么,swift中, 函数的数据结构是什么呢? 看下图:
WechatIMG38.jpeg

0x1026A37A0 就是指向 teach(TargetMethodDescriptor) 数据结构的首地址.所以,如果要找到 Impl 还需要进一步偏移, 偏移Flags所占的内存大小. 这个Flags其实就是一个4字节. 注意 TargetRelativeDirectPointer 存储的不是真正的Imp, 它存储的其实是相对指针, 存储的是一个offset . 所以 首地址+flags(4字节)+offset 最终,减去虚拟的基地址, 得到的就是当前函数的地址.

0x1026A37A0 + 4 + 0xFFFFC00C = 0x20269F7B0

得到0x20269F7B0 这个地址, 还需要减去 0x100000000 = 0x10269F7B0

0x10269F7B0 即为当前teach()函数的地址!

方法调度方式总结:


WechatIMG39.jpeg
三.影响函数派发方式
  • final: 添加了 final 关键字的函数无法被重写, 使用静态派发, 不会在vtable中出现, 且对objc运行时不可见. 如果在实际开发过程中, 属性,方法, 类不需要被重载的时候, 可以添加final关键字.
  • dynamic: 函数均可添加 dynamic 关键字, 为非objc类和值类型的函数赋予动态性,但派发方式还是函数表派发.
  • @objc: 该关键字可以将swift函数暴露给Objc运行时, 依旧是函数表派发.
  • @objc + dynamic: 消息派发的方式.
四.函数内联

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

  • 将确保有时内联函数.这是默认行为,我们无需执行任何操作.Swift编译器可能会自动内联函数作为优化.
  • always - 将确保始终内联函数.通过在函数前添加 @inline(_always)来实现此行为
  • never - 将确保永远不会内联函数. 这可以通过在函数前添加 @inline(never)来实现.
  • 如果函数很长并且想避免增加代码段大小,请使用 @inline(never) (使用@inline(never))
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容