Swift 5.2 新診斷框架
本文由知乎網友“漫慢忙”翻譯自官方部落格 《New Diagnostic Architecture Overview》
診斷程式(Diagnostics)在程式語言體驗中扮演著非常重要的角色。開發人員在編寫程式碼時非常關心的一點是:編譯器可以在任何情況下(尤其是程式碼不完整或無效時)提供適當的指導並指出問題。
在此部落格文章中,我們想分享一些即將推出的 Swift 5.2 的重要更新,以改進新版本的的診斷功能。這包括編譯器診斷故障的新策略,該策略最初是 Swift 5.1 發行版的一部分,其引入了一些令人興奮的新結果並改進了錯誤訊息。
挑戰
Swift 是一種具有豐富表現力的語言,它有豐富的型別系統,這個系統有許多特性,例如類繼承,協議一致性,泛型和過載。儘管作為程式設計師,我們會竭盡全力編寫格式良好的程式碼,但有時我們需要一點幫助。幸運的是,編譯器知道什麼樣的 Swift 程式碼是有效的或者無效的。問題是如何更好地告訴您出了什麼問題,問題在哪以及如何解決。
編譯器做了許多事情來確保程式的正確性,但是這項工作的重點一直是改進型別檢查器。Swift 型別檢查器強制執行有關如何在原始碼中使用型別的規則,並在你違反了這些規則時告訴你。
例如以下程式碼:
struct S<T> {
init(_: [T]) {}
}
var i = 42
_ = S<Int>([i!])
複製程式碼
會產生以下診斷結果:
error: type of expression is ambiguous without more context
複製程式碼
儘管這個診斷結果指出了真正的錯誤,但由於它不明確,因此並沒有太大的幫助。這是因為舊的型別檢查器主要用來猜測錯誤的確切位置。這在許多情況下都有效,但是使用者仍然會出現很多無法準確識別的程式設計錯誤。為了解決這個問題,我們正在開發一種新的診斷架構。型別檢查器不再是在猜測錯誤發生的位置,而是嘗試在遇到問題時“修復”問題,並記住所應用的修復措施。這不僅使型別檢查器可以查明更多種類的程式中的錯誤,也使它能夠提前暴露更多的故障。
型別推斷概述
由於新的診斷框架與型別檢查器緊密結合,因此我們需要先討論一下型別推斷。請注意,這裡只是簡單地介紹一下。有關型別檢查更多詳細資訊,請參閱 compiler’s documentation on the type checker[1]。
Swift 使用基於約束的型別檢查器實現雙向型別推斷,這使人聯想到經典的 Hindley-Milner
[2] 型別推斷演算法
[3]:
• 型別檢查器將原始碼轉換為約束系統,該約束系統表示程式碼中型別之間的關係。
• 型別關係是通過型別約束表達的,型別約束要麼對單個型別提出要求(例如,它是整數字面量型別),要麼將兩種型別相關聯(例如,一種型別可以轉換為另一種型別)。
• 約束中描述的型別可以是 Swift 型別系統中的任何型別,包括元組型別、函式型別、列舉/結構/類型別、協議型別和泛型型別。此外,型別可以是表示為 $<name>
的型別變數。
• 型別變數可以在任何其他型別中使用,例如,型別變數 $Foo
在元組型別 ($Foo,Int)
中使用。
約束系統執行三步操作:
• 產生約束
• 求解約束
• 應用解決方案
診斷過程關注的階段是約束生成和求解。
給定輸入表示式(有時還包括其他上下文資訊),約束求解器將生成以下資訊:
• 一組型別變數,代表每個子表示式的抽象型別
• 一組描述這些型別變數之間關係的型別約束
最常見的約束型別是二進位制約束(binary constraint),它涉及兩種型別,可以表示為:
type1 <constraint kind> type2
複製程式碼
常用的二進位制約束有:
• $X <bind to> Y
- 將型別變數 $X
繫結到固定型別 Y
• X <convertible to> Y
- 轉換約束要求第一個型別 X
可轉換為第二個 Y
,其中包括子型別和等價形式
• X <conforms to> Y
- 指定第一種型別 X
必須符合協議 Y
• (Arg1,Arg2,...) → Result <applicable to> $Function
- “適用函式(applicable function)”約束要求兩種型別都是具有相同輸入和輸出型別的函式型別
約束生成完成後,求解程式將嘗試為約束系統中的每個型別變數分配具體型別,並生成滿足所有約束的解決方案。
讓我們來看看下面的例子:
func foo(_ str: String) {
str + 1
}
複製程式碼
對於我們來說,很快就能發現表示式 str + 1
存在問題以及該問題所在的位置,但是型別推斷引擎只能依靠約束簡化演算法來確定問題所在。
正如我們之前討論的,約束求解器首先為 str
,1
和 +
生成約束。輸入表示式的每個不同子元素(如str)均由以下方式表示:
• 具體型別(提前知道)
• 用 $<name>
表示的型別變數,可以假定滿足與之關聯的約束的任何型別。
約束生成階段完成後,表示式 str + 1
的約束系統將具有型別變數和約束的組合。接下來讓我們來看一下。
型別變數
• $Str
表示變數 str 的型別,它是 +
呼叫中的第一個引數
• $One
代表文字 1
的型別,它是 +
呼叫中的第二個引數
• $Result
表示對運算子 +
呼叫的結果型別
• $Plus
代表運算子 +
本身的型別,它是一組過載方法的集合。
約束
• $Str <bind to> String
引數 str 具有固定的 String 型別。
複製程式碼
• $One <conforms to> ExpressibleByIntegerLiteral
由於 Swift 中的整數字面量(例如1)可以採用任何符合 ExpressibleByIntegerLiteral 協議的型別(例如 Int 或 Double),因此求解器只能在開始時依賴該資訊。
複製程式碼
• $Plus <bind to> disjunction((String,String) -> String,(Int,Int) -> Int,...)
運算子 `+` 形成一組不相交的選擇,其中每個元素代表獨立的過載型別。
複製程式碼
• ($Str,$One) -> $Result <applicable to> $Plus
`$Result` 的型別尚不清楚;它可以通過使用引數元組 ($Str,$One) 測試 `$Plus` 的每個過載來確定。
複製程式碼
請注意,所有約束和型別變數都與輸入表示式中的特定位置關聯:
推斷演算法嘗試為約束系統中的所有型別變數找到合適的型別,並針對關聯的約束對其進行測試。在我們的示例中,$One
可以是 Int 或 Double 型別,因為這兩種型別都滿足 ExpressibleByIntegerLiteral 協議一致性要求。但是,簡單地列舉約束系統中每個“空”型別變數的所有可能型別是非常低效,因為當特定型別變數約束不足時可以嘗試許多型別。例如,$Result
沒有任何限制,因此它可以採用任何型別。要變通地解決此問題,約束求解器首先嚐試分離選項,這使求解器可以縮小涉及的每個型別變數的可能型別的範圍。對於 $Result
,這會將可能型別的數量減少到僅與 $Plus
的過載選項相關聯的結果型別,而不是所有可能的型別。
現在,該執行推斷演算法來確定 $One
和 $Result
的型別了。
單輪推斷演算法執行步驟
• 首先將 $Plus
繫結到它的第一個析取選項 (String,String) -> String
• 現在可以測試 applicable to
約束,因為 $Plus
已繫結到具體型別。($Str,$One) -> $Result <applicable to> $Plus
約束最終簡化為兩個匹配的函式型別 ($Str,$One) -> $Result
和(String,String) -> String
, 處理流程如下:
新增新的轉換約束以將 argument 0 與 parameter 0 匹配 - `$Str <convertible to> String`
新增新的轉換約束以將 argument 1 與 parameter 1 匹配 - $One <convertible to> String
將 $Result 等同於 String,因為結果型別必須相等
複製程式碼
• 一些新產生的約束可以立即進行測試/簡化,例如:
$Str <convertible to> String 為 true,因為$Str 已經具有固定型別 String 並且 String可轉換為自身
可以根據相等約束為 $Result 分配某種 String 型別
複製程式碼
• 此時,剩下的唯一約束是:
$One <convertible to> String
$One <conforms to> ExpressibleByIntegerLiteral
複製程式碼
• $One
的可能型別是 Int,Double 和 String。這很有趣,因為這些可能的型別都不滿足所有剩餘的約束:Int 和 Double 都不能轉換為 String,而 String 不符合 ExpressibleByIntegerLiteral 協議
• 在嘗試了 $One
的所有可能型別之後,求解器將停止並認為當前型別集和過載選擇均失敗。然後,求解器回溯並嘗試 $Plus
的下一個析取選擇。
我們可以看到,錯誤位置將由求解程式執行推斷演算法時確定。由於沒有任何可能的型別與 $One
匹配,因此應將其視為錯誤位置(因為它不能繫結到任何型別)。複雜表示式可能具有多個這樣的位置,因為隨著推斷演算法的執行,現有的錯誤會導致新的錯誤。為了縮小這種情況下的錯誤位置範圍,求解器只會選擇數量儘可能少的解決方案。
至此,我們或多或少地清楚瞭如何識別錯誤位置,但是如何幫助求解器在這種情況下取得進展尚不清楚,因此無法得出一個完整的解決方案。
解決方案
新的診斷架構採用了 “約束脩復(constraint fix)” 技術,來嘗試解決不一致的情況(在這些情況下,求解器會陷入無法嘗試其他型別的情況)。我們示例的解決方法是忽略 String 不符合 ExpressibleByIntegerLiteral 協議的情況。修復程式的目的是能夠從求解器捕獲有關錯誤位置的所有有用資訊,並用於後續的診斷。這是當前方法與新方法之間的主要區別。前者是嘗試猜測錯誤的位置,而新方法與求解器是共生關係,求解器為其提供所有錯誤位置。
如前所述,所有型別變數和約束都包含有關它們與它們所源自的子表示式的關係的資訊。這樣的關係與型別資訊相結合,可以很容易地為所有通過新診斷框架診斷出的問題提供量身定製的診斷和修復程式。
在我們的示例中,已經確定型別變數 $One
是錯誤位置,因此診斷程式可以檢查輸入表示式是如何使用 $One
:$One
表示對運算子 +
的呼叫中位置 #2
的引數,並且已知的問題是與 String 不符合 ExpressibleByIntegerLiteral 協議這一事實有關。根據所有這些資訊,可以形成以下兩種診斷之一:
error: binary operator '+' cannot be applied to arguments 'String' and 'Int'
複製程式碼
關於第二個引數不符合 ExpressibleByIntegerLiteral 協議,簡化後是:
error: argument type 'String' does not conform to 'ExpressibleByIntegerLiteral'
複製程式碼
診斷涉及第二個引數。
我們選擇了第一個方案,併為每個部分匹配的過載選擇生成了關於這個操作符的診斷和註釋。讓我們仔細看一下所描述方法的內部運作方式。
診斷的剖析
當檢測到約束失敗時,將建立一個約束脩復程式
來捕獲失敗的一些資訊:
• 發生的失敗型別
• 原始碼中發生故障的位置
• 失敗涉及的型別和宣告
約束求解器會快取這些修正資訊。一旦找到解決方案,它就會檢視解決方案中的修補程式併產生可操作的錯誤或警告。 讓我們看一下這一切如何協同工作。考慮以下示例:
func foo(_: inout Int) {}
var x: Int = 0
foo(x)
複製程式碼
這裡的問題與引數 x 有關,如果沒有顯式使用 &
,則引數 x 不能作為引數傳遞給 inout 引數。
現在,讓我們看一下該示例的型別變數和約束。
型別變數
有三個型別變數:
$X := Int
$Foo := (inout Int) -> Void
$Result
複製程式碼
約束
這三個型別有以下約束
($X) -> $Result <applicable to> $Foo
複製程式碼
推斷演算法將嘗試匹配 ($X) -> $Result
與 (inout Int) -> Void
,這將產生以下新約束:
Int <convertible to> inout Int
$Result <equal to> Void
複製程式碼
Int 無法轉換為 inout Int,因此約束求解器將失敗記錄為 missing &
[4]並忽略 <convertible to>
約束。
通過忽略該約束,可以求解約束系統的其餘部分。然後,型別檢查器檢視記錄的修復程式,並丟擲描述該問題的錯誤(缺少的&)以及用於插入 &
的Fix-It:
error: passing value of type 'Int' to an inout parameter requires explicit '&'
foo(x)
^
&
複製程式碼
此示例中只有一個型別錯誤,但是此診斷架構也可以解決程式碼中多個不同的型別錯誤。考慮一個稍微複雜的示例:
func foo(_: inout Int,bar: String) {}
var x: Int = 0
foo(x,"bar")
複製程式碼
在求解此示例的約束系統時,型別檢查器將再次為 foo 的第一個引數記錄 missing &
的失敗。此外,它將為缺少的引數 bar 記錄失敗。一旦記錄了兩個失敗,也就求解了約束系統的其餘部分。然後,型別檢查器針對需要修復此程式碼的兩個問題產生錯誤(使用Fix-Its):
error: passing value of type 'Int' to an inout parameter requires explicit '&'
foo(x)
^
&
error: missing argument label 'bar:' in call
foo(x,"bar")
^
bar:
複製程式碼
記錄每個特定的失敗,然後繼續解決剩餘的約束系統,意味著解決這些故障將產生一個型別明確的解決方案。這使型別檢查器可以生成可行的診斷程式(通常帶有修復程式),從而引導開發人員使用正確的程式碼。
改進的診斷的示例
缺少標籤
考慮以下無效程式碼:
func foo(answer: Int) -> String { return "a" }
func foo(answer: String) -> String { return "b" }
let _: [String] = [42].map { foo($0) }
複製程式碼
以前,這會產生以下診斷資訊:
error: argument labels '(_:)' do not match any available overloads`
複製程式碼
新的診斷資訊是:
error: missing argument label 'answer:' in call
let _: [String] = [42].map { foo($0) }
^
answer:
複製程式碼
引數到引數轉換不匹配
考慮以下無效程式碼:
let x: [Int] = [1,2,3,4]
let y: UInt = 4
_ = x.filter { ($0 + y) > 42 }
複製程式碼
以前,這會產生以下診斷資訊:
error: binary operator '+' cannot be applied to operands of type 'Int' and 'UInt'`
複製程式碼
新的診斷資訊是:
error: cannot force unwrap value of non-optional type 'Int'
_ = S<Int>([i!])
~^
複製程式碼
丟失成員
考慮以下無效程式碼:
class A {}
class B : A {
override init() {}
func foo() -> A {
return A()
}
}
struct S<T> {
init(_ a: T...) {}
}
func bar<T>(_ t: T) {
_ = S(B(),.foo(),A())
}
複製程式碼
以前,這會產生以下診斷資訊:
error: generic parameter ’T’ could not be inferred
複製程式碼
新的診斷資訊是:
error: type 'A' has no member 'foo'
_ = S(B(),A())
~^~~~~
複製程式碼
缺少協議一致性
考慮以下無效程式碼:
protocol P {}
func foo<T: P>(_ x: T) -> T {
return x
}
func bar<T>(x: T) -> T {
return foo(x)
}
複製程式碼
以前,這會產生以下診斷資訊:
error: generic parameter 'T' could not be inferred
複製程式碼
新的診斷資訊是:
error: argument type 'T' does not conform to expected type 'P'
return foo(x)
^
複製程式碼
條件符合
考慮以下無效程式碼:
extension BinaryInteger {
var foo: Self {
return self <= 1
? 1
: (2...self).reduce(1,*)
}
}
複製程式碼
以前,這會產生以下診斷資訊:
error: ambiguous reference to member '...'
複製程式碼
新的診斷資訊是:
error: referencing instance method 'reduce' on 'ClosedRange' requires that 'Self.Stride' conform to 'SignedInteger'
: (2...self).reduce(1,*)
^
Swift.ClosedRange:1:11: note: requirement from conditional conformance of 'ClosedRange<Self>' to 'Sequence'
extension ClosedRange : Sequence where Bound : Strideable,Bound.Stride : SignedInteger {
^
複製程式碼
SwiftUI 示例
引數到引數轉換不匹配
考慮以下無效的 SwiftUI 程式碼:
import SwiftUI
struct Foo: View {
var body: some View {
ForEach(1...5) {
Circle().rotation(.degrees($0))
}
}
}
複製程式碼
以前,這會產生以下診斷資訊:
error: Cannot convert value of type '(Double) -> RotatedShape<Circle>' to expected argument type '() -> _'
複製程式碼
新的診斷資訊是:
error: cannot convert value of type 'Int' to expected argument type 'Double'
Circle().rotation(.degrees($0))
^
Double( )
複製程式碼
丟失成員
考慮以下無效的 SwiftUI 程式碼:
import SwiftUI
struct S: View {
var body: some View {
ZStack {
Rectangle().frame(width: 220.0,height: 32.0)
.foregroundColor(.systemRed)
HStack {
Text("A")
Spacer()
Text("B")
}.padding()
}.scaledToFit()
}
}
複製程式碼
以前,這被診斷為完全不相關的問題:
error: 'Double' is not convertible to 'CGFloat?'
Rectangle().frame(width: 220.0,height: 32.0)
^~~~~
複製程式碼
現在,新的診斷程式正確地指出不存在諸如systemRed的顏色:
error: type 'Color?' has no member 'systemRed'
.foregroundColor(.systemRed)
~^~~~~~~~~
複製程式碼
丟失引數
考慮以下無效的 SwiftUI 程式碼:
import SwiftUI
struct S: View {
@State private var showDetail = false
var body: some View {
Button(action: {
self.showDetail.toggle()
}) {
Image(systemName: "chevron.right.circle")
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
.animation(.spring)
}
}
}
複製程式碼
以前,這會產生以下診斷資訊:
error: type of expression is ambiguous without more context
複製程式碼
新的診斷資訊是:
error: member 'spring' expects argument of type '(response: Double,dampingFraction: Double,blendDuration: Double)'
.animation(.spring)
^
複製程式碼
結論
新的診斷架構旨在克服舊方法的所有缺點。它的架構方式旨在簡化/改進現有的診斷程式,並讓新功能實現者用來提供出色的診斷程式。到目前為止,我們已移植的所有診斷程式都顯示出非常可喜的結果,並且我們每天都在努力地進行更多移植。
參考
[1]https://github.com/apple/swift/blob/master/docs/TypeChecker.rst
[2]https://en.wikipedia.org/wiki/Hindley%E2%80%93Milner_type_system
[3]https://en.wikipedia.org/wiki/Hindley%E2%80%93Milner_type_system#An_inference_algorithm
[4]https://github.com/apple/swift/blob/master/lib/Sema/CSFix.h#L542L554
[5]https://github.com/apple/swift/blob/master/lib/Sema/CSDiagnostics.cpp#L1030L1047