Skip to content

Instantly share code, notes, and snippets.

@pcantrell
Last active July 10, 2018 05:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pcantrell/652e2764da104322962dc450dc20315e to your computer and use it in GitHub Desktop.
Save pcantrell/652e2764da104322962dc450dc20315e to your computer and use it in GitHub Desktop.
assuming

Goals

  • Create a standard way for devs to document what assumption led them to use an unsafe operation (e.g. force unwrap).
  • Cover all trapping operations (!, try!, as!, []) through a single mechanism. (Lack of this seems to have been Lattner’s primary beef with the previous proposal.)
  • Make this mechanism work as both:
    • developer documentation — clear, readable, and not excessively noisy in the code itself — and
    • runtime diagnostic, included in console / crash logs to expedite debugging.

Solution

Consider this example, where potentially useful contextualizing information is missing from the crash log:

URL(string: ":")!  // safe because a single colon is a valid URL

The newly proposed assuming(…) function (the name is placeholder!) behaves as a simple pass-through, but attaches a documentation / diagnostic message to any enclosed unsafe operations that fail. The message will appear in the crash log if the theoretically unfailable operation fails.

Taking the example above:

assuming("a single colon is a valid URL", performUnsafe: URL(string: ":")!)

Now suppose, for example, that Foundation changes its URL formatting rules and the code above suddenly fails. Here is the resulting console output:

Fatal error: Unexpectedly found nil while unwrapping an Optional value
In context of assumption: a single colon is a valid URL

(Again, the name is a placeholder. If people like the general idea, then we can argue about naming.)

The second argument is an autoclosure. It seems sensible to also allow an explicit closure form, making this is an alternate spelling:

assuming("a single colon is a valid URL") {
    URL(string: ":")!
}

Note that the force unwrap operator is still present. Unlike the previous proposal, this new construct does not replace the existing unsafe operation. In fact, semantically the new function does nothing; it is just a pass-through that ignores its first argument and returns the second:

func assuming<T>(_ assumption: String, performUnsafe operation: () throws -> T) rethrows -> T {
    return try operation()
}

func assuming<T>(_ assumption: String, performUnsafe operation: @autoclosure () throws -> T) rethrows -> T {
    return try operation()
}

The only effect of this function is that if operation traps, Swift outputs assumption to the console along with the usual crash information.

This approach has several advantages:

  • This mechanism applies to all trapping operations, including as!, try!, array index out of bounds, int overflow, etc.
  • No new operator supplanting existing ones.
  • No obfuscated respelling of existing mechanisms using more complex syntax such as ?? fatalError("…")
  • No dependency on Never being a bottom type.

Disadvantages:

  • Much more verbose than a comment.
  • Requires compiler magic.
  • People can argue about the name ad nauseum, so they will.

Examples

All of the examples in this proposal come from real open source libraries. (The one above is from Siesta.) In many cases, the assumption text comes from a comment already present in the code.

// PerfectLib

return assuming("UUIDs are always valid Unicode",
    performUnsafe: String(validatingUTF8: unu)!)

This may be overkill; just as with comments, teams will have to determine their personal threshold for how obvious is too obvious:

// Alamofire

for key in parameters.keys.sorted(by: <) {
    let value = assuming("key came from parameters.keys, thus must be in dictionary",
        performUnsafe: parameters[key]!)
    components += queryComponents(fromKey: key, value: value)
}

I find that the explicit closure form can aid readability in complex expressions:

// Siesta

return request(method,
    data: assuming("a URL-escaped string is already ASCII") {
        urlEncodedParams.data(using: String.Encoding.ascii)!
    },
    contentType: "application/x-www-form-urlencoded",
    requestMutation: requestMutation)

An advantage of this proposal is that it can cover multiple force unwraps in a single expression if there is a shared assumption that underlies them:

// PromiseKit

return _when([pu.asVoid(), pv.asVoid()]).map(on: nil) {
    assuming("both promises are now completed and have values" {
        (pu.value!, pv.value!)
    }
}

Works with try! too:

// RxSwift

return assuming("subject can't error out or be disposed",
    performUnsafe: try! _subject.value())

This is one where I found the comment really helpful in context — and where the runtime diagnostic would be very informative if the operation did ever fail:

// GRDB

serializedDatabase =
    assuming("SQLite always succeeds creating an in-memory database") {
        try! SerializedDatabase(
            path: ":memory:",
            configuration: configuration,
            schemaCache: SimpleDatabaseSchemaCache())
    }

Sometimes it’s more readable to wrap a larger expression, instead of tightly wrapping just the one part that contains the unsafe operation:

// Result

self = assuming("anything we can catch is convertible to Error",
    performUnsafe: .failure(error as! Error))

In some cases, the assumption is obvious and the existing console error message is already useful enough that there’s no point in using this new feature. For example, by far the most common occurrence of try! seems to be compiling hard-coded regular expressions:

// R

private let numberPrefixRegex = try! NSRegularExpression(pattern: "^[0-9]+")

Thorny details

Calls to assumption(…) can be nested. When the process traps, it emits all enclosing messages, most deeply nested first.

I think messages will need to be attached to traps lexically, meaning that if the operation calls another function that traps, the compiler does not print your assumption message:

assuming("death wish", performUnsafe: (nil as String?)!)  // this prints "death wish"

func unwrapIt(_ s: String?) -> String { return s! }   // trap occurs here, thus...
assuming("death wish", performUnsafe: unwrapIt(nil))  // ...this does NOT print "death wish"

// this pathologic example DOES print "death wish" again
assuming("death wish") {
    func unwrapIt(_ s: String?) -> String { return s! }
    return unwrapIt(nil)
}

Brent however points out that this limits the feature’s ability to handle subscript errors….

This function does not provide a recovery path for traps, or a way to attach any additional behavior to them other than emitting an additional diagnostic message.

Using unsafe operations outside of assumption(…) should continue to be legal, and should not even emit a warning. However, linters will certainly want to provide the option to check for this.

Appendix A

Scanning the Swift source compat suite for examples, I got a rough usage count of unsafe operations, which is sort of interesting to see:

operation count
force unwrap ~2000
as! 394
try! 107

Appendix B

Am I using the word “trapping” correctly throughout this document?

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