Diagnostics play a very important role in a programming language experience. It’s vital for developer productivity that the compiler can produce proper guidance in any situation, especially incomplete or invalid code.
In this blog post we would like to share a couple of important updates on improvements to diagnostics being worked on for the upcoming Swift 5.2 release. This includes a new strategy for diagnosing failures in the compiler, originally introduced as part of Swift 5.1 release, that yields some exciting new results and improved error messages.
The Challenge
Swift is a very expressive language with a rich type system that has many features like class inheritance, protocol conformances, generics, and overloading. Though we as programmers try our best to write well-formed programs, sometimes we need a little help. Luckily, the compiler knows exactly what Swift code is valid and invalid. The problem is how best to tell you what has gone wrong, where it happened, and how you can fix it.
Many parts of the compiler ensure the correctness of your program, but the focus of this work has been improving thetype checker. The Swift type checker enforces rules about how types are used in source code, and it is responsible for letting you know when those rules are violated.
For example, the following code:
structS(T)>{ init(_:[T]){}}vari=42_=S(Int)>([i!])
Produces the following diagnostic:
error: type of expression is ambiguous without more context
While this diagnostic points out a genuine error, it’s not helpful because it is not specific or actionable. This is because the old type checker used toguessthe exact location of an error. This worked in many cases, but there were still many kinds of programming mistakes that users would write which it could not accurately identify. In order to address this, a new diagnostic infrastructure is in the works. Rather than guessing where an error occurs, the type checker attempts to “fix” problems right at the point where they are encountered, while remembering the fixes it has applied. This not only allows the type checker to pinpoint errors in more kinds of programs, it also allows it to surface more failures where previously it would simply stop after reporting the first error.
Type Inference Overview
Since the new diagnostic infrastructure is tightly coupled with the type checker, we have to take a brief detour and talk about type inference. Note that this is a brief introduction; for more details please refer tocompiler’s documentation on the type checker.
Swift implements bi-directional type inference using a constraint-based type checker that is reminiscent of the classical (Hindley-Milner) type inference (algorithm) :
- The type checker converts the source code into aconstraint system, which represents relationships among the types in the code.
- A type relationship is expressed via atype constraint, which either places a requirement on a single type (eg, it is an integer literal type) or relates two types (eg, one is a convertible to the other).
- The types described in constraints can be any type in the Swift type system, including tuple types, function types, enum / struct / class types, protocol types, and generic types. Additionally, a type can be atype variabledenoted as
$
. - Type variables can be used in place of any other type, eg, a tuple type
($ Foo, Int) (involving the type variable) ************************ ($ Foo) .
The Constraint System performs three steps:
- (Constraint Generation)
- Constraint Solving
- Solution Application
For diagnostics, the only interesting stages are Constraint Generation and Solving.
Given an input expression (and sometimes additional contextual information), the constraint solver generates:
- A set of type variables that represent an abstract type of each sub-expression
- A set of type constraints that describe the relationships between those type Variables
The most common type of constraint is abinary constraint, which relates two types and is denoted as:
(type1)kind>type2
Commonly used binary constraints are:
- (X) Y- A conversion constraint requires that the first type
X
be convertible to the secondY
, which includes subtyping and equality - (X) Y- Specifies that the first type
X
must conform to the protocolY
(Arg1, Arg2, ...) → Result ($ Function) - An “applicable function” constraint requires that both types are function types with the same input and output types
$ X (Y
– Binds type variable$ X
to a fixed typeY
Once constraint generation is complete, the solver attempts to assign concrete types to each of the type variables in the constraint system and form a solution that satisfies all of the constraints.
Let’s consider following example function:
funcfoo() _str:String){ str1}
For a human, it becomes apparent pretty quickly that there is a problem with the expressionstr 1
and where that problem is located, but the inference engine can only rely on a constraint simplification algorithm to determine what is wrong.
As we have established previously, the constraint solver starts by generating constraints (seeConstraint Generationstage (forstr
, (1) and. Each distinct sub-element of the input expression, like
str
, is represented either by:
- a concrete type (known ahead of time)
- a type variable denoted with
$
which can assume any type that satisfies constraints associated with it .
AfterConstraint Generationstage completes, the constraint system for expressionstr 1
will have a combination of type variables and constraints. Let’s look at those now. (Type Variables)
-
$ Str
represents the type of variablestr
, which is the first argument in the call to -
$ One
represents the type of literal1
, which is the second argument in the call to -
$ Result
represents the result type of the call to operator -
$ Plus
represents the type of operatoritself, which is a set of possible overload choices to attempt.
Constraints
- ($ Str
String - (Argument) strhas a fixedStringtype.
$ OneExpressibleByIntegerLiteral
- Since integer literals like
1
in Swift could assume any type conforming to theExpressibleByIntegerLiteralprotocol (egInt
orDouble
), the solver can only rely on that information at the beginning.
$ Plusdisjunction ((String, String) ->String, (Int, Int) ->Int, ...)
- (Operator) forms adisjoint setof choices, where each element represents the type of an individual overload.
($ Str, $ One) ->$ Result ($ Plus)
- The type of
$ Result
is not yet known; it will be determined by testing each overload of$ Plus
with argument tuple($ Str, $ One)
.
Note that all constraints and type variables are linked with particular locations in the input expression:
The inference algorithm attempts to find suitable types for all type variables in the constraint system and test them against associated constraints. In our example,$ One
could get a type ofInt
orDouble
because both of these types satisfy theExpressibleByIntegerLiteral
protocol conformance requirement. However, simply enumerating through all of the possible types for each of the “empty” type variables in the constraint system is very inefficient since there could bemanytypes to try when a particular type variable is under-constrained. For example,$ Result
has no restrictions, so it could possibly assume any type. To work around this problem, the constraint solver first tries disjunction choices, which allows the solver to narrow down the set of possible types for each type variable involved. In the case of$ Result
, this brings the number of possible types down to only the result types associated with overloads choices of$ Plus
instead of all possible types.
Now, it’s time to run the inference algorithm to determine types for$ One
and$ Result
.
A Single Round of Inference Algorithm Execution:
-
Now
applicable to
constraint could be tested because$ Plus
has been bound to a concrete type. Simplification of($ Str, $ One) ->$ Result ($ Plus) constraint ends up matching two function types
($ Str, $ One) ->$ Result
and(String, String) ->String
which proceeds as follows:- Add a new conversion constraint to match argument 0 to parameter 0 –
$ Str
String - Add a new conversion constraint to match argument 1 to parameter 1 –
$ One (String)
- Equate
$ Result
to (String) since result types have to be equal
- Add a new conversion constraint to match argument 0 to parameter 0 –
-
Some of the newly generated constraints could be immediately tested / simplified e.g.
- ($ Str) ********************************************** (String) *************************** (is) truebecause
$ Str
already has a fixed type ofString
andString
is convertible to itself $ Result
could be assigned a type ofString
based on equality constraint
- ($ Str) ********************************************** (String) *************************** (is) truebecause
-
At this point only remaining constraints are:
- ($ One) ********************************************** (String)
$ One
ExpressibleByIntegerLiteral
-
The possible types for
$ One
areInt
, (Double) , and (String) . This is interesting, because none of these possible types satisfyallof the remaining constraints;Int
andDouble
both are not convertible toString
, andString
does not conform toExpressibleByIntegerLiteral
protocol -
After attempting all possible types for
$ One
, the solver stops and considers the current set of types and overload choices a failure. The solver then backtracks and attempts the next disjunction choice for$ Plus
.
Let’s start by binding$ Plus
to its first disjunction choice of(String, String) ->String
We can see that error location would be determined by the solver as it executes inference algorithm. Since none of the possible types match for$ One
it should be considered an error location (because it cannot be bound to any type). Complex expressions could have many more than one such location because existing errors result in new ones as inference algorithm progresses. To narrow down error locations in situations like that, solver would only pick solutions with the smallest possible number thereof.
At this point it’s more or less clear how error locations are identified, but it’s not yet obvious how to help the solver to make forward progress in such scenarios so it can derive a complete solution.
The Approach
The new diagnostic infrastructure employs what we are going to call aconstraint fixto try and resolve inconsistent situations where the solver gets stuck with no other types to attempt. Fix for our example is to ignore thatString
doesn’t conform toExpressibleByIntegerLiteral
protocol. The purpose of a fix is to be able to capture all useful information about the error location from the solver and use that later for diagnostics. That is the main difference between current and new approaches. The former would try toguesswhere the error is located, where the new approach has a symbiotic relationship with the solver which provides all of the error locations to it.
As we have noted before, all of the type variables and constraints carry information about their relationship to the sub-expression they have originated from. Such relation combined with type information makes it straightforward to provide tailored diagnostics and fix-its to all of the problems diagnosed via new diagnostic framework.
In our example, it has been determined that type variable$ One
is an error location, so diagnostic can examine how ($ One) is used in the input expression:$ One
represents an argument at position # 2 in call to operator, and it’s known that the problem is related to the fact that (String) doesn’t conform to
ExpressibleByIntegerLiteral
protocol. Based on all this information it’s possible to form either of the two following diagnostics:
error: binary operator ' ' cannot be applied to arguments 'String' and 'Int'
with a note about second argument not conforming toExpressibleByIntegerLiteral
protocol, or the simpler:
error: argument type 'String' does not conform to 'ExpressibleByIntegerLiteral'
with the diagnostic referring to the second argument.
We picked the first alternative and produce a diagnostic about the operator and a note for each partially matching overload choice. Let’s take a closer look at the inner workings of the described approach.
Anatomy of a Diagnostic
When a constraint failure is detected, aconstraint fixis created that captures information about a failure:
- The kind of failure that occurred
- Location in the source code where the failure came from
- Types and declarations involved in the failure
The constraint solver accumulates these fixes. Once it arrives at a solution, it looks at the fixes that were part of a solution and produces actionable errors or warnings. Let’s take a look at how this all works together. Consider the following example:
funcfoo() _:inoutInt){}varx:Int=0foo(x)
The problem here is related to an argumentx
which cannot be passed as an argument toinout
parameter without an explicit&
.
Let’s now look at the type variables and constraints for this constraint system.
Type Variables
There are three type variables:
$ X:=Int $ Foo:=(inout Int) ->Void $ Result
(Constraints)
The three type variables have the following constraint:
($ X) ->$ Result$ Foo
The inference algorithm is going to try and match($ X) ->$ Result
to(inout Int) ->Void
, which results in following new constraints:
(Int) inout Int $ ResultVoid
Int
cannot be converted intoinout Int
, so the constraint solver records the failure as amissing&
and ignores the
Constraint.
With that constraint ignored, the remainder of the constraint system can be solved. Then the type checker looks at the recorded fixes andemits an errorthat describes the problem (a missing&
) along with a Fix-It to insert the&
:
error: passing value of type 'Int' to an inout parameter requires explicit '&' foo (x) ^ &
This example had a single type error in it, but this diagnostics architecture can also account for multiple distinct type errors in the code. Consider a slightly more complicated example:
funcfoo() _:inoutInt,bar:String){}varx:Int=0foo(x,("bar")
While solving this constraint system, the type checker will again record a failure for the missing&
on the first argument to (foo) . Additionally, it will record a failure for the missing argument labelbar
. Once both failures have been recorded, the remainder of the constraint system is solved. The type checker then produces errors (with Fix-Its) for the two problems that need to be addressed to fix this code:
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:
Recording every specific failure and then continuing on to solve the remaining constraint system implies that addressing those failures will produce a well-typed solution. That allows the type checker to produce actionable diagnostics, often with fixes, that lead the developer toward correct code.
Examples Of Improved Diagnostics
Missing label (s)
Consider the following invalid code:
funcfoo() answer:Int)->(String) **************************{return"A"}FUNCfoo(answer:String)->(String){return"b"}let_:[String]=[42].map{foo($ 0)}
Previously, this resulted in the following diagnostic:
error: argument labels '(_ :)' do not match any available overloads`
This is now diagnosed as:
error: missing argument label 'answer:' in call let _: [String]=[42]. map {foo ($ 0)} ^ answer:
Argument-to-Parameter Conversion Mismatch
Consider the following invalid code:
letx:[Int]=[1,2,3,4]letY:UInt=4_=x.(filter){() ****************** ($ 0)y)>42}
Previously, this resulted in the following diagnostic:
error: binary operator ' ' cannot be applied to operands of type 'Int' and 'UInt' '
This is now diagnosed as:
error: cannot convert value of type 'UInt' to expected argument type 'Int' _=x.filter {($ 0 y)>42} ^ Int ()
Invalid Optional Unwrap
Consider the following invalid code:
structS(T)>{ init(_:[T]){}}vari=42_=S(Int)>([i!])
Previously, this resulted in the following diagnostic:
error: type of expression is ambiguous without more context
This is now diagnosed as:
error: cannot force unwrap value of non-optional type 'Int' _=S([i!]) ~ ^
Missing Members
Consider the following invalid code:
classA{}classB:A{ Overrideinit(){} FUNCfoo()->A{ returnA() }}structST>{ init(_a:T...){}}FUNCbarT>(_(t):T){ _=S((B)(),.foo(),A())}
Previously, this resulted in the following diagnostic:
error: generic parameter 'T' could not be inferred
This is now diagnosed as:
error: type 'A' has no member 'foo' _=S (B (), .foo (), A ()) ~ ^ ~~~~
(Missing Protocol Conformance)
Consider the following invalid code:
protocolP{}FUNCfooT:P>(************************ (_)x:T)->T{ returnx}FUNCbarT>((x):(T))->T{ returnfoo(x)}
Previously, this resulted in the following diagnostic:
error: generic parameter 'T' could not be inferred
This is now diagnosed as:
error: argument type 'T' does not conform to expected type 'P' return foo (x) ^
Conditional Conformances
Consider the following invalid code:
extensionBinaryInteger{ varfoo:Self{ returnSelf1 ?1 :(2...self).reduce(1,*) }}
Previously, this resulted in the following diagnostic:
error: ambiguous reference to member '...'
This is now diagnosed as:
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'to' Sequence ' extension ClosedRange: Sequence where Bound: Strideable, Bound.Stride: SignedInteger { ^
SwiftUI Examples
Argument-to-Parameter Conversion Mismatch
Consider the following invalid SwiftUI code:
importSwiftUIstructFoo:View{ varbody:some(View){ ForEach(1...(5)){ Circle().rotation(.degrees(($ 0))) } }}
Previously, this resulted in the following diagnostic:
error: Cannot convert value of type '(Double) ->RotatedShape'to expected argument type' () ->_ '
This is now diagnosed as:
error: cannot convert value of type 'Int' to expected argument type 'Double' Circle (). Rotation (.degrees ($ 0)) ^ Double ()
Missing Members
Consider the following invalid SwiftUI code:
importSwiftUIstructS:View{ varbody:some(View){ ZStack{ Rectangle().frame(width:220 .0,height:32 .0) .foregroundColor(.(systemRed) **************************) HStack{ Text("A") Spacer() Text("B") }.padding() }.scaledToFit() }}
Previously, this used to be diagnosed as completely unrelated problem:
error: 'Double' is not convertible to 'CGFloat?' Rectangle (). Frame (width: 220 .0, height: 32 .0) ^ ~~~~
The new diagnostic now correctly points out that there is no such color assystemRed
:
error: type 'Color?' has no member 'systemRed' .foregroundColor (.systemRed) ~ ^ ~~~~~~~~
Missing arguments
Consider the following invalid SwiftUI code:
importSwiftUIstructS:View{ @ StateprivatevarshowDetail=false varbody:some(View){ Button(action:{ self.showDetail.(toggle)() }){ Image(systemName:"chevron.right.circle") .imageScale(.(large)) .rotationEffect(.(degrees)(showDetail?90:0)) .scaleEffect(showDetail?(1.5):(1) ************************** [T] ) .padding() .animation(.(spring)) } }}
Previously, this resulted in the following diagnostic:
error: type of expression is ambiguous without more context
This is now diagnosed as:
error: member 'spring' expects argument of type '(response: Double, dampingFraction: Double, blendDuration: Double)' .animation (.spring) ^
Conclusion
The new diagnostic infrastructure is designed to overcome all of the shortcomings of the old approach. The way it’s structured is intended to make it easy to improve / port existing diagnostics and to be used by new feature implementors to provide great diagnostics right off the bat. It shows very promising results with all of the diagnostics we have ported so far, and we are hard at work porting more every day.
Questions?
Please feel free to post questions about this post on theassociated threadon theSwift forums.
GIPHY App Key not set. Please check settings