Refactoring Haskell when adding IO

  • A+
Category:Languages

I have a concern regarding how far the introduction of IO trickles through a program. Say a function deep within my program is altered to include some IO; how do I isolate this change to not have to also change every function in the path to IO as well?

For instance, in a simplified example:

a :: String -> String a s = (b s) ++ "!"  b :: String -> String b s = '!':(fetch s)  fetch :: String -> String fetch s = reverse s  main = putStrLn $ a "hello" 

(fetch here could more realistically be reading a value from a static Map to give as its result) But say if due to some business logic change, I needed to lookup the value returned by fetch in some database (which I can exemplify here with a call to getLine):

fetch :: String -> IO String fetch s = do     x <- getLine     return $ s ++ x 

So my question is, how to prevent having to rewrite every function call in this chain?

a :: String -> IO String a s = fmap (/x -> x ++ "!") (b s)  b :: String -> IO String b s = fmap (/x -> '!':x) (fetch s)  fetch :: String -> IO String fetch s = do     x <- getLine     return $ s ++ x  main = a "hello" >>= putStrLn 

I can see that refactoring this would be much simpler if the functions themselves did not depend on each other. That is fine for a simple example:

a :: String -> String a s = s ++ "!"  b :: String -> String b s = '!':s  fetch :: String -> IO String fetch s = do     x <- getLine     return $ s ++ x  doit :: String -> IO String doit s = fmap (a . b) (fetch s)  main = doit "hello" >>= putStrLn 

but I don't know if that is necessarily practical in more complicated programs. The only way I've found thus far to really isolate an IO addition like this is to use unsafePerformIO, but, by its very name, I don't want to do that if I can help it. Is there some other way to isolate this change? If the refactoring is substantial, I would start to feel inclined to avoid having to do it (especially under deadlines, etc).

Thanks for any advice!

 


Here are a few methods I use.

  • Reduce dependencies on effects by inverting control. (One of the methods you described in your question.) That is, execute the effects outside and pass the results (or functions with those results partially applied) into pure code. Instead of having mainabfetch, have mainfetch and then mainab:

    a :: String -> String a f = b f ++ "!"  b :: String -> String b f = '!' : f  fetch :: String -> IO String fetch s = do   x <- getLine   return $ s ++ x  main = do   f <- fetch "hello"   putStrLn $ a f 

    For more complex cases of this, where you need to thread an argument to do this sort of “dependency injection” through many levels, Reader/ReaderT lets you abstract over the boilerplate.

  • Write pure code that you expect might need effects in monadic style from the start. (Polymorphic over the choice of monad.) Then if you do eventually need effects in that code, you don’t need to change the implementation, only the signature.

    a :: (Monad m) => String -> m String a s = (++ "!") <$> b s  b :: (Monad m) => String -> m String b s = ('!' :) <$> fetch s  fetch :: (Monad m) => String -> m String fetch s = pure (reverse s) 

    Since this code works for any m with a Monad instance (or in fact just Applicative), you can run it directly in IO, or purely with the “dummy” monad Identity:

    main = putStrLn =<< a "hello"  main = putStrLn $ runIdentity $ a "hello" 

    Then as you need more effects, you can use “mtl style” (as @dfeuer’s answer describes) to enable effects on an as-needed basis, or if you’re using the same monad stack everywhere, just replace m with that concrete type, e.g.:

    newtype Fetch a = Fetch { unFetch :: IO a }   deriving (Applicative, Functor, Monad, MonadIO)  a :: String -> Fetch String a s = pure (b s ++ "!")  b :: String -> Fetch String b s = ('!' :) <$> fetch s  fetch :: String -> Fetch String fetch s = do   x <- liftIO getLine   return $ s ++ x  main = putStrLn =<< unFetch (a "hello") 

    The advantage of mtl style is that you can have multiple different implementations of your effects. That makes things like testing & mocking easy, since you can reuse the logic but run it with different “handlers” for production & testing. In fact, you can get even more flexibility (at the cost of some runtime performance) using an algebraic effects library such as freer-effects, which not only lets the caller change how each effect is handled, but also the order in which they’re handled.

  • Roll up your sleeves and do the refactoring. The compiler will tell you everywhere that needs to be updated anyway. After enough times doing this, you’ll naturally end up recognising when you’re writing code that will require this refactoring later, so you’ll consider effects from the beginning and not run into the problem.

You’re quite right to doubt unsafePerformIO! It’s not just unsafe because it breaks referential transparency, it’s unsafe because it can break type, memory, and concurrency safety as well—you can use it to coerce any type to any other, cause a segfault, or cause deadlocks and concurrency errors that would ordinarily be impossible. You’re telling the compiler that some code is pure, so it’s going to assume it can do all the transformations it does with pure code—such as duplicating, reordering, or even dropping it, which may completely change the correctness and performance of your code.

The main legitimate use cases for unsafePerformIO are things like using the FFI to wrap foreign code (that you know is pure), or doing GHC-specific performance hacks; stay away from it otherwise, since it’s not meant as an “escape hatch” for ordinary code.

Comment

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: