Scheme Monads
There is a running joke in the Haskell community that monads are analogous to burritos. The joke is that there are about a million different explainers on the Internet about what monads are, and no two are alike in any way.
This article is not one of those explanations. What I want to discuss with you here is how to solve problems using monads in the Scheme programming language. So a 5-minute introduction is provided that avoids category theory and tries to keep explanations grounded in the realm of practical computer programming. My definition of monads is terse, non-mathematical, and limited to the perspective of a software engineer. If you want to go deeper, use your favorite search engine to query "what are monads in computer programming?" and try to find one of the millions of results that makes the most sense to you.
After the introduction, I talk a little bit about the various types of monads in Haskell and whether we might be interested in reconstructing them in Scheme or not. Finally I get into how you can construct useful monads in Scheme with an example pretty printer implementation, followed by an example "list" monad implementation.
A discussion of SRFI-247, "Syntactic Monads" will be a topic of a future article, but is not elaborated here.
My 5-minute introduction to monads
Monads are one of those ideas that are so simple that when I finally did understand it, I thought I had probably missed something. The hard part is probably trying to understand what monads have to do with computers or programming. So let me start by describing how monads are useful to computer programming:
Monads are useful for modeling computations as first-class values in a program, and changing the semantics of procedural code. But you may ask: how is it useful to model a computation in a computer language that models computation? I mean, in most all modern programming languages, you have the ability to use references to functions and code.
Well, can you change the semantics of a block of code in a Python program or C program? No, of course you cannot. If you write in Python:
def inc_and_print(a)
b = a + 1
print(f'{a} -> {b}')
return b
... you can pass "inc_and_print
" around as a value,
but you can't change how this function behaves. You
can't make it lazily evaluated, you can't make the variable
"a
" assume multiple possible non-deterministic values,
not without rewriting the code. The Python interpreter decides the
details of how the function is interpreted for you, and there is no
way to change this behavior.
There are a few ways to define your own computable functions in a programming language. The most obvious way is just to implement a Lisp language. In Python, that would look like an "evaluator" that steps through lists and dictionaries and performs computation as it goes:
# One way to model computation in Python that does not use monads,
# it defines a Lisp-like language constructed from Python's own
# built-in data structures like lists and dictionaries.
def eval_lisp(begin_stmt, env_dict):
# This is a simple Lisp interpreter.
ret_val = None
for form in begin_stmt:
if form[0] == '+':
ret_val = sum(form[1:])
if form[0] == 'print':
...
else:
raise ValueError(f'unknown function {form[1]}')
But as Alan Turing and Alonzo Church first taught us, there are many ways to model computation. Python is very Von Neumann-esque in it's computational "style". Two well-known alternatives to the Von-Neumann machine are the Turing Machine, and Chruch's Lambda Calculus. Any programming language that lets you construct functions (closures) as values can emulate a Lambda Calculus computer. Although some languages, like Haskell, take this to an extreme and make it impossible to construct anything but Lambda Calculus computations.
There is one problem with using Lambda Calculus to do computation: the axioms of the calculus do not define any notion of a "procedure." The only way to "compute" is with function application and "beta reduction" (which is substituting variables for values). So how do we model procedural computation, like in a Von Neumann machine in Lambda Calculus? That is what monads are for.
Monads are a programming technique in which you construct a data structure with embedded lambda functions that represents a computation. Evaluation of the computation, like evaluation of a Lisp list, involves stepping through the data structure and calling the lambda functions embedded in the structure. The data structure can also include flow control instructions. Then you can change the evaluation semantics of the computation to suit your needs. Why would we do this? Because we might want to encode procedures where the semantics are not simply "first execute this, then execute that." Examples of alternative procedural semantics include:
Unix-like command pipelines
concurrent computation
continuation prompts
simplified exception handling
runtime code optimization
coding with graphical visualizations
Of course in an object oriented language like Python, the idiomatic
answer to all of these questions is to just define a clever object
class heirarchy, where computations are objects constructed according
to some well-defined interface. Regardless of what your problem might
be, Monads are probably not your best solution in an object oriented
language. Conversely, Haskell does not even have a notion of a
procedure or an object at at all, rather Haskell provides special
syntax for monads by way of the do
notation. So all
"procedures" in Haskell are actually macro-expanded to pure monadic
lambdas. Monads are literally the only solution for
procedural coding in Haskell.
In a language like Scheme, lambdas and closures (objects) can conveniently be constructed without declaring a class, so you may want to consider using monads, because monads are a technique that lets us take advantage of this. In Scheme, monads could be a good solution to your problem, especially if it helps to simplify your programs.
A simple monad for Scheme programming
Here is a simple example of a monadic evaluator in Scheme that behaves like any ordinary, non-concurrent, procedural program. It steps through a list of lambdas composing them all together by executing a lambda, and applying the values returned the lambda as the arguments to the next computation:
(define (monad-eval monad . args)
;; Lets say the "monad" arument here is a list of procedures.
(let loop ((procs monad) (args args))
(cond
((null? procs) (apply values args))
(else
(call-with-values
;; first execute the first procedure
(lambda () (apply (car procs) args))
;; then execute the next procedure
(lambda args (loop (cdr procs) args))
)))))
If we wanted to, we could change the evaluator to make our computations behave in non-procedural ways, to define a different semantics for the meaning of "first evaluate this, then evaluate that." For now, lets only consider procedural computations. To write our code in a monadic way, we simply define a list of thunks. Here is a simple example where we needlessly use monads to define a function to compute the average of its arguments:
(define monadic-average
(list
(lambda args ;; 1. sum all arguments, return sum and list length
(values (apply + args) (length args)))
(lambda (sum len) ;; 2. display sum, compute and return average
(display "sum = ")(write sum)(newline)
(inexact (/ sum len)))
(lambda (avg) ;; 3. display and return
(display "avg = ")(write avg)(newline)
avg)))
(monad-eval monadic-average -3 -1 4 8 15 16 23 42 66 104)
;; Outputs:
;; sum = 274
;; avg = 27.4
Why interlave "display
" with arithmetic in that way?
No reason, this is just an example. The important thing to notice is
that interleaving is possilbe using an alternative semantics to
procedural evaluation, semantics that we defined ourselves in
the monad-eval
procedure. So using monads, we can pretty
easily construct monads that subvert the procedural semantics,
without using macros, using only lambdas. And that
is what the rest of this article is about.
Properties of a Scheme Monad
In this article, I would like to define monads in Scheme as a set of data types and procedures with the following properties:
There must be a data structure that contains lazily evaluated procedures that I will call "thunks." Here, thunks refers to any lazily evaluated lambda constructed by a top-level "combinator" procedure, regardless of whether the thunk takes arguments or not. Scheme being in the Lisp family, the data structure we will use to construct monads is obviously the list. We can also define record types for more complex computations, such as monads that define alternative exception handling or flow control semantics. In Haskell, the data structures used to construct monads are usually pure, lazy functions, which you could also think of as closures (objects), composed together with the bind operator
>>=
.There must also be a way to traverse the data structure and evaluate these thunks, this is "the evaluator." All monads must have an evaluator which defines the semantics of the monadic code. Monadic "combinators" can call the evaluator recursively, so some monadic evaluators might just be an entrypoint into a monadic combinator that recursively evaluates its arguments. One example of this is the "
print
" monad shown in the example below, this monad does all the actual monadic evaluation, the "pretty
" procedure is an entrypoint to that evaluator.A set of primitive procedures for constructing these thunks embedded in the traversable data, I will call these "monadic combinators." They fit the pieces of our monadic procedure together like lego blocks. In Haskell there must always be at least one primitive called
pure
(orreturn
which is aliased topure
) which takes a constant value and turns it into a monadic thunk. In Scheme, apure
combinator is not always necessary. When I use the word "monad" in my documentation, I am referring to either a mondic combinator, or a procedure constructed from these combinators.
I will show some code examples of Scheme monads presently, but first I want to talk about the various different kinds of monadic computations available to Haskell, and consider whether or not each on may be useful to Scheme programers.
A zoo of monads
There are actually not that many different known ways of defining a computation. In Haskell, there are roughly eight or nine monads that are truly unique, and all other monads are defined by combining these unique monads together. Some of these unique monads are called "Monad Transformers" and are provided as a library. They are called transformers because they are specifically designed to be combined with other monads by way of "lifting".
Furthermore, these Monad Transformers are incredibly useful to Haskell, but not necessarily to other programming languages, because Haskell it is so strictly a pure Lambda Calculus. Monads Transformers provide useful features commonly found in other programming languages which are difficult to define in pure Lambda Calculus. These monads are not really necessary in Scheme because the Scheme language provides features not defined in Lambda Calculus.
Some of these monads are:
that is to say "mutable state," is one of the most useful monads in Haskell, because a pure functional language like Haskell does not have any notion of "state" built-in (unless you count the program itself as state). Fortunately, it is easy to define a monad that does model the mutable state of your program. Scheme provides mutable state without monads, but in my experience the "state" monad is still very useful in Scheme programs, especially if there are problems for which you want to have state data that is fully isolated and only visible to a single computation. As an asside, I would say that SRFI-247, "Syntactic Monads" is a way to simplify constructing state monads in Scheme, but I will reserve an in-depth discussion of this for a later article.
is similar to "state," but the state is a "monoid," that is a data structure that can grow. Writers are usually used to build large strings, like pretty printed documents, or log files.
is similar to "state," but the state is read-only. With a "reader" monad, the "state" is called the "environment" and can often be used in similar ways to Scheme "environment" objects. It is also possible for the environment to contain mutable boxes, and in this way a Reader can also provide mutable state. The Scheme
parameterize
primitive can be also be modeled with a "reader" monad.models Scheme's
call/cc
primitive in pure lambda caluclus. The stack of the continuation exists only for as long as the monad is being computed, so it is not precisely the same thing. I have not found this monad to be useful to Scheme, using Scheme's built-incall/cc
is usually the easiest solution.models concurrent computation. A monad has the option of returning multiple values, but each value spawns a separate concurrent computation. It is also possible to halt a "list" monadic computation by returning no values. This monad would likely be very useful for some Scheme programs, though I have personally have never had any use for it.
models Scheme's short-circuiting logic functions like
and
,or
, and theand-let*
primitives (and-let*
is defined in SRFI-2, "and-let*"). In Haskell,and-let*
is simply the "bind" operator, whereor
ismplus
. The monads I have defined in Scheme usually have combinators similar to Haskell's "maybe" monad, although the "and-let*
" statement cannot be easily replaced by monads in Scheme.models computations that can raise exceptions. It is similar to the "maybe" monad, but instead of failing with a
#f
value, it can fail with an error object that can be caught and handeled. This monad is useful to Scheme, since it allows you to define different exception conditions from which you may choose to recover or not. The monads I have defined in Scheme usually have combinators similar to Haskell's "error" monad.models "Software Transactional Memory." This is a way to define parallel computations that depend on memory values that must be updated atomically. If an atomic value changes during computation (changed by another parallel thread of computation), the computation is "rebooted" to ensure race conditions never occur. Since Haskell does not permit side-effects, "rebooting" a thread is guaranteed to produce the same exact result up to the point where it received a value from transactional memory. This monad would probably be difficult to implement in Scheme since side effects can occur anywhere in the program.
models computations that can read information into the computation, or mutate the state of the computer system outside of the computation. It is basically the "state" monad, and mostly used by the Haskell type checker to distinguish between pure computations and effectful computations that mutate the computer's state. Also, IO is built-in to the Haskell language, IO is not a monad transformer. Monad Transformers in Haskell must "lift" the IO monad if the computation is to perform effectful computations. In PureScript, the IO monad is called "effect." In my experience this monad is not necessary at all in an impure, untyped language like Scheme.
has nothing to do with "STM," is a form of "state" monad with an additional constraint that the state may not execute side-effects outside of a limited region of the computer's memory, so its side-effects can only mutate memory such as vectors. In Haskell the
ST
monad is used to define efficient vector computations while guaranteeing purity outside of the monad's evaluation. Like "IO," the ST monad is also built-in to Haskell, and used more for the purposes of the type checker, and so is not at all useful to Scheme.
A monadic pretty printer in Scheme
Now lets begin examining monads that are useful for solving problems in Scheme. I believe the simplest interesting example is a pretty printer. In fact, SRFI-166, "Monadic Formatting" is a standard Scheme monad you can use in your Scheme programs. However, for the purpose of learning, lets see how we might implement our own.
Recall the three properties we need for a monad:
A data structure containing thunks that we construct to represent the computation.
- An evaluator for this data structure.
Some simple, primitive monad "combinators" from which we construct more complex computations.
Since Scheme list data structures are already good enough to model a monad, all we ever need to do is define a record type to contain the thunks. Our data structure will then be a list of these thunks.
Note: in my experience, it is always best to wrap thunks in a unique record type. This allows your evaluator to reject monads that were not constructed by the expected combinators. If you do not want to reject combinators from other monads, you must explicitly write the evaluator's logic to handle this. This helps to prevent bugs.
Our pretty printer may want to parameterize values like indentation, or which output ports to use. Of course, we can use Scheme's built-in parameter objects for this, but lets see how we might construct a monad similar to Haskell's "reader" monad to accomplish this same goal. To construct a "reader" monad, our thunks must always take exactly one argument, the environment argument. The environment will contain the parameters we want to always be available to every thunk in the monad. So lets define two record type: one for our thunks, one for our environment.
Also, our monad will have properties of a "writer" monad. This is accomplished by storing a data structure in the monad environment that can collect output. In the case of the pretty printer, this data structure is an output port.
(define-record-type <pretty-monad-type>
(%pp thunk) ;; wraps our thunks in a record type
pretty-monad-type?
(thunk pretty-monad-thunk)
)
(define-record-type <pretty-env-type>
(make<pretty-env> port indent line)
pretty-env-type?
(port pretty-env-port) ;; port to which we print
(indent pretty-env-indent set!pretty-env-indent) ;; indentation level
(line pretty-env-newline? set!pretty-env-newline) ;; after a line break?
)
Next, lets define some combinators. A combinator is a lazy procedure, it takes its own arguments and uses those arguments to construct a thunk. Again, since we are modeling a "reader" monad, every thunk takes exactly one argument: the monad's "environment" data.
Lets make our first combinator called "print
" which
simply writes all of its arguments, and also writes indentation if it
is immediately after a line break. Lets also create a "quoted string"
data type to write a string in quoted form.
(define-record-type <quoted-string-type>
(qstr str)
quoted-string-type?
(str unquote-string)
)
(define (print . args)
;; This is a monad combinator, but it also happens to be the monad's
;; evaluator as well. It can take monads as arguments, and it will
;; recursively calls the evaluator on these arguments, thus defining
;; procedural evaluation semantics. The "pretty" evaluator is just
;; an entry point into this monad.
(%pp ;; Wrap this thunk in a "<pretty-monad-type>".
(lambda (env) ;; The thunk takes exactly one argument: a <pretty-env-type>.
(let ((port (pretty-env-port env))) ;; Port is used often, give it a short name
(let loop ((args args)) ;; Loop over the arguments.
(cond
((null? args) (values)) ;; Stop looping here.
((pair? args)
;; First thing: if we are right after a line break, out
;; indentation. This needs to happen inside of this loop
;; since arguments can be other monads which could
;; evaluate the "line-break" monad.
(when (pretty-env-newline? env)
(let loop ((n (pretty-env-indent env)))
(when (> n 0)
(display #\space port)
(loop (- n 1)))
(set!pretty-env-newline env #f)
))
;; Now, inspect the next argument and "display" or "write" it.
(let ((obj (car args))) ;; Get the current argument.
(cond
((string? obj) (display obj port))
((char? obj) (display obj port))
((quoted-string-type? obj)
(write (unquote-string obj) port))
((pretty-monad-type? obj) ;; Recursively evaluate monad arguments
((pretty-monad-thunk obj) env))
(else ;; Use Scheme's built-in writer by default.
(write obj))
))
(loop (cdr args))
)))))))
Next, lets define a combinator that outputs a line break only if a
line break has not already been output. Our combinator can optionally
take a boolean to force the line break regardless. If a line break is
written, the "pretty-env-newline?
" field of the
environment is updated to #t
.
(define line-break
(case-lambda
(() (line-break #f))
((forced?)
(%pp ;; wrap this thunk in a "<pretty-monad-type>"
(lambda (env) ;; the thunk takes an environment
(unless (and (not forced?) (pretty-env-newline? env))
(newline (pretty-env-port env))
(set!pretty-env-newline env #t))
)))))
Finally, let's define a combinator that can recursively evaluate
another monad, but with increased indentation. This is an example of
how we can simulate the Scheme "parameterize
" keyword in
our monad.
(define (indent +n monad)
(%pp ;; wrap this thunk
(lambda (env)
(set!pretty-env-indent env (+ (pretty-env-indent env) +n))
((pretty-monad-thunk monad) env)
(set!pretty-env-indent env (- (pretty-env-indent env) +n))
)))
Finally, we need our evaluator function, which I
will call the "pretty
" procedure. The
"print
" monad is really the evaluator of this monad, so
the pretty
evaluator is really just an entrypoint into
print
, we could call "pretty
" the top-level
evaluator. This top-level evaluator takes any pretty printer monad and
applies an environment to the lazy thunk within it to force
computation. So we should provide arguments to the evaluator for
setting-up the environment.
(define pretty
(case-lambda
((monad) (pretty #t 0 monad))
((init-indent monad) (pretty #t init-indent monad))
((port init-indent monad)
;; We can do some simple runtime type checking
;; when setting up the environment
(cond
((not (integer? init-indent))
(error "non-integer indentation value" init-indent))
((not (pretty-monad-type? monad))
(error "not a pretty-printer monad" monad))
(else (values)))
(let ((port
(cond
((eq? port #f) (open-output-string))
((eq? port #t) (current-output-port))
((output-port? port) port)
(else (error "wrong type for port argument" port)))))
((pretty-monad-thunk monad)
(make<pretty-env> port init-indent #f))))
))
Now we can declare our monadic computation and evaluate it.
;; Here is a function that produces a list of items
;; that we want to pretty print:
(define (iota+ init n end)
;; Iterate n numbers starting at zero and
;; delimieted by init and end lists.
(define (iota i)
(cond
((< i n) (cons i (iota (+ 1 i))))
((or (pair? end) (null? end)) end)
(else (list end))))
(let loop ((init init))
(cond
((pair? init) (cons (car init) (loop (cdr init))))
((null? init) (iota 0))
(else (cons init (iota 0))
))))
;; This function calls the above "iota+" and
;; pretty prints the result.
(define (print-seq n)
(apply print
(map (lambda (val) (print #\space val)) (iota+ #\( 10 #\)))))
;; Here we pretty print a hello world message with
;; the above list of items in between.
(pretty
(print #\( "hello" (print-seq 10) (line-break)
(indent 4 (print (qstr "world!") #\) ))
(line-break)))
;; Prints:
;; (hello ( 0 1 2 3 4 5 6 7 8 9 )
;: "world!")
A list monad in Scheme
The pretty printer monad in the previous section was still very
procedural. The print
monad simply evaluated its
arguments sequentially, and the pretty
evaluator simply
deferred evaluation to print
. There was no real change in
the logic of procedural evaluation.
So lets do one more example: the "list" monad, which is an example of simple concurrent computation. As a reminder, concurrent computation is not parallel computation on separate threads or in separate computers. Concurrency means two computational tasks which may happen indepnedently of one another, and so the ordering of these tasks is not important to obtaining the correct result. Concurrent computations can run in a single thread, but can complete faster if run in parallel threads. The list monad in this example will define single-threaded concurrent computations.
The basic idea of a "list" monad is that you compose a pipeline of procedures, the output of each procedure being applied to the input of each next procedure in the pipieline. However, each procedure has the option of returning more than one result, and when there is more than one result, each result is applied to the next procedure in the chain concurrently.
Again, we define a record type to wrap our monadic computations:
(define-record-type <list-monad-type>
;; The wrapper for thunks.
(%list thunk)
list-monad-type?
(thunk list-thunk)
)
;; The combinators
(define (do-list . monads)
;; Each argument to this monad must be a procedure, a lambda, or
;; a list monad that takes one value and returns a list, each value
;; returned is applied concurrently to the procedure that comes
;; after it. All arguments returned are collected into a list.
(%list
(lambda (arg)
(let step-monads ((monads monads) (arg arg))
(cond
((null? monads) arg)
(else
(let*((monad (car monads))
(monad
(cond
((procedure? monad) monad)
((list-monad-type? monad) (list-thunk monad))
(else (error "not a list monad or procedure" monad)))
)
(args (monad arg)) ;; apply the argument to the next monad
)
(apply append
(map (lambda (arg) (step-monads (cdr monads) arg)) args))
)))))))
(define (constants . items)
;; A list monad that simply wraps all of it's arguments, ignoring
;; any input it may receive.
(%list (lambda _ items)))
(define (eval-list-monad init-args . monads)
(let ((monad (list-thunk (apply do-list monads))))
(if (or (pair? init-args) (null? init-args))
(apply append (map monad init-args))
(monad init-args))))
There are only two combinators, "do-list
" and
"constants
", and then the monad evaluator
"eval-list-monad
". The "do-list
" monad
serves as the actual monad evaluator. The monad evaluator is an
entrypoint into this monad. Also, "do-list
" does not
require all of its arguments be monads, it can take an ordinary
procedure and turn it into a monad as well.
Lets use the list monad to break up a string into lines, and then the lines into words separated by whitespace. The procedure that breaks a string into lines only needs to return the list of lines, the monad will automatically apply each line to the next procedure, which breaks a string into words. We return the list of words wrapped in a list so that the line breaks are preserved.
(import (only (srfi 130) string-split))
(let ((line-number 0))
(eval-list-monad
(string-append
"As you liberate yourself in metaphor, think of others,\n"
"those who have lost the right to speak.\n"
"As you think of others, think of yourself,\n"
"say: \"if only I were a candle in the dark.\"\n"
)
(lambda (str) (string-split str #\newline))
(lambda (line) (list (string-split line #\space))
;; As an exercise, what happens when we do not wrap
;; the tokenized line in a list?
)
(lambda (line)
(set! line-number (+ 1 line-number))
(list (list (cons line-number line)))
)
))
;; Outputs:
;; ((1 "As" "you" "liberate" "yourself" "in" "metaphor," "think" "of" "others,")
;; (2 "those" "who" "have" "lost" "the" "right" "to" "speak.")
;; (3 "As" "you" "think" "of" "others," "think" "of" "yourself,")
;; (4 "say:" "\"if" "only" "I" "were" "a" "candle" "in" "the" "dark.\"")
;; (5 "")
;; )
The above example splits a string into lines, then into words, then applies line numbering. As you can see, we can chain together several list-producing procedures using each list element be used as input to the next procedure in the chain. Each procedure is executed on its each of it's inputs concurrently.
One last list monad example I want to demonstrate is how you can
construct power sets. A power set is when you take a set A (e.g. with
elements (a b)
) and a set B (e.g. with (c
d)
), the power set is all elements of A paired with all
elements of B (e.g. ((a c) (a d) (b c) (b d))
). To do
this, lets define a procedure called "list-apply
" which
allows us to apply a set of elements (expressed as a list) to a
procedure that is wrapped-up into a monad.
(define (list-apply elems monad)
(eval-list-monad elems (lambda (elem) (list (monad elem)))))
(list-apply
'(1 2 3)
(lambda (a)
(list-apply
'(1 2 3)
(lambda (b)
(list-apply
'(1 2 3)
(lambda (c) (list (list a b c)))
)))))
;; Outputs:
;; ((1 1 1) (1 1 2) (1 1 3)
;; (1 2 1) (1 2 2) (1 2 3)
;; (1 3 1) (1 3 2) (1 3 3)
;; (2 1 1) (2 1 2) (2 1 3)
;; (2 2 1) (2 2 2) (2 2 3)
;; (2 3 1) (2 3 2) (2 3 3)
;; (3 1 1) (3 1 2) (3 1 3)
;; (3 2 1) (3 2 2) (3 2 3)
;; (3 3 1) (3 3 2) (3 3 3))
This is simple and easy to understand way of solving a common
programming problem, and I feel makes the code more readable and
understandable. Unfortunately this monadic Scheme program is not as
nice as the equivalent program written in Haskell due to how Haskell
provides the "do
" macro (actually, syntactic sugar
built-in to the language) that expresses binding arguments to lambdas
with a nice notation:
powerset123 = do
a <- [1,2,3]
b <- [1,2,3]
c <- [1,2,3]
[a,b,c]
Concluding remarks
The idea of monads for modeling procedural computation in Lambda Calculus was first developed by category theorist Eugenio Moggi in his 1989 paper Computational Lambda-Calculus and Monads, and was introduced to Haskell by Philip Wadler only a few years later. I would say the idea has been revolutionary, at least in the realm of programming languages.
For Lisp and Scheme programmers, the theory of monads have proved
to us that we don't necessarily need to implement our
own eval
and apply
functions over Lisp lists
in order to write programs that run with evaluation semantics rules
different from those rules provided by the programming language that
we are using. All we need are lambdas (closures) interconnected in
just such a way that we can use them to define procedures, and this
can be very useful for encoding algorithms in more elegant ways.
And though I never discussed the use of Scheme macros in this article, it easy to see how one could still define macros that expand to monadic code, so the two methods of modeling semantics are certainly not mutually exclusive.
I hope the two simple examples I provided in this article were enough to teach you how to apply monadic programming as a technique to your own Scheme programs. The pretty printer monad showed how a monad passes around state, the list monad showed how monads can implement a different procedural semantics (a concurrent semantics), and how this alternative semantics makes constructing concurrent computations a lot easier.
And I hope you have learned that introducing monadic programming into your Scheme programming project is not too difficult: all you need are a few primitive monadic combinators, and an evaluator.
Download the examples in this article here:
Appendix: a monad as pseudo-C++ code
So my favorite monad anaology for monads is not burritos, but
"programmable semicolons." In the C family of procedural languages,
that is C, C++, C#, and Java, procedural blocks of code are
curly-bracketed, and each procedural step is delimited by a
semicolon. The semicolon is syntax, not an operator, but suppose in a
language like C++ you could overload the semicolon as though it were
an opreator, as you would overload the +
operator for
strings or vectors? What would that look like?
Well, quite simply, it would be a "Procedure" object, which would be a vector of "thunks", where a thunk is a callable function. A procedure itself can be called as a thunk by overloading the function call operator. Calling a thunk applies arguments to the first procedure in the vector, then the return value of that function call is applied as arguments to the next thunk in the procedure. In this way, you can model procedures as first-class objects in the program.
To demonstrate how monads are "programmable semicolons," in the
C-family of languages, we need to imagine that the semicolon syntax is
actually an operator that can be overloaded, like how the sum
opreator +
is overloaded for string and vector objects,
or how the shift operator <<
is overloaded for
stream objects. What would an overloaded semicolon "operator" look
like?
A semicolon operator would be a method that takes a left and right argument, but what would the types of these arguments be? The left and right argument of a semicolon are procedures. So suppose there was a "procedure" class, which is a vector containing thunks, which are lazy method calls, i.e. function pointers that you would "call" with some arguments in order to run them. Our theoretical "procedure" class itself would overload function application as well, so that when you "call" the whole procedure with some arguments, it steps through the vector of thunks calling each thunk in order. This is how we could model procedures in an object-oriented way.
class Procedure {
private:
std::vector<Procedure> thunk_vector;
public:
Procedure(std::vector<Procedure> init) {
this._thunk_vector = init;
}
// This is bad syntax, but lets pretend you could do this in C++:
Procedure opreator; (Procedure a, Procedure b) {
// Overload the "semicolon" operator (which isn't really an operator in C++).
return (a.thunk_vector + b.thunk_vector);
}
std::any operator() (std::vector<std::any> args) {
// Overload the function call operator.
for(const Procedure& thunk : this.thunk_vector) {
args = thunk(args); // call the thunk
}
return args;
}
}
I am eliding any explanation of how one could implement "binding,"
that is how you model a procedural statement like { int i =
f(x); }
using monads. These details aren't imporant for
the purpose of this discussion.
So this Procedure
class could represent the default
behavior of C++ semicolons. Then, we could declare subclasses
of Procedure
where we override the semantics the
operator;
method. We could then completely change what it
means for one statement to execute after another statement in a block
of code. We could model an alternative exception handling, you can
model concurrent procedure execution mechanism, or Unix-like command
pipelines, or nondeterministic procedures.
For example, in this imaginary C++ implementation, lets say blocks
of code are objects in the "Procedure
" class by default,
but we could use hypothetical subclasses of "Procedure
"
like "Concurrent
" or "Maybe
" or
"Nondeterministic
" to use their overloaded semicolon
operators of those classes instead. I imagine the syntax of such a C++
might look something like this:
int main(int argc, const char* const argv[]) {
if (argc > 0) Concurrently {
auto a = prepare(argv);
auto b = reduce(a);
return serialize(a, b);
} else Maybe {
auto a = default_prepare();
auto b = default_reduce();
return serialize(a, b);
}
}
...where "Concurrently" declares the block of code for that if-statement to be evaluated using the concurrent procedure evaluator, and Maybe declares the block of code for the else-statement to be evaluated using the "maybe" procedure evaluator.
If the C++ standardization committee wants to consider this a proposal for a language feature to be included the next iteration of the C++ standard, I would be happy to take credit!