I am new to Haskell and currently trying to port my solutions for the 2019 installment of the coding challenge AdventOfCode to Haskell. So, I would very much appreciate any suggestions how to make the code more readable and more idiomatic. In particular, I am interested in whether my use of the State
monad is sensible here.
This post is about the combined result from day 2, day 5, and day 9, an interpreter for a simple assembly language called IntCode. If you have not solved these problems and still intend to do so, stop reading immediately.
I have kept the entire solution for each part of each day in a single module with a single exported function that prints the solution. Without this selfimposed restriction, I would have split this code into different modules. For day 9 part 1 it starts as follows.
module AdventOfCode20191209_1 ( systemCheck ) where import System.IO import Data.List.Split import Data.List import Data.Maybe import qualified Data.HashMap.Strict as Map import Control.Monad.State import Data.Digits systemCheck :: IO () systemCheck = do inputText <- readFile "Advent20191209_1_input.txt" let code = toIntCode inputText let result = executeCode code [1] print result
Now, let me explain the setting, i.e. the IntCode language, and how I have implemented the different features.
As the name might suggest, the input is a stream/list of integer values. In the problems, it is provided as a comma separated string, which I read as follows.
type IntCode = [Int] toIntCode :: String -> IntCode toIntCode = map read . splitOn ","
Process State
This code also functions as the bases for the process memory, which is essentially infinite and initialized by the code itself inside its index range and by zero outside.
newtype Memory = Memory (Map.HashMap Int Int) deriving (Eq, Show) readMemory :: Int -> State Memory Int readMemory pointer = gets $ \(Memory m) -> Map.lookupDefault 0 pointer m writeToMemory :: Int -> Int -> State Memory () writeToMemory pointer 0 = modify $ \(Memory m) -> Memory $ Map.delete pointer m writeToMemory pointer value = modify $ \(Memory m) -> Memory $ Map.alter (\_ -> Just value) pointer m fromIntCode :: IntCode -> Memory fromIntCode = Memory . Map.fromList . indexedIntCode indexedIntCode :: IntCode -> [(Int, Int)] indexedIntCode = zip [0..]
In addition to memory, there is an input stream used by the Get
operation,
newtype InputStream = InputStream [Int] deriving (Eq, Show) addInput :: Int -> State InputStream () addInput input = modify $ \(InputStream xs) -> InputStream $ xs ++ [input] addInputs :: [Int] -> State InputStream () addInputs inputs = modify $ \(InputStream xs) -> InputStream $ xs ++ inputs popInput :: State InputStream (Maybe Int) popInput = state $ \input@(InputStream xs) -> case xs of [] -> (Nothing, input) y:ys -> (Just y, InputStream ys)
There are two more stateful objects involved in the computation, an instruction pointer and a relative base pointer, both starting at zero. The instruction pointer is what the name suggests and the relative base pointer is used for one of the methods to read arguments described later.
I have combined these into a ProcessState
type.
data ExecutionStatus = Running | Blocked | Terminated | Error deriving (Eq, Show, Enum) data ProcessState = ProcessState { memory :: Memory, inputs :: InputStream, instructionPointer :: Int, relativeBasePointer :: Int, status :: ExecutionStatus } deriving (Eq, Show) processStatus :: State ProcessState ExecutionStatus processStatus = gets $ status hasShutDown :: State ProcessState Bool hasShutDown = do currentStatus <- processStatus case currentStatus of Terminated -> return True Error -> return True _ -> return False isRunning :: State ProcessState Bool isRunning = do currentStatus <- processStatus case currentStatus of Running -> return True _ -> return False setProcessStatus :: ExecutionStatus -> State ProcessState () setProcessStatus processStatus = do stopped <- hasShutDown if stopped then return () else modify $ \s -> s{status = processStatus} terminateProcess :: State ProcessState () terminateProcess = setProcessStatus Terminated abortProcess :: State ProcessState () abortProcess = setProcessStatus Error setInstructionPointer :: Int -> State ProcessState () setInstructionPointer pointer = modify $ \s -> s {instructionPointer = pointer} processInstructionPointer :: State ProcessState Int processInstructionPointer = gets $ instructionPointer incrementInstructionPointer :: Int -> State ProcessState () incrementInstructionPointer offset = do instructionPointer <- processInstructionPointer setInstructionPointer (instructionPointer + offset) setRelativeBasePointer :: Int -> State ProcessState () setRelativeBasePointer pointer = modify $ \s -> s {relativeBasePointer = pointer} processRelativeBasePointer :: State ProcessState Int processRelativeBasePointer = gets $ relativeBasePointer incrementRelativeBasePointer :: Int -> State ProcessState () incrementRelativeBasePointer offset = do relativeBasePointer <- processRelativeBasePointer setRelativeBasePointer (relativeBasePointer + offset) readProcessMemory :: Int -> State ProcessState Int readProcessMemory pointer = gets $ \ProcessState{memory = m} -> evalState (readMemory pointer) m writeToProcessMemory :: Int -> Int -> State ProcessState () writeToProcessMemory pointer value = modify $ \s@ProcessState{memory = m} -> s {memory = execState (writeToMemory pointer value) m} addProcessInput :: Int -> State ProcessState () addProcessInput additionalInput = modify $ \s@ProcessState{inputs = inputStream} -> s {inputs = execState (addInput additionalInput) inputStream} addProcessInputs :: [Int] -> State ProcessState () addProcessInputs additionalInputs = modify $ \s@ProcessState{inputs = inputStream} -> s {inputs = execState (addInputs additionalInputs) inputStream} popProcessInput :: State ProcessState (Maybe Int) popProcessInput = state $ \s@ProcessState{inputs = inputStream} -> let (input, newInputs) = runState popInput inputStream in (input, s {inputs = newInputs}) initializeProcess :: IntCode -> [Int] -> ProcessState initializeProcess code initialInputs = ProcessState { memory = fromIntCode code, inputs = InputStream initialInputs, instructionPointer = 0, relativeBasePointer = 0, status = Running}
Most of the associated methods are basically plumbing between the stateful operations on the process state and the underlying stateful operations. I have added an execution status that is used to indicate that the process has terminated, via hasShutDown
. Moreover, it will be used to halt execution in case the process blocks due to missing input. This is necessary for applications as in part 2 of day 7 or on day 23.
Instructions
Each step of a computation starts by reading the value in memory the instruction pointer points to. This encodes two kinds of information: the opcode for the operation and the modes used to read the arguments from memory.
data IntCodeInstruction = IntCodeInstruction { opcode :: OpCode, argumentSpecifications :: [ArgumentSpecification] } deriving (Eq, Show) data ArgumentSpecification = ArgumentSpecification { argumentMode :: ArgumentMode, operationMode :: OperationMode } deriving (Eq, Show)
Reading Arguments
There are three basic modes how to read data.
toArgumentMode :: Int -> Maybe ArgumentMode toArgumentMode 0 = Just Pointer toArgumentMode 1 = Just Value toArgumentMode 2 = Just Relative toArgumentMode _ = Nothing
In general, the information relating to the nth argument is at an offset of n from the instruction pointer. In value mode, this is the argument, in pointer mode, it is the value of the pointer to the data and in relative mode, it is the offset from the relative base pointer where to look for the data. Unfortunately, this is not all. If the argument specifies a location to write to in memory, it the argument is the pointer instead of the data at the location the pointer points to. To encode this, I introduced an operation mode, which depends on the opcode.
data OperationMode = Read | Write deriving (Eq, Show)
Putting this together, I read arguments as follows.
instructionArguments :: IntCodeInstruction -> State ProcessState Arguments instructionArguments instruction = do basePointer <- processInstructionPointer let enumeratedArgumentSpecifications = zip [1..] (argumentSpecifications instruction) in mapM (instructionArgument basePointer) enumeratedArgumentSpecifications instructionArgument :: Int -> (Int, ArgumentSpecification) -> State ProcessState Int instructionArgument basePointer (offset, argumentSpec) = let evaluationPointer = basePointer + offset in case argumentMode argumentSpec of Value -> readProcessMemory evaluationPointer Pointer -> case operationMode argumentSpec of Write -> readProcessMemory evaluationPointer Read -> do transitiveEvaluationPointer <- readProcessMemory evaluationPointer readProcessMemory transitiveEvaluationPointer Relative -> do relativeBase <- processRelativeBasePointer baseIncrement <- readProcessMemory evaluationPointer let targetPointer = relativeBase + baseIncrement in case operationMode argumentSpec of Write -> return targetPointer Read -> readProcessMemory targetPointer
Decoding Instructions
The instruction itself is encoded as follows. The last two digits of the value in memory the instruction pointer points to represent the opcode. the higher digits represent the argument modes to be used, in inverted order when read, i.e. the lowest digit belongs to the first argument. Missing argument modes default to pointer mode.
intCodeInstruction :: State ProcessState (Maybe IntCodeInstruction) intCodeInstruction = do instructionPointer <- processInstructionPointer instructionValue <- readProcessMemory instructionPointer return (do -- Maybe opcode <- toOpCode (instructionValue `mod` 100) argumentSpecs <- toArgumentSpecifications opcode (instructionValue `div` 100) return (IntCodeInstruction opcode argumentSpecs)) toArgumentSpecifications :: OpCode -> Int -> Maybe [ArgumentSpecification] toArgumentSpecifications opcode argumentSpecifier = do -- Maybe maybeSpecifiedArgumentModes <- argumentModesFromSpecifier argumentSpecifier specifiedArgumentModes <- sequence maybeSpecifiedArgumentModes let operationModes = associatedOperationModes opcode numberOfMissingElements = length operationModes - length specifiedArgumentModes in if numberOfMissingElements < 0 then Nothing else let paddedArgumentsModes = specifiedArgumentModes ++ replicate numberOfMissingElements Pointer in return (zipWith ArgumentSpecification paddedArgumentsModes operationModes) argumentModesFromSpecifier :: Int -> Maybe [Maybe ArgumentMode] argumentModesFromSpecifier 0 = Just [] argumentModesFromSpecifier x | x < 0 = Nothing | otherwise = Just (map toArgumentMode (reverse (digits 10 x)))
OpCodes
Now, let me come to the opcodes; there are 10 of them.
data OpCode = Add | Multiply | Get | Put | JumpIfTrue | JumpIfFalse | LessThan | Equals | IncrementRelativeBase | Stop deriving (Eq, Show, Enum) toOpCode :: Int -> Maybe OpCode toOpCode 1 = Just Add toOpCode 2 = Just Multiply toOpCode 3 = Just Get toOpCode 4 = Just Put toOpCode 5 = Just JumpIfTrue toOpCode 6 = Just JumpIfFalse toOpCode 7 = Just LessThan toOpCode 8 = Just Equals toOpCode 9 = Just IncrementRelativeBase toOpCode 99 = Just Stop toOpCode _ = Nothing
Each opcode is accociated with one operation and hence also with one set of requires arguments.
--operation modes in order of arguments associatedOperationModes :: OpCode -> [OperationMode] associatedOperationModes Add = [Read, Read, Write] associatedOperationModes Multiply = [Read, Read, Write] associatedOperationModes Get = [Write] associatedOperationModes Put = [Read] associatedOperationModes JumpIfTrue = [Read, Read] associatedOperationModes JumpIfFalse = [Read, Read] associatedOperationModes LessThan = [Read, Read, Write] associatedOperationModes Equals = [Read, Read, Write] associatedOperationModes IncrementRelativeBase = [Read] associatedOperationModes Stop = [] type Arguments = [Int] associatedOperation :: OpCode -> (Arguments -> State ProcessState (Maybe Int)) associatedOperation Add = handleTerminationAndRun . add associatedOperation Multiply = handleTerminationAndRun . multiply associatedOperation Get = handleTerminationAndRun . getOperation associatedOperation Put = handleTerminationAndRun . putOperation associatedOperation JumpIfTrue = handleTerminationAndRun . jumpIfTrue associatedOperation JumpIfFalse = handleTerminationAndRun . jumpIfFalse associatedOperation LessThan = handleTerminationAndRun . lessThan associatedOperation Equals = handleTerminationAndRun . equals associatedOperation IncrementRelativeBase = handleTerminationAndRun . incrementRelativeBase associatedOperation Stop = handleTerminationAndRun . stop
Code Execution
Code execution generally works by reading input instructions, executing the associated operations and then advancing the instruction pointer to after the last argument, unless the operation alters the instruction pointer itself.
Operations
All operations share the same signature, Arguments -> State ProcessState (Maybe Int)
, in order to easily associate them with opcodes. The general operations assume that the process has not terminated. To guard against that, there is a special method used in the accociation with the opcodes.
handleTerminationAndRun :: State ProcessState (Maybe Int) -> State ProcessState (Maybe Int) handleTerminationAndRun state = do stopped <- hasShutDown if stopped then return Nothing else state
We always return Nothing
and do not alter the process state if the process has already shut down.
The individual operations work as follows.
Arithmetic operations apply the associated operator to their first two arguments and then write to the third.
add :: Arguments -> State ProcessState (Maybe Int) add = applyBinaryOperationAndWrite (+) multiply :: Arguments -> State ProcessState (Maybe Int) multiply = applyBinaryOperationAndWrite (*) applyBinaryOperationAndWrite :: (Int -> Int -> Int) -> (Arguments -> State ProcessState (Maybe Int)) applyBinaryOperationAndWrite binaryOp arguments = do let targetPointer = arguments!!2 value = binaryOp (head arguments) (arguments!!1) in writeToProcessMemory targetPointer value incrementInstructionPointer 4 setProcessStatus Running return Nothing
Binary comparisons are similar. However, they encode the return value by 1 for True and 0 for False.
lessThan :: Arguments -> State ProcessState (Maybe Int) lessThan = applyBinaryComparisonAndWrite (<) equals :: Arguments -> State ProcessState (Maybe Int) equals = applyBinaryComparisonAndWrite (==) applyBinaryComparisonAndWrite :: (Int -> Int -> Bool) -> (Arguments -> State ProcessState (Maybe Int)) applyBinaryComparisonAndWrite binaryComp arguments = do let targetPointer = arguments!!2 value = if (head arguments) `binaryComp` (arguments!!1) then 1 else 0 in writeToProcessMemory targetPointer value incrementInstructionPointer 4 setProcessStatus Running return Nothing
The two jump instructions set the instruction pointer to their second argument if the first arguments represents the corresponding truthyness. (again with 0 == False, /= 0 == True)
jumpIfTrue :: Arguments -> State ProcessState (Maybe Int) jumpIfTrue = jumpIf (/= 0) jumpIfFalse :: Arguments -> State ProcessState (Maybe Int) jumpIfFalse = jumpIf (== 0) jumpIf :: (Int -> Bool) -> (Arguments -> State ProcessState (Maybe Int)) jumpIf test arguments = do if test (head arguments) then setInstructionPointer (arguments!!1) else incrementInstructionPointer 3 setProcessStatus Running return Nothing
In addition to these operations to modify the instruction pointer, there is an operation that increments the relative base pointer by its first argument.
incrementRelativeBase :: Arguments -> State ProcessState (Maybe Int) incrementRelativeBase arguments = do incrementRelativeBasePointer $ head arguments incrementInstructionPointer 2 setProcessStatus Running return Nothing
To read from the input stream, there is the Get
operation, which i called getOperation
because of the name clash with get
from State
. It reads the first value from the input stream and writes it to its only argument. If the nput stream is empty, it blocks.
getOperation :: Arguments -> State ProcessState (Maybe Int) getOperation arguments = do maybeInput <- popProcessInput case maybeInput of Nothing -> do setProcessStatus Blocked return Nothing Just input -> do let targetPointer = head arguments in writeToProcessMemory targetPointer input incrementInstructionPointer 2 setProcessStatus Running return Nothing
The only operation providing output from the process is the Put
operation, which I again named putOperation
because of the clash with put
from State
. It simply outputs its first argument.
putOperation arguments = do incrementInstructionPointer 2 setProcessStatus Running let newOutputValue = head arguments in return $ Just newOutputValue
Finally, there is the stop
operation to terminte the process.
stop :: Arguments -> State ProcessState (Maybe Int) stop _ = do terminateProcess return Nothing
Code Execution Coordination
Now that all the pieces of the computation are specified, it only needs to be wired up. I do this using the following stateful computations.
continueExecution :: State ProcessState [Int] continueExecution = do maybeResult <- executeNextInstruction running <- isRunning if running then do remainingResult <- continueExecution case maybeResult of Nothing -> return remainingResult Just result -> return (result:remainingResult) else return [] executeNextInstruction :: State ProcessState (Maybe Int) executeNextInstruction = do maybeInstruction <- intCodeInstruction case maybeInstruction of Nothing -> do abortProcess return Nothing Just instruction -> executeInstruction instruction executeInstruction :: IntCodeInstruction -> State ProcessState (Maybe Int) executeInstruction instruction = do arguments <- instructionArguments instruction let operation = associatedOperation (opcode instruction) in operation arguments
As a convenience function, I add a function that can be used to initialize a process state and run the computation, throwing away the final state.
executeCode :: IntCode -> [Int] -> [Int] executeCode code initialInputs = let initialState = initializeProcess code initialInputs in evalState continueExecution initialState
For day 9, this function is sufficient. However, in problems like part 2 of day 7, continueExecution
needs to be used direcly in order to wire up a stateful computation with resuming after blocking temporarily.