Curtailing The Power of EditorPlaceholderExpr

Hello All,

I wanted to get some advice on a path forward for improving a behavior I discovered. Because this change affects the compiler, and especially Swift playgrounds, I wanted to come to the community for advice on a solution I've devised so that we can iterate and improve on it.

The Setup

Begin by opening a playground and declare the following dummy function

func foo(_ : Int, _ : String, _ : Int) {}

Now, instead of calling it, fill in placeholders. If you're following along at home and you'd like to make your own, type any string surrounded by the delimiters <# and #>. For example,

foo(<#Int#>, <#String#>, <#Int#>)

The playground will emit a warning about an editor placeholder being in the file, but the playground will execute itself.

That is, until it crashes.

Fatal error: attempt to evaluate editor placeholder: file MyPlayground1.playground, line 2

Let's change the example

func foo(_ : Int, _ : String, _ : Double) {}
foo(<#Int#>, <#String#>, <#Double#>)

Now we have a static error - the playground does not execute

cannot invoke 'foo' with an argument list of type '(Int, String, Double)'

This occurs because of a quirk in our typing rules. You see, if you play this game with autocomplete instead of by hand, you'll get placeholders that look a little different:

func foo(_ : Int, _ : String, _ : Double) {}
foo(<#T##Int#>, <#T##String#>, <#T##Double#>)

These placeholders come with a contextual type. That type is accepted by the type system as gospel. When that type isn't available, the compiler synthesizes a type variable and attempts to use contextual information to bind that type variable to something. If that succeeds, then the compiler transforms the editor placeholder into a call to the compiler intrinsic _undefined which looks like this

public // COMPILER_INTRINSIC
func _undefined<T>(
  _ message: @autoclosure () -> String = String(),
  file: StaticString = #file, line: UInt = #line
) -> T {
  _assertionFailure("Fatal error", message(), file: file, line: line, flags: 0)
}

The Rub

When we emit this with type variables, we have very little semantic information to go off of. Most of the time, these type variables wind up picking up a damaged type from surrounding context, and if they don't then they wind up typed as Void via a sneaky defaulting constraint. This leads to some... less than desirable diagnostics

// compile-time error!
for x in <#T#> {} // error: Type '()' does not conform to protocol 'Sequence'

In addition, emitting these as type variables is expensive. So, the decision was made in 207fffc to curtail the number of type variables we generate for placeholders to 2. The consequence is, when we call foo with just Int and Double-typed placeholders, and as long as the odd-numbered placeholders have a common type that all other odd-numbered placeholders agree to (ditto for even-numbered placeholders), we get a runtime failure. Else, as demonstrated above, we get a compile-time failure. This also means that we can get a compile-time failure simply by making the even-even or odd-odd numbered placeholders disagree

// compile-time error!
func foo(_ : Int, _ : Int, _ : String) {}
foo(<#Int#>, <#Int#>, <#String#>)  // error: Cannot invoke 'foo' with an argument list of type '(Int, Int, String)'

The Fix

I believe the intent all the way back in 2015 was to allow for playgrounds in an inconsistent or partial state of development - as long as that state was entirely placeholder-based - to still be executable. A feature reminiscent of runtime typed holes which offer a huge usability improvement to statically-typed developments. But the rules for when you do and don't get a runtime failure in a playground are not, and have not ever been, consistent.

So, I have a patch that takes the draconian route and collapses the runtime/compile-time distinction for playground diagnostics entirely: we will always emit an error for placeholders wherever we see them. This also means that we no longer have to keep around the weird type inference rules that lead to these diagnostics which improves QoI and incidentally simplifies CSApply quite nicely.

The problem, of course, is that this is a potential workflow-breaking change. If you were used to the typed holes behavior, that will go away entirely under this regime. So, I present some alternatives

Alternatives

Leave It The Way You Found It

Harden The Typing Rule

The current inference rule introduces an artificial low-information type variable. We could instead require the contextual type be provided - which it is in 99.9999% of cases by the IDE - then flip the inference rule into a checking rule and try to unify the placeholder with its surrounding context. This removes the expensive type variable and preserves the runtime-hole-like behavior - but not in all cases. User-defined holes without a contextual type will be treated as hostile invaders and given an error type. Holes with contextual types, whether user or IDE generated will, of course, still work just fine. Though, you won't be able to provide a mismatched contextual type here either

func foo(_ : Int, _ : Int, _ : String) {}
foo(<#T##Int#>, <#T##Int#>, <#T##Int#>) // error: Cannot convert value of type 'Int' to expected argument type 'String'

I doubt most people are typing their own placeholders. But, then again, that's what I'm here to find out.

4 Likes

Another possibility might be to treat the placeholders exactly as if you'd written a term with unconstrained type. If you were going to have your own hole function, you could write:

func bottom<T>() -> T { fatalError() }

If we typed placeholders like bottom(), and we're able to infer T, then we can keep the existing semantics. In the sketchier cases like for x in <#blah#>, we can raise an error. That's not perfect either, maybe, but it at least gives you semantics you can describe in terms of something else.

That seems to be precisely the performance issue @Douglas_Gregor worked around in that patch I mentioned. I’d love to implement those semantics if that is no longer true, but unfortunately I lack visibility into the radars behind these things at the moment.

1 Like

I don't think we want to take this path. The Playgrounds folks specifically requested that the typed placeholders be allowed to compile. That's what they're for. I don't know if it matters if the non-typed ones don't compile, though.

Is the extra type variable logic intentional, though? It isn't clear to me why, for the typed placeholders, we don't type check them like a normal term of the specified type (which IIUC is Robert's "Harden The Typing Rule" alternative).

1 Like

I see your patch touched only one SourceKit test - am I correct in assuming that was enough to get all the IDE/SourceKit tests passing?


If we care about the workflow for typed placeholders, I think we should probably care about untyped placeholders as well. Here's some context on where they will show up:

In expression contexts, most placeholders are typed by from code-completion, but there are important exceptions. For example, we have snippets for collection literals, e.g. [<#values#>]. The Swift Playgrounds editor also allows dragging to expand collection literals by inserting placeholder values, e.g.

let a = [1, 2, 3] // before
let a = [1, 2, 3, <#item#>, <#item#>] // after

Both of these cases need to work even when we cannot determine a type, or when there is no consistent type.

It's very common to have untyped placeholders outside of expression context. For example, closure bodies come with <#code#> in the body in code-completion. There are also snippets provided by the editor:

  • func <#name#>() { <#code#> }
  • var <#name#>: <#type#>
  • case <#pattern#>: <#code#>
  • <#name#>: <#type#> (add func param)
  • -> <#type#> (add return type to a func)