I formerly posted a less-fleshed-out version of this in the “Reducing bridging magic” thread, but I thought this might warrant its own pitch. What do you all think?
Over the past couple of years, Swift has made great strides toward seamless interoperability with existing Objective-C APIs, and with SE-0005, SE-0033, SE-0057, SE-0062, SE-0064, and SE-0070, seems poised to become even better in that regard. However, there still exists one major pain point when going back and forth between Swift and Objective-C, and that lies in the area of error reporting. Passing errors between Objective-C and Swift APIs is currently quite awkward, for several reasons:
- The Swift-approved mechanism for reporting errors is a protocol named ErrorType (ErrorProtocol in the latest sources). However, Objective-C represent errors using a class named NSError. In addition to being a reference type, which feels quite unnatural for an error object by Swift’s conventions, NSError follows a completely paradigm from what most ErrorProtocol objects use to store errors, using a string-based domain and and integer code, along with a userInfo dictionary to store information to be presented to the user. While the domain and code are available as methods on ErrorProtocol, they are prefixed with underscores, and there is no direct equivalent to userInfo.
- Unlike other Objective-C classes like NSString and NSArray which are consistently bridged to value types when presenting Objective-C interfaces to Swift, the handling of NSError objects is inconsistent. Objective-C APIs which return an error by reference using an autoreleasing NSError ** pointer are converted to use the Swift try/catch mechanism, presenting the returned error as an ErrorProtocol (which is actually an NSError). Similarly, Swift APIs using try/catch are presented to Objective-C as autoreleasing NSError ** pointers, and the ErrorProtocol-conforming error is converted to an NSError when it is called by Objective-C. However, when passing around error objects in any way other than these, the errors are not bridged. An Objective-C API that takes an NSError, such as NSApp’s -presentError: method, still leaves NSError as the type in the interface presented to Swift, as do the many asynchronous APIs in Cocoa that return an NSError as one of the arguments to a completion handler. Swift APIs that accept ErrorProtocols, on the other hand, are not presented to Objective-C at all, necessitating any such APIs also be declared to take NSErrors.
- To convert ErrorProtocols to NSErrors, Swift provides a bridging mechanism, invoked via “as NSError”, which wraps the error in a private NSError subclass class called _SwiftNativeNSError. This subclass can be cast back to the original error type, thus returning the original wrapped error. When a Swift API that is marked “throws” is called from Objective-C and then throws an error, the same bridging mechanism is invoked. However, this bridging is not very useful, since Cocoa tends to use NSError’s userInfo dictionary to present error information to the user, and ErrorProtocol contains no equivalent to the userInfo dictionary. The result of this is that when a Swift API throws an error, and this error is passed to Cocoa, the user tends to get a generic error message instead of something actually useful.
- The above problem means that a Swift developer must be very careful never to use “as NSError”, and to be sure to construct an NSError when throwing an error in an API that may be called from Objective-C, rather than simply throwing the error directly, or else the error will not be properly presented. If the developer makes a mistake here, it will not be known until runtime. I have personally wasted quite a bit of time trying to hunt down points in a complicated program where an error was accidentally converted to NSError via the bridge rather than explicitly.
- The same problem also puts the Swift developer between a rock and a hard place, if they have other code that wants to check these errors. In a pure-Swift program, checking against a particular error can often be done simply via an equality check. If the error has been converted to NSError via the bridge, this also works, since the bridge will return the original Swift error when casted. However, if the API that threw the error has been conscientious about constructing an NSError to avoid the userInfo issue, the NSError will not be easily castable back to the original Swift error type. Instead, the developer will have to compare the NSError’s error domain and code. The code itself will have to have been assigned by the throwing API. As the domain is stringly-typed and the code will often be extraneous to the actual error definition, this is all very runtime-dependent and can easily become incorrect or out of sync, which will break the program’s error reporting.
- The UI for creating NSError objects is extremely verbose, and eminently un-Swift-like, usually requiring two lines of code: one to construct a dictionary, with an extremely verbose key—NSLocalizedFailureReasonErrorKey—to indicate the actual error message text to the user, and one to construct the NSError object. The latter is itself quite verbose, requiring the developer to enter values for a domain and code which she typically does not care about, since ErrorProtocol provides decent enough default implementations for those values in most cases.
- Due to bugs in the bridging mechanism, it is possible for a _SwiftNativeNSError to get run a second time through the bridge, which removes the userInfo dictionary altogether, once again result in incorrect error reporting.
- The need for the “as NSError” bridging mechanism makes it more difficult to implement otherwise positive changes such as Joe Groff’s proposal to simplify the “as?” keyword (https://github.com/apple/swift-evolution/pull/289).
- Finally, the fact that Swift code that deals with errors must always be filled with either “as NSError” statements or explicit NSError initializations sprinkled through results in code that is quite a bit uglier than it needs to be.
I propose consistently bridging NSError to a value type whenever it is exposed to Swift code via an API signature, and doing the equivalent in the opposite direction, similarly to how NSStrings and Strings are bridged to and from each other in API signatures.
The benefits of this approach are many:
1. This is very similar to the bridging that already exists for String<->NSString, Array<->NSArray, when crossing the language boundary, so this improves the consistency of the language.
2. Special-case type checks would be mostly restricted to the special magic that the compiler inserts when crossing the boundary, thus reducing the potential for bugs.
3. NSError is no longer required to conform to ErrorProtocol, reducing the type checking that has to go on during the bridging process, also reducing the potential for bugs.
4. Since the is, as, as?, and as! operators would no longer be needed to bridge NSErrors to native errors and back, improvements to that mechanism such as (https://github.com/apple/swift-evolution/pull/289) become viable, and the casting operators can be made to no longer act in ways that are often surprising and confusing.
5. The programmer never has to deal with NSError objects in Swift code again.
1. Extend ErrorProtocol such that it has public, non-underscored methods for the domain, code, and userInfo. The first two of these retain their existing default implementations, whereas the last of these will have a default implementation that just returns an empty dictionary. The user can override any of these to provide more information as needed.
2. NSError’s conformance to ErrorProtocol is removed, since Swift code will generally no longer need to work directly with NSErrors.
3. A new private error value type is introduced that conforms to ErrorProtocol. Since this type will be private, its specific name is up to the implementers, but for the purpose of this example we will assume that it is named _ObjCErrorType. This type wraps an NSError, and forwards its domain, code, and userInfo properties to it.
4. The existing _SwiftNativeNSError class remains, and continues to work as it does currently, although it is extended to forward the userInfo property to the wrapped Swift error. Thus, this class now wraps a native Swift error and forwards the domain, code, and userInfo properties to it.
5. Objective-C APIs that return an NSError object present it as ErrorProtocol in the signature. When called by Swift, the type of the NSError is checked. If the type is _SwiftNativeNSError, the original Swift error is unwrapped and returned. Otherwise, the NSError is wrapped in an instance of _ObjCErrorType and returned as an ErrorProtocol.
6. Objective-C APIs that take NSError objects now show ErrorProtocol in their signatures as well. If an _ObjCErrorType is passed to one of these APIs, its wrapped NSError is unwrapped and passed to the API. Otherwise, the error is wrapped in a _SwiftNativeNSError and passed through to the API.
7. Swift errors would still be convertible to NSError, if the developer needed to do so manually. This could be done either via the current “as NSError” bridge, or via initializers and/or accessors on NSError.
IMPACT ON EXISTING CODE:
Required changes to existing code will mostly involve removing “as NSError” statements. Workarounds to the problem being addressed by this change will probably also want to be removed, as they will no longer be needed.
Do nothing, and let the terrorists win.