Tags: Haskell, Control Flow, Variant | December 12, 2016 |
This post is part of a series.
Control flow with Variant
We can use the Variant
data type described in the previous part to help with control flow. Basically we can write functions that return a Variant
to indicate their different possible output types. To make the code easier to read (fewer brackets), we use the following type alias:
Here is our previous example from part 1 rewritten to use Flow
:
data XY = XY ...
data UV = UV ...
data ErrA = ErrA ...
data ErrB = ErrB ...
data ErrC = ErrC ...
data ErrD = ErrD ...
data ErrE = ErrE ...
doXY :: Flow IO '[XY,ErrA,ErrB,ErrC]
doXY = ...
doUV :: XY -> Flow IO '[UV,ErrA,ErrD,ErrE]
doUV = ...
doXYUV :: Flow IO '[UV,ErrA,ErrB,ErrC,ErrD,ErrE]
doXYUV = doXY >.~^^> doUV
Notice how the same error types (such as ErrA
) can be returned by different functions without being wrapped with different constructors each time.
Another thing to notice is that all the return types are at the same level: there is no difference between “success” types and “error” types. Compared to using Either Errors UV
, we avoid the indirection caused by the Errors
sum type.
Similarly to Either
, however, we can have a convention about the most expected returned type. With Either a b
we most expect the type associated to the Right
constructor (i.e., b
); with Variant (x ': xs)
we most expect the first type in the list (i.e., x
). This is useful to define composition operators on variants such as >.~^^>
in the code above (operators are presented in the next section).
Flow operators
There are many ways to compose flows. Say we have the following function:
There are many potential g
functions that we would like to compose with f
to get the h
functions.
(It’s the first time I use a graphics tablet: it is much faster to draw things but I’m not very good at it)
Moreover, we need to make the types unambiguous without having to add type annotations everywhere, especially for intermediate values. So we have different operators with different type dependencies: f -> g h
, f g -> h
, h f -> g
, etc.
Returning a value
Let’s start with the value returning “operators”:
-- return a value by index
f :: Monad m => Flow m '[A,B,A,C,D]
f = ... flowSetN @2 a
-- return a value by type (first matching type)
f :: Monad m => Flow m '[A,B,A,C,D]
f = ... flowSet b
-- return a single value, the type list can be inferred from the type of 'x'
-- u :: Monad m => Flow m '[X]
u = ... flowSingle x
First output biased operators
We have said that by convention the first output type in the type list is the most expected one (equivalent to the Right
constructor for Either
). So we have a bunch of operators biased for this case.
First, the equivalent of fmap
for Flows (g
is pure):
The equivalent of forM
/traverse
for Flows (g
is monadic in the same Monad as f):
When g
has several output types and output types are concatenated:
When g
has several output types, f
and g
determine the output types of h
(by union):
When g
has several output types (the same as f
except for the first one):
When g
has several output types, g
determines the output types of h
:
g :: Monad m => A -> Flow m '[X,A,B,E,C,D]
g = ...
h :: Monad m => Flow m '[X,A,B,E,C,D]
h = f >.~^> g
When g
has several output types, h
determines its own output types:
g :: Monad m => A -> Flow m '[X,Y,Z]
g = ...
h :: Monad m => Flow m '[X,A,Y,C,W,D,Z,B]
h = f >.~^^> g
When g
only performs some effects:
When g
only performs some effects: this one is a little bit more suspicious (hence the !
) because some output paths are not explicitly handled:
When g
only performs some effects: this case is even more dangerous because you assert that the first output path mustn’t be followed. g
mustn’t return (i.e., it should call error
for instance).
Other operators
The operators presented in the previous sections are biased towards the first output but we also have other operators for other cases! To avoid having to learn each operator, here is the logic I have followed to build them. Operators are decomposed in three parts:
- what we select in the Variant:
.
the head..
the tail%
a mandatory type?
an optional type..%
a mandatory type in the tail..?
an optional type in the tail
- what kind of function we apply to it:
-
pure function~
monadic function~~
constant monadic function (doesn’t use its parameter)
- how we combine the result
p
with the remaining partq
to get the resultr
:+
r = concatenate p and q.
set the head of the result (p isn’t a Variant in this case)|
r = union of p and q^
lift q into p, r = p^^
lift p and q into r$
tail r = tail p = tail q=
perform an effet and passthrough the Variant!
perform an effect and return()
!!
perform a bottom effect or return q
By combining these three parts, we have operators such as: >..~.>
, >..~^>
, >..~!!>
, >%~.>
, >%~^>
, >%~!!>
>..%~.>
, >..%~^>
, >..%~!!>
, >?~!>
, >..?~!!
, etc. The full list is in the Flow module in my haskus-system package.
Note that in some cases, the combination of the parts doesn’t make sense. Also in some cases I have replaced Flow m '[x]
with m x
as it is easier to work with.
Examples
Consider the following example where we have a function f
that may return either an A
, a B
or a C
.
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE DataKinds #-}
import Haskus.Utils.Flow
data A = A
data B = B
data C = C
data D = D
f :: Int -> Flow IO '[A,B,C]
f x = do
putStrLn ("f " ++ show x)
case x of
0 -> flowSet A
1 -> flowSet B
_ -> flowSet C
Now, if we want to do something if a C
is returned, we can use the >%~!>
operator as follows.
test :: IO ()
test = do
f 0 >%~!> \(_ :: C) -> putStrLn " -> C returned!" -- ""
f 1 >%~!> \(_ :: C) -> putStrLn " -> C returned!" -- ""
f 2 >%~!> \(_ :: C) -> putStrLn " -> C returned!" -- " -> C returned!"
Now suppose that we want to do something if a D
is returned:
Oops, we get the following error:
scripts/Flow.hs:27:4: error:
• `D' is not a member of '[A, B, C]
• In a stmt of a 'do' block:
f 2 >%~!> \ (d :: D) -> putStrLn " -> D returned!"
Indeed f
never returns a D
and the compiler checks that.
Note that it is possible to write generic functions that take as parameters functions that may or may not return some types. For instance by using the >?~!!>
operator as the handleD
function does in the following code:
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE TypeFamilies #-}
import Haskus.Utils.Types.List
handleD :: MaybeCatchable D xs => Flow IO xs -> Flow IO (Filter D xs)
handleD u = u >?~!!> \(_ :: D) -> error "D returned!"
test :: IO ()
test = do
r <- handleD (f 2) -- handleD does nothing, f can't return a D (r :: Variant '[A,B,C])
_ <- handleD g -- handleD fails because 'g' returns a D
b <- handleD h -- handleD filters the possible output type D (b :: Variant '[B])
g :: Flow IO '[B,D]
g = do
putStrLn "g"
flowSet D
h :: Flow IO '[B,D]
h = do
putStrLn "h"
flowSet B
Pattern-matching and Flows
Sometimes we would like to use pattern matching instead of using a sequence of fish operators like the following one:
test = f 5 >%~=> (\(a :: A) -> putStrLn "An A has been returned")
>%~=> (\(b :: B) -> putStrLn "A B has been returned")
>%~=> (\(c :: C) -> putStrLn "A C has been returned")
We can do something similar with flowToCont
and the >::>
operator. In this case the continuations are matched by position in the tuple:
test :: IO ()
test = flowToCont (f 5) >::>
( \a -> putStrLn "An A has been returned"
, \b -> putStrLn "A B has been returned"
, \c -> putStrLn "A C has been returned"
)
We can also match them by type if there is no ambiguity with the >:%:>
operator:
test :: IO ()
test = flowToCont (f 5) >:%:>
( \(a :: A) -> putStrLn "An A has been returned"
, \(c :: C) -> putStrLn "A C has been returned" -- notice that C comes before B
, \(b :: B) -> putStrLn "A B has been returned" -- but it still works
)
Continuations like this will be the topic of a planned next part.