Swift底层探索2 - 类和结构体(二)

1、异变方法

1.1 异变 mutating

  • Swift 中 class 和 struct 都能定义方法。但是有一点区别的是:默认情况下,值类型属性不能被自身的实例方法修改.因为此时如果实例化一个对象point,调用moveBy方法会直接影响到point本身属性。如果要实现可在方法前加上mutating关键字。
  • mutating 的本质探索(sil方式)
    首先在target中添加一个script,然后添加下面的语句,即可生成sil代码并自动打开(第一次需手动打开)

swiftc -emit-sil main.swift > ./main.sil && open main.sil

接下来就用下面的代码进行探索

struct Point {
    var x = 0.0, y = 0.0
    mutating func moveBy(x deltaX: Double, y deltaY: Double) {
        self.x += deltaX
        self.y += deltaY
    }
    
    func moveBy_2(x deltaX: Double, y deltaY: Double) {
        print(x,y)
    }
}
  1. moveBy


  2. moveBy_2

    通过对比可以发现
  • 函数默认传入 self 参数的类型有区别,其中,moveBy函数传入的是inout Point类型,本质传入的是实例对象的地址,而moveBy_2传入的是Point类型,本质传入的是实例对象本身
  • 函数底层声明的行参类型不同,moveBy函数中,inout 参数会将self声明为一个 var 常量,而moveBy_2则生成的是let常量,这也解释了为什么不加mutating无法更改属性,而加了就可以。

简单来说可以用以下伪代码来表示

//moveBy
var self = &Point

//moveBy_2
let self = Point  

总结:
值类型中的属性都是直接存储在实例中,因此在方法内部修改属性就相当于修改 self,而只有在mutating关键字修饰的函数中,传入的默认self是有inout关键字修饰的,而self此时是var类型,因此才可以修改自身self的属性。

1.2 输入输出 inout

关于inout,sil文档的解释是

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

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


注意:inout不能用于let常量,只能用于var常量

2、方法调度

oc中方法调度使用的是objc_mgsend,swift中则是基于函数表的调度,接下来将通过汇编来探索

2.1 汇编探索

首先先解释下常见的指令

mov:将某一寄存器的值复制到另一寄存器(只能用于寄存器与寄存器或者寄存器
与常量之间传值,不能用于内存地址),如:
mov x1, x0 将寄存器 x0 的值复制到寄存器 x1 中

ldr: 将内存中的值读取到寄存器中,如:
ldr x0, [x1, x2] 将寄存器 x1 和寄存器 x2 的值相加作为地址,取该内存地址的值放入寄存器 x0 中

str : 将寄存器中的值写入到内存中,如:
str x0, [x0, x8] 将寄存器 x0 的值保存到栈内存 [x0 + x8] 处

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

本次探索采用的是真机调试,所以此处的汇编为arm64

import UIKit

class Person {
    func sex() {
        print("sex")
    }
    func age() {
        print("age")
    }
    func name() {
        print("name")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let t = Person()
        t.sex()
        t.age()
        t.name()
    }
}

汇编分析


图中的metadata证明,可用register read 命令查询

总结:
函数的调用过程:找到 Metadata -> 确定函数地址(metadata + 偏移量 -> 执行函数

2.2 SIL探索

通过汇编探索,可发现三个函数的偏移量分别为0x50,0x58,0x60,是一个连续的内存地址,那么是否可以推断函数在内存中是连续存放的,是基于函数表的调度,继续探索


从sil文件中可以发现有一个vtable的函数表,罗列了Person中类的所有函数,由此也可以推断是基于函数表的调度。

2.3 源码探索vTable

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
}

对于这个metadata需要关注 typeDescriptor ,不管是 Class,Struct , Enum 都有自己的Descriptor,就是对类的一个详细描述,打开源码,在 Metadata.h 中找到 Description:

private:
  /// An out-of-line Swift-specific description of the type, or null
  /// if this is an artificial subclass.  We currently provide no
  /// supported mechanism for making a non-artificial subclass
  /// dynamically.
  TargetSignedPointer<Runtime, const TargetClassDescriptor<Runtime> * __ptrauth_swift_type_descriptor> Description;

通过 TargetClassDescriptor 结构的源码得知其继承关系 TargetClassDescriptor :TargetTypeContextDescriptor : 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
}

通过继承关系还原的结构仍然没有发现vTable,此时通过搜索TargetClassDescriptor可以发现一条语句

using ClassDescriptor = TargetClassDescriptor<InProcess>;

ClassDescriptor 是它的一个别名,全局搜索,在 GenMeta.cpp 中找到下面的内容:

class TypeContextDescriptorBuilderBase
    : public ContextDescriptorBuilderBase<Impl> {
{
    ...
    void layout() {
      asImpl().computeIdentity();

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

  class ClassContextDescriptorBuilder
    : public TypeContextDescriptorBuilderBase<ClassContextDescriptorBuilder,
                                              ClassDecl>,
      public SILVTableVisitor<ClassContextDescriptorBuilder>
  {
      ...
    void layout() {
      assert(!getType()->isForeignReferenceType());
      super::layout();
      //添加函数表
      addVTable();
      //添加继承函数表
      addOverrideTable();
      addObjCResilientClassStubInfo();
      maybeAddCanonicalMetadataPrespecializations();
    }
}

通过上面的代码,可以知道这就是在创建 Descriptor ,此处做了一些赋值的操作,跟一开始还原的结构体基本对应,同时也发现vTable的踪迹。
点开addVTable:

    void addVTable() {
      LLVM_DEBUG(
        llvm::dbgs() << "VTable entries for " << getType()->getName() << ":\n";
        for (auto entry : VTableEntries) {
          llvm::dbgs() << "  ";
          entry.print(llvm::dbgs());
          llvm::dbgs() << '\n';
        }
      );

      // Only emit a method lookup function if the class is resilient
      // and has a non-empty vtable, as well as no elided methods.
      if (IGM.hasResilientMetadata(getType(), ResilienceExpansion::Minimal)
          && (HasNonoverriddenMethods || !VTableEntries.empty()))
        IGM.emitMethodLookupFunction(getType());

      if (VTableEntries.empty())
        return;

      //计算偏移量
      auto offset = MetadataLayout->hasResilientSuperclass()
                      ? MetadataLayout->getRelativeVTableOffset()
                      : MetadataLayout->getStaticVTableOffset();

      //B为ClassContextDescriptorBuilder
      //添加偏移量
      B.addInt32(offset / IGM.getPointerSize());
      //添加函数size
      B.addInt32(VTableEntries.size());
      //添加函数指针
      for (auto fn : VTableEntries)
        emitMethodDescriptor(fn);
    }

从addVTable函数中,我们可知计算 offset 之后,调用了 addInt32 函数,这个函数就是去计算添加方法到函数表的偏移量后添加到B,然后添加函数size,最后 for 循环添加函数的指针。至此,可以将TargetClassDescriptor结构体补充为:

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
}

2.4 Mach-O探索

2.4.1 Mach-O基础

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

Mach-O文件格式


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

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


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

2.4.2 Mach-O探索vTable
  • 从工程中的Products文件夹中找到 .app 文件,新版xcode可用点击Product -> Show Build Folder in Finder,找到应用程序后显示包内容即可。


  • 用Mach-OView打开.app文件



    Section64(_TEXT,__swift5_types) 这里存放的是Swift 结构体、枚举、类的 Descriptor,在这里找到 Descriptor 的地址信息



    在Mach-O文件中,是按4个字节来区分的,因此计算 Descriptor 在 Mach-O 的内存地址:

FFFFBF0 + 0000BC58 = 0x1000B848

因为Mach-O文件有虚拟内存的基地址


虚拟内存的基地址

所以,当前得到的地址还需要减去这个基地址才是Person的 Descriptor在Data 区的首地址,也就是B848 就是 Descriptor 在 Mach-O 中的偏移量,定位位置如下:


第一个红圈就是 Descriptor 的首地址,后面就是 Descriptor 结构体里面的内容,通过上文我们可以得出 Descriptor 中有 13 个 UInt32,也就是13 个 4 字节,所以可以得出结论:

B87C就是第一个函数sex的首地址(偏移量),又因为方法的指针应该是8个字节,所以B884是函数age的首地址(偏移量),B88C是函数name的首地址(偏移量)。

  • 找到函数真实调用地址
    iOS中每个应用程序都有一个ASLR(随机偏移地址),所以函数的真实调用地址应该是函数在 Mach-O 文件中的偏移量加上ASLR,然后偏移 TargetMethodDescriptor中 flag 的4个字节,加上impl中的偏移量,最后再减去 Mach-O 的虚拟基地址

(1) 通过 image list 命令得到 ASLR 程序运行的基地址 0x0000000104efc000


ASLR

(2) 通过源码中的Metadata.h,找到方法在内存的数据结构

struct TargetMethodDescriptor {
  /// Flags describing the method.
  MethodDescriptorFlags Flags;

  /// The method implementation.
  TargetRelativeDirectPointer<Runtime, void> Impl;

  // TODO: add method types or anything else needed for reflection.
};

其中:
Flags 一个UInt32 类型,占4个字节;Impl 不是真正的 imp,而是相对指针 Offset

所以sex函数的真实地址为

0x0000000104efc000 + B87C + 4 + FFFFB9AC - 0x100000000 = 0x104F0322C

通过register read 命令,可以得出我们的计算是相符的


验证

2.5 其它函数的调度

2.5.1 结构体的函数调度
import UIKit

struct Person {
    func sex() {
        print("sex")
    }
    func age() {
        print("age")
    }
    func name() {
        print("name")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let t = Person()
        t.sex()
        t.age()
        t.name()
    }
}

可以看到 struct 的函数调用,就是直接的地址调用,也就是静态派发。

2.5.2 extension 函数调用
import UIKit

class Person {

}

extension Person{
    func name() {
        print("name")
    }
}

struct People {

}

extension People{
    func name() {
        print("name")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let t = Person()
        t.name()
        let t1 = People()
        t1.name()
    }
}

可以看到 无论是 class或者是struct 在extension 中的的方法都是通过静态调用的方式

2.5.3 子类的函数调用
import UIKit

class Person {
    func age() {
        print("age")
    }
}

class People : Person{
    func sex() {
        print("sex")
    }
}

extension Person{
    func name() {
        print("name")
    }
}


class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let t = People()
        t.name()
        t.sex()
        t.age()
    }
}
image.png

通过汇编可以发现只有name函数是静态派发,其它的方法仍然是函数表的调度

2.6 总结

方法调度总结

3、影响函数派发的方式

3.1 final

添加了 final 关键字的函数无法被继承重写,使用静态派发,不会在 vtable 中出现,且 对 objc 运行时不可见。实际开发过程中属性,方法,类不需要被重载时使用

class Person {
    final  func name() {
        print("name")
    }
}

3.2 dynamic

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

import UIKit

class Person {
    dynamic func age() {
        print("age")
    }
}

extension Person{
    @_dynamicReplacement(for: age)
    func name() {
        print("name")
    }
}

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let t = Person()
        t.age()
    }
}

动态性类似于oc中的交换方法,上列中@_dynamicReplacement(for: age) 即用name方法替换了age方法,打印结果为 name。

3.3 @objc

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

class Person {
    @objc  func name() {
        print("name")
    }
}

3.4 @objc + dynamic:

消息派发的方式,也就是Objc_msgSend,意味着可以使用oc中的runtime,多用于swift和oc的交互使用

class Person {
    @objc dynamic   func name() {
        print("name")
    }
}

4、函数内联

4.1 基础认知

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

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

可以通过xcode 更设置


4.2 优化

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

  • fileprivate: 只允许在定义的源文件中访问
  • private : 定义的声明中访问
class Person {
    private var sex: Bool
    
    private func unpdateSex() {
        self.sex = !self.sex
    }
    
    init(sex innerSex: Bool) {
        self.sex = innerSex
    }
    
    func test() {
        self.unpdateSex()
    }
}

let t = Person(sex: true)
t.test()


从上图可得,unpdateSex函数被优化为静态派发

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,335评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,895评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,766评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,918评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,042评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,169评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,219评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,976评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,393评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,711评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,876评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,562评论 4 336
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,193评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,903评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,699评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,764评论 2 351

推荐阅读更多精彩内容