从一次RxSwift调用说起
问题发现
在一次编写业务界面场景中,有一个界面需要展示加载页面,等待数据加载完成后,再渲染实际界面。数据可多次加载,每次加载数据视图都要显示Loading页。所以为此在ViewModel中构建了一个发送可选值的信号:
let data: BehaviorRelay<DataType?> = BehaviorRelay(value: nil)
复制代码
这个信号的值如果发送nil,则代表数据在加载中,视图需要显示Loading态;如果是非nil,则是代表新数据加载完成,视图层接收此时机,做页面展示工作。为了让视图层更纯粹的响应信号,这两种操作在viewModel中被分开来,如下图:
仅考虑上面交互的链路,要想过滤nil,自然用到了compactMap操作符,同时为了不让视图层直接接触数据Data,后面加了一个map操作符,把数据本身map成一个常量。所以视图层订阅的信号实际上变成了如下写法:
let dataReady = data.compactMap { $0 }.map { true }
复制代码
我们聚焦的逻辑可以概括为:
视图层在ViewModel接收到非空数据时机,去做视图构建动作
视图层监听信号的操作是这样的:
viewModel.dataReady.subscribe(onNext: {
// do something with build Content
})
复制代码
这是一段构建绑定的代码,我们都知道,当信号被subscribe订阅时,即触发订阅本身的逻辑闭包。但是由于原信号是个有初始值nil的BehaviorRelay,经过compactMap操作符过滤掉nil之后,理论上首次订阅不会触发监听闭包,预期数据流如下图:
然而事实是符合预期么?
Of course not
运行起来后,可以从调用栈上看到订阅立即触发了监听闭包(也就是83行和84行是同步串行执行的)。嗯?nil值不是应该被compactMap过滤掉了么?信号应该根本发不出来才对。
问题初判断
遇到这个现象,第一反应是RxSwift的使用姿势不对,因为毕竟它有如此多的操作符和概念,让人没有那么大的信心确定使用方式是正确的。所以直接去翻了下CompactMap操作符的源码:
可以看到当源信号发送next值时,CompactMapSink里的处理逻辑(红框部分),做了一个可选值绑定,如果不为nil,才继续发送给下游信号。而_transform闭包就是上文中的 { $0 } 部分,所以self._transform(element)就是element本身。也就是说,理论上这个if是走不进来的。
我们尝试打个断点:
可以看到确实是走到了if里面,mappedElement是个 (())
嗯?这个(())
是什么,可以清楚的看到element是nil,然后经过 { $0 } 闭包计算之后,变成了(())
这个东西,故而被绑定到了mappedElement上,发送给了下游信号。
意识到(())
这个其实是个空元组,也就是Tuple。我们知道Swift对于Void的类型就是空元组,但它并不等于nil,所以造成了这个问题。那么为什么{ $0 }
(nil)的结果是个()
呢?难道Rxswift背后有什么黑科技对于nil做了特殊处理?
思路扩散
当查找问题陷入停滞的时候,可以从多角度尝试观察,比如换一个写法。所以经过几次写法的尝试后,不出所料,这次成功遇到了更诡异的问题。我发现如果把信号转换写成这样:
data.compactMap { $0 }.map { _ in true }
复制代码
问题就解决了…..
(为了方便调试mappedElement的值,稍微更改了写法)
对比一下原写法:
// 有问题的写法
data.compactMap { $0 }.map { true }
// 没问题的写法
data.compactMap { $0 }.map { _ in true }
复制代码
这抛出了两个新的疑问:
- 最直接的疑问:难道 「_ in」增加与否会对代码逻辑产生影响?
- 最诡异的疑问:为什么更改 map 闭包的写法会反过来对compactMap的逻辑有影响?
问题根源定位
问题简化
到这里我逐渐意识到应该不是RxSwift的问题,可能是语言层面上的原因。为此我构造了一个简化版的Demo:
struct Transform<E> {
let element: E
func foo<T>(transform: (E) -> T) -> TransformB<T> {
let mappedElement = transform(element)
return TransformB<T>(element: mappedElement)
}
}
struct TransformB<E> {
let element: E
func foo<T>(transform: (E) -> T) -> T {
return transform(element)
}
}
let instance = Transform(element: "1")
// 模仿写法
let result = instance.foo{$0}.foo{true}
复制代码
其中Transform的结构体有一个foo方法,接收一个闭包,并把闭包的结果包装转换为TransformB的类型;而TransformB同样接收一个闭包,直接输出转换后的值。
运行起来,发现仍然和前文的问题一样:第一次的mappedElement输出并不是 “1”,而是()
可以从图中看到mappedElement确实是个Tuple,而且是个空Tuple。
这时意识到Swift对于无返回值函数的类型都是看做返回Void,也就是空Tuple。难道{ $0 }的写法有些问题?
带着这个疑问,我尝试着把这两个调用拆开,是想看看第一个闭包调用后的类型是什么。
第一个调用结果是TransformB,看着没什么问题。
当我想查看第二个类型的时候,编译器报错了
意思是在这个闭包语境中需要显式列出参数列表,而闭包参数我没有使用到,所以建议我插入_ in
。
哈?为啥连在一起调用没有编译错误,拆开就编不过了?一时间感觉被编译器针对了。
于是我又放到了一起调用,还是能编译成功。但是这时报了一个之前没有注意的警告:
编译器说$0
这个变量我没有用到。 (嗯?明明用到了呀,把它当做返回值用到了)
这时突然意识到可能是编译器本身的问题,猜测它把 { $0 }
的闭包错误地编译成了返回 Void的闭包,绕过了$0而没有使用到这个变量,同时又返回了空Tuple值。 这样现象就解释的通了。
经过不断的尝试各种写法,总结出了相似的问题和现象:
//编译通过,但现象诡异(第一个闭包返回())
instance.foo { $0 }.foo { true }
instance.foo { e in e }.foo { return true }
instance.foo { $0 }.foo { return true }
//编译不过
instance.foo { e in return e }.foo { true }
instance.foo { e in return e }.foo { return true }
instance.foo { return $0 }.foo { true }
//编译通过,现象符合预期
instance.foo { $0 }.foo { _ in true }
instance.foo { return $0 }.foo { _ in true }
instance.foo { e in return e }.foo { _ in true }
复制代码
到这里可能有小伙伴对于要解决的问题比较混乱,这里做个小结:
对于let result = instance.foo { $0 }.foo { true }
这个表达式:
- 第一个闭包在某些情景下被编译器错误的编译成返回Void的值,也就是{$0}不论入参是什么,闭包的结果都是()
- 两个闭包对于各类闭不影响逻辑的包语法糖的改动(比如省略return关键字、省略闭包参数列表。)都会影响整个表达式的编译结果。
寻找定位
既然程序在运行时在源代码层面展示信息有限,我们从汇编代码里挖掘现象。打开Xcode的Debug -> Debug WorkFlow -> Always show Disassembly。
程序运行时,走到第一个transform闭包调用点:
此时transform闭包的地址已经在rdx寄存器里,执行LLDB的register read命令,可以看到控制台输出的注释:
TestOnMac partial apply forwarder for reabstraction thunk helper from @callee_guaranteed (@guaranteed Swift.String) -> () to @escaping @callee_guaranteed (@in_guaranteed Swift.String) -> (@out ()) at
这个{$0}
transform闭包的类型是(String) -> ()
,而不是(String) -> (String)
。验证了我们的猜想,确实是编译器产生的可执行代码不符合预期。
那么在整个编译链条中,何处出了问题呢?我决定不放过问题,一探究竟。
我们都知道swift编译链和其它Objective-C、C、C++等语言的区别是,在IR阶段之前,swift有一套功能强大的编译前端系统,它包括类型检查、SIL语言等大力度的优化。
现在,我们去查看SIL的生成结果。
swift编译前端命令是swiftc
(实际上和swift命令是同一个),输出sil的命令是
swiftc -emit-sil xxx.swift
执行后得到很多SIL语言的输出,经过简化后,第一个transform被编译成SIL后的结果:
// closure #1 in
sil private @$s4mainySSXEfU_ : $@convention(thin) (@guaranteed String) -> () {
// %0 "$0" // user: %1
bb0(%0 : $String):
debug_value %0 : $String, let, name "$0", argno 1 // id: %1
%2 = tuple () // user: %3
return %2 : $() // id: %3
} // end sil function '$s4mainySSXEfU_'
复制代码
closure#1 即为{$0}
这个闭包,可以看到在SIL中,它的类型已经是(String) -> ()
了,而且看bb0(base block)的代码,可以看出%0是闭包的参数,就是传入的string,%2是空tuple,之后就直接返回了这个空tuple。由此我们可以发现在SIL阶段已经出了问题。
继续沿着编译链条向前,既然是类型判断出了问题,那么去类型决议之后的AST阶段看看:输入命令
swiftc -dump-ast main.swift
我们可以看到输出了色(kan)彩(zhe)缤(tou)纷(teng)的AST结构:
这些是经过类型决议之后的AST结构树,继续在大量结果输出中寻找我们关键的transform闭包类型:
可以看到经过类型决议后的AST结构也已经把transform判断成(String) -> ()类型的闭包。我们再次执行没有经过类型决议的AST看看:
swiftc -dump-parse main.swift
此阶段还没有经过类型决议,仅仅是分析swift源码文本的AST树。
此时我们可以发现在swift编译器构建原始AST结构之后,还没有经过类型决议,经过对AST的语义分析,才得出了错误的类型结果,我们终于定位到了问题所在。
到这里,我们怎样继续深入语义分析的阶段呢?在Swift官方文档有说明:
执行如下命令:
swiftc -dump-ast main.swift -Xfrontend -debug-constraints
上述Log输出了语义分析阶段,类型推断的过程Log,但这次我们略有些看不懂了,所以在继续深入之前,我们先了解一下文章的主角 —— Swift Type Inference.
探究Swift Type Inference
简述
Swift语言的一大特性就是具有强大的类型推断。它很大程度上简化了开发人员的工作量,我们不仅可以省略很多变量类型的显式指定,而且类型推断也同样支撑着代码自动补全的功能。Swift类型推断非常强大,不仅包括变量、表达式的类型推断,而且支持各类泛型结构、泛型函数、协议的关联类型等等。
Swift类型推断系统有三个重要特征:
- 类型推断是双向进行的
- 类型推断是以单个表达式或陈述语句为范围限制进行处理
- 类型推断过程基于约束系统实现
一、类型推断是双向进行的
Swift’s type inference allows type information to flow in two directions.
我们直接举个例子,下面定义了一个add函数,并且调用此函数:
func add(_ lhs: Int, _ rhs: Int) -> Int {
lhs + rhs
}
let result = add(4, 5) //result: Int
复制代码
我们都知道一个表达式(expression)或者陈述语句(statement)被构建成AST结构时,通常表达式的结果是根节点,组成该值的各部分为叶子节点。下图为 let result = add(4, 5) 的AST结构树:
在此例中,result类型就可以通过add
函数、4
、5
(字面量)的类型,最后被决议成了Int类型。这样的类型判断流向 “看起来” 是 叶子 -> 根(Leaf To Root),决议的过程如下图所示:
上文提到,Swift类型判断是双向的,让我们继续看下面的例子:
func add(_ lhs: Int, _ rhs: Int) -> Int {
lhs + rhs
}
func add(_ lhs: Double, _ rhs: Double) -> Double {
lhs + rhs
}
let result: Double = add(4, 5) //4: Double, 5: Double
复制代码
对于 let result = add(4,5),其AST的结构和上一个例子一样,但是这次result被显式指定了Double类型,那么swift类型判断就会反过来决议4
,5
这两个字面量的类型,在这个case中,通过对add函数的返回值和参数类型、result的类型,最后把4
,5
两个字面量判断为Double类型。也因此调用到了第二个add函数。这个例子中,类型判断流向 “看起来” 是根->叶子。
注释:
- 上述两个例子中的
4
,5
是字面量,属于integer_literal
,不可以直接等价于Int
,所以其类型也是需要判断的。- 类型推断方向加了引号,是“看起来”。是因为swift类型决议实际上并没有方向的概念,下文会详细说明
双向类型推断(Bi-directional Type Inference)在类ML语言中常见,而主流的C++, Java, C#, Objective-C等语言中不具备此功能。
二、类型推断是以单个表达式或陈述语句为范围限制进行推断
Swift limits the scope of type inference to a single expression or statement.
Swift是强类型语言,而且具有复杂的类型系统,除了普通的表达式类型推断,还需要处理泛型、函数重载、协议约束等复杂的情形,所以出于执行性能和给出更准确诊断的考虑,Swift类型推断把范围限制在单个表达式或者陈述语句。
三、类型推断是基于约束系统实现
像布局约束一样,类型推断过程中也是基于类型约束来计算类型的,比如有相等性约束;转换型约束;成员约束;子类约束等等。Swift这样设计是可以把约束系统本身和求解过程解耦。约束本身的定义和描述基本不会变化,而求解过程可以持续优化。
工作流
上文提到,Swift类型推断的过程是基于单个表达式或陈述语句,在求解过程中,这个过程可以在被称作一个Scope的概念中进行,对于一个求解Scope,类型推断的过程如下:
Constraint Generation
第一个步骤是生成此次求解Scope的所有类型约束,这个过程可能会根据一些已有的上下文(比如上一个Scope的求解结果)去生成。
Type Checker会遍历AST结构树,生成一系列约束,这些约束会描述表达式以及子表达式所有的类型之间关系,比如有成员类型约束、父子类型约束、转换类型约束等等。有些未被决议的类型被标记为变量,用Tx(T0,T1,….)表示,之后这些类型变量会在Solving阶段被决议。
Constraint Solving
当Scope中的所有类型约束生成后,Swift类型推断会进行到这一步骤,这里也是类型推断中最重要的过程,它通过第一阶段生成的类型约束,利用各种不同类型对应的求解策略,计算出所有还未被决议的类型变量。
求解的过程包含大量的策略,有可能进行函数的类型转换、也有可能求出多个类似解,Solving过程有一套Solution的对比策略,来对比出最佳的求解结果。
当然,这一求解过程中有可能因为代码类型的不匹配而出错,这时Swift类型推断会把错误记录下来、会尝试进行修复,以此用于生成编译器的诊断,最后通过编译器的warning或error报出。
Solution Application
当Solving步骤求解成功,决议了所有类型变量之后,Swift类型推断会把之前的求解结果通过Rewrite AST的过程把所有类型变量的结果赋值给对应的类型上去,在这一步骤中,所有类型都必须显式的被决议成具体类型(Concrete types)。
Solving过程解析
Step
Solving作为类型推断的核心流程,其整个过程是可以理解为一步步SolverStep对每个类型约束的的求解过程,若干个不同种类的Step逐步解析、最后得到一个完整的求解方案。 在源码中,表示求解步骤的基类是SolverStep,其简化的源码头文件定义如下所示:
class SolverStep {
protected:
ConstraintSystem &CS; // 表示整个求解Scope的约束系统
StepState State; // Step的状态 (Setup, Ready, Running, Suspended, Done)
SmallVectorImpl<Solution> &Solutions; // 求解结果
public:
// 初始化方法, 持有传入的约束系统(获得约束),和Solution数组 (待填充)
explicit SolverStep(ConstraintSystem &cs,
SmallVectorImpl<Solution> &solutions)
: CS(cs), Solutions(solutions) {}
virtual ~SolverStep() {} // 析构方法
virtual void setup() {} // setup方法
virtual StepResult take(bool prevFailed) = 0; // take方法,主要求解实现。返回StepResult
virtual StepResult resume(bool prevFailed) = 0; // resume,在suspended状态被唤起时调用。返回StepResult
}
复制代码
SolverStep抽象了求解步骤的功能,其内部状态流转图如下所示
SolverStep的流转主要是通过5个状态 + 2个函数支撑的。五个状态的意义分别代表
Step状态 | 作用 | 可以产生的StepResult |
---|---|---|
Setup | Step处于初始状态 | 不产生Result |
Ready | Step处于可执行状态 | 不产生Result |
Running | Step正在执行 | 不产生Result |
Suspended | Step被挂起,仍未求解完成,等待被恢复 | Unsolved |
Done | Step已结束。求解成功 / 失败 | Solved / Error |
这五个状态的流转对该Step的求解过程十分重要,它的状态流转可由内部驱动,也可由外部操作。
而关于take()
函数和resume()
的函数,它们驱动着实际求解的逻辑。
take()
函数一般是作为求解过程的核心逻辑,不同的Step类型有着不同的目标逻辑。resume()
函数是作为suspended状态唤醒之后的入口,通常会根据之前保存的数据结合此次唤醒的操作判断是否可继续执行,如果可继续执行,则会继续调用take()
函数。
SolverStep是基类,用来抽象Step状态流转。实际的求解过程在子类中实现,它有四种子类,其中BindingStep子类又被细分成两个子类。它们的作用及特点如下表所示:
子类名称 | 主要作用 |
---|---|
SplitterStep | 运用算法,将整个约束图(Constraint Graph)拆解成独立可求解的子系统(component),之后再进行合并 |
DependentComponentSplitterStep | 依赖其它component的求解,负责合并依赖的部分求解结果 |
ComponentStep | 可独立求解的部分,可以产生BindingStep |
BindingStep | 主要用来做类型绑定,可以认为是类型求解系统的基本执行单元。它有两个细分子类TypeVariableStep 和 DisjunctionStep |
Solver
SolverStep是用来求解的步骤,那么整体必然有一只“手”,用来调度各类的步骤。这个过程在CSSolver.cpp文件里的solveImpl函数中体现,核心源码如下:
void ConstraintSystem::solveImpl(SmallVectorImpl<Solution> &solutions) {
// 给整个ConstraintSystem设置Solving阶段
setPhase(ConstraintSystemPhase::Solving);
// 确保函数退出时,整个ConstraintSystem设置Finalization阶段
SWIFT_DEFER { setPhase(ConstraintSystemPhase::Finalization); };
// 创建新的求解Scope
SolverScope scope(*this);
// 创建workList
SmallVector<std::unique_ptr<SolverStep>, 16> workList;
// workList推入第一个Step
workList.push_back(std::make_unique<SplitterStep>(*this, solutions));
// 对「上一步骤失败」初始化为false
bool prevFailed = false;
// advance驱动函数, 用来驱动下一个Step的执行
auto advance = [](SolverStep *step, bool prevFailed) -> StepResult {
// 获取当前步骤的执行状态
auto currentState = step->getState();
// 如果是setup状态,执行setup操作,并置位ready状态
if (currentState == StepState::Setup) {
step->setup();
step->transitionTo(StepState::Ready);
}
// 获取当前状态(在更新running state之前保存)
currentState = step->getState();
// step置为running态
step->transitionTo(StepState::Running);
// 判断running态之前的状态, 如果是ready,则调用take函数;如果是suspended,则调用resume唤起
return currentState == StepState::Ready ? step->take(prevFailed)
: step->resume(prevFailed);
};
// 对workList的step任务进行LIFO顺序的执行
while (!workList.empty()) {
// 取最后一个(最新)的step
auto &step = workList.back();
{
// 执行step,得到StepResult
auto result = advance(step.get(), prevFailed);
switch (result.getKind()) {
// 错误也可以被认为完成状态,表示此步骤没有找到结果。需要退出step。
case SolutionKind::Error:
fallthrough;
// 求解完成,弹出workList
case SolutionKind::Solved: {
workList.pop_back();
break;
}
// 尚未求解完成,处于suspended状态,需要求解后续step来回溯等待唤起
case SolutionKind::Unsolved:
break;
}
// 标识上一步是否error
prevFailed = result.getKind() == SolutionKind::Error;
// 根据result的结果更新worklist,尾部添加新的step,或者什么也不做(添加0个)
result.transfer(workList);
}
}
}
复制代码
整个操作流程的函数步骤还是比较清晰的,其实不难发现整个workList更像是一个栈,可以不断的在栈顶添加新产生的step,当栈顶的step被完成(Solved或者Error),step就会出栈,从而继续求解下一个step,直到栈为空,整个scope就求解完成了。
问题带入
从Log中观察
对类型推断的过程有了了解后,我们把目光回到问题中来,此时我们观察Log输出大概就有了基本的概念认知。对于我们的表达式,整个求解过程的Log的结构大致是这样的:
// 求解 let result = instance.foo{$0}.foo{true} 中所有类型的过程
---Constraint solving at [/Users/Rico/Rico/Program/TestOnMac/TestOnMac/main.swift:62:15 - line:62:42]---
---Initial constraints for the given expression---
Score: 0 0 0 0 0 0 0 0 0 0 0 0 0 0
Type Variables:
$T0 : ......
$T1 : ......
.
.
.
$T13: .....
Active Constraints:
$T4 arg conv (String) -> $T3
$T11 arg conv ($T3) -> $T10
Inactive Constraints:
$T4 closure can default to ($T5) -> $T6
$T11 closure can default to () -> $T12
Opened Types:
....
---Solver statistics---
Maximum depth reached while exploring solutions: 9
Time: 7.490000e+00ms
---Solution---
Fixed score: 0 0 0 0 0 0 0 2 0 0 0 0 0 0
Type Variables:
$T0 as Transform<String>
$T1 as ((String) -> ()) -> TransformB<()>
.
.
.
$T13 as Bool
复制代码
我们可以看到,Log输出基本体现了上述描述的求解过程。整个Log有点长,让我们逐步分析。
类型变量
首先,Type-Checker拿到表达式对应的原始的AST结构,遍历后生成了14个类型变量。这里为了能更清晰地看懂后续的求解过程,这里详细拆解这14个类型变量都代表着什么。
再次回顾一下源码
struct Transform<E> {
let element: E
func foo<T>(transform: (E) -> T) -> TransformB<T> {
let mappedElement = transform(element)
return TransformB<T>(element: mappedElement)
}
}
struct TransformB<E> {
let element: E
func foo<T>(transform: (E) -> T) -> T {
return transform(element)
}
}
//instance已经被决议成TransformB<String>
let instance = Transform(element: "1")
// 模仿写法
let result = instance.foo{$0}.foo{true}
复制代码
这14个类型变量分别代表以下的类型:
类型变量 | 说明 | 可等价于 |
---|---|---|
T0 | Instance的类型 | Transform<String> |
T1 | 第一个foo函数的类型 | ((String) -> $T3) -> TransformB<$T3> |
T2 | 第一个foo函数transform的入参(E) | String |
T3 | 第一个foo函数transform的出参(T) | |
T4 | 第一个foo函数transform的类型 | ($T2) -> $T3 |
T5 | 第一个闭包{$0}Tuple element $0的类型 | |
T6 | 第一个闭包的结果 | |
T7 | 第一个foo函数的结果 | TransformB<$T3> |
T8 | 第二个foo函数的类型 | (($T3) -> $T10) -> $T10 |
T9 | 第二个foo函数transform的入参(E) | $T3 |
T10 | 第二个foo函数transform的出参(T) | |
T11 | 第二个foo函数transform的类型 | ($T3) -> $T10 |
T12 | 第二个闭包的结果 | |
T13 | 第二个foo的结果 | $T10 |
可以看出,仅一个表达式中所产生的类型变量就有14个,主要是因为一个闭包所产生的的类型变量就很多。这里可能有人会疑问,闭包的类型难道不直接是transform参数类型么?其实不然,闭包本身的类型本质上是由闭包语法中的类型指定的(比如 { (str: String) -> Bool in ….. }) ,但是在此例中,因为表达式足够简单,我们省略了闭包类型的显式指定,所以这里会产生相对应的类型变量,因为它是未被决议的。
类型决议结果
因为求解过程比较复杂,让我们先直接跳到结果部分,对生成的结果有个预期,之后带着结果去看过程,会比较清晰。求解结果如下:
类型变量 | 说明 | 决议结果 |
---|---|---|
T0 | Instance的类型 | Transform<String> |
T1 | 第一个foo函数的类型 | ((String) -> ()) -> TransformB<()> |
T2 | 第一个transform的入参(E) | String |
T3 | 第一个transform的出参(T) | () |
T4 | 第一个transform的类型 | (String) -> () |
T5 | Tuple element $0的类型 | String |
T6 | 第一个闭包的结果 | () |
T7 | 第一个foo函数的结果 | TransformB<()> |
T8 | 第二个foo函数的类型 | (() -> Bool) -> Bool |
T9 | 第二个transform的入参(E) | () |
T10 | 第二个transform的出参(T) | Bool |
T11 | 第二个transform的类型 | () -> Bool |
T12 | 第二个闭包的结果 | Bool |
T13 | 第二个foo的结果 | Bool |
可以看到,有大量类型都被决议成了(),这里一定存在着某些问题,现在,让我们对log中求解过程做分析。
求解过程Log解析
以下是Log中求解的过程(已简化)
($T4 involves_type_vars bindings={(subtypes of) (String) -> $T3})
(attempting type variable $T4 := (String) -> $T3
($T5 involves_type_vars bindings={(supertypes of) String})
($T11 involves_type_vars bindings={(subtypes of) ($T3) -> $T10})
(attempting type variable $T5 := String
($T6 involves_type_vars bindings={(supertypes of) String; (supertypes of) ()})
($T11 involves_type_vars bindings={(subtypes of) ($T3) -> $T10})
Initial bindings: $T6 := String, $T6 := ()
(attempting type variable $T6 := String
($T3 potentially_incomplete fully_bound involves_type_vars bindings={(supertypes of) String})
($T11 involves_type_vars bindings={(subtypes of) ($T3) -> $T10})
(attempting type variable $T11 := ($T3) -> $T10
(increasing score due to function conversion)
($T3 potentially_incomplete bindings={(supertypes of) String; (subtypes of) ()})
Initial bindings: $T3 := String, $T3 := ()
(attempting type variable $T3 := String
(failed constraint $T3 subtype ()
)
(attempting type variable $T3 := ()
(failed constraint $T6 subtype $T3
)
)
)
(attempting type variable $T6 := ()
(increasing score due to function conversion)
($T3 potentially_incomplete fully_bound involves_type_vars bindings={(supertypes of) ()})
($T11 involves_type_vars bindings={(subtypes of) ($T3) -> $T10})
Initial bindings: $T11 := ($T3) -> $T10
(attempting type variable $T11 := ($T3) -> $T10
(increasing score due to function conversion)
($T3 potentially_incomplete bindings={(supertypes of) ()})
($T17 literal=3 involves_type_vars bindings={(subtypes of) (default from ExpressibleByBooleanLiteral) Bool})
Initial bindings: $T3 := ()
(attempting type variable $T3 := ()
($T17 literal=3 involves_type_vars bindings={(subtypes of) (default from ExpressibleByBooleanLiteral) Bool})
Initial bindings: $T17 := Bool
(attempting type variable $T17 := Bool
($T12 involves_type_vars bindings={(supertypes of) Bool; (supertypes of) ()})
Initial bindings: $T12 := Bool, $T12 := ()
(attempting type variable $T12 := Bool
($T10 potentially_incomplete bindings={(supertypes of) Bool})
Initial bindings: $T10 := Bool
(attempting type variable $T10 := Bool
(found solution 0 0 0 0 0 0 0 2 0 0 0 0 0 0)
)
)
(attempting type variable $T12 := ()
(increasing score due to function conversion)
(solution is worse than the best solution)
)
)
)
)
)
)
)
复制代码
Log的表达信息有限,但可以看到求解过程就是类似Solving过程的工作机制。 在这段Log中我们可得到如下的关键信息:
整体求解的过程是有一次失败的,具体是决议$T3的类型,也就是transform函数的T类型。从而回溯导致$T6为String时失败。
这一点也是直接导致整个问题的关键,第一个闭包结果,以及transform函数的出参T都被决议成了()
类型。那么,就让我们先来看看为何导致T3类型决议的失败。
(attempting type variable $T3 := String
(failed constraint $T3 subtype ()
)
(attempting type variable $T3 := ()
(failed constraint $T6 subtype $T3
)
复制代码
首先,尝试T3作为String类型,发现不满足 T3 subtype (),这里A subtype B的意思是 A等于B或者A是B的子类型。 那么()
类型,也就是Void类型不能作为String的父类型。那么到底在哪里要求” T3 subtype ()
“呢? 这里确实也迷惑了很久,后来经过猜测,发现这条约束已经在Log中有体现。但是不是在求解过程中,是在生成约束的阶段:
Active Constraints:
$T4 arg conv (String) -> $T3
$T11 arg conv ($T3) -> $T10
Inactive Constraints:
$T4 closure can default to ($T5) -> $T6
$T11 closure can default to () -> $T12
复制代码
- $T11 arg conv ($T3) -> $T10
- $T11 closure can default to () -> $T12
在这里我们发现 $T11 can default to () -> $T12, 并且$T11 等价于($T3) -> $T10,综合这两个约束,可以得出 T3 subtype () 的要求。
第一条约束失败后,继续尝试T3为Void类型,发现不满足”T6 subtype T3″,这里很好理解,T6的类型是闭包的返回结果,T3代表参数签名中的E->T中的T类型,所以T6需要满足是T3或者T3的子类。此时T6在前面已经尝试作为String类型,而String不是Void的子类型,此次求解失败。
回溯到T6,在T6尝试决议String失败后,T6尝试决议成 ()
类型,我们发现之后的过程没有遇到失败的情况,最后决议完成。最后得出Log中体现的结果。
可能有人在Log中发现,Solution: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 是什么?
这个Solution的分数可以简单理解成在求解过程中的“妥协”值,这14个0分别代表14种情况,每当对应的情况发生时,该标志位就+1分。最后Type-Checker会进行对比,最好的求解分数就是14个0。可以看到这个case中最终分数其实是 「 0 0 0 0 0 0 0 2 0 0 0 0 0 0」,是因为进行了两次函数类型的转换。
到这里,从Log中来看,我们可以大致猜测出几个结论:
- 问题的重点就是在T3和T6的判断上,和我们在表象上看到的现象一致,也就是问题的关键在
{$0}
返回值的类型。我们可以看出求解过程已经将T3和T6尝试过判断为String类型,可是由于()
类型的约束干扰,最后没有成功。 - 最终的决议结果并不是最完美的,期间经历了两次函数类型转换,而且闭包的实际结果是可以被隐式转换成
()
类型的。
种种线索都指向了一个问题,那就是:
()
类型是产生的根源在哪里?
源码寻找答案
既然在Log中的信息已经挖掘完毕,那么我们就去源代码中寻找答案。我们发现,求解过程中的很多约束限制导致了类型被判断为Void,那么我们去生成约束的地方寻找线索。Swift类型推断的源码在语义分析阶段,文件夹位置在lib/Sema中。(经过了头秃式的挖掘)果然,在生成closure闭包约束的地方找到了关键信息。
在遍历AST结构时,我们看到visitClosureExpr
的实现中,调用了一个叫inferClosureType
的函数。这个函数的作用就是生成这个闭包的类型。函数的简化版源码如下:
FunctionType *inferClosureType(ClosureExpr *closure) {
SmallVector<AnyFunctionType::Param, 4> closureParams;
// 添加参数列表
if (auto *paramList = closure->getParameters()) {
for (unsigned i = 0, n = paramList->size(); i != n; ++i) {
// ..... 省略
closureParams.push_back(param->toFunctionParam(externalType));
}
}
auto extInfo = CS.closureEffects(closure);
// Closure expressions always have function type. In cases where a
// parameter or return type is omitted, a fresh type variable is used to
// stand in for that parameter or return type, allowing it to be inferred
// from context.
// 闭包表达式总是有一个对应的函数类型,在参数或者返回类型被省略的时候,会创建一个新的类型变量
Type resultTy = [&] {
// 如果有显式的return类型
if (closure->hasExplicitResultType()) {
const auto resolvedTy = ......
if (resolvedTy)
return resolvedTy;
}
// If no return type was specified, create a fresh type
// variable for it and mark it as possible hole.
// 如果没有明确指定return类型,创建新的typeVariable
return Type(CS.createTypeVariable(
CS.getConstraintLocator(closure, ConstraintLocator::ClosureResult),
shouldTypeCheckInEnclosingExpression(closure) ? 0
: TVO_CanBindToHole));
}();
// 调用AST模块的函数类型判断
FunctionType* f = FunctionType::get(closureParams, resultTy, extInfo);
return f;
}
复制代码
从这段源码中我们了解,如果没有显式指定闭包的参数类型以及返回类型,约束系统会生成类型变量代替还未被确定的变量。而闭包的参数变量,是由AST模块的功能函数判断的。
换句话说,如果没有标明参数列表,则闭包等价于没有参数的函数,也就是说参数类型是()
! 而如果闭包中如果写了_ in
,即使忽略了参数的具体值,也是会被当做有参数的,会生成一个类型变量,同样地,如果使用了$0
,也会被当做有参数。
而闭包的结果参数则不同,可以看到结果参数是由resultTy代码块去决定的,其中注释也明确说明了会给省略return的闭包新建一个TypeVariable。
下面的图示表明了不同闭包的省略写法,对类型推断产生变量造成的影响:
回到case中上,这次我们知道第一个源码说明了为何第二个闭包会被推断成() -> T12类型,也就是生成了 $T11 closure can default to () -> $T12 这个约束,进而引入了()
类型。我们似乎发现了问题的直接原因所在。
这里其实还有一个问题,如果根据这个结论,{ $0 }
和 { return $0 }
生成的类型变量数是一样的,但是根据前文的尝试,我们发现,
instance.foo{return $0}.foo{ true }
这样的写法会编译不过,这是为什么呢?
原因在第二个关键代码:
ConstraintSystem::TypeMatchResult
ConstraintSystem::matchTypes(Type type1, Type type2, ConstraintKind kind,
TypeMatchOptions flags,
ConstraintLocatorBuilder locator) {
//.........
if (auto elt = locator.last()) {
if (kind >= ConstraintKind::Subtype &&
(type1->isUninhabited() || type2->isVoid())) {
// A conversion from closure body type to its signature result type.
if (auto resultElt = elt->getAs<LocatorPathElt::ClosureBody>()) {
// If a single statement closure has explicit `return` let's
// forbid conversion to `Void` and report an error instead to
// honor user's intent.
if (type1->isUninhabited() || !resultElt->hasExplicitReturn() ) {
increaseScore(SK_FunctionConversion);
return getTypeMatchSuccess();
}
}
}
}
// .......
}
复制代码
这段代码是在solving求解过程中进行类型匹配的地方,可以看到,如果闭包是单语句表达式,且省略了return
关键字,那么type2(目标类型) 就可以被转换为()
类型。并且increase了分数,原因是FunctionConversion函数类型转换。这个现象在Log中也有体现。
至此,整个问题大概有了结论。我们可以做一个总结,问题产生的原因如下:
对于 let result = instance.foo{$0}.foo{true}
- 因为第二个闭包既没有写出参数列表,又没有使用匿名变量,所以闭包被判断成了
() -> T0
类型。 - 在判断第一个闭包的返回值类型时,String类型不满足
()
类型的相关约束,所以类型决议失败。 - 因为第一个闭包省略了
return
关键字,所以闭包返回值类型可以被决议成()
类型,但此时编译器做了妥协,并不是最佳解法。
现在再来回顾之前几种闭包写法,似乎思路清晰了很多:
//编译通过, 但现象不符合预期
instance.foo{$0}.foo{ true }
instance.foo{e in e}.foo{return true }
instance.foo{$0}.foo{ return true }
//编译不过
instance.foo{e in return e}.foo{ true }
instance.foo{e in return e}.foo{ return true }
instance.foo{return $0}.foo{ true }
//编译通过,现象符合预期
instance.foo{$0}.foo{ _ in true }
instance.foo{return $0}.foo{ _ in true }
instance.foo{e in return e}.foo{ _ in true }
复制代码
可以对以上三种现象做原因归类,在两个闭包都是单个语句表达式的情况下:
-
如果第二个闭包标明参数,那么整个表达式逻辑符合预期
-
如果第二个闭包不写参数:
- 第一个闭包有return语句,那么编译不过
- 第一个闭包没有return语句,那么可以编译通过,但是实际运行逻辑会不符合预期。
让我们从Log中验证上述逻辑:
首先是逻辑正确的 let result = instance.foo { $0 }.foo { _ in true }
可以看到,在生成类型变量阶段会多了一个T12变量,这个变量指的就是 _ in
中的 _
,也就是被省略的参数。从而没有产生()
类型。而后在解决约束的过程中,没有了()
类型的限制,自然而然就被决议成了String类型。
第二个是有return语句的编译失败的闭包:let result = instance.foo { return $0 }.foo { true }
可以看到生成的变量情况,和源表达式是一致的。但是在过程中稍有不同:
在尝试把T6当做()
的时候,因为第一个闭包显式的写了return关键字,所以根据第二段源码逻辑,不能把闭包结果转换为()
类型,T6决议失败,随之整个决议过程失败。
问题总结
到这里,我们可以总结下所遇到的所有问题和疑惑:
问:为什么第二个闭包的写法会对第一个闭包的逻辑有影响?
答:因为Swift类型推断是以整个表达式Scope为求解范围的,所以是整体决议,本质上没有前后的区别。
问: 为什么{$0}
闭包的出参会被推断成()
类型?
答:在Swift类型推断过程中,因为后一个闭包省略了参数,入参被直接判断为()
类型,从而在求解过程中对前一个闭包的出参类型产生了影响。
问:为什么后一个闭包加了_ in
结果就符合预期了?
答:_ in
的作用代表了后一个闭包是有参数的,在类型判断的过程中会结合此参数进行整体判断,从而求解出符合预期的结果。
问:为什么把两个调用语句拆开后,第二个语句会编不过?
答:Swift类型推断是以单个表达式语句作为求解范围,和第一个问题不同,第一个foo调用已经得到了TransformB的类型,但因为第二个闭包省略了参数,所以第二个闭包的入参()
和已经决议完成的第一个闭包的出参String有冲突,所以编译器报错 “ambiguous”
总结及后续
总结
Swift的类型推断是个非常复杂的过程,相信各位在编码过程中也遇到过各种各样的编译报错,而在编码时,尤其是像RxSwift这样连续闭包调用的情况,一般我们都是在编译不过时,直接通过编译器的诊断去自动修正,就比如提示插入_ in
这样的情况,而没有探究背后的过程。如果遇到像本文的场景,可能会遇到意想不到的逻辑错误。可能有人说本文的Rxswift的case不常见,其实有个更容易理解的case,小伙伴们可以动手试试:
struct Data {}
let arr = [1, 2, nil, 3]
let mappedArr = arr.compactMap { $0 }.map { Data() } // result: { Data, Data, Data, Data }
复制代码
虽然编译器会报警告,但是在实际大型项目中,这类警告很容易被忽略掉,而且产生的现象也十分让人疑惑。
那可能有的小伙伴们会问,怎样避免这类隐藏的坑呢?对此,貌似没有什么好的方法,总不能把所有的闭包参数及结果类型全部写出来,只能说注意编译器的所有可疑警告。而且,我始终认为这似乎是编译器为了满足闭包的各类语法糖,在类型求解策略过程中的一个边缘的bad case,所以我给Swift提了个bug,想看看编译器开发者的回答。目前还未得到很多合理的解释。
探究心得
此次对部分源码的研究只是针对遇到的问题找到一些源码关键点,对自己的疑惑得到求证。其相对于整个Swift Type Inference可以说冰山一角,比如类型约束具体都有什么类型?Constraint Graph是什么?具体的求解Step逻辑是什么?泛型类型是怎么推断的?它们同样有很大的价值去深究。
此篇文章很长,感谢阅读。整篇文章的顺序是本人真实的探究过程,从业务场景编码中发现问题,直到探究从未了解过的Swift类型推断的实现,不断调试运行,找到关键线索,到后来给Swift项目提问题,寻求官方帮助,整个过程还是有些收获。在日常开发遇到问题时,如果有精力的话,建议保持刨根问底的态度,不仅问题本身,其中寻找问题、思考的过程也会带来很大收获,尤其是针对开源项目,比如Swift标准库、编译器以及相关工程,非常值得研究。
加入我们
飞书-字节跳动旗下企业协作平台,集视频会议、在线文档、移动办公、协同软件的一站式企业沟通协作平台。目前飞书业务正在飞速发展中,在北京、深圳等城市都有研发中心,前端、移动端、Rust、服务端、测试、产品等职位都有足够的 HC,期待你的加入,和我们一起做有挑战的事情(请戳链接:future.feishu.cn/recruit)。
我们也欢迎和飞书的同学一起进行技术问题的交流,有兴趣的同学请点击飞书技术交流群入群交流。