Swift 5.2版本的诊断架构概述(附代码)

266 阅读8分钟

诊断在编程语言的体验中起着非常重要的作用。编译器在任何情况下都能产生正确的指导,尤其是不完整或无效的代码,这对开发人员的工作效率至关重要。

在这篇博文中,我们想分享几个重要的更新,即为即将发布的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

虽然这个诊断指出了一个真正的错误,但它没有帮助,因为它不具体,也没有可操作性。这是因为旧的类型检查器用来猜测错误的确切位置。这在许多情况下是有效的,但仍有许多用户写的编程错误,它无法准确识别。为了解决这个问题,一个新的诊断基础设施正在建设中。与其猜测错误发生的位置,类型检查器试图在遇到问题的地方 "修复 "问题,同时记住它已经应用的修复方法。这不仅使类型检查器能够在更多种类的程序中找出错误,而且还使它能够浮现出更多的故障,而以前它在报告第一个错误之后就会停止。

类型推理概述

由于新的诊断基础设施与类型检查器紧密相连,我们必须简短地绕道谈谈类型推理。请注意,这只是一个简单的介绍;更多细节请参考编译器关于类型检查器的文档

Swift使用一个基于约束的类型检查器实现了双向类型推理,这让人想起经典的Hindley-Milner类型推理算法

  • 类型检查器将源代码转换为一个约束系统,该系统表示代码中各类型之间的关系。
  • 类型关系通过类型约束来表达,它或者对单一类型提出要求(例如,它是一个整数字面类型),或者将两个类型联系起来(例如,一个是可以转换为另一个)。
  • 约束中描述的类型可以是 Swift 类型系统中的任何类型,包括元组类型、函数类型、枚举/结构/类类型、协议类型和通用类型。此外,一个类型可以是一个类型变量,表示为$<name>
  • 类型变量可以用来代替任何其他类型,例如,一个元组类型($Foo, Int) ,涉及类型变量$Foo

对于诊断来说,唯一有趣的阶段是约束生成和解算。

给定一个输入表达式(有时还有额外的上下文信息),约束解算器会生成:

  1. 一组类型变量,代表每个子表达式的抽象类型
  2. 一组类型约束,描述这些类型变量之间的关系

最常见的约束类型是二元约束,它关系到两种类型,被表示为:

type1 <constraint kind> type2

常用的二元约束有:

  1. $X <bind to> Y - 将类型变量 ,与一个固定的类型相联系$X Y
  2. X <convertible to> Y - 转换约束要求第一种类型 ,可转换为第二种 ,这包括子类型和平等性X Y
  3. X <conforms to> Y - 规定第一种类型 ,必须符合协议。X Y
  4. (Arg1, Arg2, ...) → Result <applicable to> $Function - 一个 "适用的函数 "约束要求两个类型都是具有相同输入和输出类型的函数类型。

一旦约束生成完成,求解器就会尝试为约束系统中的每个类型变量分配具体类型,并形成一个满足所有约束的解决方案。

让我们考虑下面这个函数的例子:

func foo(_ str: String) {
  str + 1
}

对于人来说,很快就会发现表达式有问题str + 1 ,以及这个问题的位置,但是推理引擎只能依靠约束简化算法来确定问题所在。

正如我们之前所确定的,约束解算器首先为str1+ 生成约束(见约束生成阶段)。输入表达式的每个不同的子元素,如str ,都由以下两种方式表示:

  1. 一个具体的类型(提前知道)。
  2. 一个用$<name> 表示的类型变量,它可以承担任何满足与之相关的约束的类型。

约束生成阶段完成后,表达式str + 1 的约束系统将有一个类型变量和约束的组合。 现在让我们来看看这些。

类型变量

  • $Str 代表变量 的类型,它是调用 的第一个参数。str +

  • $One 代表字面意义的类型 ,它是调用的第二个参数。1 +

  • $Result 代表调用运算符的结果类型+

  • $Plus 代表运算符 本身的类型,这是一组可能的重载选择,可以尝试。+

限制条件

  • $Str <bind to> String
    • 参数str 有一个固定的String类型。
  • $One <conforms to> ExpressibleByIntegerLiteral
    • 由于像Swift中的1 这样的整数字元可以承担任何符合ExpressibleByIntegerLiteral协议的类型(例如IntDouble ),所以求解器只能在开始时依赖这些信息。
  • $Plus <bind to> disjunction((String, String) -> String, (Int, Int) -> Int, ...)
    • 操作员+ ,形成了一个不相交的选择,其中每个元素都代表了单个重载的类型。
  • ($Str, $One) -> $Result <applicable to> $Plus
    • $Result 的类型还不知道;它将通过用参数元组($Str, $One) 测试$Plus 的每个重载来确定。

请注意,所有的约束和类型变量都与输入表达式中的特定位置相联系:

推理算法试图为约束系统中的所有类型变量找到合适的类型,并针对相关的约束测试它们。在我们的例子中,$One 可以得到IntDouble 的类型,因为这两种类型都满足ExpressibleByIntegerLiteral 的协议一致性要求。然而,简单地枚举约束系统中每个 "空 "类型变量的所有可能的类型是非常低效的,因为当一个特定的类型变量受到约束时,可能有很多类型需要尝试。例如,$Result ,没有任何限制,所以它有可能承担任何类型。为了解决这个问题,约束解算器首先尝试二选一,这使得解算器可以缩小每个类型变量的可能类型集合。在$Result 的例子中,这使得可能的类型数量减少到只有与$Plus 的重载选择相关的结果类型,而不是所有可能的类型。

现在,是时候运行推理算法来确定$One$Result 的类型了。

一轮推理算法的执行。

  1. 让我们先把$Plus 绑定到它的第一个二乘选择中去。(String, String) -> String

  2. 现在可以测试applicable to 的约束,因为$Plus 已经被绑定到一个具体的类型上。简化($Str, $One) -> $Result <applicable to> $Plus 约束,最终匹配两个函数类型($Str, $One) -> $Result(String, String) -> String ,其过程如下。

    • 添加一个新的转换约束,将参数0与参数0相匹配 -$Str <convertible to> String
    • 增加一个新的转换约束,将参数1与参数1相匹配。$One <convertible to> String
    • $Result 等同于String ,因为结果类型必须是相等的。
  3. 一些新生成的约束可以立即测试/简化,例如

    • $Str <convertible to> String 是 ,因为 已经有一个固定的类型 ,而 是可以转换为自己的。true $Str String String
    • $Result 可以根据平等约束分配一个类型为String
  4. 在这一点上,唯一剩下的约束是:

    • $One <convertible to> String
    • $One <conforms to> ExpressibleByIntegerLiteral
  5. $One 的可能类型是Int,Double, 和String 。这很有趣,因为这些可能的类型没有一个满足所有剩下的约束;IntDouble 都不能转换为StringString 不符合ExpressibleByIntegerLiteral 协议。

  6. 在尝试了$One 的所有可能类型后,求解器停止了,并认为当前的类型集和重载选择是失败的。然后求解器回溯并尝试为$Plus 的下一个disjunction选择。

我们可以看到,错误的位置将由求解器在执行推理算法时决定。由于没有一个可能的类型与$One 匹配,它应该被认为是一个错误位置(因为它不能被绑定到任何类型)。复杂的表达式可能有不止一个这样的位置,因为现有的错误会随着推理算法的进行而产生新的错误。在这样的情况下,为了缩小错误位置的范围,解算器将只挑选其中数量尽可能少的解决方案。

在这一点上,我们或多或少清楚了错误位置是如何被识别的,但是如何帮助求解器在这种情况下取得进展,从而得出一个完整的解决方案,还不是很明显。

该方法

新的诊断基础设施采用了我们所说的约束修复,以尝试解决不一致的情况,即求解器被卡住而没有其他类型可供尝试。我们的例子的修复方法是忽略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 有关,如果没有明确的& ,就不能作为参数传给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缺失的&,并忽略了 的约束。<convertible to>

忽略了这个约束,约束系统的其余部分就可以被解决了。然后,类型检查器查看记录的修正,并发出一个描述问题的错误(缺失的& )以及一个插入& 的修正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 的第一个参数上缺少& 。此外,它还将记录一个失败,因为缺少参数标签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 convert value of type 'UInt' to expected argument type 'Int'
_ = x.filter { ($0 + y)  > 42 }
                     ^
                     Int( )

无效的可选解包

考虑下面的无效代码:

struct S<T> {
  init(_: [T]) {}
}

var i = 42
_ = S<Int>([i!])

以前,这导致了以下诊断:

error: type of expression is ambiguous without more context

这现在被诊断为:

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(), .foo(), 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)
                     ^

结论

新的诊断基础设施旨在克服旧方法的所有缺点。它的结构方式旨在使其易于改进/移植现有的诊断方法,并被新的功能实现者用来直接提供伟大的诊断方法。到目前为止,我们所移植的所有诊断方法都显示出非常有希望的结果,而且我们每天都在努力移植更多的诊断方法。