Introducing `Unwrappable`, a biased unwrapping protocol

I like the proposal in that it generalizes a built-in feature of the language.

But with regards to the example in the motivation I would not be using it for my own Result implementations. I always want throwing behavior when unwrapping Results to encourage handling the error.

I know that this is just one example, but I can’t think of any types in my own code that would benefit from this behavior.

So can I say +1 for the nice generalization and flexibility that it could bring to the language, but a ‘0’ for concrete motivation for use cases?

I’m -1, simply because I feel we should have the behavior for a Result type to be defined before we try to abstract commonalities at the compiler.

For example, I would expect optional chaining to propagate any errors, rather than returning .none, and might expect the failure to be accessible inside an if-let-else or guard-let-else.

It seems more appropriate that if Result is added, an internal protocol abstracts commonalities, then when desirable third party usage is discovered, we evaluate exposing that based on those use cases.

2 Likes

As already pointed out, this protocol could actually be called OptionalConvertible — and it might be possible to easily turn it into something that is much more versatile:

protocol Convertible {
   associatedtype Element
   func poseAs() -> Element
}

Besides changed names, this only removes a “?” — but insted of having T as associated type, you simply use T?, and the whole unwrapping would work as in the proposal.
But you could also do something like this:

struct Person {
    var firstName: String
    var lastName: String
}

struct Customer: Convertible {
    var person: Person
    var customerId: Int

    func poseAs() —> Person {
        return person
    }
}

and use a Customer-struct like

print(customer.firstName)

Changing the protocol requirement to a property instead a function would make this stunt even easier, and we had something to work around the lack of inheritance for structs.

3 Likes

I like these result like types and how they neatly tie in with if let binding and guard. I think it both brings uniformity where needed and allows a different path for results propagation than exceptions (which should not be used IMHO for flow control).

The proposal idea is great, but I believe it still needs compiler magic as unwrap() function still returns Optional. I think this implementation is more consistent with the aim to remove compiiler magic.

protocol Unwrappable {
    associatedtype Element
    func canUnwrap() -> Bool
    func unwrap() -> Element
}

extension Unwrappable {
    static func ? (lhs: Self) -> Element? { // postfix operator
        if lhs.canUnwrap() {
            return lhs.unwrap()
        } else {
            // Still optional here. Maybe we must introduce something in compiler to allow terminate optional chaining.
            return nil
        }
    }
    
    static func ! (lhs: Self) { // postfix operator
        return lhs.unwrap()
    }

    static func ?? (lhs: Self, rhs: Element) -> Element { // postfix operator
        if lhs.canUnwrap() {
            return lhs.unwrap()
        } else {
            return rhs
        }
    }
}

enum Optional<T> {
    associatedType Element = T
    case some(T)
    case none

    func canUnwrap() -> Bool {
        return self != .none
    }

    func unwrap() -> T {
        switch self {
        case .some(let value):
            return value
        case .none:
            fatalError(“Bad Access”)
    }
 }

A better way is to keep Optional as compiler magic and introduce OptionalConvertible protocol instead.

1 Like

I’ll take that :slight_smile:

On a serious note, and this might be worth posting as a separate proposal, but I think that there is something to gain both from splitting the ?? operator into two, with distinct rhs’s, as well as having them de-magicised into functions on Optional, maybe mapNil: (() -> Element) -> Element? and flatMapNil: (() -> Element?) -> Element?

In addition, my understanding of foo?.bar has always been as sugar for foo.flatMap { $0.bar }. The key question is: is this sugar supposed to be specific to optional, or should it be extended to Monad if that ever comes through? My intuition is that it only makes sense on optional, and that if we really want to we can introduce a separate, more general, operator for the sugar above, maybe *. or whatever. So Tino’s proposal of OptionalConvertible makes the most sense to me, maybe with the following definition:

// Maybe with alternative that doesn't throw
protocol OptionalConvertible {
    associatedtype Element
    func toOptionalValue() throws -> Element?
}

extension Result: OptionalConvertible {
    func toOptionalValue() throws -> Element? {
        switch self {
        case let .success(value): return value
        case let .failure(error): throw error
        }
    }
}

let personResult = Result.success(person)

try (personResult.toOptionalValue()?.age == personResult?.age) // true

if let person = try personResult { // equivalent to if let person = try personResult.toOptionalValue()
    ...
}

The difference with the original proposal is that the name makes the link to optional clear, and allows for throwing in the case or Result-like types.

I instinctively love this proposal, but the more I think about it the more I have doubts:

  • All Optional sugar is made in a way that completely ignores the non-“positive” cases, which for Optional it makes sense because there’s not much to say about none, apart from knowing it’s not it. Basically we’re trying to fit an Optional-size dress to other types.
  • From my point of view this is just moving more and more in the Monad direction:
    • guard let can be seen as a map with a scope reversal (is this even a thing?). Of course it has the benefit of guard with the compiler-checked else scope.
// Sugared
guard let unwrapped = wrapped else {
  bar()
  return
}
foo(unwrapped)
// Monadic
wrapped.map({ unwrapped in
  foo(unwrapped)
})
bar()
    • if let can be seen as a map.
// Sugared
if let unwrapped = wrapped {
  foo(unwrapped)
}
// Monadic
wrapped.map({ unwrapped in 
  foo(unwrapped)
})
    • ?. is flatMap. When the chained value is not Optional, it can be interpreted either as a map or still a flatMap hopping on the implicit promotion of values to Optionals.
// Sugared
let foo = wrapped?.wrappedProperty
let bar = wrapped?.unwrappedProperty
// Monadic
let foo = wrapped.flatMap({ $0.wrappedProperty })
let bar = wrapped.map({ $0.unwrappedProperty })
let bar2 = wrapped.flatMap({ $0.unwrappedProperty }) // will work as the function will return an `Optional`

of course this sugar is helpful as you can easily bind multiple unwrappings together, and also easier to debug as it doesn’t change scope.
Though at this point I wonder if we should aim for a do-notation style directly instead? (I suppose this would mean making Optional conform to Sequence? :scream:)

But I believe that even in the case that it gets ultimately determined that it’s better that this sugar is for Optional only, probably moving the compiler magic to a dependency and not the type itself might be a good idea…

2 Likes

I didn’t think carefully about the proposed design. I agree that a proper solution would propagate “non-wrapped” values. This would require a design that generalized “short-circuiting” monads such as Optional and Result. I don’t think such a design is possible without type system enhancements. IMO it’s more important to get the design right than it is to add it now.

Strongly dislike this proposal. I love how elegant it feels, but the semantics of if let, optional chaining etc don’t really work elsewhere, and can be downright dangerous.

For example, as noted by others, being able to if let or optional chain a Result makes it way too easy to ignore failure conditions, or in the case of someone reading your code, not even be aware that they can exist - it’s like an implicit, empty catch block that swallows exceptions.

.

2 Likes

Kinda like how if let lets you ignore the scenario when you get .none? By your argument, we should have to switch on every optional value and handle .some(_) and .none explicitly.

And yeah, we already have implicit empty catch blocks that swallow exceptions. It's try?.

4 Likes

Indeed, but what about the else of that if let? In the Optional case, you already have all the available data, while in other Unwrappable types you are likely to lose information.
I can see the value in this because my Result has var value: Wrapped? { ... } and I do use it often, but the fact that you have to do If let unwrapped = wrapped.value { makes it a bit more explicit and feels more like “I know what I’m doing” (even though of course that doesn’t have to be true).
Maybe it’s just a thing on being used to have the sugar for Optional and feels weird otherwise :man_shrugging:

Very true, which also means that the language supports an easy way of ignoring errors. But it’s still explicit.

1 Like

FWIW, I regret try?. I suspect the most common use is probably to ignore an error entirely (_ = try? foo()), and when you aren’t ignoring the error you end up not being able to even log what it was.

3 Likes

I was inclined to argue with this, but a quick search of my own code reveals that it is indeed true.

In defense of try?:

  • In the places where I did use it, ignoring the error really was the right thing to do. (Common case: chain of several operations, and error is better reported as top-level “operation failed on the following data” instead of “opaque implementation detail failed in this way that’s not meaningful outside of this particular code.”)
  • It’s really convenient for code scripting, running a research experiment, etc. where the developer is always the one running it, and “I’ll dig in if it breaks” is the preferable approach.
  • The parallel structures of !/?, as?/as!, try?/try! yield a nice mental model.
2 Likes

In this sort of situation, it seems to me like the ! forms should be the ones you want.

4 Likes

Sure, I’ll concede that. There are moments when a script really does want “maybe do this and then proceed regardless,” but even in quick & dirty scripting, they’re rare.

Is there any way this could be based on ExpressibleByNilLiteral? The two concepts seem very closely related:

extension ExpressibleByNilLiteral where Self: Equatable {
    //Unwrappable
    func unwrap() -> Self? {
        return self == nil ? nil : self
    }
}

That would not make sense. The most immediate example we have of a conforming type is Result and that is most definitely should not be ExpressibleByNilLiteral. We could invent struct NilError: Error {} and use that in the nil literal initializer but it would defeat the purpose of using Result instead of `Optional in the first place. If people want to do that it should happen outside the standard library.

Good point. Perhaps this would just make sense as an automatic conformance to Unwrappable for ExpressibleByNilLiteral types. While you can still compare the result to nil, it should universally make sense to optional-chain and if let unwrap on these types.

+1 to this!

This has the added benefit of helping constrain extensions to optional, too. Currently this requires manually implementing an OptionalType protocol and conforming Optional to it.

extension Array where Element == Optional { } // not gonna work

While you can compare an Unwrappable to nil, you can’t instantiate one from it (since you don’t know the internals from the protocol itself), so conforming to ExpressibleByNilLiteral is a no-go. Unless you want the protocol to also define a “default” failure case? e.g. let aResult: Result<Int> = nil // .failure(NilError()).

Terms of Service

Privacy Policy

Cookie Policy