在上篇Swift-类与结构体(上)了解了类与结构体在初始化和类型上的区别,本篇主要分析一下函数的调用。
异变方法
上一篇我们了解到,Swift中class
和struct
都能定义方法。但是有一点区别的是,默认情况下值类型属性不能被自身的实例方法修改。
看上面的定义编译会报错,提示当前对象是不可变的,
struct
是值类型,当改变实例的属性时,相当于改变了实例本身,这是不被允许的。如果需要修改struct
的属性值,需要在方法定义前添加mutating
关键字。
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()
2. moveBy(x:y:)
从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
地址。可以通过一个简单的示例来验证一下。根据上面示例的结果,给
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
模式。
常用汇编指令
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
架构下的方法的调用过程。
上面
bl
是类的初始化,下面bl
执行了release
操作,那中间的blr
猜测就是调用了teach
函数,接下来在33
行bl x8
打个断点,然后按住control
键,选择step into
,发现就是调用了teach
函数。
接下来再增加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()
}
}
上下2个
bl
还是对应的初始化
和release
,中间的3个blr
就是对应就是teach
、teach1
和teach2
,x8
在汇编里对应的就是寄存器
,那x8
的值是怎么获取的?
结合上面的汇编指令:
- x0的值赋值给x20(x0就是初始化的实例对象)
- x20的值存储在x8中
- x8偏移0x50地址后,再把值放入x8中。 从第2步执行后,通过
register read x8
,控制台输出得到的值是metadata
,metadata
偏移0x50
地址后就得到我们的teach
函数。 因此Swift函数的调用过程大致是这样的:找到metadata
~>确定函数地址(metadata+偏移量)
~>执行函数
基于函数表的调度
还是通过上面的案例,发现三个地址每个相差8个字节,这8个字节就相当于函数指针的大小,而且是连续的内存空间,所以Swift函数是基于函数表的调度。
SIL代码分析
我们把示例代码ViewController
编译成SIL
文件,可以看到有个sil_vtable
,包含了示例中定义的方法。这个sil_vtable
就是每个类自己的函数表。
在上一篇中已经分析了
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
,不管是Class
、Struct
还是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
等等。
把Xcode编译出来的可执行文件拖入
MachOView
可以查看具体的信息,如下:
- 首先是文件头,表明该文件是
Mach-O
格式,指定目标架构,还有一些其他的文件属性信息,文件头信息影响后续的文件结构安排。 Load commands
是一张包含很多内容的表。内容包括区域的位置、符号表、动态符号表等。
表字段 | 表信息 |
---|---|
LC_SEGMENT_64 | 将文件中(32位或64位)的段映射到进程地 址空间中 |
LC_DYLD_INFO_ONLY | 动态链接相关信息 |
LC_SYMTAB | 符号地址 |
LC_DYSYMTAB | 动态符号表地址 |
LC_LOAD_DYLINKER | dyld加载 |
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 Command
,Segment
中Section
就可以被解读为代码、常量或者一些其他的数据类型。在装载在内存中时,也是根据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字节就是ATTeacher
的Descriptor
信息。这里用到了MachOView
工具,需要把可执行文件拖入到这个工具中。由于Xcode13
在项目结构中去除了Product
文件目录,可执行文件放在了Xcode/DerivedData
里。(Xcode ~> Preferences ~> Locations ~> DerivedData)
把
0xFFFFFB80
(iOS是小端模式,字节读取从后往前读),加上0xBBCC
,得到0x10000B74C
,再减去Mach-O文件的虚拟基地址0x100000000
,虚拟基地址可以在Mach-O文件的Load Commands
~> LC_SEGMENT_64(__PAGEZERO)
看到,得到0xB74C
,这个0xB74C
就是Descriptor
在整个Data区的内存地址。
接下来找一下
0xB74C
在Mach-O
文件中的位置,在Section64(__TEXT,__const)
找到了0xB740
,按4字节读取,0xB74C
就在第4段中。
0xB74C
就是上面提到的TargetClassDescriptor
的首地址,VTable就是在这个数据段的后面。
按照
TargetClassDescriptor
的结构,在Mach-O
文件以每4字节数12个,size
后面标记的1
、2
、3
分别是3个VTable
,也就是对应的teach
、teach1
和teach2
。
我们通过计算来验证一下这里是不是就是函数的地址,需要用VTable对应的
虚拟地址
+ASLR
(程序运行时的偏移地址),这里第一个VTable
对应的地址就是上图中1对应前面的地址0xB780
,再运行项目通过image list
找到第一个就是程序运行时的基地址。
运行程序得到的基地址是
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
,我们拿到首地址加上Flag
和Offset
就是实际函数的地址。
0x10413F780 + 0x4(Flag) + 0xFFFFC030(Offset) = 0x20413B7B4
// 0x20413B7B4再减去Mach-O的虚拟基地址0x100000000
0x20413B7B4 - 0x100000000 = 0x10413B7B4
以上计算得出的0x10413B7B4
就是函数的指针地址,我们再运行程序通过register read
获取一下寄存器的地址。
根据程序的运行结果可以看出和我们上面计算的结果一致,也就是
ATTeacher
的teach
方法。
回顾
- 通过
Mach-O
文件发现VTable
是一个连续函数表- 通过源码找到了
GenMate.cpp
里ClassContextDescriptorBuilder
函数定义,调用父类layout
,找到了Descriptor
的结构- 调用
addVTable
可以看到添加Offset
,添加函数指针- 通过
Mach-O
文件的__swift5_types
找到Decriptor
的地址信息- 根据
Mach-O
的信息计算出方法的地址- 结合程序运行最终验证计算结果的正确性。
方法调度方式总结
类型 | 调度方式 | extension |
---|---|---|
值类型 | 静态派发 | 静态派发 |
类 | 函数表派发 | 静态派发 |
NSObject子类 | 函数表派发 | 静态派发 |
结合示例来说明一下,把之前的案例class
改成struct
,然后断点运行,可以看到是直接的地址调用(也就是静态调用)
struct
是值类型,没有继承关系,在编译的时候就已经确定。再加一个extension
extension ATTeacher {
func teach3() {
print("teach3")
}
}
再次运行,发现还是地址调用。
如果在
class
类加个extension
,同样增加teach3
方法,发现不管是struct
还是class
对应的extension
都是地址调用。
影响函数派发方式
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
编译模式按默认的优化等级,默认是不优化。
然后以汇编的模式打开debug,在
main
函数调用sum
打个断点。运行
可以看到把
0x1
传给寄存器w0
,0x2
传给寄存器w1
,然后bl
调用sum
函数。我们再把优化等级(Optimization Level)
改成Fastest, Smallest[-Os]
再次运行代码:
发现编译器已经把
sum
函数优化了,直接把结果0x3
返回出来然后传给寄存器w8
。同样Swift项目也可以设置优化等级,Optimize for Speed[-O]
和Optimize for Size[-Osize]
,一个是对速度的优化,一个是对大小的优化。
当然
Swift
还可以手动指定函数的内联。
// 始终内联
@inline(__always) func test() {
print("test")
}
// 始终不内联
@inline(never) func test1() {
print("test1")
}
如果对象只在声明的文件中可⻅,可以用private
或fileprivate
进行修饰。编译器会对private
或fileprivate
对象进行检查,确保没有其他继承关系的情形下,自动打上final
标记,进而使得对象获得静态派发的特性(fileprivate
: 只允许在定义的源文件中访问,private
:定义的声明中访问)
总结
本篇主要从SIL
代码了解了Swift
方法的定义及结构,通过示例结合Mach-O
文件验证了方法在内存中存储以及方法的调度方式,再了解了影响函数的派发方式,结合案例说明函数内联优化的异同点。