- 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.
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.
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]+")
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.
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 |
Am I using the word “trapping” correctly throughout this document?