Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to limit code changes when introducing state?

I am a senior C/C++/Java/Assembler programmer and I have been always fascinated by the pure functional programming paradigm. From time to time, I try to implement something useful with it, e.g., a small tool, but often I quickly reach a point where I realize that I (and my tool, too) would be much faster in a non-pure language. It's probably because I have much more experience with imperative programming languages with thousands of idoms, patterns and typical solution approaches in my head.

Here is one of those situations. I have encountered it several times and I hope you guys can help me.

Let's assume I write a tool to simulate communication networks. One important task is the generation of network packets. The generation is quite complex, consisting of dozens of functions and configuration parameters, but at the end there is one master function and because I find it useful I always write down the signature:

generatePackets :: Configuration -> [Packet]

However, after a while I notice that it would be great if the packet generation would have some kind of random behavior deep down in one of the many sub-functions of the generation process. Since I need a random number generator for that (and I also need it at some other places in the code), this means to manually change dozens of signatures to something like

f :: Configuration -> RNGState [Packet]

with

type RNGState = State StdGen

I understand the "mathematical" necessity (no states) behind this. My question is on a higher (?) level: How would an experienced Haskell programmer have approached this situation? What kind of design pattern or work flow would have avoided the extra work later?

I have never worked with an experienced Haskell programmer. Maybe you will tell me that you never write signatures because you have to change them too often afterwards, or that you give all your functions a state monad, "just in case" :)

like image 803
trunklop Avatar asked Sep 29 '16 10:09

trunklop


1 Answers

One approach that I've been fairly successful with is using a monad transformer stack. This lets you both add new effects when needed and also track the effects required by particular functions.

Here's a really simple example.

import Control.Monad.State
import Control.Monad.Reader

data Config = Config { v1 :: Int, v2 :: Int }

-- the type of the entire program describes all the effects that it can do
type Program = StateT Int (ReaderT Config IO) ()

runProgram program config startState = 
  runReaderT (runStateT program startState) config

-- doesn't use configuration values. doesn't do IO    
step1 :: MonadState Int m => m ()
step1 = get >>= \x -> put (x+1)

-- can use configuration and change state, but can't do IO
step2 :: (MonadReader Config m, MonadState Int m) => m ()
step2 = do
  x <- asks v1
  y <- get
  put (x+y)

-- can use configuration and do IO, but won't touch our internal state
step3 :: (MonadReader Config m, MonadIO m) => m ()
step3 = do
  x <- asks v2
  liftIO $ putStrLn ("the value of v2 is " ++ show x)

program :: Program
program = step1 >> step2 >> step3

main :: IO ()
main = do
  let config = Config { v1 = 42, v2 = 123 }
      startState = 17
  result <- runProgram program config startState
  return ()

Now if we want to add another effect:

step4 :: MonadWriter String m => m()
step4 = tell "done!"

program :: Program
program = step1 >> step2 >> step3 >> step4

Just adjust Program and runProgram

type Program = StateT Int (ReaderT Config (WriterT String IO)) ()

runProgram program config startState =
    runWriterT $ runReaderT (runStateT program startState) config

To summarize, this approach lets us decompose a program in a way that tracks effects but also allows adding new effects as needed without a huge amount of refactoring.

edit:

It's come to my attention that I didn't answer the question about what to do for code that's already written. In many cases, it's not too difficult to change pure code into this style:

computation :: Double -> Double -> Double
computation x y = x + y

becomes

computation :: Monad m => Double -> Double -> m Double
computation x y = return (x + y)

This function will now work for any monad, but doesn't have access to any extra effects. Specifically, if we add another monad transformer to Program, then computation will still work.

like image 86
user2297560 Avatar answered Sep 28 '22 20:09

user2297560