A feature that is frequently requested in the broader community is async-await (e.g., Concrete proposal for async semantics in Swift · GitHub). Personally, I would consider that a nightmare: a new feature with a distinct implementation and possibly entirely new syntax (e.g. putting 'async' in front of a function). So before that gains momentum, I wanted to bring up an idea how to unify throws-try, async-await and potentially many more in a way that it can even be customized.
The technology that everybody uses and that everybody is afraid of when someone names it is called 'monads'. To put it simply, monads are just a monoid in the category of endofunctors
But hear me out. I promise I will actually keep it simple.
In many programming languages, a monad is implemented simply as a generic type with a few operators. First of all, there is 'map'. You may already have seen it in arrays, optionals, the generic result type or PromiseKit's promise. All it does is to transform a function
f : (A) -> B
to
map(f) : (F(A))-> F(B)
where F is the generic type. The important part here is that map should semantically preserve function composition:
map{f(g($0))}
should be the same as
map(g).map(f)
For instance, if you map over an array two times, you would expect the same result as if you had mapped over the composition of the two functions. Note that this is true only on a semantic level, mapping two times over an array will probably be slower.
The second function that a monad must have is flatMap. Say, our monad is a struct F. Then, flatMap has the form:
func flatMap<U>(transform: @escaping (Value) -> F<U>) -> F<U>
For instance, if you flatMap over an optional, the function that you flatMap can either return a value or nil. FlatMap will then take care of the unwrapping so you end up with Optional rather than Optional<Optional>.
In order to reason about flatMap, the order of evaluation of multiple chained flatMaps must be irrelevant. I'll explain later.
The third function that a monad must have is
static func pure(_ value: Value) -> Self
where Value is the generic type argument (or associated type if we think protocols). This should be a 'do nothing' operation. In other words:
monadicValue.flatMap(F.pure)
should (semantically) be exactly the same as
monadicValue
These rules seem arbitrary at first, but they are quite powerful. In fact, any effect you can think of can be safely wrapped into a monad. In Haskell, they even do IO with monads.
This last fact lead to the creation of a research topic called 'algebraic effects'. From all I can gather, this is just fancy syntactic sugar around things that are effectively monads. Which brings me back to async-await.
I propose
- a monad protocol (there are some difficulties here, see below).
- a 'syntactic sugar for monad' protocol inheriting from monad and defining something analogous to throws, try and rethrows (or async, await and reasync) for a monad.
Protocol 2 could look like this:
protocol Effect : Monad{
static var unbindKeyword : StaticString{get}
static var throwsKeyword : StaticString{get}
static var rethrowsKeyword : StaticString{get}
}
For this special protocol, an additional requirement enforced by the compiler would be that these StaticStrings can be used by at most one implementing type in the module.
Once you have a type implementing both protocols, the compiler should be able to generate from the given static strings new keywords that can appear in exactly the positions where throws, try and rethrows appear.
As a nice bonus, this would enable to switch back and forth between the two idioms
f : (A) -> Promise<B>
and
f : (A) async -> B
Here is how it would work. Say, you have the following definitions:
f : (A) async -> B
g : (B) async -> C
func h(a: A) async -> C{
let b = await f(a)
return await g(b)
}
Then, the compiler would simply transform this into:
f : (A) -> Promise<B>
g : (B) -> Promise<C>
func h(a: A) -> Promise<C>{
f(a).flatMap{b in g(b)}
}
The throws/rethrows mechanism would be implemented the following way: obviously, it is statically known if a closure actually produces an effect ('throws') or not; depending on that, the compiler inserts either a flatMap or simply a map. Simple!
This is a great point to clarify why it is important that the order of evaluation (not: execution) of flatMap should not matter. Say, you have the following code:
f : (A) throws -> B
g : (C) throws -> D
h : (B,D) throws -> E
func j(a: A, c: C) throws -> E{
let b = try f(a)
let d = try g(b)
return try h(b,d)
}
Obviously, you don't want that the result depends on wether you declare b first or d first.
There have been async-await proposals that even want something like 'throws async' - functions that can throw and will run asynchronously. Note that there is no obvious meaning to that, the order of keywords is relevant here. Result<Promise> is different from Promise<Result>. I personally don't like deep monad stacks precisely because of that reason, but if people want to toy with that, another protocol could enable that feature: monad transformers.
I just included the last paragraph for sake of completeness so everyone knows that there is no limitation here. If you are interested, you can read it up - there are even pseudo-swift articles about that.
This brings me to the hardest thing to implement: The monad protocol itself. Let's have another look at map and flatMap:
protocol Monad{
associatedtype Value
static func pure(_ value: Value) -> Self //... works
func map<U>(transform: @escaping (Value) -> U) -> ???
func flatMap<U>(transform: @escaping (Value) -> ???) -> ???
}
The question marks indicate a problem here. You would want to write a Self there, but that isn't possible in Swift.
The ability to write that has been a long requested feature. Most of the time, people asked for so called higher kind types, but these are apparently difficult to harmonize with Swift's type system. I have seen a proposal that uses only a similarity relation, although I am not sure if this is any less invasive to the type system: Douglas Gregor's Swift Generics Manifesto, with added markdown formatting · GitHub
In any case, I hope the fact that I pitched monads from a practical point of view (consistency and extensibility of the 'effect system', in particular consistency between throw-try and a potential future async-await) sparks enough interest that we finally get this done.
Further intricacies are:
- Would it be reasonable to retrofit Result/throw/try to that approach? At the very least, I expect this effect to be as flexible as any other effect, but maybe this code transformation may yield less performant results than the current implementation (whatever that looks like).
- Would calling throwing functions without try now be legal? After all, they would simply return a Result then. Under which circumstances should there be a compiler warning and when should it be fine? This is mostly a discussion about communicating the intended use, at the type level there is no problem.
So, what do you think?
Edit: If you are like me and jump to the latest answers after reading the pitch, I want to clarify how to actually implement async-await with the above approach.
Async-await is just syntactic sugar around Promises like in PromiseKit (GitHub - mxcl/PromiseKit: Promises for Swift & ObjC.). To paraphrase and (brutally) oversimplify:
struct Promise<T>{
private let declaration : (@escaping (T) -> ()) -> Void //"produces" a T and calls the completion
init(declaration : @escaping (@escaping (T) -> ()) -> Void){...} //describes how to produce a T and call the completion
func call(_ handler: @escaping (T) -> ()){
declaration(handler) //actually produces a T and calls the completion that has been passed as a function argument
}
static func pure(_ value: T) -> Promise<T>{
Promise{completion in completion(value)}
}
func map<U>(transform: @escaping (T) -> U) -> Promise<U>{
Promise<U>{completion in self.call{t in completion(transform(t))}}
}
func flatMap<U>(transform: @escaping (T) -> Promise<U>) -> Promise<U>{
Promise<U>{(completion : (U) -> Void) in
self.call{t in transform(t).call(completion)} //this is where the infamous pyramid of doom is safely hidden away from the eye of human readers
}
}
}
What flatMap does is: it makes the promise produce a T and uses that T to produce a new promise that will then call the completion handler. So, if you make a network request to get some weather forecast, you can use flatMap in order to fetch the website of some online shop providing appropriate clothing and eventually, the completion block will be called. All automatically.
Production code would need to make sure that one can specify on which thread/queue the closures are executed. Also, there needs to be a mechanism to make sure that the completion handler is called exactly once - probably at runtime, as static analysis is complicated (at least on the monadic side; on the syntactic sugar side, it's easy).
In this design 'await' can only appear in functions that are async. Just like try can only appear in functions that throw. With try, there is the additional do-catch semantic that I haven't covered here. That's because I'm not sure how to best do this here. Most useful monads have some mechanism to produce 'pure' values, like reduce in Arrays or ?? in Optionals. Some languages implement a synchronous await function that actually waits for the result (rather than being syntactic sugar around inherently async code).
The issue here is not so much how to do it. The issue here would be to figure out syntax or a protocol that fits all monads imho.
Edit: The fact that the question comes up how to implement async-await with this approach is precisely why I wouldn't be too happy if there was just an async-await feature isolated from other possible effects. It misleads people to think this is some compiler magic, while in reality this is code that you and me could write - just enhanced with some syntactic sugar. Swift can and should do better and empower the community.
Edit Edit: I adjusted the title, because I want people to focus more on the extensible syntax part than on async-await that I only used as an example because it is a commonly requested feature. I am not an expert on async-await, but I think this would be an ideal candidate to showcase the proposed extensible syntax feature (assuming that implementing async-await is easier than settling for a syntax). And yes, probably I am using async-await as clickbait, but how else can I get you to read about the evil M-word from FP-land?