I'm learning Haskell, and writing a short parsing script as an exercise. Most of my script consists of pure functions, but I have two, nested IO components:
What I have works, but the nested IO and layers of fmap "feel" clunky, like I should either be avoiding nested IO (somehow), or more skillfully using do notation to avoid all the fmaps. I'm wondering if I'm over-complicating things, doing it wrong, etc. Here's some relevant code:
getPaths :: FilePath -> IO [String]
getPaths folder = do
allFiles <- listDirectory folder
let txtFiles = filter (isInfixOf ".txt") allFiles
paths = map ((folder ++ "/") ++) txtFiles
return paths
getConfig :: FilePath -> IO [String]
getConfig path = do
config <- readFile path
return $ lines config
main = do
paths = getPaths "./configs"
let flatConfigs = map getConfigs paths
blockConfigs = map (fmap chunk) flatConfigs
-- Parse and do stuff with config data.
return
I end up dealing with IO [IO String]
from using listDirectory as input for readFile. Not unmanageable, but if I use do notation to unwrap the [IO String]
to send to some parser function, I still end up either using nested fmap
or pollute my supposedly pure functions with IO awareness (fmap, etc). The latter seems worse, so I'm doing the former. Example:
type Block = [String]
getTrunkBlocks :: [Block] -> [Block]
getTrunkBlocks = filter (liftM2 (&&) isInterface isMatchingInt)
where isMatchingInt line = isJust $ find predicate line
predicate = isInfixOf "switchport mode trunk"
main = do
paths <- getPaths "./configs"
let flatConfigs = map getConfig paths
blockConfigs = map (fmap chunk) flatConfigs
trunks = fmap (fmap getTrunkBlocks) blockConfigs
return $ "Trunk count: " ++ show (length trunks)
fmap, fmap, fmap... I feel like I've inadvertently made this more complicated than necessary, and can't imagine how convoluted this could get if I had deeper IO nesting.
Suggestions?
Thanks in advance.
The I/O monad contains primitives which build composite actions, a process similar to joining statements in sequential order using `;' in other languages. Thus the monad serves as the glue which binds together the actions in a program.
IO actions are used to affect the world outside of the program. Actions take no arguments but have a result value. Actions are inert until run. Only one IO action in a Haskell program is run ( main ). Do-blocks combine multiple actions together into a single action.
I think you want something like this for your main
:
main = do
paths <- getPaths "./configs"
flatConfigs <- traverse getConfig paths
let blockConfigs = fmap chunk flatConfigs
-- Parse and do stuff with config data.
return ()
Compare
fmap :: Functor f => (a -> b) -> f a -> f b
and
traverse :: (Applicative f, Traversable t) => (a -> f b) -> t a -> f (t b)
They are quite similar, but traverse
lets you use effects like IO
.
Here are the types again specialized a little for comparison:
fmap :: (a -> b) -> [a] -> [b]
traverse :: (a -> IO b) -> [a] -> IO [b]
(traverse
is also known as mapM
)
Your idea of 'nestedness' is actually a pretty good insight into what monads are. Monads can be seen as Functors with two additional operations, return with type a -> m a
and join with type m (m a) -> m a
. We can then make functions of type a -> m b
composable:
fmap :: (a -> m b) -> m a -> m (m b)
f =<< v = join (fmap f v) :: (a -> m b) -> m a -> m b
So we want to use join here but have m [m a]
at the moment so our monad combinators won't help directly. Lets search for m [m a] -> m (m [a])
using hoogle and our first result looks promising. It is sequence:: [m a] -> m [a]
.
If we look at the related function we also find traverse :: (a -> IO b) -> [a] -> IO [b]
which is similarly sequence (fmap f v)
.
Armed with that knowledge we can just write:
readConfigFiles path = traverse getConfig =<< getPaths path
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