Swift学习(二)方法

369 阅读8分钟

异变方法

Swift中 类和结构体都能定义方法,但默认情况下值类型不允许修改实例本身

WX20211230-104529.png

上面代码编译器会报错。如果真的想修改本身。需要加上mutating关键字

extension Int {
    mutating func square() {
        self = self * self
    }

    /**
     *以下代码会报错
     *原因:结构体不能自己改自己。必须在函数前面加mutating变成了 异变方法
     *底层:会在参数里面加上inout修饰
     *把地址传递变成了值传递
     */
//    func square() {
//        self = self * self
//    }
}

如果需要修改值本身,需要添加mutating关键字。添加完mutating后,查看SIL,会发现对self参数加上@inout修饰

WX20211230-105641.png

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

异变方法的本质:对于变异方法, 传入的 self 被标记为 inout 参数。无论在 mutating 方法 内部发生什么,都会影响外部依赖类型的一切。

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

func inoutMethod() {
        let age1 = 10;
        modifyAge(age1);
        var age2 = 10;
        modifyAge(&age2);
        print(age1,age2)
        //函数形式参数默认为let,需要转化为var,再进行计算
        func modifyAge(_ age: Int) {
            var temp = age;
            temp = temp + 1;
        }
        func modifyAge(_ age: inout Int) {
            age = age + 1;
        }
    }

输出为

10 11

可以看出,加上inout修饰,函数内的改变结果,在函数结束之后依然生效。

方法调度

我们知道在OC中,方法调度是通过msgSend来实现动态调度的。在swift中方法是如何调度的,我们可以通过汇编调试来看一看。

swift代码

class OSDogModel: NSObject {
    func sleep() {
        print("dog will go to sleep")
    }
}
class OSSwiftMethodController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = UIColor.white
        self.dogSleep()
    }
    func dogSleep() {
        let dog = OSDogModel()
        dog.sleep()
    }
}

在dog.sleep()这行下断点,开启汇编调试。(本文截图机型为M1)

WX20211228-215449@2x.png 在看汇编代码前,可以简单的认识几个汇编指令,就可以大致了解以上汇编内容

  • mov:将某一寄存器的值复制到另一寄存器(只能用于寄存器与寄存器或者寄存器与常量之间传值,不能用于内存地址),如
mov x1, x0    将寄存器x0的值符知道寄存器x1中
  • ldr:将内存中的值读取到寄存器中,如:
ldr x0, [x1, x2] 将寄存器x1和寄存器x2的值相加作为地址,取改地址的值放入寄存器x0中
  • bl、blr:跳转到某地址(有返回)

x代表寄存器,x0用来存放函数计算结果

mov x20, x0x0里面放的是刚刚新建好的dog对象。把dog对象放到x20中。

str x20 [sp, #0x8]

str x20 [sp, #0x10]

这两句先忽略,不影响正常流程

ldr x8, [x20]

把dog的metaData放入x8中。x20是dog对象,取dog对象的地址,并且把前8字节放入x8中。而dog对象的地址的前8字节正好放的就是metaDate。

WX20211228-213920@2x.png

通过register read 可以不断的打印寄存器里面的只。看到这串字符,可以通过xcrun来还原

$ xcrun swift-demangle _TtC11ObjectStudy10OSDogModel
_TtC11ObjectStudy10OSDogModel ---> ObjectStudy.OSDogModel

最后得到我们的类名。而这时候在x8寄存器中存放的地址,应该是我们 ObjectStudy.OSDogModel 的metaData,在这之后又做了 ldr 0x50 计算,也就是从ObjectStudy.OSDogModel 的metaData之后偏移了 0x50个地址。而这个地址是sleep 函数的地址

到这里我们大概理解了swift中函数调用分为了3个步骤

  1. 找到metadata
  2. 确定函数地址(metadata + 偏移量)
  3. 执行函数

接下来,我们给DogModel增加几个方法,并且调用。

class OSDogModel: NSObject {
    func sleep() {
        print("dog will go to sleep")
    }
    func eat() {
        print("dog will go to eat")
    }
    func catchMouse() {
        print("dog will go to catch mouse")
    }
}

继续来到汇编断点中

WX20211228-222404@2x.png 不难发现这个偏移量是个等差数列,差值正好是8,这个正好是函数地址的大小。那就可以大胆的猜想,这三个函数的地址是放在连续的地址中,存放在一个list中。

我们可以通过swiftc工具,把OSDogModel这个类转换成SIL文件,看看swift中间语言是怎么实现的。

swiftc -emit-sil OSDogModel.swift >osdog.sil

在sil文件中可以看到代码


sil_vtable OSDogModel {
  #OSDogModel.sleep: (OSDogModel) -> () -> () : @$s10OSDogModelAAC5sleepyyF	// OSDogModel.sleep()
  #OSDogModel.eat: (OSDogModel) -> () -> () : @$s10OSDogModelAAC3eatyyF	// OSDogModel.eat()
  #OSDogModel.catchMouse: (OSDogModel) -> () -> () : @$s10OSDogModelAAC10catchMouseyyF	// OSDogModel.catchMouse()
  #OSDogModel.init!allocator: (OSDogModel.Type) -> () -> OSDogModel : @$s10OSDogModelAACABycfC	// OSDogModel.__allocating_init()
  #OSDogModel.deinit!deallocator: @$s10OSDogModelAACfD	// OSDogModel.__deallocating_deinit
}

目前看到的都是一系列预测和猜想,可以通过Mach-O文件来证实这些猜想。

tips:Mahco:Mach-O其实是MachObject文件格式的缩写,是mac以及iOS上可执行文件的格式,类似于windows上的PE格式(PortableExecutable),linux上的elf格式(ExecutableandLinkingFormat)。常⻅的.o,.a.dylib Framework,dyld.dsym。 mahco文件格式如下

WX20211229-161233.png

mahco文件主要由3部分组成

  1. 首先是文件头,表明该文件是Mach-O格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排
  2. Loadcommands是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。
  3. Data区主要就是负责代码和数据记录的。Mach-O是以Segment这种结构来组织数据的,一个Segment可以包含0个或多个Section。根据Segment是映射的哪一个LoadCommand,Segment中section就可以被解读为是是代码,常量或者一些其他的数据类型。在装载在内存中时,也是根据Segment做内存映射的。例如__Text,__text Assembly 里面存放汇编指令,OC的类都记录在__Data,__objc_classlist中。 WX20211229-220835@2x.png

可以通过烂苹果(MachOView)来查看Mach-O文件。找到app的可执行文件,并拖入到MachOview中。 WX20211230-092835@2x.png

在swift5__types中找到OSDogModel的地址。顺序为编译顺序,每4个字节是一个类或者结构体。我的是在第三个。

WX20211229-232856@2x.png 0x32F24 + 0xFFFF7EB4 = 0x10002ADD8 这个值需要减去一个基地址,基地址可以在LoadCommands中找到

WX20211229-232856@2x.png 0x10002ADD8 -0x100000000 = 0x2ADD8

我们可以在__const中找到0x2ADD8对应的内容。

WX20211229-234445@2x.png 右边的OSDogModel的提示已经证实找对了地方。

在上文中我们解了

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
}

而现在,0x2ADD8所指向的就是OSDogModel的typeDescriptor,而他的数据结构大致如下

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-Tabl
}

每个Uint32占4个字节。我们要找到V-tab需要向后偏移13个4字节,我们来到0x2AE0C。而V-Table里面存放的是TargetMethodDescriptor,其数据结构如下: tip:方法放在v-tab中是为了继承。所以需要继承的方法都会放在v-tab中(对象方法、类方法)、而在extention中定义的方法不能被继承,所以也不在v-tab中。

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.
};

也就是说sleep 前4个字节是存放Flogs,后4个字节存放Impl,Impl的地址是0x2A1E0.

可以先看Flags的定义,其实这个就是一个枚举值,来标记方法类型的。

class MethodDescriptorFlags {
public:
  typedef uint32_t int_type;
  enum class Kind {
    Method,
    Init,
    Getter,
    Setter,
    ModifyCoroutine,
    ReadCoroutine,
  };
private:
  enum : int_type {
    KindMask = 0x0F,                // 16 kinds should be enough for anybody
    IsInstanceMask = 0x10,
    IsDynamicMask = 0x20,
    IsAsyncMask = 0x40,
    ExtraDiscriminatorShift = 16,
    ExtraDiscriminatorMask = 0xFFFF0000,
  };
  int_type Value;
}

目前这三个方法的flags都是0x10,对应的枚举是IsInstanceMask,顾名思义是对象方法的意思。

再看真正的函数地址,需要用impl的内存地址+偏移量。而在程序正真运行的时候,还要➕上随机偏移地址(aslr)。

aslr 可以在lldb中通过 image list获取,如下:

WX20211230-001726@2x.png sleep函数地址 = 0x2A1E0 + 0xFFFFBEB0 + 0x000000010420c000 - 0x100000000 = 0x104232CC0

这是通过手动计算的结果。我们再用汇编断点来看看寄存器内的地址,如果一样就能证实以上的猜想。运行输出如下:

WX20211230-002450@2x.png 可以看到,函数地址和我们计算的一模一样!。如果觉得这只是巧合,可以同理计算eat()函数catchMouse()函数的地址。

影响函数派发方式

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

函数内联

函数内联是一种编译器优化技术,它通过使用方法的内容替换直接调用方法,从而优化性能. 在xcode里面可以看到编译器的这一选项,默认情况下debug,是None的,Release是Fastest,Smallest(最快最小)如图:

WX20220102-112849@2x.png

swift的如图:

WX20220102-112943@2x.png

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

}