Add opt-in analyzer warning (or syntax highlighting?) for dynamic types; add documentation for static vs. dynamic types; make @_disfavoredOverload official

I'd like to pitch adding additional documentation about static vs. dynamic types, reference vs. value semantics, and adding some new compiler warnings to help reinforce the "semantic friction" intended to be conveyed by the new existential syntax of "any ___" (such as an opt-in warning that would flag any static types that are implicitly incurring dynamic resolution, etc.).

Rationale

The original "protocol oriented programming" talk given at WWDC 2015 talked a lot about dynamic vs. static types, but what 99.9% of developers took away from it was pretty much just, "everything should have a protocol, and use structs instead of classes."

At the time I doubt there were more than a handful of people in the world who actually understood 95% of that talk. Many of us didn't even start to understand what was being said about heterogeneity vs. homogeneity, and static vs. dynamic until that famous YouTube video about PATs.

Even then, unless you spent significant time on this forum and experimenting around with the language, it's unlikely you will understand the nuances, such as:

  • opaque types can only wrap statically known types, so they don't sacrifice the benefits of value-semantics like existential types do
  • given two functions with the same signature, where one takes an existential type and the other takes a generic type, Swift will default to calling the existential version unless it's marked with @_disfavoredOverload
  • how to quickly and easily create type-erasers that don't incur reference semantics

Even today, judging from what I've seen at work, most Swift developers lack a clear understanding of what's meant by "dynamic type" vs. "static type", even if they generally do understand the basic benefits of using structs. Unless they frequent these forums, how are they supposed to know this stuff? It's not like Swift's documentation explains what "dynamic type" or "static type" actually mean. It's not like our developer tools show you which of the two applies in a given circumstance.

And so the result of being told to do protocol-oriented programming without understanding that the real point was to get away from reference-semantics and dynamic types has been the Swift developers have generated billions of lines of code that deal with instances of structs as protocol-type objects and generally avoid taking advantage of generics—kind of defeating the point of structs, because it means you're wrapping a static type in a dynamic type subject to the down-sides of reference semantics such as ARC, slow heap storage, and hard limits on how the compiler can optimize that code.

It's very non-obvious that to get the full performance benefits of structs, you can't use protocol-type objects. I'm sure the authors of the original 2015 talk and the creators of Swift probably did not envision a proliferation of existential types in Swift codebases as the result of their efforts.

So when the proposal was made to allow PATs to be used as existential types, there was some fear this would just make the proliferation of existentials worse. To alleviate that fear, the argument was made that also requiring people to prefix "any" on their existential will provide "semantic friction" (similar to the friction provided by "!" for force-unwrapping or force-casting) so as to discourage the practice of using existentials by clarifying which code is going off into the world of dynamic types and thus compromising the benefits of value semantics.

I certainly think that the initial pain of having to add "any" in millions of lines of code will be seen as "friction", but I'm afraid that friction alone is not going to help anyone to understand the pros and cons of existentials or the nuances of the Swift dynamic vs. static type system. So we also need to add proper documentation and additional static and runtime analysis features before we can expect people to understand the situation that the friction of "any" is meant to alert them to.

Otherwise, I'm afraid people will just misunderstand the symmetry of "any" with "some" as a legitimizing sign that, with these new PAT-existentials and constrained existentials, Swift has resolved any of the previous drawbacks that were formerly associated with existentials in Swift. They'll rush head-first into these new types, with no tools by which to understand how they're actually sacrificing many of the benefits of value semantics by doing so.

Consider the following code that illustrates how confusing it is which kind of type we're dealing with in a given line of code, or how it will resolve to a function:

protocol Bar {}

func foo<B: Bar>(bar: B) where B: Numeric { 
    print("Generic function used; dynamic \(type(of: bar)), static: \(B.self)") 
}

func foo(bar: any Bar) { 
    print("Existential function used; dynamic \(type(of: bar)), static: \((any Bar).self)") 
}

extension Int: Bar {}

let a: Int = 1

foo(bar: a)

// Outputs: 
// Existential function used; dynamic Int, static: Bar

In this situation, variable a has a statically defined type of Int, which conforms to Numeric. Nonetheless, the function that gets called will be func foo(bar: any Bar)!

Why, if we're trying to provide friction and promote the use of static types, does an overload default to the non-generic function like that?

Why do you have to resort to a hidden feature @_disfavoredOverload to get around this behavior?

The language should facilitate people using specifically-constrained functions based on static type information to specialize program behavior without modifying the core of a given module. The more general, existential version of the function ought to just be a fallback, to be used in the absence of a more specialized form of the function.

As it stands, Swift is really frustrating if you're coming from Kotlin and you're accustomed to using a simple where clause to effectively switch over the conformances to some interface. We should at least have a language that, if it's not going to incentivize the use of generics as opposed to defaulting back to existentials whenever given a chance, will at least warn you when it's going to do that.

The only workaround in Swift is to either resort to using the undocumented @_disfavoredOverload attribute on the existential version of the function, or use an awkward switch statement in the body of the function where you resort to casting the dynamic existential to various types until you find the right one—but good luck getting that to work as expected unless you know the magic rain dance of casting to any to un-existentialize the dynamic type (a dance that makes even less sense now that any is also the keyword for existentials!).

This is confusing enough if you understand the difference between static types and dynamic, but if you don't understand that distinction, it's completely baffling. Given this distinction is discussed nowhere in the documentation of Swift, it's no wonder that we rarely see developers writing code that steers entirely clear of generics and just uses protocols in the same manner as Obj. C.

Most Swift devs avoid associated types like the plague, since every time they tried to use a PAT, they got stuck with the heterogeneity problem and inability to use the protocol as a type. Now we're solving that, but I'm afraid unless we also revise Swift's documentation and add some additional analysis tools to help people see the problem, we're just going to wind up with millions more of any's which will certainly be friction but won't help people avoid dynamic types or to better use static types, a foreign concept in most languages people are entering Swift from, and which our official docs aren't very helpful in clarifying.

Proposed Solution

The solution I'd like to pitch here has the following minimum aspects:

  1. Make a default-on compiler warning anytime a statically-known value-type variable will get implicitly cast into an existential class, thus sacrificing its value semantics.
  2. For future Swift versions: at least make @_disfavoredOverload into an official language feature, including documentation of when you might want to use it.
  3. Make a default-on compiler error anytime a protocol extension marks a conforming function with @_disfavoredOverload that's not also marked as @_disfavoredOverload in conformances.
  4. Make an opt-in compiler warning (default-on for static analysis) that performs a deeper analysis to warn anytime some optimizability or performance was sacrificed by using an existential type.
  5. Enhance the Swift.org official documentation with proper explanation of:
  • the pros and cons of dynamic vs. static types (irrespective of value or reference semantics)
  • the pros and cons of value vs. reference semantics (irrespective of dynamic vs. static types)
  • how dynamic vs. static types relate to value vs. reference semantics
  • how to get heterogeneity with static types without sacrificing the benefits of value semantics (e.g. how to write a type eraser, and how to write a protocol that makes it easier to write a type eraser)
  • to avoid clarify the reference semantics that are almost certainly incurred by existentials, we should call them "protocol classes" or "existential classes" to make sure it's understood what we're talking about

(That bit might also help people understand why an instance of a protocol class cannot be thought of as conforming to the protocol, since it might someday have generic or static requirements added.)

As it pertains to 1. thru 3. above, see this example:

protocol Bar {}

protocol Baz {
    // @_disfavoredOverload
    func foo(bar: any Bar)
    func foo<B: Bar>(bar: B) where B: StringProtocol
}

extension Baz {
    // @_disfavoredOverload
    func foo(bar: any Bar) { 
        print("Existential function used; dynamic \(type(of: bar)), static: \((any Bar).self)") 
    }
    
    func foo<B: Bar>(bar: B) where B: StringProtocol {
        print("Generic function used; dynamic \(type(of: bar)), static: \(B.self)") 
    }
}

struct Qux: Baz {}

extension String: Bar {}

let a: String = "The quick brown fox jumped over the lazy dog."

(Qux() as any Baz).foo(bar: a)

// prints "Existential function used; dynamic String, static: Bar" unless both @_disfavoredOverload
//  lines are uncommented above. 

In this example, under suggestion 1. we would have a warning on the last executable line, e.g.:

"Warning: value a of type Int gets implicitly abstracted by existential class any Bar, thus sacrificing performance and optimizability, because Int is a value-type while any Bar is a reference-type. Resolve this by explicitly casting a as any Bar before calling this function."

Under suggestion 3., if @_disfavoredOverload was uncommented on the protocol but not the extension, or vice-versa we'd have a compiler error stating, e.g.:

"Error: any conformance of signature `func foo(bar: any Bar)` must have attribute `@_disfavoredOverload` because it's present in the protocol requirement." 

or conversely:

"Error: any conformance of signature `func foo(bar: any Bar)` must not have attribute `@_disfavoredOverload` because it's not present in the protocol requirement."

Request for Comments

Please let me know if this pitch makes sense or if it could be improved. Also, let me know if it's redundant to another proposal elsewhere. Thanks!

2 Likes

There are good reasons to consider whether overload resolution should be changed to favor generic parameters as "more specific" than existential parameters in the next major language version as a source-breaking change. Sounds like this would address your principal concerns here without generalizing an internal hack to manually tweak overload resolution where it currently doesn't do something sensible.

7 Likes

My first draft of the pitch also included this suggestion to change the default overload ordering behavior for Swift 6.

However, I removed it because I don't know when Swift 6 is likely to be available to most of us to use for work-related projects, as Apple seems unlikely to release it with a minor Xcode version bump.

In the meantime though, people will be writing lots of new code based on the expanded existential support, so it might be nice to have a more official @_disfavoredOverload to at least provide a way to opt-in to the new behavior early, and clear any of the new warnings about the overload ordering. Then when it's convenient to migrate to Swift 6, if the decision was made to reverse the default ordering behavior, anyone who added @disfavoredOverload could just get a warning with a fixit to simply remove the attribute.

Other reasons I left out the suggestion to reverse the behavior:

  • I don't know if there might be some technical reason why the current behavior was kept in Swift's original design. (See below)
  • Not sure what the ABI impact might be to change the default behavior. (Could it affect using a module compiled for 5.x with Swift 6?)
  • Maybe there's a third option that's better than just reversing the existing behavior.

Maybe someone else already has a solid idea for how best to declare a function in a protocol such that the most specific conformance possible will get used, without breaking existing source?

Originally I had thought maybe just have an @specializable attribute that tells the runtime and/or compiler to use the most specific option, or perhaps re-use the "any" keyword with a plural function requirement in a protocol, e.g. any funcs process(_: StringProtocol, _: Numeric) -> Collection which must at least be conformed to by a catchall func process(_ a: any StringProtocol, _ b: any Numeric) -> any Collection but can also be specialized along any generic axes.

Thoughts? Thanks