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.
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.
Reader monad can do the same thing, passing information around the program almost implicitly, providing it to the application-level code when it's
asked 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
So this is what forms the foundation of the application:
type App = ReaderT GlobalContext IO
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 metaphor* 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
IOmonad: it has all of the entries and exits for information to go in and out of the building i.e. program.
- The floor above
ReaderT: 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 on
ReaderTpushes 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 elevator** appears at their location and they send instructions down to the
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.
* The web is already full of bad metaphors for monads, so why not add another to the fire?
** Incidentally, the function used for jumping down to the other monadic levels is actually named
This article was tweaked 2021-06-07 and again on 2021-06-16