First of all I'd like to mention, I deeply appreciate the work of the Core Team and compiler engineers focused on delivering these new features for 5.1 release. Even though we have a somewhat "special" review process for few of these, the improvements did start clicking in my head by this point. They feel like very impactful changes that would allow us to build very powerful stuff.
Please correct me if I'm wrong, but I haven't seen any points mentioned about variable bindings in the recent pitch discussion. I'll name it "function builders" for now, even though there seems to be a strong preference to change this to "builder functions".
People keep mentioning inability to use for
and switch
statements in the DSL, but I think that lack of variable bindings is far more important and could have a much bigger implications long term.
The new DSL could allow expressing not only markup-related structures like View
types in SwiftUI and HTML
in the proposal itself or in libraries that started popping up, but also more interesting control flow structures. Consider this extension that utilises Combine:
struct UnknownError: Error {}
extension URLSession {
func request(url: URL) -> AnyPublisher<Data, Error> {
.init(Publishers.Future { completion in
self.dataTask(with: url) {
switch ($0, $2) {
case let (_, error?):
return completion(.failure(error))
case let (data?, _):
return completion(.success(data))
default:
return completion(.failure(UnknownError()))
}
}
})
}
}
Then given this builder
@_functionBuilder
struct PublisherBuilder {
static func buildBlock<T>(
_ publishers: AnyPublisher<T, Error>...
) -> AnyPublisher<T, Error> {
guard let first = publishers.first else {
return Publishers.Empty().eraseToAnyPublisher()
}
return publishers.reduce(first) { f1, f2 in
f1.flatMap { _ in f2 }.eraseToAnyPublisher()
}
}
}
func chain<T>(
@PublisherBuilder child: () -> AnyPublisher<T, Error>
) -> AnyPublisher<T, Error> {
return child()
}
we could write something like this without async/await:
let session = URLSession(configuration: .default)
chain {
session.request(url: url1)
session.request(url: url2)
session.request(url: url3)
}
In this example requests would run sequentially one after another. Quite frequently though, one sequential async operation needs to feed data into the other:
chain {
session.request(url: url1)
session.request(url: url2).flatMap {
session.request(url: processResponse($0))
}.eraseToAnyPublisher()
}
If variable bindings were supported in the DSL, I hope we could write it like this:
chain {
session.request(url: url1)
let response = session.request(url: url2)
session.request(url: processResponse(response))
}
I admit, I see a lot of feedback for the builders proposal pitch about the magic of DSL being hidden, so this could be more explicit in the fact that response
is being "unwrapped" from the publisher (in addition to whatever changes are possible made to the DSL itself to make it more explicit):
chain {
session.request(url: url1)
let response <- session.request(url: url2)
session.future(url: processResponse(response))
}
Looking at the draft proposal, the most obvious API surface could be added on top with
static func buildBinding(
_ component: Component,
_ binder: (Expression) -> Component
) -> Component
optional requirement on the @functionBuilder
type being involved, e.g. for Publishers
:
static func buildBinding<T1, T2>(
_ publisher: AnyPublisher<T1, Error>,
_ binder: @escaping (T1) -> AnyPublisher<T2, Error>
) -> AnyPublisher<T2, Error> {
publisher.flatMap {
binder($0)
}.eraseToAnyPublisher()
}
On the implementation side, AST rewrite for this new API would need to handle pattern bindings, but I hope this is realistic. If there's a positive feedback to this pitch, I'm ready to try adding this as a PR to the current @John_McCall's work in progress PR.
Why is this called "monadic"?
What pushed me towards writing this is an apparent similarity between the proposed syntax and the monadic do-notation in PureScript and Haskell. Also, coroutines were mentioned in the builders thread, and coroutines are also monads. Now I know some people might say that monads are a useless mathematical abstractions that is hard to explain, but I'm not convinced that's true. As Swift developers we use monads on a regular basis in our code: optionals, arrays, Result
type, futures, and now Publishers
in Combine. Not having abstractions that capture common patterns among these types causes us to reinvent the wheel multiple types for every possible combination: that's why we have separate flatMap
and compactMap
implementations, even though they do the same thing and could be expressed with a slight change to the type system mentioned in the Generics Manifesto.
I do think that the best way to explain monads is not to rely on any mathematical abstractions, but to simply declare that a monad is a "protocol" with two simple requirements. Then showing how many real types follow these requirements, and how this helps building useful abstractions is key. I assume many people did suspect something fishy was going on, when we had flatMap
for both arrays of arrays and arrays of optionals . I finally understood how powerful monads in Swift could be, when I saw flatMap
name used for futures composition, instead of the usual then
. And now with flatMap
in Combine
, we really do need to expand our vocabulary with a common name for types that have a flatMap
implementation and certain initializer defined on them.
In pseudocode it could look like this:
protocol Monad {
associatedtype Element
init(_ single: Element)
func flatMap<T>(_ binder: (Element) -> Self<T>) -> Self<T>
}
The only reason I call this "pseudocode" is that we can't express Self<T>
requirements in protocols yet. If/when we have that, we could have the Monad
protocol in the standard library, along with implementations of this protocol for Optional
, Array
, Set
, Result
etc. Implementing @functionBuilder
for such types would be trivial, I actually do have a feeling that a simple builder could be synthesized by the compiler automatically for every type conforming to Monad
.
Many interesting things can be built on top of that. Ever wish to avoid unwrapping optionals with guard
just to use that optional in the next statement? Optional
is a Monad
, so it's easy with the DSL:
optional {
let a <- array.first
let b <- array.last // won't be executed if binding of `a` fails
a + b // won't be executed if any bindings fail
}
We could express far more interesting things, e.g. Monad
implementation for Array
allows us to find all combinations of elements from given arrays that satisfy a predicate:
array {
let a <- [1,2,3]
let b <- [1,2,3]
a + b == 4 ? [(a, b)] : []
} // returns [(1, 3),(2, 2),(3, 1)]
Here it feels like arrays with the monadic notation can express "branching" computations that explore all possible combinations.
Since the Monad
protocol is such a powerful abstraction, much more interesting stuff can be expressed, e.g. monadic DSL for Result
is equivalent to try
and throws
. We've been using specialized monadic notation for error handling, years before we even had Result
type itself in the standard library
I'm pretty sure people can find a lot more interesting applications for this, e.g. recoverable errors, coroutines, monad transformers that lift monads of one type to another etc.
Hope any of this makes sense, thank you for reading. Looking forward to your feedback!