Swift 原理探索:类与结构体(下)

662 阅读8分钟

一、异变方法

1.1 mutating 关键字

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

image.png

  • 可以看到提示说的是 self 不可被修改,因为 x 和 y 是属于 self 的,修改它们就是修改 self 本身,在自己的方法里修改自己。

解决方式:方法用 mutating 关键字进行修饰就不报错了

struct Point {
    var x = 0.0, y = 0.0
    mutating func moveBy() {
        x = 10
        y = 20
    }
}

1.2 sil 分析

为什么加上 mutating 就不报错了呢,main.swift 添加下面测试代码:

struct Point {
    var x = 0.0, y = 0.0
    
    func test() {
        let tmp = self.x
    }
    
    mutating func moveBy() {
        x = 10
        y = 20
    }
}

通过下面命令,生成 main.sil 文件

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

得到 main.sil 文件,通过 sil 来对比一下,不添加 mutating 访问和添加 mutating 两者有什么本质的区别

// test 函数:
sil hidden @$s4main5PointV4testyyF : $@convention(method) (Point) -> () {
  debug_value %0 : $Point, let, name "self", argno 1 // id: %1
  ...
} 
// moveBy 函数:
sil hidden @$s4main5PointV6moveByyyF : $@convention(method) (@inout Point) -> () {
  debug_value_addr %0 : $*Point, var, name "self", argno 1 // id: %1
  ...
}
  • 对比两个函数可以发现 test 隐藏参数是 Point, moveBy 的隐藏参数是 @inout Point,关于 @inout 的官方解释:

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

  • test 函数的赋值过程可以表示为:
    let self = Point
    
  • moveBy 函数的赋值过程可以表示为:
    var self = &Point
    
  • 总结起来就是:用 @inout 修饰接受的是一个地址就,是可以修改的,否则接受的就是一个值,就不能够被修改。

可以用一个示例来表示:

var point = Point()

let p1 = point
var p2 = withUnsafePointer(to: &point){$0}

point.x = 10

print(p1.x)
print(p2.pointee.x)

运行结果:
0.0
10.0
  • 根据结果可以发现修改 point 的值,p1 不能被修改,p2 可以被修改。

1.3 输入输出参数 inout

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

如果形参没有被 inout 修饰,修改形参就会报错:

image.png

想要修改形参,需要用 inout 进行修饰,并传入地址类型:

func changeAge(_ age: inout Int) {
    age += 10
}
changeAge(&age)
print(age)

打印结果:
0.0
10.0
15

二、函数表的调度

2.1 汇编探索

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

class ViewController: UIViewController {

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

image.png

  • 上图中 x8x9x9分别代表teach()teach1()teach2()
  • 读取x8,进行验证:
    (lldb) register read x8
    x8 = 0x00000001023f22f4  SSLTwoTest`SSLTwoTest.LGTeacher.teach() -> ()
    
  • 我们也可以看到函数调用前都有偏移的操作[x8, #0x50][x9, #0x58][x9, #0x60]

函数的调用过程是:找到 Metadata ,确定函数地址(metadata + 偏移量), 执行函数,下面进行分析。

2.2 sil 验证

通过下面命令,生成 sil 文件

swiftc -emit-silgen -Onone -target x86_64-apple-ios14.2-simulator -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) ViewController.swift > ./ViewController.sil && open ViewController.sil

sil 文件中,可以看到有 vtable 函数表,里面罗列了类中所有的函数

sil_vtable LGTeacher {
  #LGTeacher.teach: (LGTeacher) -> () -> () : @$s14ViewController9LGTeacherC5teachyyF	// LGTeacher.teach()
  #LGTeacher.teach1: (LGTeacher) -> () -> () : @$s14ViewController9LGTeacherC6teach1yyF	// LGTeacher.teach1()
  #LGTeacher.teach2: (LGTeacher) -> () -> () : @$s14ViewController9LGTeacherC6teach2yyF	// LGTeacher.teach2()
  #LGTeacher.init!allocator: (LGTeacher.Type) -> () -> LGTeacher : @$s14ViewController9LGTeacherCACycfC	// LGTeacher.__allocating_init()
  #LGTeacher.deinit!deallocator: @$s14ViewController9LGTeacherCfD	// LGTeacher.__deallocating_deinit
}

2.3源码分析 查找V-Table

我们在 上一篇文章 讲到了 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 ,不管是 Class , Struct , Enum 都有自己的 Descriptor ,就是对类的一个详细描述。

打开源码,在 metadata.h 中找到 Description

TargetSignedPointer<Runtime, TargetClassDescriptor> Description;

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 ,做了一些赋值的操作,我们也看到了 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.addInt32(offset / IGM.getPointerSize());
      B.addInt32(VTableEntries.size());
      
      for (auto fn : VTableEntries)
        emitMethodDescriptor(fn);
    }
  • B 就是 descriptor,遍历添加了函数指针,和函数的数量

至此,还原出 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
    //V-Table
}

2.4 什么是 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。

Macho 文件格式:

image.png

image.png

  • 首先是文件头,表明该文件是 Mach-O 格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排
  • Load commands 是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等 image.png
  • Data 区主要就是负责代码和数据记录的。Mach-O 是以 Segment 这种结构来组织数据 的,一个 Segment 可以包含 0 个或多个 Section。根据 Segment 是映射的哪一个 Load Command,Segment 中 section 就可以被解读为是代码,常量或者一些其他的数据类型。在装载在内存中时,也是根据 Segment 做内存映射的。

2.5 Mach-O 分析V-Table

Section64(_TEXT,__swift5_types) 中存放的就是 Descriptor

image.png

计算 Descriptor 在 Mach-O 的内存地址:

FFFFFBA8 + 0000BB7C = 0x10000B724

0x10000 是虚拟地址的开端,B724 就是 Descriptor 在 Mach-O 中的偏移量,定位位置如下:

image.png

如上图红圈就是 Descriptor 的首地址,后面就是 Descriptor 结构体里面的内容,Descriptor 中有 13 个 UInt32,也就是13 个 4 字节。

移动 13 个 4 字节,定位到下面的位置:

image.png

B758 就是 teach() 在 Mach-O 文件中的偏移量, B758 + ASLR(随机偏移地址) 就是 teach() 的地址,

通过 image list 命令得到 ASLR 程序运行的基地址 0x00000001023ec000

image.png

所以 teach() 函数的首地址是:0x00000001023ec000 + B758 = 0x1023F7758,也就是下面这个结构

image.png

在源码中找到下面的结构,这个就是 Swift 中的方法:

struct TargetMethodDescriptor {

  /// Flags describing the method.
  MethodDescriptorFlags Flags; // 4字节
  /// The method implementation. // offset
  TargetRelativeDirectPointer<Runtime, void> Impl; 
};
  • 计算 Impl 的地址:0x1023F7758 + 4 + FFFFAB98 = 0x2023F22F4
  • 所以 teach() 函数地址:0x2023F22F4 - 0x1000 = 0x1023F22F4

读取的 teach() 的地址:

image.png

可以看到地址都是 0x1023F22F4 证明了我们上面的说法,V-Table 就是在 Descriptor 结构的后面。

2.6 获取函数 为什么要偏移

static void initClassVTable(ClassMetadata *self) {
  const auto *description = self->getDescription();
  auto *classWords = reinterpret_cast<void **>(self);

  if (description->hasVTable()) {
    auto *vtable = description->getVTableDescriptor();
    auto vtableOffset = vtable->getVTableOffset(description);
    auto descriptors = description->getMethodDescriptors();
    for (unsigned i = 0, e = vtable->VTableSize; i < e; ++i) {
      auto &methodDescription = descriptors[i];
      swift_ptrauth_init_code_or_data(
          &classWords[vtableOffset + i], methodDescription.Impl.get(),
          methodDescription.Flags.getExtraDiscriminator(),
          !methodDescription.Flags.isAsync());
    }
  }
  ...
}

可以看到,在函数存储到 VTable 时就进行了偏移操作 vtableOffset,所以取的时候也要进行偏移操作。

三、其他函数调度方式

3.1 struct 函数调度

将 class 换成 struct,再次进行汇编调试:

image.png image.png

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

3.2 struct 的 extension 函数调度

给 SSLTeacher 添加 extension:

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

汇编调试:

image.png

  • 可以看到 teach3 也是直接的地址调用, struct 的 extension 也是静态派发

3.3 class 的 extension 函数调度

将 struct 重新改回 class:

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

汇编调试:

image.png image.png

  • 可以看到,class 的 extension 也是静态派发

3.4 方法调度方式总结

image.png

四、关键字对派发方式的影响

4.1 final

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

class SSLTeacher {
    final  func teach() {
        print("teach")
    }
    ...
}

4.2 dynamic

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

class SSLTeacher {
    dynamic func teach() {
        print("teach")
    }
}

extension SSLTeacher {
    @_dynamicReplacement(for: teach)
    func teach3() {
        print("teach3")
    }
}

let t = SSLTeacher()
t.teach()

打印结果:
teach3

4.3 @objc

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

class SSLTeacher: NSObject {
    // 消息调度的机制
    @objc func teach() {
        print("teach")
    }
    
    func teach1() {
        print("teach1")
    }
    func teach2() {
        print("teach2")
    }
}

extension SSLTeacher {
    
    @objc func teach3() {
        print("teach3")
    }
}

查看:

image.png

4.4 @objc + dynamic

用 @objc + dynamic 修饰方法,我们就可以使用 runtime 的 api

class SSLTeacher {
    // 消息调度的机制
    @objc dynamic func teach() {
        print("teach")
    }
    
    func teach1() {
        print("teach1")
    }
    func teach2() {
        print("teach2")
    }
}

extension SSLTeacher {
    
    @objc dynamic func teach3() {
        print("teach3")
    }
}

五、函数内联

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

5.1 OC 项目优化示例

先创建一个 OC 项目,添加代码:

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

int main(int argc, char * argv[]) {
    
    int a = sum(1, 2);
    NSLog(@"%d",a);
    return 0;
}

没有优化时

在下面就可以配置优化等级,Debug 环境下我们先不选择优化:

image.png

进行汇编调试:

image.png

  • 可以看到没有优化时,会先将 1 和 2 的值分别存入 w0 和 w1
  • 然后再调用 sum 函数。

最快最小优化

接下来选择 最快最小 优化:

image.png

再次进行汇编调试:

image.png

可以看到优化后的代码,将计算结果 3 直接存入到 w8 寄存器,然后调用了 NSLog 函数。

5.2 Swift 中的内联

优化的设置:

image.png

内联的操作

  • Swift 中的内联函数是默认行为,我们无需执行任何操作. Swift 编译器可能会自动内联函数作为优化。
    // 编译器会认为 test 没有太多意义,会省略test的符号调用,直接调用print
    func test() {
        print("test");
    }
    
  • always - 将确保始终内联函数。通过在函数前添加 @inline(__always) 来实现此行为
  • never - 将确保永远不会内联函数。这可以通过在函数前添加 @inline(never) 来实现。
  • 如果函数很⻓并且想避免增加代码段大小,请使用@inline(never)(使用@inline(never))

5.3 private 的优化操作

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

添加代码:

class SSLPerson {
    private var sex: Bool
    
    func unpdateSex() {
        self.sex = !self.sex
    }
    
    init(sex innerSex: Bool) {
        self.sex = innerSex
    }
    
    func test() {
        self.unpdateSex()
    }
}

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

image.png

可以看到 unpdateSex 的调用是直接的地址调用,并没有使用函数表。