Make try? + optional chain flattening work together

If the goal is correctness then why doesn’t optional chaining work like the following example:
let doubleOptional = optionalMethod()?.anotherOptional
// doubleOptional: Optional<Optional<...>>

I think that correctness in this case was ruled out because it made the language more difficult to deal with and the same would go for try? With optional chaining. I have been wanting this for a long time. The question mark on the try seems anough to cause me to think about the error handling and don’t feel that having nested optional types will help that anymore

1 Like

Most arguments in favor of this change in behavior I have thus far read seem to argue this: try? is supposed to ignore the errors produced by the expression, and fails do do so when the expression is an Optional. This totally ignores the following:

If you try? an Optional-returning throwing method you are ignoring the error. Look at how these two examples compare:

let a = someOptionalString() 
// a is Optional<String>

guard let b = try? someOptionalThrowingString() else { ... }
// b is Optional<String>, error cases successfully ignored \o/

The result of the function call in the success case, where no error is thrown, is an Optional. Thus, changing try? to flatten that optional would conflate the case where an error was thrown with some of the cases where the function returned successfully. Now this may be exactly what you want in some cases, especially where Optional is (mis?)used for error handling, but it certainly is not applicable in all cases.


The issue of consistency, especially wrt. generics has also been brought up:

func doInBackground<T>(
    _ fn: @escaping () throws -> T,
    then: @escaping (T) -> Void,
    onError: @escaping () -> ()
) {
    DispatchQueue.global().async {
        guard let res = try? fn() else { onError(); return }
        then(res)
    }
}

func loadDataFromWeb() throws -> Data { ... }
func loadCachedData() throws -> Data? { ... }

doInBackground(
    loadCachedData,
    then: { print("yay: \($0)") },
    onError: { print("nay :(" }
)

If loadCachedData returns nil in this example, is then called, or is onError called?

5 Likes

If the compiler is changed to flatten double-Optionals resulting from use of try?, then your implementation of doInBackground() is buggy. No one is arguing that this won't break some code.

So you’re telling me you want to change the languale in a way that try? can not be used on generic types anymore without introducing (hidden) buggy behaviour? Or are you saying this should be a compiler error (which would be better, but still pretty bad)?

Not all uses are buggy. If you know that try? truly throws away all error information, you write such code differently. Such as

func doInBackground<T>(
    _ fn: @escaping () throws -> T,
    then: @escaping (T) -> Void,
    onError: @escaping () -> ()
) {
    DispatchQueue.global().async {
        do {
            if let res = try fn() { then(res) }
        }
        catch { onError(); }
    }
}

In code like this, I'd expect the developer to pass the error on, anyway, and let the caller ignore it if it wants to.

But that’s what I’m getting at. try? throws away all error information at this very moment. A nil return value is not an error.

3 Likes

That an error occurred is information about the error.

1 Like

if I use try? I don't care if an error occured. I just want a value or nil. If I wanted to know that information I wouldn't use try? in the first place!

1 Like

Precisely.

The compiler does type inference automatically in this case, but if we were to spell it out, the crucial line would look like this:

// ... snip ...
guard let res: T = try? fn() else { onError(); return }
// ... snip ...

Note that res has a type of T here, which is not known to be optional. Even in the case where func loadCachedData()->Data? is passed, the compiler just sees T, not Data?.

As a result, the compiler will not attempt any double-unwrapping. As I'm planning to implement it and propose, the behavior of this code would not change. try? should continue to work as it always has with generics.

1 Like

Yup, I admit that was kind of a rhetorical question, because with the way generics work in Swift, this couldn't behave any differently unless try? did some run-time checking for whether the value is actually an Optional and nil.

And that's the consistency argument: With the proposed change, this code:

func doInBackground<T>(
    _ fn: @escaping () throws -> T,
    then: @escaping (T) -> Void,
    onError: @escaping () -> ()
) {
    DispatchQueue.global().async {
        guard let res = try? fn() else { onError(); return }
        then(res)
    }
}

will behave wildly different than the same code, just replacing T with a concrete type:

func doInBackground(
    _ fn: @escaping () throws -> Data?,
    then: @escaping (Data?) -> Void,
    onError: @escaping () -> ()
) {
    DispatchQueue.global().async {
        guard let res = try? fn() else { onError(); return }
        then(res)
    }
}

I'm not sure we have any other construct that changes behaviour this much depending on the types/when generics are "manually specialized" (does anyone have examples?), but I do know that in my opinion this would make try?'s behaviour inconsistent and hard to reason about, even ignoring the conflation of errors and result values.

4 Likes

That's true; the code does behave differently when "manually specialized", and that does feel a little weird. I'm not sure it's a deal-breaker, though.

If your code is actually trying to detect error cases, (as in the doInBackground example above), it seems unlikely that you'd want to use try? anyway. You're much more likely to be using regular do-try-catch in that case. With this change, we would be formalizing that concept:

  • try is used to detect and react to error conditions
  • try? is used to extract a value when possible out of a throwing expression

Under this proposed change, there is a small chance of a "correctness trap" when you manually choose to specialize something that was previously generic. Under the current behavior, I would suggest that there's a much larger chance of a correctness trap when someone writes if let x = try? foo() as? Int and finds that x is still an Optional.

2 Likes

Another common point of friction I run into would not be fixed by this proposal, which makes me wonder if this proposal is the wrong approach:

func getObjectCount() throws -> Int {
   // ...
}

func numberOfRowsInTableView() -> Int {
    return try? self.getObjectCount() ?? 0
}

Currently, this warns that the ?? 0 is unused because it is parsed as try? (self.getObjectCount() ?? 0), and getObjectCount() does not return an optional. It also errors because I'm returning an Optional<Int> from a function that expects an Int return. I often end up manually wrapping it like so:

func numberOfRowsInTableView() -> Int {
    return (try? self.getObjectCount()) ?? 0
}

That works, and is the same solution I commonly apply to guard let x = (try? throwingThing()) as? Int. The fact that the same solution is applied in multiple places makes me wonder if the right approach is instead to bind try? more tightly than ?? and as?. In practice, I think that's what I generally want. Changing the precedence of an expression like that, though, feels like it could break a lot of other things.

3 Likes

Your case is inherently different than what is being discussed here. In your case, a successful call of getObjectCount() doesn't return an Optional, so after getting your precedence correct for ??, you get the non-Optional you are after.

The two issues are orthogonal, so I wouldn't expect one proposal to cover both.

Perhaps so. It was just interesting to me that in two distinct cases involving try?, the behavior I generally always want involves binding the try? more tightly with parentheses.

I'm not sure how binding try? more tightly to its expression will help with unwrapping double-Optionals.

In many cases, tighter binding would avoid creating double-optionals in the first place.

// With current binding:
let x = try? makeSomething() as? Int     // let x: Int??

// With explicit tighter binding:
let y = (try? makeSomething()) as? Int   // let y: Int?

Tighter binding doesn't fix the case of a throwing function that also returns an optional, though, as in the original post on this thread. So maybe tighter binding is just a separate proposal that could be considered on its own merits, aside from the try?-flattening behavior proposed here.

I think it would have to be a separate proposal, yeah.

If there's some sort of consensus that double optionals don't make much sense as a result of try?, I think we have to discuss if they make sense at all.

Ceylon, for example, models Optionals as Null | T, so it doesn't have the issue presented here.
My personal opinion is that this approach is better, but Swift took a different route with enums, and I don't think we should introduce a special case for try?.

1 Like

I've thought more about this since my earlier post and I wanted to expand on it a little. Generally I would prefer fewer special case hacks like this because they make the system harder to understand. It's much easier to understand try? as turning throws -> T into T? without adding “unless T is statically known to be already Optional<U> in which case it's just T, and if T is Optional<Optional<U>> then who knows).

Now, if it poses a significant burden then it might be worthwhile having some special casing. Optional chaining would be easier to understand if every ?. introduced a new level of optionality, but that would be burdensome in some cases, particularly when interoperating with Objective-C frameworks where nil messaging and nullable references are pervasive. So that special case can be reasonably justified in context. Even there, I think it can be argued that it went too far. It's not clear to me that the type of i1 and i2 in this should be the same:

struct S {
  func returnsOptional() -> Int? { return nil }
  func returnsNonOptional() -> Int { return 0 }
}

let s: S? = S()
let i1 = s?.returnsNonOptional() // Int?
let i2 = s?.returnsOptional()    // Int?

because it unnecessarily conflates the two things (nil messaging and return type). But perhaps this is also justifiable if it's common to call functions that return Optionals in deep hierarchies of Optional types. Is it?

So I guess my question is, does this come up often enough that it's worth adding another special case to the system? I would guess that it is fairly infrequent, though perhaps the automatic conversion of some of the Objective-C framework APIs into throwing functions in Swift has made this more common than I think. Does anyone have good practical examples where this situation is common?

2 Likes