Swift-类与结构体(下)

437 阅读14分钟

在上篇Swift-类与结构体(上)了解了类与结构体在初始化和类型上的区别,本篇主要分析一下函数的调用。

异变方法

上一篇我们了解到,Swift中classstruct都能定义方法。但是有一点区别的是,默认情况下值类型属性不能被自身的实例方法修改。 01.png 看上面的定义编译会报错,提示当前对象是不可变的,struct是值类型,当改变实例的属性时,相当于改变了实例本身,这是不被允许的。如果需要修改struct的属性值,需要在方法定义前添加mutating关键字。 struct.gif

SIL代码分析

下面在代码里增加一个有mutating关键字的函数,一个没有mutating关键字的,通过编译成SIL查看两者的区别。代码如下:

struct Point {
    var x = 0.0, y = 0.0
    
    func test() {
        let temp = self.x
        print(temp)
    }
    
    mutating func moveBy(x X: Double, y Y: Double) {
        x += X
        y += Y
    }
}
# SIL编译命令(把main.swift编译成sil,并输出到main.sil文件)
swiftc -emit-sil main.swift > ./main.sil

SIL类的定义来看,test函数和moveBy只是有无mutating关键字,其他没有区别,我们再看看具体函数的定义:

1. test()

02.png 2. moveBy(x:y:)

03.png

从SIL代码里分析2个函数

  • test函数传了默认self参数(Point类型)
  • moveBy函数有3个参数,第一个参数是x,第2个参数是y,第3个也有默认的self参数(@inout Point类型),只是多了inout关键字

下面看一下inout关键字的官方解释:

An @inout parameter is indirect. The address must be of an initialized object.(当前参数类型是间接的,传递的是已经初始化过的地址) 也就是用inout关键字修饰的接收的是对象的地址,而没有inout修饰的接收的是值,因此上面test函数接收的是Point值,而moveBy函数接收的是Point地址。可以通过一个简单的示例来验证一下。 04.png 根据上面示例的结果,给p.x重新赋值后,x1.x还是0,而x2.x已经变为30.0,所以在struct类型的实例中通过mutating修饰的函数,通过inout修饰的获取其地址,也就可以改变其值。

异变方法的本质

对于异变方法, 传入的self被标记为inout参数。无论在mutating方法内部发生什么,都会影响外部依赖类型的一切。 输入输出参数:如果我们想函数能够修改一个形式参数的值,而且希望这些改变在函数结束之后依然生效,那么就需要将形式参数定义为输入输出形式参数。在形式参数定义开始的时候在前边添加一个inout关键字可以定义一个输入输出形式参数。

方法调度

对于Objective-C是通过objc_msgSend消息机制去调用方法的,Swift是通过什么方式去调用方法的,我们通过一个简单的示例去了解Swift的方法调用。代码如下:

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

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

t.teach()处打个断点,以汇编的模式开启debug模式。 01.png

常用汇编指令

  • mov x1, x0: 将寄存器x0的值复制到寄存器x1中
  • add x0, x1, x2: 将寄存器x1和x2的值相加后保存到寄存器x0中
  • sub x0, x1, x2: 将寄存器x1和x2的值相减后保存到寄存器X中
  • and x0, x0, #0x1: 将寄存器x0的值和常量1按位与后保存到寄存器x0中
  • orr x0, x0, #0x1: 将寄存器 x0的值和常量1按位或后保存到寄存器x0中
  • str x0, [x0, x8]: 将寄存器x0中的值保存到栈内存[x0 + x8]处
  • ldr x0, [x1, x21]: 将寄存器x1和寄存器x2的值相加作为地址,取该内存地址的值放入寄存器x0中
  • cbz: 和0比较.如果结果为零就转移(只能跳到后面的指令)
  • cbnz: 和非0比较,如果结果非零就转移(只能跳到后面的指令)
  • cmp: 比较指令
  • blr: 跳转到某地址(无返回)
  • bl: 跳转到某地址(有返回)
  • ret: 子程序(函数调用)返回指令,返回地址已默认保存在寄存器lr(x30)中

案例分析

结合上面的汇编指令,真机运行上面的项目,可以看到arm64架构下的方法的调用过程。 05.png 上面bl是类的初始化,下面bl执行了release操作,那中间的blr猜测就是调用了teach函数,接下来在33bl x8打个断点,然后按住control键,选择step into,发现就是调用了teach函数。 06.png 接下来再增加2个函数,然后在3个函数调用的地方分别打上断点,再真机运行看一下对应的汇编代码。

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

class ViewController: UIViewController {

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

07.png 上下2个bl还是对应的初始化release,中间的3个blr就是对应就是teachteach1teach2x8在汇编里对应的就是寄存器,那x8的值是怎么获取的? 09.png 结合上面的汇编指令:

  1. x0的值赋值给x20(x0就是初始化的实例对象)
  2. x20的值存储在x8中
  3. x8偏移0x50地址后,再把值放入x8中。 从第2步执行后,通过register read x8,控制台输出得到的值是metadatametadata偏移0x50地址后就得到我们的teach函数。 因此Swift函数的调用过程大致是这样的:找到metadata ~> 确定函数地址(metadata+偏移量) ~> 执行函数

基于函数表的调度

还是通过上面的案例,发现三个地址每个相差8个字节,这8个字节就相当于函数指针的大小,而且是连续的内存空间,所以Swift函数是基于函数表的调度。 10.png

SIL代码分析

我们把示例代码ViewController编译成SIL文件,可以看到有个sil_vtable,包含了示例中定义的方法。这个sil_vtable就是每个类自己的函数表。 11.png 在上一篇中已经分析了metadata的数据结构,那么v-table存放在哪里?把之前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
}

这里需要关注一下typeDescriptor,不管是ClassStruct还是Enum都有自己的Descriptor

源码解析

打开Swift源码找到Metadata.h文件,找到Description的定义。

TargetSignedPointer<Runtime, const TargetClassDescriptor<Runtime> * __ptrauth_swift_type_descriptor> Description;

可以看到它属于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
    // VTable
}

当前结构没有看到v-table,通过搜索TargetClassDescriptor可以找到有个别名定义:

using ClassDescriptor = TargetClassDescriptor<InProcess>;

再全局查找ClassDescriptor,找到了GenMeta.cpp文件里ClassContextDescriptorBuilder类的定义,这个就是类和描述相关的定义。

class ClassContextDescriptorBuilder
    : public TypeContextDescriptorBuilderBase<ClassContextDescriptorBuilder,
                                              ClassDecl>,
      public SILVTableVisitor<ClassContextDescriptorBuilder>
{
    ......
    省略部分代码
    
    void layout() {
      assert(!getType()->isForeignReferenceType());
      super::layout();
      // 创建v-table
      addVTable();
      addOverrideTable();
      addObjCResilientClassStubInfo();
      maybeAddCanonicalMetadataPrespecializations();
    }

    省略部分代码
    .......
}

这里的layout就是布局,并创建了v-table,再看一下super::layout的定义。

void layout() {
    asImpl().computeIdentity();

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

这也就是上面的TargetClassDescriptor的结构。回到addVTable调用,先看一下它的定义:

void addVTable() {
    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这个结构体
    B.addInt32(offset / IGM.getPointerSize());
    // addVTable size
    B.addInt32(VTableEntries.size());

    // 遍历数组,添加函数指针
    for (auto fn : VTableEntries)
        emitMethodDescriptor(fn);
}

这里的B其实就是上面的Descriptor,为这个结构体添加内容,也就是offset添加完成后,后面就是Method

以上是通过源码得到的Swift类的结构,下面通过Mach-O文件进一步的验证。

Mach-O验证

Mach-O其实是Mach Object文件格式的缩写是Mac以及iOS上可执行文件的格式。常⻅的有.o.a.dylib.Framework.dyld.dsym等等。 12.png 把Xcode编译出来的可执行文件拖入MachOView可以查看具体的信息,如下: 13.png

  • 首先是文件头,表明该文件是Mach-O格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排。
  • Load commands是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。
表字段表信息
LC_SEGMENT_64将文件中(32位或64位)的段映射到进程地 址空间中
LC_DYLD_INFO_ONLY动态链接相关信息
LC_SYMTAB符号地址
LC_DYSYMTAB动态符号表地址
LC_LOAD_DYLINKERdyld加载
LC_UUID文件的UUID
LC_VERSION_MIN_MACOSX支持最低的操作系统版本
LC_SOURCE_VERSION源代码版本
LC_MAIN设置程序主线程的入口地址和栈大小
LC_LOAD_DYLIB依赖库的路径,包含三方库
LC_FUNCTION_STARTS函数起始地址表
LC_CODE_SIGNATURE代码签名
  • Data区主要就是负责代码和数据记录的。Mach-O是以Segment这种结构来组织数据的,一个Segment可以包含0个或多个Section。根据Segment映射的哪一个Load CommandSegmentSection就可以被解读为代码、常量或者一些其他的数据类型。在装载在内存中时,也是根据Segment做内存映射的。 | 表字段 | 表信息 | | --- | --- | | Section64(__TEXT,__text) | 汇编指令 | | Section64(__TEXT,__cstring) | 代码 | | Section64(__DATA_CONST,__objc_classlist) | Objective-C类 | | Section64(__TEXT,__swift5_types) | Swift类、结构体、Enum的Descriptor的地址信息 |

结合项目编译出来的Mach-O文件找到Data区的__swift5_types的地址信息,以4字节存放,第1个4字节就是ATTeacherDescriptor信息。这里用到了MachOView工具,需要把可执行文件拖入到这个工具中。由于Xcode13在项目结构中去除了Product文件目录,可执行文件放在了Xcode/DerivedData里。(Xcode ~> Preferences ~> Locations ~> DerivedData) 01.png0xFFFFFB80(iOS是小端模式,字节读取从后往前读),加上0xBBCC,得到0x10000B74C,再减去Mach-O文件的虚拟基地址0x100000000,虚拟基地址可以在Mach-O文件的Load Commands ~> LC_SEGMENT_64(__PAGEZERO)看到,得到0xB74C,这个0xB74C就是Descriptor在整个Data区的内存地址。 02.png 接下来找一下0xB74CMach-O文件中的位置,在Section64(__TEXT,__const)找到了0xB740,按4字节读取,0xB74C就在第4段中。 03.png 0xB74C就是上面提到的TargetClassDescriptor的首地址,VTable就是在这个数据段的后面。 04.png 按照TargetClassDescriptor的结构,在Mach-O文件以每4字节数12个,size后面标记的123分别是3个VTable,也就是对应的teachteach1teach205.png 我们通过计算来验证一下这里是不是就是函数的地址,需要用VTable对应的虚拟地址+ASLR(程序运行时的偏移地址),这里第一个VTable对应的地址就是上图中1对应前面的地址0xB780,再运行项目通过image list找到第一个就是程序运行时的基地址。 06.png 运行程序得到的基地址是0x0000000104134000,加上Mach-O文件里的偏移量0xB780,得到的就是第一个VTable函数的地址。

0x0000000104134000 + 0xB780 = 0x10413F780

接下来在源码找一下函数的结构定义:

template <typename Runtime>
struct TargetMethodDescriptor {
  // 占用4字节
  MethodDescriptorFlags Flags;

  // Offset
  TargetRelativeDirectPointer<Runtime, void> Impl;
};

所以上面计算出来的0x10413F780就是TargetMethodDescriptor结构体的首地址。要找到imp还需要做偏移,首先偏移Flags,根据Flags的定义占用4字节,而TargetRelativeDirectPointer存储的不是实际的imp,而是Offset,我们拿到首地址加上FlagOffset就是实际函数的地址。 07.png

0x10413F780 + 0x4(Flag) + 0xFFFFC030(Offset) = 0x20413B7B4
// 0x20413B7B4再减去Mach-O的虚拟基地址0x100000000
0x20413B7B4 - 0x100000000 = 0x10413B7B4

以上计算得出的0x10413B7B4就是函数的指针地址,我们再运行程序通过register read获取一下寄存器的地址。 08.png 根据程序的运行结果可以看出和我们上面计算的结果一致,也就是ATTeacherteach方法。

回顾
  • 通过Mach-O文件发现VTable是一个连续函数表
  • 通过源码找到了GenMate.cppClassContextDescriptorBuilder函数定义,调用父类layout,找到了Descriptor的结构
  • 调用addVTable可以看到添加Offset,添加函数指针
  • 通过Mach-O文件的__swift5_types找到Decriptor的地址信息
  • 根据Mach-O的信息计算出方法的地址
  • 结合程序运行最终验证计算结果的正确性。

方法调度方式总结

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

结合示例来说明一下,把之前的案例class改成struct,然后断点运行,可以看到是直接的地址调用(也就是静态调用) 14.png struct是值类型,没有继承关系,在编译的时候就已经确定。再加一个extension

extension ATTeacher {
    func teach3() {
        print("teach3")
    }
}

再次运行,发现还是地址调用。 15.png 如果在class类加个extension,同样增加teach3方法,发现不管是struct还是class对应的extension都是地址调用。 16.png

影响函数派发方式

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

函数内联

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

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

案例说明

下面结合个案例说明一下,创建一个简单的OC项目,在main.m增加个函数,代码如下:

int sum(int a, int b) {
    return a + b;
}

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        
        int x = sum(1, 2);
        NSLog(@"%d", x);
        
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

debug编译模式按默认的优化等级,默认是不优化。 01.png 然后以汇编的模式打开debug,在main函数调用sum打个断点。运行 02.png 可以看到把0x1传给寄存器w00x2传给寄存器w1,然后bl调用sum函数。我们再把优化等级(Optimization Level)改成Fastest, Smallest[-Os]再次运行代码: 03.png 发现编译器已经把sum函数优化了,直接把结果0x3返回出来然后传给寄存器w8。同样Swift项目也可以设置优化等级,Optimize for Speed[-O]Optimize for Size[-Osize],一个是对速度的优化,一个是对大小的优化。 04.png 当然Swift还可以手动指定函数的内联。

// 始终内联
@inline(__always) func test() {
    print("test")
}

// 始终不内联
@inline(never) func test1() {
    print("test1")
}

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

总结

本篇主要从SIL代码了解了Swift方法的定义及结构,通过示例结合Mach-O文件验证了方法在内存中存储以及方法的调度方式,再了解了影响函数的派发方式,结合案例说明函数内联优化的异同点。