[Pitch] Enum Case Inferencing

I was reading the discussion on Let’s fix if let syntax. It's a good discussion, but it overlooks how weird Swift is to encourage us to create variables that replace existing variables but with a different type (optional type replaced with non-optional type). let x = x is a weird pattern, but we are all just accustomed to it.

Meanwhile, the proposal to just use if x != nil { } is not getting enough attention. There was an objection saying "I find it really weird that merely performing a comparison would change the type of a variable from optional to non-optional." But what if we weren't changing the type of a variable? What if it is the same variable - the Swift compiler just infers what we want to do with it based on prior knowledge.

There is this other similar objection, saying that setting value to nil in this code would cause an error:

var value: Int? = 42
if value != nil {
    value = nil // Error because value is now non-optional
}

It was proposed that this would cause an error because value became a non-optional inside the if block, but then we try to set it to nil.

But I don't think this should be an error, because value should be the same variable, not a new one that replaces the old one with a different type.

So, really it should be like this:

var value: Int? = 42
if value != nil {
    let x: Int = value // This is fine - the compiler should infer that this optional is of case .some and stick the int in here.
    value = nil // This is fine too - because `value` is still Int?
}
print(value) // Prints nil - because we changed `value` in the if block.

And modifying value later can also lead the compiler to lose its ability to infer the case in subsequent code:

var value: Int? = 42
if value != nil {
    let x: Int = value // This is fine
    value = nil // This is fine too
    let y: Int = value // This is not fine, because value has been changed to something that no longer guarantees it is non-nil - Error: Value of optional type 'Int?' must be unwrapped to a value of type 'Int'
}

Enum Case Inferencing:

This behavior can actually be built into all enums, not just Optional.

For example, say I have an enum:

enum FooBar {
    case foo(String)
    case bar(Int)
}

Now I use a FooBar:

func useFooBar(fooBar: FooBar) {
    let x: FooBar = fooBar
    let s: String = fooBar // Cannot convert value of type 'FooBar' to specified type 'String'
}

I can't convert it to a String obviously - it is a FooBar. But what if the compiler knows it is case .foo?

func useFooBar(fooBar: FooBar) {
    guard case .foo = fooBar else { return } // Compiler now knows this is case .foo
    let x: FooBar = fooBar
    let s: String = fooBar // This is fine, because the compiler knows this is case .foo which can be unwrapped as a String
}

We could also use this with multiple parameters, and the compiler can infer it as the tuple:

enum FooBarWithParams {
    case foo(x: Int, y: Int)
    case bar(Int)
}

func useFooBar(fooBar: FooBarWithParams) {
    guard case .foo = fooBar else { return }
    let v1: FooBarWithParams = fooBar
    let v2: Int = fooBar.x // Accessing parameter by name - compiler knows this is case .foo which can be unwrapped as a tuple of (x: Int, y: Int).
    let v3: Int = fooBar.0
    let (x, y) = fooBar
}

Plus, maybe with this we can start to leave behind ugly case syntax.

Anyway, inferencing is cool - we all love how the Swift compiler infers so much when it comes to types. Type inferencing helped Swift leave behind a lot of cruft from the C world, and we are all thankful for it. Enum case inferencing seems like a similar step - the compiler often knows exactly what case we are dealing with (and so do we) - it should use that information for our convenience in an obvious and intuitive way.

2 Likes

Small counterexample: What about

public class DoesNotWork {
    public var changeMe: Int?
    
    public func trouble() {
        if changeMe != nil {
           someFunctionThatTakesQuiteSomeTime()
           let amIStillThere: Int = changeMe
        }
    }
}

Things get complicated whenever multithreading comes into play...

3 Likes

You don't need multithreading to make things complicated

3 Likes

This is a fair objection. Actually, in Kotlin and TypeScript, this operation can be done and as you said after assignment it becomes optional again. (I found it after this comment)

Anyway, there is still possibility to be source breaking change.
(EDIT: First three patterns would not be source breaking change because x still can work as String?, but last two patterns would be source breaking)

let x: String? = "Hello"

func foo(_ x: String?) { /* ... */ }
func foo(_ x: String) { /* ... */ }
if x != nil {
    print(x!)            // after x != nil starts inferring, is this possible?
    print(x ?? "Empty")  // after x != nil starts inferring, is this possible?
    print(x?.count)      // after x != nil starts inferring, is this possible?
    foo(x)               // after x != nil starts inferring, which foo is called?
    _ = x.map{$0}        // after x != nil starts inferring, which is called? (Optional.map or String.map)
}

I agree inferencing is cool, but it cannot stay cool because it has too many limitation.

struct Foo {
    var x: Int? {
        return Bool.random() ? 42 : nil
    } 
    func test() {
        if x != nil {
            // can x be used as `Int` ?
        }
    }
}
4 Likes

There is obviously a big difference here between value semantics and reference semantics. The compiler cannot reason about reference types being used in this way, but it seems like it would be able to reason about all the cases when a value type is used (value types win again!).

There is a similarity here in how we capture variables in closures, have to use self when capturing self, and have to declare @escaping vs non-escaping closures. These are all parts of the compiler reasoning about our code, and the status of an enum can similarly be reasoned about.

In the other thread cukr said:

Yeah, that thread is about making something better than if let, but there might not be anything better - they all have downsides. The main proposals all still rely on creating a new variable to replace an old one with the same name, different type - which is a bizarre thing to do when you think about it. It deserves a bizarre syntax like if let x = x to halt the reader to let them know x is about to mean something totally different.

My thread is not about fixing that - it's just about getting the compiler to know what the programmer already knows is true. It goes beyond just optionals, and it allows other enums to "unwrap" naturally as well. It's fine if it only works in 90% of the cases. Type inferencing doesn't work in every case either.

phoneyDev wrote in the other thread, regarding if myViewController != nil { }:

It makes sense that he wondered that - the programmer made an assertion that something was non-nil - the programmer knows it is non-nil, why shouldn't the compiler?

class C {
  var vc: UIViewController?

  func f() {
    DispatchQueue.global().async { self.vc = nil }

    if vc != nil {
      print(vc.view) // <--- might self.vc be nil?
    }
  }
}

This is a problem for reference types - the inferencing would not work in this case, and the compiler would throw an error. All the more reason to use a struct instead of a class.

struct S {
    var vc: String?

    mutating func f() {
        if vc != nil {
            print(self.vc!)
        }
        else {
            print("empty")
        }
    }
}

var s = S()
s.f()

DispatchQueue.global().async { s.vc = nil }
2 Likes

Ooo, you are getting tricky - hanging out in 2 separate mutating funcs at the same time. It is a good point. I suppose my counter argument is that such code is already problematic since structs are not inherently thread safe anyway. There are all kinds of things you can do in there that would cause problems.

I looked at a kotlin project of my coworkers, searched for != null, and counted how many times smart cast succeded, and how many times it failed:
Succeded: 6
optional parameter to function: xxxxx
worked correctly, not a function parameter: x

Failed:21
complex expression, so variable was defined before if: xxxxxxxxx
simple expression, but variable was defined before if anyway: xxxxx
it's checked for null, but they had to !! anyway: xx
it's checked for null, but they had to ?. anyway: xxx
it's checked for null and type, but they had to force cast anyway: xx

Neither: 19
used as a value: xxxxxxxxxxxx
variable not used inside the if: xxxx
variable is used inside if, but it doesn't matter if it's nullable or not: xxx

3 Likes

My understanding is that Swift is designed to signal when tricky things might be happening.

Allowing if x != nil { to treat x as non-Optional and non-nil within the block is just allowing one to omit the ! or ?. Since the compiler can't determine if another thread might modify the property, it would be very inconsistent to allow the omission for, e.g., local variables, but require it for anything else.

There are also tricks with local vars and inout that makes those unsafe from a code-analysis perspective. The number of completely safe scenarios are probably fewer than the problematic ones.

Since Swift prohibits overlapping mutable accesses of value types, I think the compiler would be allowed to assume even in this case that during the execution of f, the value of vc cannot change except by means local to f. Otherwise, there would have to be some other mutable access which occurred during the execution of the mutating function f, which is disallowed under Swift's ownership rules.

ETA: I think we even get a slightly stronger guarantee than "the compiler would be allowed to assume"—since Swift is (intended to be) safe by default, any concurrent access to s during the execution of s.f should be disallowed by the compiler or result in a runtime error during execution.

3 Likes

I do not think there is anything weird about it. Actually it is just a combination of best practice imo:

  1. let x = x just creates a new local variable for a new scope.
  2. Reusing the same name for the same thing after you know it is not nil is also preferable to let xNotNil = x
1 Like

Lucky me, I just stumbled across this code in which someone mistakenly wrote if error.localizedDescription == error.localizedDescription:

func expectFailure<T: Publisher>(of publisher: T, withError error: Error) -> CompetionResult {
    let exp = expectation(description: "Unsuccessful completion of " + String(describing: publisher))
    let cancellable = publisher
        .sink(receiveCompletion: { completion in
            if case .failure(let error) = completion {
                if error.localizedDescription == error.localizedDescription {
                    exp.fulfill()
                }
            }
        }, receiveValue: { _ in })
    return (exp, cancellable)
}

I'm sure they actually meant to be comparing the error from the function parameter with the error created inside the closure. But both variables had the same name. Maybe, if they weren't prone to overriding variable names, they wouldn't have done this?

I think it would be helpful if they could have written:

            if case .failure = completion {
                if completion.localizedDescription == error.localizedDescription {
                    exp.fulfill()
                }
            }

if value != nil {
should be equivalent to
if let value = value {

meaning that inside the braces value is a shadow of the variable outside the braces, with its type promoted to non-optional. Modification of the variable outside the braces in the same thread or other threads will have no effect on the variable inside the braces. inout or attempts to directly assign to nil will have no effect because the variable inside the braces isn't Optional so can't be set to nil.

That's even worse, because if value != nil { already compiles, and does not change the type of value (and why would anyone expect it too?). You'd be breaking any code that does value = nil or calls a method on Optional.

Yes it would be source breaking. I've updated code a million times where an Optional was changed to non-optional. Not a big deal in my opinion.

This whole discussion is about finding another way to spell if let x = x that is somehow better. if x != nil is one way.

I understand what you are saying, but I don't think 90% of them will work. Let's look at cases where 'non-optional inferencing' cannot be applied.

  • Properties which has nonmutating set, especially properties of class
    Because they can be changed silently, it is unsafe.

  • Properties with getter/setter
    As I posted before, getter/setter can make things really complicated. If a property has getter/setter, you must forbid non-optional inferencing. Especially, all computed properties are excluded due to this restriction.

  • Properties of generic type parameters, existential types, and opaque types
    If SomeProtocol has constraint to have value: Int? { get set }, with type parameter T: SomeProtocol and x: T, you can use x.value. However, for x.value, non-optional inferencing should not be applied because it can have getter/setter. The same goes for existential types and opaque types.

    Note that it's not due to the risk that T can be reference type, but due to the risk that property of T can have getter/setter. Value type also has the risk, too.

  • Property wrappers
    wrappedValue can have getter/setter.

  • Subscripts
    Subscripts have getter/setter,

  • Dynamic lookups
    Because internally subscript is used, it is almost equal to have getter/setter.

  • static/class variables and global variables
    Variables declared static or class can be changed across threads, so that it can be changed during operation. (It can be wrong considering the point of @Jumhyn)

  • Function results / Expressions
    Of course, non-optional inferencing should not be applied for x() != nil and x + y != nil.

Roughly speeking, only in a case that all variables in the chain w.x.y.z... are only let declared constant or var declared variable which we know it cannot have getter/setter (e.g. local variables), non-optional inferencing can work. Even array.first != nil cannot work under this limitation because first is served by getter. Then, what percentage of use cases can meet these hard limitations?

I don't think this would be a cool feature, but a source of bugs and confusion. Rather than making some syntax halfway useful, we would better do nothing at all.

3 Likes

Well, every programmer is different, but I practice encapsulating logic into value types and breaking logic into smaller functions, so parameters and vars on structs become the main thing I end up unwrapping. For my reference types, since I avoid a lot of logic in them, I can usually use the map function to avoid if let anyway. So it does seem like 90% to me.

You earlier used the example of a getter that used random() in its body, and sure Swift can't infer whether the result is optional, but there are many getters which Swift would be able to infer from, like var x: Int? { 1 } as the simplest example.

What bugs do you think would be caused by inferencing? I haven't experienced many bugs from type inferencing, so I don't see this causing bugs. I do see it avoiding bugs, like the example of error.localDescription == error.localDescription I mentioned earlier (thats a real world example I stumbled upon today). Creating scoped variables is definitely a helpful tool, but can often be error prone.

Now now, that it solves some common scenarios is admirable. Still, it should never allow incongruent type and value, like having a value of type Int be nil at runtime (as demonstrated above if we allow computed properties). That simply means unsounded type rules, which is a non-starter.

If we try to track whether a getter is safe for this feature... well, it'd become a much larger pitch than it originally let on, not to mention a myriad of corner cases and exceptions.

Barring the computed property isn't viable either, since whether a property is stored or computed is usually not of users' concern. So it would pose some usability issues.

1 Like
Terms of Service

Privacy Policy

Cookie Policy