[Pitch] Type inference from default expressions

FWIW, using the OP's Box examples, I think I can match the call-site syntax with today's toolchain. This all works in an Xcode 13.2.1 playground...

Boilerplatey Stuff that Probably Doesn't Matter but I Included Anyway for the Sake of Completeness
enum Flag: String, CustomStringConvertible {
    case fragile
    case pastDue
    case damaged
    case flatRate
    var description: String { self.rawValue }
}
protocol Flags { var flags: [Flag] {get set} }
struct DefaultFlags: Flags { var flags: [Flag] = [] }
struct FlatRate: Flags { var flags: [Flag] = [.flatRate] }
struct Damaged: Flags { var flags: [Flag] = [.damaged] }

enum ShippingFlag: String, CustomStringConvertible {
    case express
    var description: String { self.rawValue }
}
protocol ShippingFlags { var flags: [ShippingFlag] {get set} }
struct DefaultShippingFlags: ShippingFlags { var flags: [ShippingFlag] = [] }
struct Express: ShippingFlags { var flags: [ShippingFlag] = [.express] }

struct Dimensions {
    static let small = Dimensions(length: 1, width: 2, height: 3)
    static let medium = Dimensions(length: 1, width: 2, height: 3)
    static let large = Dimensions(length: 1, width: 2, height: 3)
    var length: Double
    var width: Double
    var height: Double
}

Existing "code-site" syntax:

struct StructBox<F: Flags> {
    var flags: F
    var dimensions: Dimensions
    
    init(dimensions: Dimensions, flags: F) {
        self.dimensions = dimensions
        self.flags = flags
    }
    func ship<F2: ShippingFlags>(_ f2: F2) -> [ShippingFlag] {
        f2.flags
    }
    func ship() -> [ShippingFlag] {
        ship(DefaultShippingFlags())
    }
}
extension StructBox where F == DefaultFlags {
    init(dimensions: Dimensions) {
        self.init(dimensions: dimensions, flags: DefaultFlags())
    }
}

enum EnumBox<F: Flags> {
    case regular(Dimensions, F)
    
    var flags: [Flag] {
        switch self {
        case .regular(_, let flags): return flags.flags
        }
    }
}
extension EnumBox where F == FlatRate {
    static func flatRate(_ dimensions: Dimensions) -> EnumBox {
        .regular(dimensions, FlatRate())
    }
}

And the resulting call-site code & output:

let structBox1 = StructBox(dimensions: .medium)
let structBox2 = StructBox(dimensions: .medium, flags: Damaged())
print(structBox1.flags.flags)       // []
print(structBox2.flags.flags)       // [damaged]
print(structBox1.ship())            // []
print(structBox1.ship(Express()))   // [express]

let enumBox1: EnumBox = .flatRate(.large)
let enumBox2: EnumBox = .regular(.large, Damaged())
print(enumBox1.flags)               // [flatRate]
print(enumBox2.flags)               // [damaged]

IIUC, you're proposing that the following syntax would have the same behavior?

struct StructBox<F: Flags> {
    var flags: F
    var dimensions: Dimensions
    
    init(dimensions: Dimensions, flags: F = DefaultFlags()) {
        self.dimensions = dimensions
        self.flags = flags
    }
    func ship<F2: ShippingFlags>(_ f2: F2 = DefaultShippingFlags()) -> [ShippingFlag] {
        f2.flags
    }
}

enum EnumBox<F: Flags> {
    case regular(Dimensions, F)
    case flateRate(Dimensions, F = FlatRate())
    
    var flags: [Flag] {
        switch self {
        case .regular(_, let flags): return flags.flags
        case .flateRate(_, let flags): return flags.flags
        }
    }
}

I can see the advantage.

I can't help but wonder, though, if there are ways to accomplish the same thing in the OP's example, could this is another example of "convenient code-gen stuff that gets added to the compiler instead of being a use case for meta-programming"? I'm not saying this is the case here, I'm just asking the question. Arguably my example just skates in as "working" due to a technicality... the OP's use case only had instantiating an enum, not switching on one, and I'm not sure if there's a way to handle that with the existing toolchain.

(Also, I didn't have time to even try @stephencelis's example use case.)

1 Like

Yes, that is what I'm proposing, the ergonomic improvements target declarations not the call site, call sites continue to work as before.

Theoretically speaking we could go that way and teach the compiler to synthesize all the declarations in different ways (conditional extensions, where clauses, and generic parameter replacement which is going to be unprecedented if we did), I just don't see any advantages of that approach versus the one proposed here. Doing so would also result in degraded user experience because synthesized code doesn't have code locations to anchor on.

Also note that all listed existing approaches add new overloads and the code synthesis approach is not going to help with that. This is a major disadvantage, because adding new overloads purely to default some parameters have negative effect on user experience as well as type-checker performance.

2 Likes

Love it!

SwiftUI itself is littered with these declarations:

extension Section where Header == EmptyView { ... }
extension Section where Footer == EmptyView { ... }
extension Section where Header == EmptyView, Footer == EmptyView { ... }

… just to be able to afford the user with convenience inits with default headers and footers.

These declarations explode when you have a few generic parameters, and you want to provide default values for all combinations of them. I have exactly the same problem in my own code, often with SwiftUI Views, but in other types as well. Hundred of lines of boilerplate and repetitive copy-pasted code.

7 Likes

Oh, I know you're not proposing that. I just keep seeing things that could be implemented that way and it makes me wonder if there's a more general problem to get at is all.

Off-Topic Tangent Regarding the Type-Checker

I thought the big issue with type-checker performance was when you overloaded functions with different argument types, and that overloading functions with different numbers of arguments was fine. Is that not the case? Or is it just that it's only "fine" when compared to type-based overloading?

It depends - usually concretely typed overloads are fine but in situations relevant to this proposal overloads have to have the same labels plus generic types, which is the worst combination because type-checker cannot rule out any of such overloads upfront.

1 Like

Ah, got it, thanks

1 Like

The feedback is very positive, and there is no doubt that the pitch addresses a common pain point. It greatly helps writing apis where one generic argument has one sensible default of one concrete type.

Then I read Pitch: Allow functions with void-params to be called as void-functions.

It made me think that one way to generalize this "Type inference from default expressions" pitch is apis where one generic argument has multiple sensible defaults depending on the concrete type. For example, the api author would like the default value to be () when the generic type is Void, 42 for Int, and "Hello World!" for String, DefaultFlags() for DefaultFlags, etc.

This gives me one concern:

I can imagine one user, who is really (but unconsciously) looking after the generalized use case, but starts with this proposal, with success. The user thinks "Yeah! I can provide DefaultFlags() as a default value!". What he should have thought was "Yeah! I can provide DefaultFlags() as a default value for DefaultFlags", but he missed this subtlety (or maybe this subtlety is not a concern at this stage).

Later this user realizes the need to provide a different default value for a different concrete type. So he needs to refactor his api, and provide defaults values as we used to do before this pitch (with as many overloads as needed). Less sexy, but still workable. And the change is API-stable.

But what if the user's code has ABI-stability concerns? Is is possible to opt-out of this pitch in an ABI stable way?

 // Is this diff ABI-stable?
 struct Box<F: Flags> {
-  init(flags: F = DefaultFlags()) { ... }
+  init(flags: F) { ... }
 }
+extension Box where F == DefaultFlags {
+  init() { self.init(DefaultFlags()) }
+}

If there is no way to opt-out of this pitch in an ABI-stable way, then it looks very dangerous for users who have ABI-stability constraints.

Adding overloads (with appropriate availability) and adding/removing default arguments are both resilient changes. Default arguments are not ABI, and this pitch doesn't change that. We also have tools like the ABI checker to help library authors with ABI constraints ensure that they don't break ABI.

EDIT: The above diff would be fully source and ABI compatible if the new init is marked @_alwaysEmitIntoClient.

1 Like

Thank you Holly. So you're confident that (at least) the stdlib won't fall in a pit because of this pitch.

aside about the abi checker

We also have tools like the ABI checker to help library authors with ABI constraints ensure that they don't break ABI.

A google search for "Swift ABI checker" did not yield clear results. Is this checker available for developers who are not working on the stdlib?

This is an interesting point, but I wouldn't call it a generalization of this pitch because it doesn't allow for this default concrete type to be inferred. Providing a default depending on the concrete type means the concrete type needs to be supplied first in order for the default value to be chosen. This pitch suggests the opposite - if a default argument is used, type inference will use its type for the generic argument. This prevents programmers form having to specify that concrete type at the call-site, e.g. via type annotation.

You are correct. My point was about the risk that the two use cases are conflated by the api developer, because they have identical effects (when / as long as) a single concrete type is involved.

When the api developer is wrong, we end up, after a few rounds of api evolution, with code such as below, where the privileged/special position of DefaultFlags has no meaning at all:

struct Box<F: Flags> {
  // Symmetry breakage for DefaultFlags, which should
  // be handled as OtherFlags and YetAnotherFlags below.
  init(_ flags: F = DefaultFlags()) { ... }
}
extension Box where F == OtherFlags {
  init() { self.init(OtherFlags()) }
}
extension Box where F == YetAnotherFlags {
  init() { self.init(YetAnotherFlags()) }
}

I know that we should not negatively judge language changes because some people may misuse them. Yet the risk for confusion is high, here, and I think it is better if it is expressed in this pitch.

I am guilty this confusion myself!

Answering your ABI checker question

Yep! The swift-api-digester tool has options to inspect and check ABI. It's included in the toolchain; if you have one installed, running swift api-digester --help on the command line should show you the various flags. A Google or Swift Forum search of "swift-api-digester" turns up more useful results.

2 Likes

The biggest question for me here is whether it's sensible for API authors to even provide a default in this case or instead require that argument is always specified explicitly if there are multiple possible choices, like with this Void example for Pitch: Allow functions with void-params to be called as void-functions - the choice, it seems, is to provide default only if there is no output to improve ergonomics of that API, it doesn't means that send() should be supported for every possible Output.

Hey all,

Thanks for the great discussion in the pitch. We've gone ahead and scheduled a review for this proposal as SE-0347 starting on March 22nd.

Doug

13 Likes