Make try? + optional chain flattening work together


#3

This is not an optional-chaining issue *per se*. If you simply write,

let a = try? SomeType().doThrow()

// a has type SomeType??

you get a double-optional as well. Are you proposing to change that?

Nevin

···

On Fri, Jan 12, 2018 at 12:25 PM, Russ Bishop via swift-evolution < swift-evolution@swift.org> wrote:

Greetings swift-evolution!

There is currently a disconnect between optional chaining and try? when it
comes to optional flattening:

struct SomeType {
    func nonThrow() -> SomeType? { return self }
    func doThrow() throws -> SomeType? { return self }
    func nonOptional() throws -> SomeType { return self }
}

let w = SomeType().nonThrow()?.nonThrow()?.nonThrow()?.nonThrow()
// w has type SomeType?

let x = try? SomeType().nonOptional().nonOptional().nonOptional().nonOpti
onal()
// x has type SomeType?

let y = try! SomeType().doThrow()?.doThrow()?.doThrow()?.doThrow()
// y has type SomeType?

let z = try? SomeType().doThrow()?.doThrow()?.doThrow()?.doThrow()
// z has type SomeType??

We get a double-optional only when combining try? and optional-chaining.
That is inconvenient and it would be natural to have the compiler do the
flattening here.

If anyone is interested in working on the proposal or implementation
please let me know. It would make a nice self-contained task if you're
looking to start contributing.

Russ Bishop
 Simulator

_______________________________________________
swift-evolution mailing list
swift-evolution@swift.org
https://lists.swift.org/mailman/listinfo/swift-evolution


(John McCall) #4

I agree that this behavior is annoying. However, wouldn’t it be source-breaking to change this now?

Source compatibility means that we can't change the behavior of Swift 3 / Swift 4 source. This would be a semantic change when building Swift 5 source (or later). There is no technical reason we couldn't make this change. It does need to meet a very high bar, because we are trying to avoid making significant semantic breaks. My personal sense is that it meets that bar because double-optionals can be very confusing for novices and very annoying for everyone else.

I think most use sites probably do want the optional-collapsing behavior. 'try?' is already "sugar" syntax and should aim to be as convenient as possible for the majority of use cases. Much like the collapsing done by optional chaining, I think you can come up with examples where somebody would want the non-collapsing behavior, but it doesn't seem unreasonable to say that they just shouldn't use the sugar.

John.

···

On Jan 12, 2018, at 12:53 PM, BJ Homer via swift-evolution <swift-evolution@swift.org> wrote:

-BJ

On Jan 12, 2018, at 10:25 AM, Russ Bishop via swift-evolution <swift-evolution@swift.org <mailto:swift-evolution@swift.org>> wrote:

Greetings swift-evolution!

There is currently a disconnect between optional chaining and try? when it comes to optional flattening:

struct SomeType {
    func nonThrow() -> SomeType? { return self }
    func doThrow() throws -> SomeType? { return self }
    func nonOptional() throws -> SomeType { return self }
}

let w = SomeType().nonThrow()?.nonThrow()?.nonThrow()?.nonThrow()
// w has type SomeType?

let x = try? SomeType().nonOptional().nonOptional().nonOptional().nonOptional()
// x has type SomeType?

let y = try! SomeType().doThrow()?.doThrow()?.doThrow()?.doThrow()
// y has type SomeType?

let z = try? SomeType().doThrow()?.doThrow()?.doThrow()?.doThrow()
// z has type SomeType??

We get a double-optional only when combining try? and optional-chaining. That is inconvenient and it would be natural to have the compiler do the flattening here.

If anyone is interested in working on the proposal or implementation please let me know. It would make a nice self-contained task if you're looking to start contributing.

Russ Bishop
 Simulator

_______________________________________________
swift-evolution mailing list
swift-evolution@swift.org <mailto:swift-evolution@swift.org>
https://lists.swift.org/mailman/listinfo/swift-evolution

_______________________________________________
swift-evolution mailing list
swift-evolution@swift.org
https://lists.swift.org/mailman/listinfo/swift-evolution


(Matthew Johnson) #5

I agree that this behavior is annoying. However, wouldn’t it be source-breaking to change this now?

Source compatibility means that we can't change the behavior of Swift 3 / Swift 4 source. This would be a semantic change when building Swift 5 source (or later). There is no technical reason we couldn't make this change. It does need to meet a very high bar, because we are trying to avoid making significant semantic breaks. My personal sense is that it meets that bar because double-optionals can be very confusing for novices and very annoying for everyone else.

I think most use sites probably do want the optional-collapsing behavior. 'try?' is already "sugar" syntax and should aim to be as convenient as possible for the majority of use cases. Much like the collapsing done by optional chaining, I think you can come up with examples where somebody would want the non-collapsing behavior, but it doesn't seem unreasonable to say that they just shouldn't use the sugar.

I think I agree with this. `try?` already has some implicit optional collapsing behavior when it is used in an expression where there is more than one call that can throw. The most intuitive behavior is for this collapsing to compose with the collapsing of optional chaining.

···

On Jan 12, 2018, at 2:00 PM, John McCall via swift-evolution <swift-evolution@swift.org> wrote:

On Jan 12, 2018, at 12:53 PM, BJ Homer via swift-evolution <swift-evolution@swift.org <mailto:swift-evolution@swift.org>> wrote:

John.

-BJ

On Jan 12, 2018, at 10:25 AM, Russ Bishop via swift-evolution <swift-evolution@swift.org <mailto:swift-evolution@swift.org>> wrote:

Greetings swift-evolution!

There is currently a disconnect between optional chaining and try? when it comes to optional flattening:

struct SomeType {
    func nonThrow() -> SomeType? { return self }
    func doThrow() throws -> SomeType? { return self }
    func nonOptional() throws -> SomeType { return self }
}

let w = SomeType().nonThrow()?.nonThrow()?.nonThrow()?.nonThrow()
// w has type SomeType?

let x = try? SomeType().nonOptional().nonOptional().nonOptional().nonOptional()
// x has type SomeType?

let y = try! SomeType().doThrow()?.doThrow()?.doThrow()?.doThrow()
// y has type SomeType?

let z = try? SomeType().doThrow()?.doThrow()?.doThrow()?.doThrow()
// z has type SomeType??

We get a double-optional only when combining try? and optional-chaining. That is inconvenient and it would be natural to have the compiler do the flattening here.

If anyone is interested in working on the proposal or implementation please let me know. It would make a nice self-contained task if you're looking to start contributing.

Russ Bishop
 Simulator

_______________________________________________
swift-evolution mailing list
swift-evolution@swift.org <mailto:swift-evolution@swift.org>
https://lists.swift.org/mailman/listinfo/swift-evolution

_______________________________________________
swift-evolution mailing list
swift-evolution@swift.org <mailto:swift-evolution@swift.org>
https://lists.swift.org/mailman/listinfo/swift-evolution

_______________________________________________
swift-evolution mailing list
swift-evolution@swift.org
https://lists.swift.org/mailman/listinfo/swift-evolution


#6

I think the double optional is correct. If the try succeeds (nothing thrown) then the value returned must be .some. If doThrow() returns nil, then the chained expression is .none. Hence the value may be .some(.none), which requires a double optional to express.


#7

Are you looking for something similar to this sample?

func someThrowingFunc() throws -> Int? {
    return nil
}

(try? someThrowingFunc()).flatMap { $0 } // Int?

Which declaration should be invoked when user call method with try? and store result to let result: Int?

func someThrowingFunc() throws -> Int? {
    return nil
}

func someThrowingFunc() throws -> Int {
    return 10
}

(BJ Homer) #8

I'm interested in moving this forward, and I'd love to work on an implementation. I'm starting to poke around a bit in the compiler and I've found OptionalTryExpr used in a few places, but I definitely still have a lot of exploring still to do. Russ, If you have any pointers of where to look, I'd love to hear it.

I've run into another pain point with try? lately. I believe this would also be addressed by the same change.

class NSManagedObjectContext {
    func existingObject(with identifier: NSManagedObjectID) throws -> NSManagedObject
    // ...
}

class PizzaTopping: NSManagedObject { }


func test() {
    guard let topping = try? context.managedObject(with: someObjectID) as? PizzaTopping 
        else { return }
    
    // topping is of type `Optional<PizzaTopping> here, because the try? expression 
    // produced a nested optional, and then `guard let` unwrapped one of them.
    // It seems natural that the user would expect it to be non-optional at this point.
}

If we have try? flatten the optional when it's expression produces an Optional, then I believe this use case would also feel more natural, because the user has explicitly expressed that they wanted a PizzaTopping, and guard let x = someExpr as? MyType generally leads to let x: MyType.


(John McCall) #9

In ConstraintGenerator::visitOptionalTryExpr in CSGen.cpp, it currently builds the type τ?, sets it as the type of the try?, and then adds a ConstraintKind::ObjectType constraint between τ? and the type of the sub-expression. This constraint says that the type of the sub-expression should be the object type of τ?, which basically means it should be τ with some additional unimportant rules about l-values and so on. You should instead add a constraint of type ConstraintKind::Conversion between the type of the sub-expression and τ? (so also flipping the order of the types).

And then in ExprRewriter::visitOptionalTryExpr in CSApply.cpp, it currently just assumes that the sub-expression is already the right type, which won't be good enough. Instead, you will need to call coerceToType on the sub-expression (using the simplified type of the try? as the destination type). You can pattern the code after what visitOptionalEvaluationExpr does, except without the special case in the middle (the if (!SuppressDiagnostics) block).


(BJ Homer) #10

Awesome, thank you! I'll see if I can get this working, then work on writing up a proposal.


(Jeremy Pereira) #11

I agree with Bob. The double optional is correct. Each "layer" means a different thing. The outermost one means "this function should have thrown an error but we are suppressing it". The inner one means the function executed fine and returned an optional.

Using try? with a function that returns optional is a bit of a code smell in the first place so using it should be a bit awkward.


#12

While you and bob are technically correct, the behavior is not useful. The entire point of using a construct such as guard let x = try? ... else { } is to ignore the error. The current behavior forces the developer to not ignore the error, because he is forced to unwrap in the (common) case where there is no exception.

In other words, a developer will always write the equivalent to

guard let maybeX = try? throwingFunc(), let x = maybeX else { return }

because that's what he wanted in the first place by using try? over try.


(Jeremy Pereira) #13

While you and bob are technically correct, the behavior is not useful

Yes it is.

The entire point of using a construct such as guard let x = try? ... else { } is to ignore the error.

Exactly. Why are you ignoring the error? I really don't think making it easy to ignore errors should be the goal of the language.

Put it this way, if somebody designs a function that can both return an optional and throw an error, then knowing what the error is must be really important or the designer would just make it return an optional and not throw. Or at least, there must be a conceptual difference between the reason for returning nil and the reason for throwing and covering it up is a bad move IMO.


(Matthew Johnson) #14

This only considers a very simple expression. It is also possible that there are two different subexpressions, one of which throws and one of which returns Optional. Consider the following code:

func nonEmpty(_ s: String) -> String? {
    guard !s.isEmpty else {
        return nil
    }
    return s
}

func makeString() throws -> String {
    return ""
}

let s = try? nonEmpty(makeString())

This is admittedly a toy example but demonstrates the point. The Optional produced by try? is because an input to nonEmpty threw, not because nonEmpty returns an Optional and also `throws. The double optional is almost certainly not what is intended here.

try? is a form of syntactic sugar. The design of syntactic sugar should be informed by the most common syntactic patterns in real-world code. I have been paying close attention to the behavior of try? in my code since the last time this issue was raise. I have never once wanted the double Optional. In some contexts I have used flatMap { $0 } to flatten it out, particularly in cases involving Objective-C interop and in code that is migrating from Objective-C to Swift.

I have see little merit in the "correctness" argument for this syntactic sugar in a language that has optional chaining. IMO, try? should be integrated with optional chaining (by flattening if necessary). In my experience this is the sugar that would be most useful and is what most people intuitively expect (because of the way optional chaining works).

I would challenge people who support the double optional behavior to demonstrate use cases where the double optional is in fact helpful. Is there real world code which relies on this behavior which would become worse if it was removed? If real world code is using the double optional behavior advantageously that would be a strong case against a source-braking change that would affect such code.


#15

In my view, your argument is valid with respect to including try? in the language. That battle, if there was one, was lost. The question today is whether anyone wants the double-Optional behavior in practice.


Should `OptionalTryExpression` changes require SILGen changes too?
#16

This is not the only question. Even if it's rare to want a particular behaviour, it may be preferable because it's more consistent, easier to reason about, and/or fits in better with the type system (thinking carefully about what happens in generic code, etc). I don't know what I think in this particular case, though.


#17

It's possible to get the current behavior by splitting the statements.

let attempt = try? ...
if let attempt = attempt, let success = attempt ...

Optional chaining only unwraps for conditional let statements, such as with if and guard. If the statement is a simple let, no unwrapping is done, and you'll still have the double-Optional if no exception was thrown.

That's how I would proceed, anyway.


(Jeremy Pereira) #18

The double optional is almost certainly not what is intended here.

No, what is intended is usually to silence the error produced by the compiler. Making the author think a bit in this example is a good thing in my opinion.

I have see little merit in the "correctness" argument for this syntactic sugar in a language that has optional chaining

It currently is correct in the sense that the behaviour is logical. I see little merit in deliberately breaking the correctness for something that probably has occasional use and encourages the conflation of error conditions and normal nil returns.


#19

Correctness is subjective in this instance. If the expected behavior on first seeing if let x = try? ... is that x will be non-Optional, then that is at least as correct as the current behavior. And unless someone can come up with a compelling use for the current behavior, I don't see how improving the ergonomics is bad.


(Matthew Johnson) #20

This is a syntactic sugar feature. A design that is orthogonal to optional chaining is not any more logical than a design that is integrated with optional chaining.

In the cases where I've run into this the code is usually written in a way such that there are exactly two paths that matter: the one in which you can get a value out and the other where you can't. It doesn't matter why you couldn't get the value out. For example, I have seen a number of cases where code moves from weakly typed to strongly types data and an as? cast is also involved. If the underlying cause is not required for diagnostics there may not be any reason to distinguish the reason why you failed to go from Data to a value of the expected type.


(Kaden Wilkinson) #21

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


(Lukas Stabe 🙃) #22

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?