Introducing `Unwrappable`, a biased unwrapping protocol

Introducing an Unwrappable protocol

Introduction

This proposal introduces an Unwrappable protocol, which introduces Optional-like behavior for any biased wrapped type. It extends Optional sugar to any associated-type enumeration.

Swift-evolution thread:
TBD

Motivation

Optional magic should not be limited to the Optional type. So long as an enumeration is biased towards a positive case (in Optional, that is .some), they should be able to conform to an Unwrappable protocol and inherit the features that power optional sugar in the Swift language.

Unwrappable will allow the compiler to move unwrapping from its internals into the standard library. This allows Swift to simplify compiler implementation and unify its optional-specific behaviors (including chaining, nil-coalescing (??), if let/guard let, forced unwraps (!), etc) so they can be repurposed for additional types.

An Unwrappable protocol instead of a single generic enum or struct (as with Optional) supports more complex cases, greater overall flexibility, and better reach to other types that can take advantage of unwrapping features.

For example, a Result type conforming to Unwrappable could use already-existing constructs to process success cases:

enum Result: Unwrappable { ... }

if let value = result {
    // use the value from Result's `.success(T)` case
}

Detailed Design

Conforming type define an Element associated type, and provide an unwrap method that attempts to extract that value from any enum case:

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

Unwrappable types declare their preference for a single unwrapped type like the some case for Optional and the success or value case in a (yet to be designed) result type, e.g. enum Result<T, E> { case success(T), other(E) }. Adopting Unwrappable enables conforming types to inherit many behaviors currently limited to optionals.

Conforming Result to Unwrappable enables users to introduce if let binding and functional chaining that checks for and succeeds with a success case (type T) but otherwise discards and ignores errors associated with other. Unwrappable allows you to shortcut, coalesce, and unwrap-to-bind as you would an Optional, as there's always a single favored type to prefer.

Unwrappable enables code to ignore and discard an error associated with failure, just as you would when applying try? to convert a thrown value to an optional. The same mechanics to apply to Optional would apply to Result including features like optional chaining, forced unwrapping and so forth.

The design of Unwrappable should allow an unpreferred error case to throw on demand and there should be an easy way to convert a throwing closure to a Result. Should a boilerplate enum property proposal be accepted, you could produce code along these lines:

if let value = result {
    // use result
} else {
  if let error = result.fail.value {
    throw error
  }
  Fatal.unreachable("Should never get here") // Dave DeLong's Fatal umbrella type
}

Impact on Existing Code

This proposal will largely impact the compiler instead of end-users, who should not notice any changes outside of new standard library features.

Source compatibility

This proposal is strictly additive.

Effect on ABI stability

This proposal does not affect ABI stability.

Effect on API resilience

This proposal does not affect ABI resilience.

Alternatives Considered

Not adopting this proposal

22 Likes

Overall I'm a +1 on this, on both the naming and the concept.

My only hesitation is that I worry about abusing Unwrappable to add it to things that maybe shouldn't have it, but off the top of my head I can't even think of such an abuse, so maybe it's a red herring?

But yeah, +1 for turning compiler magic in to standard library constructs.

1 Like

Intriguing! And nifty!

Like Dave, I’m inclined to like anything that turns specially blessed magical types into composable, comprehensible features.

I'm having trouble imagining all the applications and implications. Result (and other “error-bearing optional” sorts of things) are the obvious one. Are there others? Erica, what (other) kinds of applications did you have in mind?

Swift-evo often tends toward argument from the abstract, but with this proposal especially, I would love to see examples of how this could play in out practice to solve concrete problems.

I like some of the ideas here, but I don't like the dilution of ?.

  • Types: Int? means Optional<Int> (Optional)

  • Chaining: foo?.bar means what foo.unwrap()?.bar means today (Unwrappable)

  • Coalescing: foo ?? bar means…well, I'm not sure, actually. That needs to go into the proposal specifically if you want to extend it to Unwrappable. (I'm particularly concerned about the form that takes two Optionals, not the one that takes an Optional and an Element.)

  • Pattern-matching: case let foo?: could mean Unwrappable, but then you'd still get diagnostics about not handling all cases, because the compiler can't tell how unwrap() maps to cases.

  • try?: still produces an Optional (also kind of an anti-pattern anyway)

  • init?: still produces an Optional

  • Ternary operator: a ? b : c has nothing to do with Optional or Unwrappable (included for completeness and because I think "Replace ternary _ ? _ : _ operator with a binary _ ? _ operator" is clever but not something we'll actually do)

So in the end, the only feature with ? in it that clearly and easily benefits from this is chaining. It also benefits if let and guard let. I'm not sure those justify the proposal, especially when it's not really that bad to call .unwrap() explicitly or use a higher-order function (think flatMap or ifSuccess).

I do appreciate having this written out! I don't think I would have been able to consider the tradeoffs otherwise.

12 Likes

If unwrap was changed to a property, which is more in line with naming conventions anyway, then chaining if let, and guard let are probably short enough to not worry about the rest of the sugar. Useful addition to the standard library without all the sugar anyway.

It would mean, if lhs wraps the preferred type it returns the wrapped value, otherwise bar.

I don't see Int? changing. case let foo? attempts to unwrap the associated type. If it can't, it's nil. try? and init? are unchanged. Ternary is unaffected.

So the features with easy benefits are: guard let, if let, case let, ?? and ? chaining.

+1. This seems like a simple and elegant generalization that would streamline code in a variety of small but noticeable ways. Even if Result were the only possible use case, that alone would be a pretty compelling reason to implement this.

I have mixed feelings about the name Unwrappable, though. As an English word, it's ambiguous: does it mean "something that can be unwrapped" or "something that cannot be wrapped"? Obviously, the intended meaning is the former, but my brain still flips back and forth between the two interpretations even though the intention is clear.

More to the point, the unwrap operation of an Unwrappable doesn't in fact "unwrap" its central value. Au contraire: it wraps that value in an Optional. I wonder if something like OptionalConvertible wouldn't be clearer and more accurate.

Optional retains its special role and compiler magic under this proposal. @jrose seems to think that's a design weakness, but it seems fine to me. This proposal is just about adding automatic conversion to Optional in a few specific contexts. (Yet another reason, perhaps, to frame the protocol name in those terms rather than in terms of a vaguely-defined "unwrapping".)

Presumably, Optional itself would not be Unwrappable, is that correct?

It's tacitly assumed in the description that only enumerations have any business being Unwrappable. But there's currently no way to enforce this, and I'm not sure it would make sense to do so just out of conservatism. That said, I can imagine a variety of ways developers might apply this feature in regrettable ways. For example, I wouldn't want to see if let person = person as a way to check a model object for validity.

6 Likes

+1 to this direction. I have wanted something like this ever since Swift was released. As for details, @jrose asks some good questions. I would like to see the proposed details be elaborated and clarified before moving forward.

One minor quibble is with the choice of Element for the name of the associated type. Optional's type argument is called Wrapped and this proposal is called Unwrappable. IMO it makes the most sense to call the associated type Wrapped.

1 Like

I'm very much in favor of replacing highly specific compiler magic with more general mechanisms that can be easily composed and was hoping to look at doing so for Optional specifically at some point.

This pitch seems a little low on details at the moment, though.

For example how does force-unwrapping work? The pitch seems to indicate that it would be supported for these types.

Is there an implied subtype relationship between the associated type Element and the types the conform to Unwrappable?

For Optional, we consider T a subtype of T? and this plays a part in a variety of places in the type checker. For example in determining types of collections, in performing conversions to T? in arguments, etc. This is all hard-coded at the moment and would need to be generalized as well, which may very well be relatively straightforward, but still needs to be thought through.

2 Likes

Huge fan of this direction. While I'm still formulating thoughts on details, I'd like to throw the name OptionalProtocol into the mix for future bikeshedding.

  • It's familiar and obvious for those on the path of progressively disclosed complexity (learn Optional in the first chapters of a book, learn about the protocol behind it in the protocol chapter, this name would create a natural continuation).
  • It's somewhat "traditional". For example, we have StringProtocol and IteratorProtocol from standard library today.
  • "optional" is a precise description of the unwrap interface: you may get an Optional from something that conforms to OptionalProtocol.
4 Likes

I like this idea a lot. I think a full proposal could delve a little further into how this might affect the standard library and other patterns that are already out there. For example, the lack of this protocol has prevented adding a simple compact() method for sequences of optionals; the proposal might want to include this addition:

extension Sequence where Element : Unwrappable {
    func compact() -> [Element.Element] { ... }
}

Would the proposal recommend that APIs throughout the language that work with optionals be genericized to work with the Unwrappable protocol? For example, the closure you pass to Sequence.flatMapcompactMap could return any kind of Unwrappable type, not just optionals:

extension Sequence {
    func compactMap<T: Unwrappable>(_ transform: (Element) -> T) -> [T.Element] { ... }
}

The ?? operator has a couple different variants — for the version that currently allows chaining optionals we should be clear whether the new version chains to an optional or chains to the same Unwrappable type used on the left-hand side. That is, which of these would we use, or would we use both?

/// always results in an optional value
func ??<T: Unwrappable>(lhs: T, rhs: @autoclosure () -> T.Element?) -> T.Element? { ... }

/// results in the same `Unwrappable` type that you started with
func ??<T: Unwrappable>(lhs: T, rhs: @autoclosure () -> T) -> T { ... }

Does it work when mixing two different Unwrappable types? Except that I can't figure out how to write such a function, so I guess the answer is no.

In foo ?? bar, if foo and bar are Results in the failure state, the "traditional" thing to do is to use foo's error, not bar's. That's at odds with the definition you provided.

("traditional" = "what Haskell's Either and Rust's Result do")

Again, it can do this, but it won't be exhaustive. Maybe that's okay, though?

switch someResult {
case let foo?:
  print(foo)
case .error(_):
  print("uh oh")
} // error, .success not handled

I think you mean compactMap. :-)

1 Like

:man_facepalming:t3:

1 Like

So if I’m understanding this pitch correctly, it doesn’t so much remove the compiler magic around Optionals but extend the compiler magic to also work on types that aren’t optional, because it relies on the unwrap method to return an optional, which would then be unwrapped as normal...

I was hoping for a more comprehensive rethink of the optional unwrap system to put Optional on par with others as sibling unwrap types, but I can see how that would be difficult because each type would need a way to denote whether the value unwrapped or failed. The only way I would think that could work would be for the unwrap method to throw instead, but I expect that would be a massive overhead at runtime...

My initial feeling is to be against this, since I don't think the structures and operations we use with Optionals are necessarily suited to any other type, even limiting those to two part enums with a weighted side. For example, nothing in the pitch would allow any easier use of a Result type's .failure state, which is rather important for proper error handling. Treating Result.success like Optional.some feels inappropriate when we want users to handle errors properly. Otherwise, they might as well just return optionals. So really, this proposal allows users to treat any conforming type as an optional, up until the moment they need to access whatever it is that make the type different than an optional. This is especially true when many of these weighted types expose their captured values as properties. For example Result typically has value and error properties, which can be used like any other optional.

6 Likes

This. The reason Optional works with this model is because it has 2 states - a value, and an empty state with no further information. Are there motivating examples besides Result? I couldn't think of any good ones.

@nnnnnnnn also had a good point about when to use Optional vs. Unwrappable in generic code. This is close what I call the "Mappable anti-pattern" that I've seen some people fall in to: as a protocol (aside from the compiler magic it would enable), Unwrappable is useless; you can't write any generic algorithms using it (you do the single transformation which it provides, but otherwise it's an algorithm on Optional).

2 Likes

This is a good point. Clearly, this is no good:

guard let result = resultProducingFunction() else {
    // What, exactly? The result is already lost.
}

But the naive solution of just saving the raw return value brings its own warts:

let result = resultProducingFunction() // Extra line
guard let result = result else { // Vacuous
    // Wait, what? Ahh, result has its original value despite
    // being embedded in what looks like an unwrapped context.
    fatalError("Fatal error, code \(result.failure.errorCode)")
}
5 Likes

Just off the top of my head:

enum PingResponse : Unwrappable {
     typealias Element = Data
     case { responsive(Data), unresponsive(String)
    func unwrap() -> ...
}

if let data = response { ...use data... }
enum Grade: Unwrappable {
   typealias Element = Int
   case pass(Int), failing(Int), incomplete
   func unwrap() -> Int? {
     switch self {
     case pass(let grade): return grade
     case fail(let grade): return grade
     case incomplete: return nil
     }
   }
}

guard let grade = grade else { return }
print("Your final grade was \(grade)"}

I think the basic idea of the proposal is very interesting and I am glad it was brought up. At first I thought that this would be really cool to have but the longer I think about it the less I think this is the way to go.

When I look at someone's code and see a guard let, if let, optional chaining, or nil coalescing, I immediately know what is going on.

Should the same syntax be used with arbitrary result types it would be next to impossible to know what such a construct does immediately unless I go hunting down the type that is being used with it. And at the next callsite, some other type could be involved with optional syntax.

But maybe I am missing something here.

4 Likes