Suppose, we have a struct M
:
public struct M<T> {
let value: T
public init(_ value: T) { self.value = value }
public func map<U>(f: T -> U) -> M<U> { return M<U>(f(value)) }
public func flatMap<U>(f: T -> M<U>) -> M<U> { return f(value) }
}
and a few functions which compute a value (T
) and return it as a wrapped value with M
:
func task1() -> M<Int> {
return M(1)
}
func task2(value: Int = 2) -> M<Int> {
return M(value)
}
func task3(value: Int = 3) -> M<Int> {
return M(value)
}
func task4(arg1: Int, arg2: Int, arg3: Int) -> M<Int> {
return M(arg1 + arg2 + arg2)
}
Now, suppose we want to compute the values of task1, task2 and task3 and then pass all three computed values as arguments to task4. It seems, this requires to use nested calls to flatMap
and map
:
let f1 = task1()
let f2 = task2()
let f3 = task3()
f1.flatMap { arg1 in
return f2.flatMap { arg2 in
return f3.flatMap { arg3 in
return task4(arg1, arg2:arg2, arg3:arg3).map { value in
print("Result: \(value)")
}
}
}
}
But that doesn't look quite comprehensible. Is there a way to improve that? For example, using custom operators?
Well, for reference, it would do well to document here what Haskell does in this situation:
example1 = do
arg1 <- task1
arg2 <- task2
arg3 <- task3
value <- task4 arg1 arg2 arg3
putStrLn ("Result: " ++ show value)
This desugars to the >>=
operator, which is a flipped infix flatMap:
-- (>>=) :: Monad m => m a -> (a -> m b) -> m b
--
-- It's a right-associative operator
example2 = task1 >>= \arg1 ->
task2 >>= \arg2 ->
task3 >>= \arg3 ->
task4 arg1 arg2 arg3 >>= \value ->
putStrLn ("Result: " ++ show value)
So yeah, what you've done here is rediscover the motivation for Haskell's do
-notation—it's precisely a special flat syntax for writing nested flatMaps!
But here's another trick that might be relevant to this example. Note that in your computation, task1
, task2
and task3
don't have any interdependencies. This can be the basis for designing a "flat" utility construct for merging them into one task. In Haskell, you can do this easily with the Applicative
class and pattern matching:
import Control.Applicative (liftA3, (<$>), (<*>))
-- `liftA3` is the generic "three-argument map" function,
-- from `Control.Applicative`.
example3 = do
-- `liftA3 (,,)` is a task that puts the results of its subtasks
-- into a triple. We then flatMap over this task and pattern match
-- on its result.
(arg1, arg2, arg3) <- liftA3 (,,) task1 task2 task3
value <- task4 arg1 arg2 arg3
putStrLn ("Result: " ++ show value)
-- Same thing, but with `<$>` and `<*>` instead of `liftA3`
example4 = do
(arg1, arg2, arg3) <- (,,) <$> task1 <*> task2 <*> task3
value <- task4 arg1 arg2 arg3
putStrLn ("Result: " ++ show value)
If task1
, task2
and task3
return the same type, then another way of flattening it is to use the Traversable
class (which bottoms out to the same Applicative
technique as above):
import Data.Traversable (sequenceA)
example5 = do
-- In this use, sequenceA turns a list of tasks into a
-- task that produces a list of the originals results.
[arg1, arg2, arg3] <- sequenceA [task1, task2, task3]
value <- task4 arg1 arg2 arg3
putStrLn ("Result: " ++ show value)
So one idea then is to build an utility library that provides similar functionality. Some example operations:
(M<A1>, ..., M<An>) -> M<(A1, ..., An)>
Note that #1 and #2 have equivalent power. And also note that if we're talking about asynchronous tasks, these operations have an advantage over flatmaps, which is that they're much easier to parallelize.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With