Private init unexpectedly calls ExpressibleByIntergerLiteral init

I have a simple struct which conforms to ExpressibleByIntergerLiteral and which defines a static computed property (.infinity) as a helper:

struct Duration {
    private var value: Int
    
    private init(_ value: Int) {
        self.value = value
    }
    
    static var infinity: Duration {
        return Duration(-1)
    }
}

extension Duration: ExpressibleByIntegerLiteral {
    init(integerLiteral value: Int) {
        precondition(value >= 0, "Duration must be a positive integer")
        self.init(value)
    }
}

@main
enum App {
    static func main() async throws {
        _ = Duration.infinity
    }
}

However, when I run the above code which creates an instance using the .infinity helper, this triggers the precondition in the integer literal init. This seems to indicate that the expression Duration(-1) in the static helper calls the integer literal init. I don't understand how it is possible since this init would have been called Duration(integerLiteral: -1) or just return -1. How is that possible?

If I change the label of the private init (for instance init(unsafe duration: Int)) then issue does not occur anymore.

1 Like

It's because of swift-evolution/0213-literal-init-via-coercion.md at main · apple/swift-evolution · GitHub which makes Duration(-1) behave like -1 as Duration

To disable that behavior, write Duration.init(-1) instead

4 Likes

Thanks for your quick answer!

IMO this is a weird and dangerous behavior. I definitely understand the rationale for scalar / standard library types (Int8(1_000) or Character("ab") being caught at compile time for instance) but this seems odd for custom types.

Empirically, end users simply expect T(42) to mean 42 as T for any integer literal expressible type T; it didn’t matter how hard we tried to educate users, significant mistakes due to this expectation kept arising over and over. So we changed the language to match the expectation.

Do not design your type so that an unlabeled initializer T(42) behaves differently from the bare literal 42: longstanding experience shows that it will be surprising and lead to user error. If you need custom behavior in an initializer, give the parameter a label.

11 Likes

Again, I perfectly understand and support the rationale of the proposal but I'm still not convinced that "changing the language to mach [their] expectation" did solve this issue. We now have a magic behavior that is really difficult to discover. I'm not even sure that this is stated somewhere in the documentation or in the Swift Programming Language book.

This is unfortunate since good alternatives were proposed in the proposal's discussion thread:

Wouldn't this solution have both solved the issue and helped educate users while still keeping the language semantic consistent?

Anyway, I don't want to highjack this post to discuss a past decision as my issue is now solved, thanks again!

Perhaps unrelated to the question, but I think Duration works better as an enum with an associated type.

enum Duration {
    case .infinity
    case .value(Int)

    // …
}

You could encapsulate this as a Storage type in Duration if you want to hide the value propety.

1 Like