本文作者:柯布
一、SIL 介绍
依据文档的描绘,SIL (Swift Intermediate Language) 依据 SSA 办法,它针对 Swift 言语规划,是一门具有高档语义信息的中心言语。
SIL is an SSA-form IR with high-level semantic information designed to implement the Swift programming language
Swift 和 Objective-C 运用的相同的编译架构 LLVM,LLVM 分为前端、中端和后端三部分,经过中心言语 LLVM IR 将前端和后端串联起来。swiftc 作为 Swift 言语的的编译器,负责 LLVM 前端的作业。swiftc 与其它编译器作业相似,进行词法剖析、语法剖析、语义剖析后构建抽象语法树(AST),然后生成 LLVM IR 交由 LLVM 的中端和后端。在这个流程傍边,swiftc 比较 Objective-C 运用的 clang ,swiftc 在构建完结 AST 后,生成终究的 LLVM IR 之前,参加了 SIL。
SIL 具有更全的 Swift 言语信息,能更好的对代码进行优化。关于开发者,SIL 具有杰出的可读性,可以作为了解 Swift 的底层细节的一个东西。
二、生成 SIL
首先,将下面的代码,生成 SIL,来看看 SIL 里详细有什么。
// Contents.swift
class Cat {
func speak() {
print("喵喵")
}
}
let cat = Cat()
cat.speak()
怎样生成 SIL?经过命令行,swiftc -emit-silgen >> result.sil 来生成 SIL 文件。添加 -Onone 告知编译器不要进行任何优化,有助于咱们了解完整的细节。
同时添加 xcrun swift-demangle 命令将符号进行还原,增强 Swift 办法名、类型等符号的可读性。
swiftc -emit-silgen -Onone Contents.swift | xcrun swift-demangle >> result.sil
生成的 SIL 主要包括类型的声明和界说、代码块 和 函数表 三个中心部分。
声明和界说
文件的最上方,是声明和界说部分。
// 1
sil_stage raw
// 2
import Builtin
import Swift
import SwiftShims
import Foundation
class Cat {
func speak()
@objc deinit
init()
}
@_hasStorage @_hasInitialValue let cat: Cat { get }
// 3
// cat
sil_global hidden [let] @Contents.cat : Contents.Cat : $Cat
- sil_stage 分为 raw 和 canonical,两种类型,raw 标明当时的 SIL 是未经优化的,而 canonical 代表的则是优化后。raw 更适合咱们对照代码进行剖析。
- 进行类型声明,和源代码差异不大。
- 界说了一个变量 cat,sil global 说明这是一个全局变量,hidden 则代表当时变量只在当时模块可见。若将 Cat 和该变量声明为 public,则不会存在 hidden 关键字。
代码块
紧接着,是一系列办法和它的代码块,它们依据代码中的办法逐个生成。
// main
sil [ossa] @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 { ... }
// Cat.speak()
sil hidden [ossa] @Contents.Cat.speak() -> () : $@convention(method) (@guaranteed Cat) -> () { ... }
// Cat.deinit
sil hidden [ossa] @Contents.Cat.deinit : $@convention(method) (@guaranteed Cat) -> @owned Builtin.NativeObject { ... }
// Cat.__deallocating_deinit
sil hidden [ossa] @Contents.Cat.__deallocating_deinit : $@convention(method) (@owned Cat) -> () { ... }
// Cat.__allocating_init()
sil hidden [exact_self_class] [ossa] @Contents.Cat.__allocating_init() -> Contents.Cat : $@convention(method) (@thick Cat.Type) -> @owned Cat { ... }
// Cat.init()
sil hidden [ossa] @Contents.Cat.init() -> Contents.Cat : $@convention(method) (@owned Cat) -> @owned Cat { ... }
每个代码块上方,都注释了对应的办法称号。除了 speak 办法,还包括了 main 函数、init、deinit 等办法。
main 函数作为整个代码的进口,经过 @convention 关键字约好函数的调用办法,@convention(c) 标明运用 C 言语的的调用规则来进行调用。下面的几个办规律约好调用办法为 method,此办法调用时会将 self 作为该实例办法第一个参数。此外还有 swift/objc_method/witness_method 多种约好。
接下来打开 main 函数来看一下详细的完结。
// main
sil [ossa] @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
// 1
alloc_global @Contents.cat : Contents.Cat // id: %2
%3 = global_addr @Contents.cat : Contents.Cat : $*Cat // users: %8, %7
%4 = metatype $@thick Cat.Type // user: %6
// 2
// function_ref Cat.__allocating_init()
%5 = function_ref @Contents.Cat.__allocating_init() -> Contents.Cat : $@convention(method) (@thick Cat.Type) -> @owned Cat // user: %6
%6 = apply %5(%4) : $@convention(method) (@thick Cat.Type) -> @owned Cat // user: %7
store %6 to [init] %3 : $*Cat // id: %7
// 3
%8 = load_borrow %3 : $*Cat // users: %11, %10, %9
%9 = class_method %8 : $Cat, #Cat.speak : (Cat) -> () -> (), $@convention(method) (@guaranteed Cat) -> () // user: %10
%10 = apply %9(%8) : $@convention(method) (@guaranteed Cat) -> ()
end_borrow %8 : $Cat // id: %11
%12 = integer_literal $Builtin.Int32, 0 // user: %13
%13 = struct $Int32 (%12 : $Builtin.Int32) // user: %14
return %13 : $Int32 // id: %14
} // end sil function 'main'
SIL 代码,经过各种指令来构成整个流程。代码全体十分易懂,咱们分为三部分来解析:
- 分配内存空间
- alloc_global 指令分配了全局变量 cat 所需求的内存空间,其类型为 Cat。
- 经过 global_addr 读取该变量的内存地址,存入 %3 寄存器中。
- metatype 指令获取 Cat 的元类型信息,存入 %4 寄存器中。
- 初始化实例
- 经过 function_ref 指令,引证了 Cat.__allocating_init() 办法。
- 紧接着经过 apply 指令履行 Cat.__allocating_init() 办法,创立出对应的实例,并存储到 %3 的内存地址上。
- 办法调用
- 在完结了全局变量 cat 创立之后,SIL 经过 load_borrow 指令从 %3 所存储的内存地址上读取对应的值。
- 接着运用 class_method 指令,查询实例对应的函数表,获取到需求履行的办法。
- 终究调用 apply 办法完结办法调用。
函数表
在整个 SIL 文件的结尾,咱们可以看到函数表部分。Swift 中 class 类型最常见的办法派发办法便是经过函数表派发,经过查询函数表里的办法后进行调用。
sil_vtable Cat {
#Cat.speak: (Cat) -> () -> () : @Contents.Cat.speak() -> () // Cat.speak()
#Cat.init!allocator: (Cat.Type) -> () -> Cat : @Contents.Cat.__allocating_init() -> Contents.Cat // Cat.__allocating_init()
#Cat.deinit!deallocator: @Contents.Cat.__deallocating_deinit // Cat.__deallocating_deinit
}
经过上面的比如,可以感受到 SIL 的可读性十分强,全体流程也十分的明晰。不必太了解详细的指令操作也能大概理解其内容。
三、Swift 办法派发
Swift 办法派发办法,有直接派发、函数表派发和动态派发三种办法。
直接派发
Swift 的一大优势是支撑值类型,值类型无法被承继,值类型中的办法都是经过直接派发的办法进行调用。
检查以下代码:
struct Dog {
func speak() {
print("汪汪")
}
}
let dog = Dog()
dog.speak()
在 SIL 中 function_ref 指令用于生成函数的引证。找到 speak 办法调用的部分,此处经过 function_ref 直接获取了 Dog.speak 办法的引证,随之调用。
// function_ref Dog.speak()
%9 = function_ref @Contents.Dog.speak() -> () : $@convention(method) (Dog) -> () // user: %10
%10 = apply %9(%8) : $@convention(method) (Dog) -> ()
// ...
可以得出结论,直接派发在 SIL 中的表现是,经过 function_ref 指令引证函数并调用。
不是只要值类型办法才进行直接派发,引证类型经过添加 final 关键字,办法也会经过直接派发的办法完结调用。此外在 extension 中完结的办法,由于无法被重写,也是直接派发的。读者可以自己生成 SIL 后验证。
class Dog {
final func speak() {
//...
}
}
// 或许
extension Dog {
func speak() {
//...
}
}
函数表派发
关于引证类型,未添加 final/dynamic 关键字且不在 extension 中完结的办法,会经过函数表派发的办法来调用办法。在上面的比如里出现的 VTable(Virtual Method Table),便是一种函数表。SIL 运用 class_method 指令去获取对应 VTable 中的办法进行调用,此处不再重复介绍。
在 Swift 中还有另一个函数表,WTable(Witness Table 用于存储 Protocol 中界说办法。检查以下代码:
protocol Animal {
func speak()
}
class Cat: Animal {
func speak() {
print("喵喵")
}
}
let cat = Cat()
cat.speak()
将代码生成 SIL 之后,SIL 最底下函数表部分发现 Cat 类型多了一个 Witness Table,里边有咱们协议中界说的 speak 办法。
sil_witness_table hidden Cat: Animal module Contents {
method #Animal.speak: <Self where Self : Animal> (Self) -> () -> () : @protocol witness for Contents.Animal.speak() -> () in conformance Contents.Cat : Contents.Animal in Contents // protocol witness for Animal.speak() in conformance Cat
}
可是当咱们检查 main 函数中调用的指令,依然是经过 class_method 指令去获取办法,WTable 如同没起效果?
%9 = class_method %8 : $Cat, #Cat.speak : (Cat) -> () -> (), $@convention(method) (@guaranteed Cat) -> () // user: %10
%10 = apply %9(%8) : $@convention(method) (@guaranteed Cat) -> ()
这是由于 Swift 自动进行类型推导,cat 变量被推导成了 Cat 类型,而 WTable 只要类型为 Protocol 时才会运用。声明 cat 为 Animal,从头生成 SIL:
let cat: Animal = Cat()
// 注:
// %3 为 cat 实例的内存地址
// %6 为 cat 实例
// 1
%7 = init_existential_addr %3 : $*Animal, $Cat // user: %8
store %6 to [init] %7 : $*Cat // id: %8
%9 = open_existential_addr immutable_access %3 : $*Animal to $*@opened("8ACFC95A-BFE1-11ED-B83F-56DB1A421F1A") Animal // users: %11, %11, %10
// 2
%10 = witness_method $@opened("8ACFC95A-BFE1-11ED-B83F-56DB1A421F1A") Animal, #Animal.speak : <Self where Self : Animal> (Self) -> () -> (), %9 : $*@opened("8ACFC95A-BFE1-11ED-B83F-56DB1A421F1A") Animal : $@convention(witness_method: Animal) <_0_0 where _0_0 : Animal> (@in_guaranteed _0_0) -> () // type-defs: %9; user: %11
%11 = apply %10<@opened("8ACFC95A-BFE1-11ED-B83F-56DB1A421F1A") Animal>(%9) : $@convention(witness_method: Animal) <_0_0 where _0_0 : Animal> (@in_guaranteed _0_0) -> () // type-defs: %9
-
类型擦除
- init_existential_addr 指令初始化了一个容器,该容器包括了实例(完结协议的目标)的引证。
- 经过 open_existential_addr 获取到上述容器,完结了类型擦除。在之后 SIL 访问都是 @opened(“XXX”) Animal 这一详细的协议类型。
-
办法调用
- 经过 witness_method 查找协议的办法进行调用。
- 获取到的办法的调用办法为:@convention(witness_method: Animal) ,代表该办法是在 WTable 表中的办法,需求经过函数表派发的办法履行。
从 SIL 中可以剖析,类型会影响函数的调用办法,比较于类办法,协议办法还需创立额定的内存空间。而要判别办法是否经过函数表派发,可以由 class_method/witness_method 来判别。
音讯派发
音讯派发,也属于动态派发办法中的一种。咱们最为熟悉 Objective-C 的办法都是经过音讯派发的办法进行调用的。
在 Swift 中,可以经过添加 @objc 关键字将办法暴露给 OC,但在 Swift 中调用 @objc 办法会经过音讯派发的办法来调用办法吗?经过 以下比如进行实验。
@objc
class Cat: NSObject {
@objc func speak() {
print("喵喵")
}
}
let cat = Cat()
cat.speak()
// SIL
%9 = class_method %8 : $Cat, #Cat.speak : (Cat) -> () -> (), $@convention(method) (@guaranteed Cat) -> () // user: %10
%10 = apply %9(%8) : $@convention(method) (@guaranteed Cat) -> ()
检查 SIL 可以注意到,即时是添加 @objc 的办法,依然是经过函数表进行派发的。那添加 @objc 关键字它的效果表现在哪?
检查办法的代码块会发现,多了一个针对 @objc 办法的代码块,而内部的完结直接引证了对应的办法,经过直接派发的办法进行调用。
// @objc Cat.speak()
sil hidden [thunk] [ossa] @@objc Contents.Cat.speak() -> () : $@convention(objc_method) (Cat) -> () {
// ...
%3 = function_ref @Contents.Cat.speak() -> () : $@convention(method) (@guaranteed Cat) -> () // user: %4
%4 = apply %3(%2) : $@convention(method) (@guaranteed Cat) -> () // user: %7
// ...
}
因而仅仅是添加 @objc 关键字,不会影响办法的派发办法,仅仅生成了一个 OC 可见的版别。
而要让 Swift 的办法在运行时以音讯派发的办法调用,还需求添加 dynamic 关键字。
// 添加 dynamic 关键字
@objc dynamic func speak() {}
// SIL
%9 = objc_method %8 : $Cat, #Cat.speak!foreign : (Cat) -> () -> (), $@convention(objc_method) (Cat) -> () // user: %10
%10 = apply %9(%8) : $@convention(objc_method) (Cat) -> ()
添加之后,办法由 objc_method 指令获取,同时办法被修饰为 @convention(objc_method), 标明该办法便是一个 OC 的办法,上述流程等价于 objc_msgSend()。同时 SIL 底部的 VTable 之中不会包括该办法。
动态派发保留了灵活性,除了能与 Objective-C 进行交互以外,也是 @dynmaicCallable 等新特性的根底。这边不再打开。
总结
Swift 依据详细情况,别离用不同的办法进行办法派发,从 SIL 中更好的了解到这类细节:
- 三种派发办法,别离是静态派发、函数表派发和音讯派发
- 静态派发功能最好
- 动态派发经过函数表查找办法,若调用协议办法还需求拓荒额定的内存空间
- 保留了 objc_msgSend 音讯派发的能力,以兼容 OC 的特性
四、处理问题
场景一:Protocol Extension
问:以下代码会输出什么?
protocol Animal {}
extension Animal {
func speak() {
print("adhansxkjaw")
}
}
class Cat: Animal {
func speak() {
print("喵喵")
}
}
let cat: Animal = Cat()
cat.speak() // adhansxkjaw
答案是:“adhansxkjaw”,猫猫不喵喵,胡说八道了。
问题出在哪?在 OC 中,都是经过音讯派发进行的办法调用,以 OC 的思考办法直觉上会输出“喵喵”。参阅上述 Swift 派发办法的描绘,此场景下的调用函数表动态派发调用十分相似,那就生成 SIL 来验证一下。
%10 = function_ref @(extension in Contents):Contents.Animal.speak() -> () : $@convention(method) <_0_0 where _0_0 : Animal> (@in_guaranteed _0_0) -> () // user: %11
%11 = apply %10<@opened("FCCE8690-C00E-11ED-A4DD-56DB1A421F1A") Animal>(%9) : $@convention(method) <_0_0 where _0_0 : Animal> (@in_guaranteed _0_0) -> () // type-defs: %9
// ...
sil_witness_table hidden Cat: Animal module Contents {
}
经过 SIL 咱们看到,此刻的 WTable 是空的,而该办法是经过静态派发的办法调用的,原因是 Protocol Extension 中的办法若没有在协议中进行声明,则不会进入到 WTable 中。由于办法是在 extension 中完结的,因而经过静态派发的办法进行调用。要获取的正确的成果,需求将 speak 办法在协议中进行声明。
场景二:父类未完结协议办法
问:以下代码会输出什么?
protocol Animal {
func speak()
}
extension Animal {
func speak() {
print("adhansxkjaw")
}
}
class Cat: Animal {}
class PetCat: Cat {
func speak() {
print("meow~")
}
}
let cat: Animal = PetCat()
cat.speak() // adhansxkjaw
答案依然是:“adhansxkjaw”,持续胡说八道。
这更加反直觉了,现已对 speak 办法进行了声明。而且完结了协议办法,怎样还是不行。生成 SIL 来看看:
%7 = init_existential_addr %3 : $*Animal, $PetCat // user: %8
store %6 to [init] %7 : $*PetCat // id: %8
%9 = open_existential_addr immutable_access %3 : $*Animal to $*@opened("928F6BFC-C188-11ED-8314-56DB1A421F1A") Animal // users: %11, %11, %10
%10 = witness_method $@opened("928F6BFC-C188-11ED-8314-56DB1A421F1A") Animal, #Animal.speak : <Self where Self : Animal> (Self) -> () -> (), %9 : $*@opened("928F6BFC-C188-11ED-8314-56DB1A421F1A") Animal : $@convention(witness_method: Animal) <_0_0 where _0_0 : Animal> (@in_guaranteed _0_0) -> () // type-defs: %9; user: %11
%11 = apply %10<@opened("928F6BFC-C188-11ED-8314-56DB1A421F1A") Animal>(%9) : $@convention(witness_method: Animal) <_0_0 where _0_0 : Animal> (@in_guaranteed _0_0) -> () // type-defs: %9
检查 SIL 完结的调用办法,并没有什么缺点,是动态派发,而且查询了 WTable 里的办法。那就看看那 WTable 吧。发现 WTable 只要其父类 Cat 的对应的函数表,没有 PetCat 的函数表。
sil_witness_table hidden Cat: Animal module Contents {
method #Animal.speak: <Self where Self : Animal> (Self) -> () -> () : @protocol witness for Contents.Animal.speak() -> () in conformance Contents.Cat : Contents.Animal in Contents // protocol witness for Animal.speak() in conformance Cat
}
查阅 SIL 文档,里边描绘到:
- WTable 只会对契合显示声明的目标生成。
A witness table is emitted for every declared explicit conformance.
- 而且还说到,SIL 只会引证父类的协议完结,若父类没有完结子类的完结则不会被引证到。
If a derived class conforms to a protocol through inheritance from its base class, this is represented by an *inherited protocol conformance*, which simply references the protocol conformance for the base class.
依据文档可以解说上面的问题。处理办法:子类的完结的协议办法,父类也需求完结,才有办法被履行。
另一个问题:假如将 cat 的声明由 Animal 修改为 Cat 呢?成果还是相同的,但详细的原因稍有不同,读者可以自己测验剖析。
场景三:OC 混编
如下代码,界说了一个 OC 的协议,该协议由 ModuleA 遵循但没有进行完结。ModuleB 进行承继后完结协议办法,此刻调用协议办法能正常输出内容。由于 OC 的协议办法是经过音讯派发的办法调用的,只要 ModuleB 的办法对 OC 可见,就可以被调用到,一切正常。
@objc protocol XXXModuleProtocol: NSObjectProtocol {
@objc optional func applicationDidFinishLanuch()
}
class ModuleA: NSObject, XXXModuleProtocol {
}
class ModuleB: ModuleA {
func applicationDidFinishLanuch() {
print("ModuleB applicationDidFinishLanuch")
}
}
// 在 OC 里调用
id<XXXModuleProtocol> module = [ModuleB new];
[module applicationDidFinishLanuch]; // ModuleB applicationDidFinishLanuch
检查 SIL,ModuleB 的 applicationDidFinishLanuch 办法即使没有添加 @objc,也生成了对应的代码块,因而该办法对 OC 可见,可以被正常履行。
// @objc ModuleB.applicationDidFinishLanuch()
sil hidden [thunk] [ossa] @@objc Contents.ModuleB.applicationDidFinishLanuch() -> () : $@convention(objc_method) (ModuleB) -> () { ... }
但在项目中遇到两个问题:
- 问题一,若 ModuleA 类型运用泛型,则 ModuleB 的 applicationDidFinishLanuch 办法不会生成的 @objc 版别的办法,因而无法被调用。
- 原因:由于 Swift 泛型是无法导出到 OC 的,因而无法自动生成 @objc 办法也契合情况。
- 处理:对办法手动声明 @objc,使其对 OC 可见。
- 问题二,ModuleA 和 ModuleB 分属于两个组件库,若 ModuleA 所在的组件库为静态库,则无法正常调用;若为源码库则一切正常。
- 原因:详细原因不知道,只能揣度是编译器存在依据源码进行揣度的行为。
- 处理:对办法手动声明 @objc,使其对 OC 可见。
- 通用的处理办法
- 在场景二中说到,编译器对只会引证父类的完结,那在父类中完结对应的协议办法能不能行呢?
- 经过测验,也是可行的,添加后能正常调用到子类的协议办法。
五、总结
“计算机科学范畴的任何问题都可以经过添加一个间接的中心层来处理”,在 Swift 言语和 LLVM IR 之间,swiftc 里参加了 SIL。经过 SIL,可以对 Swift 进一步的优化。SIL 比较汇编,更简单读懂。咱们将其作为东西,了解和学习 Swift 言语的办法派发机制。也借助于 SIL 解说编码过程中遇到的问题。本文的内容仅仅 SIL 中关于办法调用的一小部分,欢迎参阅指正。
六、参阅资料
- 图源:unsplash.com/photos/LGG5…
- Swift SIL 官方文档
- swift-c-llvm-compiler-optimization
- LLVM概述——根底架构
- LLVM编译流程
- Swift 底层是怎样调度办法的
- 试着读一下 SIL
本文发布自网易云音乐技术团队,文章未经授权禁止任何办法的转载。咱们常年接收各类技术岗位,假如你预备换作业,又刚好喜爱云音乐,那就参加咱们 grp.music-fe(at)corp.netease.com!