Add Simple Swift Promises

This commit is contained in:
Shadowfacts 2020-02-18 22:12:41 -05:00
parent c36ca7b590
commit 3cffb9084c
Signed by: shadowfacts
GPG Key ID: 94A5AB95422746E5
1 changed files with 324 additions and 0 deletions

View File

@ -0,0 +1,324 @@
```
metadata.title = "Simple Swift Promises"
metadata.category = "swift"
metadata.date = "2020-02-18 22:10:42 -0400"
metadata.shortDesc = "Building a rudimentary implementation of asynchronous promises in Swift."
metadata.slug = "simple-swift-promises"
```
Recently, I've been working on cleaning up the networking code in Tusker, my iOS client for Mastodon/Pleroma and I briefly played around with using the new [Combine](https://developer.apple.com/documentation/combine) framework as well as the built in `URLSession.DataTaskPublisher` helper. Combine, much like SwiftUI, uses Swift's incredibly powerful type system to great advantage because it's a Swift-only framework. It's quite efficient, but because there are so many generic types and implementations of different protocols, the API (in my experience) isn't the most pleasant to work with. I was thinking about other asynchronous programming schemes and the one that came to mind as being the nicest to use was JavaScript's Promises. It has a fairly simple API, so I started wondering how much work it would be to build something similar in Swift. Turns out: not that much.
<!-- excerpt-end -->
Be warned, this code isn't great. It's the result of a few hours of fiddling around trying to build something, not the best possible solution.
To start off with, there's a `Promise` class that's generic over its result type. It stores 1) a list of closures that will be invoked when it is resolved and 2) the resolved result (or `nil`, if the promise hasn't yet been resolved). There's a helper function that resolves the promise by storing the result and invokes any already-added completion handlers with the result. There's another function that's called to add a handler to the promise, storing it if the promise hasn't been resolved and invoking it immediately if it has.
```swift
public class Promise<Result> {
private var handlers: [(Result) -> Void] = []
private var result: Result?
func resolve(_ result: Result) {
self.result = result
self.handlers.forEach { $0(result) }
}
func addHandler(_ handler: @escaping (Result) -> Void) {
if let result = result {
handler(result)
} else {
handlers.append(handler)
}
}
}
```
To keep things clean, everything in the public API is implemented in a public extension on `Promise`. To start with, the most primitive way of constructing a promise. The initializer takes a closure (`resultProvider`) which itself receives as an argument a closure that takes a `Result`. In the initializer, the result provider is immediately invoked passing the `self.resolve` helper function from earlier. This will kick off whatever potentially long-running/asynchronous task is being wrapped in a promise. Once the task has completed, it will call the closure passed in as the `resolve` parameter with whatever value it ultimately got.
```swift
public extension Promise {
convenience init(resultProvider: @escaping (_ resolve: @escaping (Result) -> Void) -> Void) {
self.init()
resultProvider(self.resolve)
}
}
```
Using it might be something like this:
```swift
let promise = Promise<String> { (resolve) in
performLongOperation() { (result) in
resolve(result)
}
}
```
With this in place, the first helper function can be implemented. It will take a single value and produce a promise that has that is resolved with that value:
```
public extension Promise {
static func resolve<Result>(_ value: Result) -> Promise<Result> {
let promise = Promise<Result>()
promise.resolve(value)
return promise
}
}
```
Using it is as simple as `Promise.resolve("blah")`. (The only reason this is a static method instead of just another convenience initializer on Promise is to match the JavaScript API that it's modeled after.)
Next up, there needs to be a way of adding a completion block to a promise. There are a couple different possibilities for using this and each will be implemented slightly differently.
The first and simplest is adding a completion block that receives the result of the promise and doesn't return anything. Another `then` implementation takes a closure that receives the value of the promise and produces a new value, resulting in a promise that produces the new value. Finally, there's one that takes a closure which produces a promise of a new value, resulting in a promise that returns the new value.
```swift
public extension Promise {
@discardableResult
func then(_ fn: @escaping (Result) -> Void) -> Promise<Result> {
addHandler(fn)
return self
}
func then<Next>(_ mapper: @escaping (Result) -> Next) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newResult = mapper(parentResult)
next.resolve(newResult)
}
return next
}
func then<Next>(_ mapper: @escaping (Result) -> Promise<Next>) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newPromise = mapper(parentResult)
newPromise.addHandler(next.resolve)
}
return next
}
}
```
In the simplest case, the promise can simply add the handler to itself and return itself for other uses. This is marked with `@discardableResult` because the API user should be able to add a completion handler without causing a unnecessary compile-time warning.
When given a closure that produces a value, `then` should return a new promise that's for the type of the result of the closure. To achieve this, the `then` function is generic for the `Next` type which is both the return type of the closure and the result type of the promise returned by `then`. A new promise is constructed, and a completion handler is added to `self` to resolve the next promise once self has resolved with the value produced by passing its own result through the mapper function.
Finally, when given a closure that produces a promise, a new promise is also constructed and a completion handler added to the current promise. This time, when the parent result is passed into the mapper function, it receives back a promise. A completion handler is added to that promise which resolves the next promise with the value it produces, linking the promise returned by `then` onto the promise produced by the closure. This version of `then` in particular is very powerful, because it allows promises to composed together and sets of nested callbacks collapsed.
And with that, a barebones promises API is born.
## Handling Errors
This promise implementation can fairly easily be extended to support handling errors in much the same manner as normal results (a lot of the code will look very familiar).
Promise could be made generic over some failure type as well, but using the plain Swift `Error` type makes things a bit simpler.
```swift
public class Promise<Result> {
private var catchers: [(Error) -> Void] = []
private var error: Error?
func reject(_ error: Error) {
self.error = error
self.catchers.forEach { $0(error) }
}
func addCatcher(_ catcher: @escaping (Error) -> Void) {
if let error = error {
catcher(error)
} else {
catchers.append(catcher)
}
}
}
```
Similarly to the normal promise resolution stuff, the Promise class stores a list of functions which handle any error as well as the error itself, if one's already been produced. There's also a `reject` internal helper function which is called to reject the promise, storing the error and passing it to any already-registered catch functions. Also paralleling the `addHandler` method, there's an `addCatcher` helper which takes a closure that consumes an error, either invoking it immediately if the promise has already been rejected or appending it to the internal array of catcher functions.
The main convenience initializer is also amended to receive a closure that itself takes two closure parameters: functions that respectively resolve and reject the promise. The closure is invoked immediately passing `self.resolve` and `self.reject` as the resolver and rejecter functions.
```swift
public extension Promise {
convenience init(resultProvider: @escaping (_ resolve: @escaping (Result) -> Void, _ reject: @escaping (Error) -> Void) -> Void) {
self.init()
resultProvider(self.resolve, self.reject)
}
}
```
With that in place, a static `reject` helper can also be created, which works almost exactly the same as the static `resolve` helper. It takes an error and produces a promise that's rejected with that error by immediately invoking the `reject` function with that error in the result provider closure.
```swift
public extension Promise {
static func reject<Result>(_ error: Error) -> Promise<Result> {
let promise = Promise<Result>()
promise.reject(error)
return promise
}
}
```
Additionally, the two `then` functions that produce new promises are changed to make them reject the next promise when they themself reject. The one that accepts a closure returning a promise is also tweaked so that, when the new promise is received from the closure, the next promise is made to fail if that promise fails.
```swift
public extension Promise {
func then<Next>(_ mapper: @escaping (Result) -> Next) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newResult = mapper(parentResult)
next.resolve(newResult)
}
addCatcher(next.reject)
return next
}
func then<Next>(_ mapper: @escaping (Result) -> Promise<Next>) -> Promise<Next> {
let next = Promise<Next>()
addHandler { (parentResult) in
let newPromise = mapper(parentResult)
newPromise.addHandler(next.resolve)
newPromise.addHandler(next.reject)
}
addCatcher(next.reject)
return next
}
}
```
Next, for actually handling errors there are public `catch` functions on `Promise` in the same fashion as `then`:
```swift
public extension Promise {
@discardableResult
func `catch`(_ catcher: @escaping (Error) -> Void) -> Promise<Result> {
addCatcher(catcher)
return self
}
func `catch`(_ catcher: @escaping (Error) -> Result) -> Promise<Result> {
let next = Promise<Result>()
addHandler(next.resolve)
addCatcher { (error) in
let newResult = catcher(error)
next.resolve(newResult)
}
return next
}
func `catch`(_ catcher: @escaping (Error) -> Promise<Result>) -> Promise<Result> {
let next = Promise<Result>()
addHandler(next.resolve)
addCatcher { (error) in
let newPromise = catcher(error)
newPromise.addHandler(next.resolve)
newPromise.addCatcher(next.reject)
}
return next
}
}
```
The interesting implementations of the `catch` function both first add a handler to itself which simply resolves the next promise with the same result. They also add catchers to themselves which invoke the `catcher` closure with error produced by the parent and either gets a result back immediately, in which case it resolves the next promise, or gets back a promise for a new result, in which case it adds a handler to the new result promise to either resolves the next promise when the new promise succeeds or reject it if the new promise fails (that is, if a catcher function produces a promise that resolves, the parent's error is resolved, or if it produces a promise that rejects, the parent's error is replaced with the new error).
One difference between these functions and the `then` function, is that the result type of the parent promise must be the same as the new promise's result type. This is done because JavaScript promises have the semantic where `then` handlers added after a `catch` are invoked regardless of whether or not the promise resolved (unless, of course, the catch block produced a rejected promise). This means that the catcher closure must produce a value of the same type as the parent promise, otherwise, there would be a case in which subsequent thens could not be invoked with the actual result value. That makes the following example, in which a potential error is replaced with some default value meaning `print` will always be invoked, possible:
```swift
longRunningPossiblyRejectingPromise()
.catch { (error) -> String in
// log error
return "default value"
}.then { (str) -> Void in
print(str)
}
```
Now, the simple promise implementation is capable of handling errors as well.
## Finishing Touches
First, because of the way promises are implemented, the queue a then/catch closure is executed on depends solely on the queue on which the previous promise resolved/rejected. To make switching queues easier, a simple helper function can be written that simply passes the result through, just resolving on a different queue.
```swift
public extension Promise {
func handle(on queue: DispatchQueue) -> Promise<Result> {
return self.then { (result) in
return Promise { (resolve, reject) in
queue.async {
resolve(result)
}
}
}
}
}
```
Next, the `Promise.all` helper function can be implemented using a `DispatchGroup` to take an array of promises with the same result type and create a new promise that resolves to an array of values of the same type as the result type:
```swift
public extension Promise {
static func all<Result>(_ promises: [Promise<Result>], queue: DispatchQueue = .main) -> Promise<[Result]> {
let group = DispatchGroup()
var results: [Result?](repeating: nil, count: promises.count)
var firstError: Error?
for (index, promise) in promises.enumerated() {
group.enter()
promise.then { (res) -> Void in
queue.async {
results[index] = res
group.leave()
}
}.catch { (err) -> Void in
if firstError == nil {
firstError = err
}
group.leave()
}
}
return Promise<[Result]> { (resovle, reject) in
group.notify(queue: queue) {
if let firstError = firstError {
reject(firstError)
} else {
resolve(results.compactMap { $0 })
}
}
}
}
}
```
This method follows the same semantics as the JavaScript equivalent. If any of the individual promises rejects, the `all` promise will be rejected with the first error that occurred. It also maintains the order of the results.
## Conclusion
Promises can be pretty useful, but they're not without their own pitfalls. Primarily, if you want to use the result of an intermediate promise in one further along the chain, you have to do something like passing it along with every intermediate result in a tuple, which is less than ideal. But, in some specific cases, they can be quite useful.
Consider making a new post in a social media app. First, any selected attachments are uploaded to the server. Then, only after all of those have completed successfully, can the post be made. After the post has completed, the resulting post received back from the API is stored. After that, UI changes can be made on the main thread to indicate that the post has succeeded. And, for all of those steps, there's some common error handling code to show a message to the user. As in the following (simplified) example, this fits fairly well into the model of promises we've constructed.
```swift
let attachmentPromises = attachments.map { (attachment) -> Promise<Attachment> in
ApiClient.shared.uploadAttachment(attachment)
}
Promise<[Attachment]>.all(attachmentPromises).then { (attachments) -> Promise<Post> in
ApiClient.shared.createPost(text: self.postText, attachments: attachments)
}.then { (post) -> Post in
ApiObjectCache.shared.store(post)
self.currentDraft?.remove()
return post
}.handle(on: DispatchQueue.main).then { (post) in
self.dimiss(animated: true)
}.catch { (error) -> Void in
let alert = createAlertController(title: "Couldn't Post", message: error.localizedDescription)
self.present(alert, animated: true)
}
```
As for my own purposes, I don't know whether I'll end up using this or not. It's neat, but it feels like it's verging on an unnecessary abstraction. Either way, it was a fun experiment.
If you want to check out the full code, the project is in [a repo](https://git.shadowfacts.net/shadowfacts/SimpleSwiftPromises) on my Gitea (trying to do anything asynchronous in a Swift Playground is painful). I've also made public a [branch](https://git.shadowfacts.net/shadowfacts/Tusker/src/branch/simple-swift-promises) of Tusker which is using these promises in some places.