IO Monadic code: standard vs flipped function composition
https://softwareengineering.stackexchange.com/questions/343927
-
07-01-2021 - |
Вопрос
Below is a line that handles socket connections in a simple Haskell program.
mainLoop :: Socket -> IO ()
mainLoop sock = accept sock >>= forkIO . handle . fst >> mainLoop sock
The "flow" of data in the function is not consistent: it's left to right when using bind >>=
or >>
(sequence?), but right to left when using function composition .
.
Is the below better? Using a simplified/modified form of >>>
from Control.Arrow, which is a flipped function composition.
infixr 2 >>>
(>>>) = flip (.)
mainLoop :: Socket -> IO ()
mainLoop sock = accept sock >>= fst >>> handle >>> forkIO >> mainLoop sock
It has a consistent data flow, left to right. Are there any drawbacks to using this as a go-to pattern for most code?
An alternative, as discussed in comments, is to use =<<
. But it looks like the flipped form of >>
is also not defined.
infixr 1 <<
(<<) = flip (>>)
mainLoop :: Socket -> IO ()
mainLoop sock = mainLoop sock << forkIO . handle . fst =<< accept sock
So actually I have the same question for this form as for the form using >>>
Решение
I think the real answer is
- If you have any sort of colleagues on this project, run this by them to get their opinion.
- If it is just for you, pick whichever you like most.
While Haskell supports point-free programming very well, the language and community have more or less standardized around math-based right-to-left composition with (.)
and ($)
. Unfortunately as you noted, these chains are frequently less readable than their flipped left-to-right counterpart.
Now it is pretty easy to defined flipped versions of the above operators but the difficulty is that there is no real convention. You can use (>>>)
from Control.Arrow
. I have also seen (#)
in diagrams
or (&)
in Data.Functions
for flip ($)
. And it is of course only a couple of lines to define your own versions. Because there is not a single standard answer here you'll run into a couple of problems:
- Unclear precedence.
(.)
and($)
have very clear and well known precedence: highest and lowest respectively. The precedence of any flipped version will not be so familiar (readers of your code will not know immediately how to parse the expression). - Cognitive burden. There is a small but definite cost for using unfamiliar notation -- readers have to keep reminding themselves what they are reading.
- Unfamiliarity. While code using any flipped versions could be totally valid and working Haskell code, it will not look exactly like normal Haskell code.
I run into these problems in both of your cases. In the first case, the compresence of (>>>)
and (>>)
makes visually parsing the line difficult. Because (>>)
is quieter I want to group the last two expressions together even though that is not correct. The second version has the problem that reading a right-to-left monadic chain is just weird looking. Again there's nothing incorrect about either version, but I think they make your code less idiomatic for little gain.
I think the real solution (other than getting a time machine and redesigning Haskell's standard library) is that whenever you run into a chain (or in this case a chain-of-chains) that is difficult to read; rather than futzing with directions or anything it is best just to rewrite / decompose that expression. Here I might suggest
mainLoop :: Socket -> IO ()
mainLoop sock = do
contents <- accept sock
forkIO . handle . fst $ contents
mainLoop sock
Or even better
mainLoop sock = forever (accept sock >>= handleContents)
where
handleContents (h, _) = forkIO . handle $ h