Why does optional binding allow multiple levels of optionality?

TSPL states that

If any of the values in the optional bindings are nil or any Boolean condition evaluates to false , the whole if statement’s condition is considered to be false .

So I'm surprised to find that optional binding checks for only one level of optionality:

let x: [Int?]: [nil, 1, 2]
if let y = x.first {
    print(type(of: y)) // prints "Optional<Int>"
    print(y)           // prints "nil"
}

In many cases in the language (e.g. optional chaining (thanks to @cukr for the correction) conditional type casting try?), multi-level optionality is flattened to just one level (e.g. Optional<Optional<Optional<_>>> is treated as Optional<_>). So I wonder what the rationale behind the different behavior for optional binding is, or if either this behavior or the documentation is incorrect?

The way I see it is that with optional chaining you are explicitly flattening.
Also, one can rely on the optionality of the first element and this wouldn't be possible if you binding could elide more optionality levels. juste compactMap x if you don't care about nil values.

1 Like

in optional chaining, each question mark removes only a single level of optionality

example
extension Optional {
    var foo: String { "this is from Optional" }
}
extension Int {
    var foo: String { "this is from Int" }
}
let multiOptional: Int??? = .some(.some(123))

print(multiOptional???.foo) // Optional("this is from Int")
print(multiOptional?.foo) // Optional("this is from Optional")

Casting ignores multi-level optionality, but that's consistent with ignoring multi-level inheritance (for example you can cast to a subclass of a subclass of a subclass)


try? in the past wasn't flattening the Optional, but we changed that. You can read about it here: swift-evolution/0230-flatten-optional-try.md at main · apple/swift-evolution · GitHub

I didn't know this. Thanks for letting me know. I was basing my original statement on this from TSPL:

However, multiple levels of optional chaining don’t add more levels of optionality to the returned value.

To put it another way:

  • If the type you are trying to retrieve isn’t optional, it will become optional because of the optional chaining.
  • If the type you are trying to retrieve is already optional, it will not become more optional because of the chaining.

Without answering your question, I can offer this workaround to test for any level of optional chaining:

if let y = x.first ?? nil {
    // the ?? flattens the optional to a single level
}
1 Like

This seems like a bug to me.

It is not. The array do have a first element.

4 Likes

Let me be more explicit then. I cannot think of a rational explanation for this exception to the rule that if let x = ... flattens to a single level of Optional<> and then tests that against nil.

It is difficult to understand why the following example behaves differently:

let x: Int?? = nil
print(type(of: x))    // prints "Optional<Optional<Int>>"
if let y = x {
    print(type(of: y))
}
else {
    print("x is nil") // prints "x is nil"
}

I think that would be

let x: Int?? = .some(nil)

to match the array example.

Is there a problem though? Storing optionals in containers is (IMHO) a very rare thing.

I think the fact that you are correct is itself the problem. The distinction is completely non-obvious, and even knowing that it exists, I can't explain to myself why it behaves differently.

I may try, using our if let y = x.first example.

Array.first is documented as "If the collection is empty, the value of this property is nil".

OK, so we can build as isEmpty function which accepts any array and uses first in order to compute its result:

func isEmpty<T>(_ x: [T]) -> Bool {
    if let y = x.first { return false } else { return true }
}

And we expect isEmpty to return false for [nil, 1, 2]:

let x: [Int?] = [nil, 1, 2]
let result = isEmpty(x)
print(result) // false

Do you agree so far?

And if we replace isEmpty with its implementation, we get:

let x: [Int?] = [nil, 1, 2]
let result: Bool
if let y = x.first { result = false } else { result = true }
print(result) // still false (and y was set nil)

Conclusion: according to this story, we could say that the "why" is "support for generic programming, where an array of Int? is processed in the same way as an array of Int, String, or URL?". Emptiness, and having a first element or not, do not depend on the type of the elements.

There are other parts of the language where optionals are squashed together. For example, take try?:

func foo() throws -> Int? { ... }
let y = try foo()  // Int?
let y = try? foo() // Int? as well

So yes you'll find that the language is not 100% regular. Yet if let a = b peals only one level of optionality, that's for sure.

3 Likes

If I may be pedantic, (at least as far as I understand) the documentation means empty collection implies nil from first, but nil from first does not imply empty collection. So, it should not be correct to do this:

Well… :roll_eyes: You're being pedantic indeed. If first worked as you say it could work, it would be a pretty poor piece of useless api.

1 Like

To say it more kindly than @gwendal.roue, I think the correct understanding is as-follows:

  1. Collection.first will always return nil if the collection is empty.
  2. If the collection's first element is itself nil, then first returns Optional(nil). i.e. an Optional wrapping an Optional.

The last point is always true. It thus follows that the inverse of (1) is also true: first will never return nil if the collection is not empty. The value, or even type, of the first element does not matter.

I now understand the difference between the behavior of if let with respect to Optional chaining and the type of the last property in the chain. I think I knew this before, but it was not clear in my mind.

2 Likes

Thank you @Avi, and apologies, @wowbagger, if my rolled eyes sounded rude. I've been looking for other pieces of documentation where first has a less ambiguous documentation, without success. But yes, we are 100% sure that first will never return nil for a non-empty array:

func sanityTest<T>(array: [T]) {
    if let y = array.first {
        assert(array.isEmpty == false)
    } else {
        assert(array.isEmpty)
    }
}

This is actually an important technique, for functions that need to process empty arrays in a specific way, and generally empty collections. For example:

extension String {
    var uppercasingFirstCharacter: String {
        guard let first = first else {
            // Empty input, empty output
            return ""
        }
        return String(first).uppercased() + dropFirst()
    }
}

"player".uppercasingFirstCharacter // "Player"
2 Likes

The misunderstanding here is that nil is not an independent value—it requires some sort of type context in order for it to be meaningful. In the context of the first property, that type context is Element?. With respect to the type Element?, nil always means Optional<Element>.none. That the Element type itself could possibly be optional (in which case nil is given an additional meaning ‘internal’ to the collection) is independent of the documented behavior for first (which is general over all possible types for Element).

5 Likes

Well my pedantry deserved the rolled eye, because I know that in practice a non-empty collection must always return a .some.

Now I do see the utility of optional binding unwrapping only 1 level of optionality from this snippet:

which cannot be rewritten as

if x.first != nil ...

This also reminds me that optional binding is a special case of patten matching, which if desugared is just case .some(let ...).

:point_up:

This is really important. nil is a literal, and needs a type to be used as a value.

Int?, and String? is just sugar for Optional<Int> and Optional<String>. You cannot compare, assign, or otherwise mix one nil of type Int? with a nil of type String?.

Similarly, you cannot mix Collection.Element? aka Optional<Collection.Element> with Int? aka Optional<Int> unless Collection.Element == Int. This is key. For in this example Collection.Element != Int, but rather Optional<Int>. Thus, the return value from .first is

Collection.Element? aka Optional<Collection.Element> aka Optional<Int?> aka Int??

To fix this, one could help the type system interpret nil. These all work

if x.first ?? nil == nil ...
if x.first != nil as Int? ...
if x.first != .some(nil) ...
if x.first != Int?(nil) ...

It is left as an exercise to the reader to determine the type of each nil literal in the code above.

2 Likes

The syntax if let y = x.first is syntatic sugar for if case .some(let y) = x.first. It is performing a pattern match. To unwrap two levels of Optional, you need the pattern to match two levels: if case .some(.some(let y)) = x.first. You can use a trailing ? instead of .some(...) instead: if case let y? = x.first unwraps one level and if case let y?? = x.first unwraps two levels.

You can unwrap any number of levels of Optional using a cast: if let y = x.first as? Int.

8 Likes