Skip to content

Instantly share code, notes, and snippets.

@pixyzehn
Forked from erica/unwrap.md
Created November 27, 2016 14:27
Show Gist options
  • Save pixyzehn/12ce274b111c156c89b39e663f628ec1 to your computer and use it in GitHub Desktop.
Save pixyzehn/12ce274b111c156c89b39e663f628ec1 to your computer and use it in GitHub Desktop.

Better Unwrapping

Introduction

This proposal redesigns common unwrapping tasks:

  • It introduces the unwrap keyword for optional values
  • It re-architects guard case and if case grammar to support unwrapping more complex enumerations by dropping the case keyword and replacing = with ~=.
  • It applies unwrap to non-optional values

Swift Evolution threads:

Motivation

Unwrapping values is one of the most common Swift tasks and it is unnecessarily complex. This proposal simplifies this process and enhances code safety and readability.

Optionals

Swift lacks a safe way to bind an optional to a shadowed same-name variable in condition clauses like those used in guard and if statements.

Compare:

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

guard unwrap foo else { ... } // simpler

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. Further, the unwrap keyword is common, simple to understand, and easy to search for if Swift users are unfamiliar with it.

In the rare case that the binding is variable, use the re-imagined var ~= syntax.

General Enumerations

Swift's guard case and if case statements stand out for their unintuitive approach. They look like assignment statements but they are not assignment statements. This violates the principle of least astonishment. This presents difficulties for new language adopters by combining several concepts in a confusing form. They are arguably underutilized by language experts.

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) = and if case let .foo(x) = performs conditional binding on enumerations and applies pattern matching

Both guard case and if case statements perform simultaneous pattern matching and conditional binding. Here are examples demonstrating their use in current Swift:

enum Result<T> { case success(T), error(Error) }

// valid Swift
guard case let .success(value) = result
    else { ... }
    
// valid Swift
guard case .success(let value) = result
    else { ... }

The problems with guard case and if case include:

  • The = operator looks like assignment and not like pattern matching (~=).
  • The case layout is both too close to a switch's case but doesn't follow its syntax. In switch, a case is followed by a colon, not an equal sign.
  • Using the case syntax is unneccessarily wordy. It incorporates case, =, and optionally let/var assignments.

Indirect and Direct Pattern Matching

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

Detailed Design

This proposal introduces the unwrap keyword. The unwrap statement shadows an enumeration variable to an unwrapped version of the same type. Upon adopting this proposal the following statements produce equivalent behvior:

// New unwrap keyword
if unwrap value { ... }

// 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

// Proposed same-name pattern matching and conditional binding
if .some(let value) ~= value { ... } // new grammar
if let .some(value) ~= value { ... } // new grammar

In if case and guard case, this proposal drops the case keyword and replaces the equal sign with the pattern matching operator. The results look like this:

guard let .success(value) ~= result else { ... }
guard .success(let value) ~= result else { ... }
if let .success(value) ~= result { ... }
if .success(let value) ~= result { ... }
guard let x? ~= anOptional else { ... }
if let x? ~= anOptional { ... }

Users may choose to use var instead of let to bind to a variable instead of a constant.

In this update:

  • The case keyword is subsumed into the (existing) pattern matching operator
  • The statements adopt the existing if-let and guard-let syntax, including Optional syntactic sugar.
if let x = anOptional { ... } // current
if case let x? = anOptional { ... } // current, would be removed

if let x? ~= anOptional { ... } // proposed replacement for `if case`

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

Using pattern matching without conditional binding naturally simplifies to a standalone Boolean condition clause.

unwrap and Non-Optionals

Real world Swift enumerations rarely follow the Optional pattern, which can be summed up like this:

enum OptionalLike<T> { case aCaseWithOneAssociatedValue(T), anotherCase }

They more typically look like this:

// One generic, one known type
enum Result<Value> { case success(Value), failure(Error) }

// Many cases of mixed types
enum JSON {
    case string(String)
    case number(Double)
    case boolean(Bool)
    case array([JSON])
    case dictionary([String: JSON])
    case null
}

// Multiple associated values
enum Response { case contact(code: Int, message: String), failure }

You can adapt the bind keyword to work with these real-world cases in one of two ways. The first way uses unwrap instead of let or var. Here are a few varieties of how that call might look versus the proposed update for normal pattern matching:

if unwrap .string(myString) ~= json { ... }
if unwrap .contact(code, _) ~= response { ... }
if unwrap .contact(code, var message) ~= response { ... }

// vs proposed

if let .string(myString) ~= json { ... }
if var .string(myString) ~= json { ... }
if .contact(let code, _) ~= response { ... }
if .contact(let code, var message) ~= response { ... }

Although slightly wordier than let and var, the unwrap solution offers advantages:

  • It enhances readability. If the goal is to unwrap an embedded value, unwrap uses a more appropriate term.
  • It establishes one place for the keyword to live instead of the "does it go inside or outside" status quo. A consistent place means prettier code.
  • As shown in these examples, it can easily be adapted for variable binding. If you want to override the let behavior, you can insert a var inside the parentheses.

A second, riskier, cleaner approach looks like this. It assumes the compiler can pick up and shadow using either the associated value labels (preferred) or the enumeration name (as you'd see with String raw value enumerations):

if unwrap .contact ~= response { 
   ... some compiler magic picks up on the `code` and `message` labels
   used for initialization, so the bound variables are `code`
   and `message`
}

if unwrap .string ~= json { 
   ... use `string` here (same name as enum case) because
   no label was used to define the associated value
}

for and switch

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

switch result {
    case .contact(0, _): ...
    case let .contact(code, message) where code < 10: ...
    case unwrap .contact(code, message) where code > 100: ...
    ...
}

for unwrap .string(aString) in jsonArray {
    ...
}

Impact on Existing Code

This proposal is breaking and would require migration.

Alternatives Considered

  • Leaving the grammar as-is.
  • Retaining case and replacing the equal sign with ~= (pattern matching) or : (to match the switch statement).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment