Skip to content

Instantly share code, notes, and snippets.

@erica
Last active March 28, 2022 03:56
Show Gist options
  • Save erica/aea6a1c55e9e92f843f92e2b16879b0f to your computer and use it in GitHub Desktop.
Save erica/aea6a1c55e9e92f843f92e2b16879b0f to your computer and use it in GitHub Desktop.

Better Unwrapping

Unwrapping values is one of the most common Swift tasks and it is unnecessarily complex.

Consider the following solutions:

  • Introduce an unwrap keyword for Optional values
  • Introduce an Unwrappable protocol for associated-value enumerations.
  • Apply unwrap to non-Optional values.
  • Extend for and switch.
  • Fix pattern match binding issues.
  • Simplify complex binding.

Motivation

Unwrapping with conditional binding and pattern matching is unnecessarily complex and dangerous:

  • Using "foo = foo" fails DRY principles.
  • Using case let .some(foo) = foo or case .some(let foo) = foo fails KISS principles.
  • Using the = operator fails the Principle of Least Astonishment.
  • Allowing user-provided names may shadow existing variables without compiler warnings.
  • The complexity and freedom of let and var placement can introduce bugs in edge cases.

Don't Repeat Yourself

The following code fails DRY:

guard let foo = foo else { ... } // redundant

Keep it Simple

The following code fails KISS:

guard case let .some(foo) = foo else { ... } // overly complex

Least Astonishment

The status quo for the = operator is iteratively built up in this fashion:

  • = performs assignment
  • let x = performs binding
  • if let x = performs conditional binding on optionals
  • if case .foo(let x) = performs conditional binding on enumerations and applies pattern matching

These two statements are functionally identical:

if range ~= myValue { ... } // simpler
if case range = myValue { ... } // confusing

guard case and if case look like standard non-conditional assignment statements but they are not assignment statements.

Further:

  • In switch, a case is followed by a colon, not an assignment operator.
  • Swift has a pattern matching operator (~=) but does not use it here.

Shadow Danger I

The following code shadows an existing symbol, since the unwrapped version does not constrain itself to a same-name convention:

if let existingSymbol = optionalSymbol { ... }

Shadow Danger II

Using let externally can introduce bugs in outlier edge conditions when simultaneously pattern matching and performing variable binding. The following example demonstrates this issue.

Consider the following enumeration and values:

// An enum with one, two, or three associated values
enum Value<T> { case one(T), two(T, T), three(T, T, T) }

// An example with two associated values
let example2: Value<Character> = .two("a", "b")

// A bound symbol
let oldValue = "x"

This code's goal is to conditionally bind newValue and pattern match the value stored in the oldValue symbol. The first example succeeds. The second example compiles and runs but does not match the coder's intent. Using an external let creates a new oldValue shadow instead of pattern matching oldValue's stored value:

// Safe
if case .two(let newValue, oldValue) = example2 { 
    ... 
}

// Syntactically legal but incorrect
if case let .two(newValue, oldValue) = example2 { 
    ... 
}

Solutions

The following solutions address the issues discussed in the previous section.

Introduce an unwrap keyword

Introducing an unwrap keyword produces a shadowed same-name variable in condition clauses like those used in guard and if statements.

guard unwrap foo else { ... } // simpler
guard unwrap var foo else { ... } // variable version
guard unwrap let foo else { ... } // for language consistency

This simplifies the status quo and eleminates unintended shadows

// Existing same-name shadowing
if let value = value { ... }

// Existing same-name pattern matching and conditional binding
if case .some(let value) = value { ... } // old grammar
if case let .some(value) = value { ... } // old grammar

Using unwrap guarantees that an unwrapped shadow uses the same name as the wrapped version. This ensures that a conditionally-bound item cannot accidentally shadow another symbol. It eliminates repetition and retains clarity. The unwrap keyword is common, simple to understand, and easy to search for if Swift users are unfamiliar with it.

Introduce an Unwrappable protocol

Optional magic should not be limited to the Optional type. An Unwrappable protocol allows associated-type enumerations to support unwrapping beyond the Optional type. This opens the door to a better way to introduce biased-value enumerations. Biased-value refers to an associated type enumeration that has a natural "success" underlying type, which may or may not be limited to a single case.

Removing unwrapping support from the compiler into the standard library enables Swift to simplify compiler details and unify optional-specific behaviors like chaining, nil-coalescing (??), if let, forced unwrap (!), etc, enabling their repurposing for additional types as well as optionals.

Using a protocol for Unwrappable instead of a single generic enum or struct (as with Optional) supports more complex cases and enhances overall flexibility.

This protocol design allows a conforming type to unwrap a single associated value type, which can be used in one or more "success" cases:

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

Apply unwrap to non-Optional values

Unwrappable types declare their preference for a single unwrapped type like the some case for Optional and the success case in a (yet to be designed) Result type. Adopting Unwrappable allows conforming types to inherit many behaviors currently limited to optionals.

Conforming Result to Unwrappable would enable you 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 failure (conforming to Error). 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.

For example:

public enum Result<T> { 
    case success(value: T)
    case failure(error: Error) 
}

public extension Result {
    public typealias BoxedValue = T

    public func unwrap() -> T? {
       guard case success(let value) = self 
           else { return nil }
       return value
	}
}

public enum Either<Left, Right> {
    case left(Left)
    case right(Right)
}

public extension Either: Unwrappable {
    public typealias BoxedValue = Right
    
    public func unwrap() -> Right? {
        guard case right(let value) = self
            else { return nil }
        return value
    }
}

public enum EitherOr<T> {
    case left(T), right(T)
}

public extension EitherOr: Unwrappable {
    public typealias BoxedValue = T
    public func unwrap() -> T? {
        switch self {
        case left(let value): return value
        case right(let value): return value
        }
    }
}

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.

// Any throwing call or block can become a `Result`:
let myResult = Result(of: try myThrowingCall()) 

// Later (this could even be in a different thread)
let value = try unwrap myResult // throws on `failure`

Extend for and switch

If unwrap is adopted for non-Optionals, valid code might include:

switch result {
    case eitherInstance: ...
    case .left(let value) where value < 10: ...
    case unwrap value where value > 100: ...
    ...
}

for unwrap value in resultsArray {
    ...
}

Fix Pattern Match Binding

Simultaneously unwrapping and pattern matching can introduce errors by introducing a new shadow instead of matching an existing symbol.

...oldValue is bound...

// Syntactically legal but incorrect
// The intent is to bind 
if case let .two(newValue, oldValue) = example2 { 
    ... 
}

To prevent this, Swift can:

  • Emit a warning when shadowing a bound symbol during pattern matching; or
  • Disallow external let/var, moving their use to each site.

See SR-4174.

Simplify Complex Binding

Swift uses two kinds of pattern matching.

Indirect pattern matching such as the kind you see in switch and for statements receives an argument in from the statement structure. The argument is not mentioned directly in the case:

switch value {
case .foo(let x): ... use x ...
...
}

for case .foo(let x) in value { ... }

Direct pattern matching including guard/if statements and with the pattern matching operator place the argument to be matched to the right of an operator, either = or ~=. The argument is explicitly mentioned:

if case .foo(let x) = value { ... use x ... }
if 100...200 ~= value { ... }

When using if case/guard case in the absence of conditional binding, statements duplicate basic pattern matching with less obvious semantics. These following two statements are functionally identical. The second uses an assignment operator and the case keyword.

if range ~= value { ... } // simpler
if case range = value { ... } // confusing

Dropping the case keyword and replaces = with ~= simplifies pattern matching for non-Unwrappable values. The results look like this, showcasing a variety of let placement, variable binding, and optional sugar alternatives.

guard .success(let value, _, _) ~= result else { ... } guard .success(var value, _, _) ~= result else { ... } if .success(let value, _, _) ~= result { ... } if .success(var value, _, _) ~= result { ... }

On adopting this syntax, the two identical range tests naturally unify to this single version:

if range ~= value { ... } // before
if case range = value { ... } // before

if range ~= value { ... } // after

Some list participants have requested values on the lhs, patterns on the rhs. Using =~ instead of ~= could support reversed layout.

@dwaite
Copy link

dwaite commented Mar 8, 2017

Re: Apply Unwrap to Non-optional Values, I don't know if this is a good idea, because the types may be implicit. Someone may not know the variable contains a Result and not an Optional.

For result, if unwrap result would make it very difficult for someone reading the code to understand that there is potentially an error being ignored when the block skips.

For an Either type, this makes it unclear that there may be another value that is being ignored.

Of course, without Result and Either implementing Unwrappable, there isn't really a reason to have the protocol

@1oo7
Copy link

1oo7 commented Oct 1, 2020

This is the best Swift proposal I've ever read. Shame it wasn't adopted!

@jpmhouston
Copy link

jpmhouston commented Mar 28, 2022

Since SE-0345 I've also been pondering an "unwrap" keyword. You could say I've been thinking about additional uses to justify its addition.

In my posts on that recent pitch and proposal, I've realized something about existing if foo = someOptional expressions: they're somewhat lacking in the clarity department since unwrapping is only subtly implied. The equals sign behaves differently in that context as opposed to normal initialization. Maybe the existing syntax should become just a shortcut for a retconned full syntax that involves "unwrap":
if let constantName = unwrap someOptional { ... }
guard let constantName = unwrap someOptional else { ... }

This could extend to a retconned full syntax for force unwrapping as well. Admittedly this might not be so useful an addition in practice. but maybe it's nice for it to exist for consistency:
let constantName = unwrap! someOptional
print(unwrap! someOptional)

These unwrap expressions could then lead to new force unwrap statements, and the related use cases from your proposal that can be used for same-name shadowing:
unwrap! foo // as a statement, behaves like a force-unwrap "guard", afterwards unwrapped foo shadowed
if unwrap foo { ... }
guard unwrap foo else { ... }
guard var unwrap foo else { ... } // i prefer this with "var" as an adverb, over "unwrap var"

What were the objections to your proposal originally? If a followup was focused on just the above, do you think it would fare any better?

– Pierre in Vancouver, big "!!" fan

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment