With the libraries selected, I could start giving thought to the architecture of the application, starting with the foundation.
Base: The IO Monad
This application was to have a GUI interface, read/write WAV files, and play audio, so naturally the IO
monad would be the base upon which the rest of the codebase stands.
But IO is not the only thing the IO
monad has to handle in this application.
Graphics in the SDL2 library are handled on the main thread of the application, but an IO
callback is used to retrieve audio for playback. Since the callback needs to interact with the internal application state, the IO
monad has to facilitate access to it so the callback can get the audio it needs to play and update the playback position outside of the main thread.
In addition to handling the application state, the IO
monad runs a few longer-running operations in threads to keep the UI responsive. These threads report progress to the user, so the IO
monad handles the state used for progress reporting as well.
Configuration: ReaderT
Different parts of this application are configurable (e.g. the visual style), and a few settings need to be accessed in multiple places within the codebase.
In an object-oriented program there are a multiple ways one might design the code to enable this; among them are:
- Having each setting be available to the rest of the application as a value provided by a singleton
- Having a (read-only) configuration object that is passed around to wherever it's needed
In Haskell an obvious approach to accomplishing the same thing would be to pass the settings and/or a configuration data-structure around as arguments to each of the functions that needed them, as with the latter approach above, but this would be rather cumbersome.
Instead, a Reader
monad can do the same thing, passing information around the program almost implicitly, providing it to the application-level code when it's ask
ed for. We can pass the configuration settings around the program by stacking a Reader
monad on top of IO
using the ReaderT
monad transformer, which is advised as part of the ReaderT
pattern.
So this is what forms the foundation of the application:
type App = ReaderT GlobalContext IO
Aside: Monads
At this point some readers may be feeling a bit uncomfortable because not only have I mentioned the dreaded "monad" but also this "monad transformer" thing. For those unfamiliar with these concepts, let me try to clarify things -- I'm not going to turn this into a monad tutorial, just give a high-level metaphor1 for how you can think of these concepts within this application.
Say the application is a building:
- On the top floor is your application code: an executor on this level walks around and does stuff, running the program.
- The ground floor is the
IO
monad: it has all of the entries and exits for information to go in and out of the building i.e. program. - The floor above
IO
isReaderT
: this floor is empty except for a person with a cart full of information; as the executor on the application level walks around above, the person onReaderT
pushes their cart to the same location within the building, just a floor below.
Whenever the application-level executor needs information from the reader or needs to interact with the outside world, a magic elevator2 appears at their location and they send instructions down to the IO
or ReaderT
levels, getting information back in return. This is how information and control flows through this program.
(To round out this metaphor, I guess you could say that unsafePerformIO
is the ability to open a window on one of the upper floors -- you'd better be sure you know what you're doing if you try to go out there!)
Every time we talk about adding a monad to the stack, think of it as a new floor being added to the building below the application code's level.