Surprising behavior from SE-0213

SE-0213 changed the way that initialization from literals works.

Quoting from the source compatibility section:

This is a source breaking change because it’s possible to declare a conformance to
a literal protocol and also have a failable initializer with the same parameter type:

struct Q: ExpressibleByStringLiteral {
  typealias StringLiteralType =  String

  var question: String

  init?(_ possibleQuestion: StringLiteralType) {
    return nil
  }

  init(stringLiteral str: StringLiteralType) {
    self.question = str
  }
}

_ = Q("ultimate question")    // 'nil'
_ = "ultimate question" as Q  // Q(question: 'ultimate question')

Although such situations are possible, we consider them to be quite rare
in practice. FWIW, none were found in the compatibility test suite.

It turns out that I use this pattern a lot when implementing literal protocols where not all literal values are valid. Something like:

struct Hour: ExpressibleByStringLiteral {
    let hour: Int, minute: Int

    init?(_ string: String) {
        // Check whether `string` is a valid "HH:MM". If not, return `nil`
    }

    init(stringLiteral value: String) {
        self.init(value)!
    }
}

Confusingly, the end result is:

// Swift 4
_ = TimeOfDay("X") // calls `init(_:)`, returns `nil`
_ = TimeOfDay("X")?.hour // calls `init(_:)`, returns `nil`

// Swift 5
_ = TimeOfDay("X") // calls `init(stringLiteral:)`, **crashes**
_ = TimeOfDay("X")?.hour // calls `init(_:)`, returns `nil`

It looks like the optional chaining in the second statement causes the compiler to use the failable initializer instead of the literal one.

I find it pretty surprising/unintuitive that TimeOfDay("X") and TimeOfDay("X")?.hour call different initializers. Is that the desired behavior?

cc @xedin (proposal author) and @John_McCall (review manager)

It seems unfortunate to me that this is treated as an overload rather than just unconditionally selecting the literal initializer. Was it specified that way in the proposal?

1 Like

I have not followed the previous discussion but as a daily Swift user I would not expect the compiler to call a totally different init than I explicitly called TimeOfDay("X") // calls init(stringLiteral:) (this does not make any sense to me).

I would call this a bug. And since this is now part of Swift 5 and maybe even Swift 5.1, you would need to provide workarounds in your code base for OS versions that are shipped with Swift that has this bug.

1 Like

One other confusing aspect of this is how it surfaced in my unit tests:

// Swift 4
XCTAssertNil(TimeOfDay("X")) // calls `init(_:)` passes
XCTAssertNil(TimeOfDay("X")?.hour) // calls `init(_:)` passes

// Swift 5
XCTAssertNil(TimeOfDay("X")) // calls `init(stringLiteral:)` crashes
XCTAssertNil(TimeOfDay("X")?.hour) // calls `init(_:)` passes

I don't really understand why passing the result to XCTAssertNil() -- which expects an Optional -- doesn't trigger the use of the failable initializer.

1 Like

Also one note, the proposal is misleading.

_ = "ultimate question" as Q // Q(question: 'ultimate question')

There is no such thing as init(question:) in that struct, because as soon as you provide a custom initializer the implicit member-wise init is gone.

struct Q: ExpressibleByStringLiteral {
  typealias StringLiteralType =  String

  var question: String

  init?(_ possibleQuestion: StringLiteralType) {
    print(1)
    return nil
  }

  init(stringLiteral str: StringLiteralType) {
    print(2)
    self.question = str
  }
}

_ = Q("ultimate question")    // prints 2 - BUG should print 1
_ = "ultimate question" as Q  // prints 2

@nonsensery can you please file a bug report.

1 Like

This question has been discussed by core team during review, and as far as I remember, it has been considered an anti-pattern to have unlabeled failable initializer and literal initializer with the same type, because it represents a conflict in logic where something could and could not be represented by a literal at the same time, like in your struct Hour example. So I think recommendation was for failable initalizer have a label. I think it might make sense to update a proposal with that. /cc @Douglas_Gregor @Ben_Cohen

Sorry if I missed the original conversation, but I don't understand the issue here. The proposal shows an example and describes the expected result but fails to deliver the promise (see my post above).

There are only two initializer available on Q:

  • init?(_:)
  • init(stringLiteral:)

I don't see any issue with these initializers. It it however the literal that is passed to init?(_:) interpreted by the compiler as it's passed to init(stringLiteral:) which is in my eyes a bug.

(Although I personally like the pattern, the validity of it doesn't really matter here.)

The core issue here is that the compiler selects a different initializer based on the surrounding context in a non-obvious way.

Are you just saying you don't like SE-0213?

No that's not what I meant. I'm saying that the proposal say this:

_ = Q("ultimate question")    // 'nil'
_ = "ultimate question" as Q  // Q(question: 'ultimate question')

but in reality the first line not nil.

1 Like

I just re-read the rule and kinda start to understand why this happens, but then the example with Q is highly misleading.


The issue can be avoided if one would pass an instance of the literal type instead of the literal expression.

let string =  "ultimate question"
_ = Q(string)    // 'nil'
_ = Q("ultimate question")    // Q(stringLiteral: 'ultimate question')
_ = "ultimate question" as Q  // Q(stringLiteral: 'ultimate question')

I'm pretty sure that section is trying to represent what happens "today" (i.e. before the proposal). I agree it could be clearer.

I am a bit confused as to why, according to the proposal, this was a source breaking change if the compiler continues to favor the failable initializer. Wouldn't it be source breaking if, on the contrary, that didn't happen?

There is one more confusion I have with this. Why is Q?.init any different from Q.init ??

let string = "string"
_ = Q(string)         // 'nil'
_ = Q("string")       // before: 'nil' after: Q(stringLiteral: "string")
_ = Q.init("string")  // 'nil'
_ = Q?.init("string") // Q(stringLiteral: "string") | this is the `init` on `Optional` followed by implicit `"string" as Q`.
_ = "string" as Q     // Q(stringLiteral: "string")

During my shift to Swift 5, this originally surprised and concerned me, because all sorts of tests started failing because of it:

struct Thing: ExpressibleByStringLiteral {
    init?(_ string: String) {
        guard let parsed = doSomeParsing(string) else {
            return nil // String is invalid.
        }
        self.init(parsedStuff: parsed)
    }
    init(stringLiteral: String) {
        guard let initialized = Thing(string) else {
            preconditionFailure("Invalid string representation.")
        }
        self = initialized
    }
}
XCAssertNil(Thing("Invalid")) // Test used to pass, now it crashes.

I fixed all the tests using a layer of indirection:

XCAssertNil(Thing(String("Invalid"))) // Works fine again.

And that is when I realized the whole thing didn’t really matter, since I can only ever be surprised if I deliberately pass a known literal that I want to fail. No one does that in normal code; just doing x = nil is infinitely better than x = Thing("Please fail and give me nil."). Sure enough, after updating all the code I work with to Swift 5, the issue only manifested itself in test code that semantically was deliberately broken and nonsensical to begin with.

In the end I do not mind this quirk and find the improved consistency between let x = Stuff("") and let x: Stuff = "" to be well worth it.

2 Likes

One place where this can arise outside of failable initialisers is when passing a nil literal to Optional's init(_ some: Wrapped) initialiser, e.g:

let opt: String?? = Optional(nil)
print(opt as Any)
// in Swift 4.x: Optional(nil)
// in Swift 5.0: nil

Though arguably that's also a questionable thing to be doing.

[Of course, I can only speak for myself here...]
I like SE-0213, but imho there is something odd that predates the proposal (?):
Calling init(stringLiteral str: StringLiteralType) with Q(stringLiteral: "Test") should obviously work (and it does) - Q("Test") however is a different thing, and it's not intuitive that it should have the same effect as calling the init as it is defined by its creator.

That’s literally all that SE-0213 does, though: it says that T(literal) constructs the literal directly as a T if that type conforms to the appropriate literal protocol. If you’re uncomfortable with that rule, you’re uncomfortable with SE-0213.

2 Likes

I guess I might have simply misunderstood SE-0213 - I thought the motivation wasn't how the initializer can be called, but rather to avoid unwanted conversations that could happen with literals

As a result expressions like UInt64(0xffff_ffff_ffff_ffff) , which result in compile-time overflow under current rules, become valid.

¯_(ツ)_/¯

That was my original impression as well and I thought this would be just some convenience for some T(literal as T) voodoo for types that already have T.init(_ other: T).