Notes on debugging Clojure code

Clojure is a great programming language, but a recurring complaint one keeps hearing from developers hacking on Clojure code is that debugging can be unpleasant. First of all, I agree! Debugging Clojure code can be more daunting on average than, say, debugging Python code. This is mainly due to two reasons:

  1. Clojure's Java legacy. Clojure is compiled to Java bytecode, which has some terminology and idiosyncracies Clojure programmers aren't always familiar with. These terms tend to pop up in stack traces and cause confusion (e.g. IFN).
  2. Clojure - being a Lisp - has a certain code structure which is different from, say, a more common imperative coding style. Rather than being a sequence of statements, Clojure programs tend to involve long call chains of nested expressions. Where only part of an expression fails, it's often non-trivial to figure out why.

In this post I want to share some notes from my own experience debugging Clojure programs.

Dealing with Clojure's cryptic exceptions

The first problem with Clojure's runtime exceptions is that we usually don't get to see the full stack trace by default. Let's say we have this silly, nonsensical, function in a file called sample.clj:

(defn foo
  (cond (> n 40) (+ n 20)
        (> n 20) (- (first n) 20)
        :else 0))

Then to try how it works, we load the file into the REPL and type the following [1]:

debugging.core=> (foo 24)
IllegalArgumentException Don't know how to create ISeq from: java.lang.Long
  clojure.lang.RT.seqFrom (

Uh oh. There are two problems here. First, what does this error message mean? What's ISeq and what's java.lang.Long? Second, it's not clear where it is actually failing (thanks for that pointer to though, Clojure!) Let's address the second problem first. The magic incantation to show the stack trace of the last exception is calling the pst function:

debugging.core=> (pst)
IllegalArgumentException Don't know how to create ISeq from: java.lang.Long
  clojure.lang.RT.seqFrom (
  clojure.lang.RT.seq (
  clojure.lang.RT.first (
  clojure.core/first--4339 (core.clj:55)
  clojure.core/first--4339 (core.clj:55)
  debugging.sample/foo (sample.clj:10)
  debugging.sample/foo (sample.clj:7)
  debugging.core/eval13715 (form-init6539101589609174055.clj:1)
  debugging.core/eval13715 (form-init6539101589609174055.clj:1)
  clojure.lang.Compiler.eval (
  clojure.lang.Compiler.eval (
  clojure.core/eval (core.clj:3105)

This is much better because at least some files in this trace are familiar. core.clj is not our core.clj, it's Clojure's core library. But sample.clj is our file, and we can infer that on line 10 we call clojure,core/first and something goes wrong. Line 10 happens to be:

(> n 20) (- (first n) 20)

So now things become more clear. The call (first n) must be bad, and bad in a way that tries to coerce clojure into creating an ISeq from a Long. In other words, we're passing a number into a function that expects a sequence, and this is, indeed, bad. Learning to map from Clojure values and types to the JVM's expectations will take time and grit - especially if you (like me) don't have much Java experience. I suggest doing a bit of reading on Clojure/Java interoperability, and about other Java-isms Clojure inherits; it ain't pretty, and you may not always want to use it, but being familiar with the terms can go a long way in deciphering cryptic stack traces.

For a more detailed treatment of this debugging issue I highly recommend Aphyr's article on debugging Clojure.

Finding which form an exception comes from

Let's invoke the foo function in a different way that demonstrates another issue with debugging Clojure:

debugging.core=> (foo nil)

NullPointerException   clojure.lang.Numbers.ops (

OK, we know what to do next:

debugging.core=> (pst)
  clojure.lang.Numbers.ops ( ( (
  debugging.sample/foo (sample.clj:9)
  debugging.sample/foo (sample.clj:7)
  debugging.core/eval14693 (form-init6539101589609174055.clj:1)
  debugging.core/eval14693 (form-init6539101589609174055.clj:1)
  clojure.lang.Compiler.eval (
  clojure.lang.Compiler.eval (
  clojure.core/eval (core.clj:3105)
  clojure.core/eval (core.clj:3101)
  clojure.main/repl/read-eval-print--7408/fn--7411 (main.clj:240)

So the exception comes from line 9, which is:

(cond (> n 40) (+ n 20)

This exception also tells us it comes from from which we can infer it's the > operator that is complaining. But imagine for a second that we had two forms with the same operator on that line:

(cond (> c 40) (* (+ n 20) (+ m 30)))

If we got a NullPointerException about an addition, we wouldn't know which one fails. Luckily, Clojure comes with a very useful module that helps debugging - tools.trace. In this particular case, we'd use the trace-forms macro which tells us which nested form (expression) is failing. We can modify our function to be:

(defn foo
  (trace-forms (cond (> n 40) (+ n 20)
                     (> n 20) (- (first n) 20)
                     :else 0)))

And now when called with nil, we get:

debugging.core=> (foo nil)
NullPointerException : No message attached to throwable java.lang.NullPointerException
  Form failed: (> n 40)
  Form failed: (if
 (> n 40)
 (+ n 20)
 (clojure.core/cond (> n 20) (- (first n) 20) :else 0))
  Form failed: (cond (> n 40) (+ n 20) (> n 20) (- (first n) 20) :else 0)
  clojure.lang.Numbers.ops (

Neat, huh? trace-forms breaks the form it traces to all the nested forms and reports precisely which one failed - propagating this information upwards towards the top form [2]. trace-forms is very useful when errors manifest as exceptions.

Unfortunately, this isn't sufficient for all cases. Our foo wasn't designed to handle nils, and the bug here is in the place where the nil came from. This may be quite a bit removed - and not on the same stack trace - from where foo is invoked. We'll get an exception when foo is called, but the real challenge is to find where the nil came from. More generally, bugs that manifest as thrown exceptions are the easier kind of bugs. The more insidious bugs hide in programs that run just fine end-to-end but compute slightly incorrect results.

Tracing and logging

This gets us into the more general domain of debugging, where the tricks and tools programmers use are as varied as the bugs hiding in our programs. When it comes to debugging, I'm firmly in the printf camp; I rarely prefer debuggers over printf-based debugging [3], and Clojure is no exception. In fact, due to the way Clojure programs look (nested forms), I find that debuggers are even less useful in Clojure than in other languages. On the other hand, Clojure's macros make it possible to trace / print stuff in a very nice way.

For example, I find that it's useful to be able to turn debugging printouts on and off frequently. So I have this trusty code in my utilities:

(def ^:dynamic *verbose* false)

(defmacro printfv
  [fmt & args]
  `(when *verbose*
     (printf ~fmt ~@args)))

Calls to printfv can be freely scattered around the code; by default, they will not print anything. When I do want to see what these printfvs have to say, another macro comes useful:

(defmacro with-verbose
  [& body]
  `(binding [*verbose* true] ~@body))

Here's how it works; Suppose we've written this factorial function, with a debugging printout:

(defn factorial
  (printfv "factorial: %d%n" n)
  (if (< n 1)
    (* n (factorial (- n 1)))))

Now, if we just call it as usual from the REPL, we get:

debugging.core=> (factorial 6)

But if we want to actually see the debugging output, we call:

debugging.core=> (with-verbose (factorial 6))
factorial: 6
factorial: 5
factorial: 4
factorial: 3
factorial: 2
factorial: 1
factorial: 0

This optional verbosity is perfect when you're in the middle of a furious bug hunt, adding printfvs in many places in your code. with-verbose can turn verbose logging on selectively and control the amount of debugging spew [4].

This example brings us back to the tools.trace library, which provides another awesome tool that helps trace function calls (the bread and butter of Clojure programs). Enter trace-vars. After importing it, all we need to do is invoke it on any functions we want traced; for example:

debugging.core=> (trace-vars factorial)

And now invoking our factorial produces:

debugging.core=> (factorial 6)
TRACE t16315: (debugging.core/factorial 6)
TRACE t16316: | (debugging.core/factorial 5)
TRACE t16317: | | (debugging.core/factorial 4)
TRACE t16318: | | | (debugging.core/factorial 3)
TRACE t16319: | | | | (debugging.core/factorial 2)
TRACE t16320: | | | | | (debugging.core/factorial 1)
TRACE t16321: | | | | | | (debugging.core/factorial 0)
TRACE t16321: | | | | | | => 1
TRACE t16320: | | | | | => 1
TRACE t16319: | | | | => 2
TRACE t16318: | | | => 6
TRACE t16317: | | => 24
TRACE t16316: | => 120
TRACE t16315: => 720

We get to see the full call tree, including values of parameters and what each call returns. It even works for mutually-recursive functions:

(defn iseven?
  (if (= n 0)
    (isodd? (- n 1))))

(defn isodd?
  (if (= n 0)
    (iseven? (- n 1))))

Let's try it:

debugging.core=> (trace-vars iseven? isodd?)
debugging.core=> (iseven? 7)
TRACE t16332: (debugging.core/iseven? 7)
TRACE t16333: | (debugging.core/isodd? 6)
TRACE t16334: | | (debugging.core/iseven? 5)
TRACE t16335: | | | (debugging.core/isodd? 4)
TRACE t16336: | | | | (debugging.core/iseven? 3)
TRACE t16337: | | | | | (debugging.core/isodd? 2)
TRACE t16338: | | | | | | (debugging.core/iseven? 1)
TRACE t16339: | | | | | | | (debugging.core/isodd? 0)
TRACE t16339: | | | | | | | => false
TRACE t16338: | | | | | | => false
TRACE t16337: | | | | | => false
TRACE t16336: | | | | => false
TRACE t16335: | | | => false
TRACE t16334: | | => false
TRACE t16333: | => false
TRACE t16332: => false

Note how easy it to see what calls what. Quite often, bugs are uncovered simply by carefully studying the chain of function calls some input tickles in our code, and trace-vars is a very low-effort method to enable this kind of debugging.

Deeper tracing inside cond forms

Tracing function calls is great, but sometimes insufficient. It's not uncommon to have cond forms in functions, and sometimes it's pretty hard to know which condition was actually "taken" (this isn't always easy to infer from the return value of the function). We've seen how to explore where exceptions come from with trace-forms, but exceptions are just one kind of problem. The more difficul problem arises when the code throws no exceptions but still produces a wrong value.

I've mentioned how Clojure's macro superpowers let us write very powerful debugging tools. What follows is another example.

Consider this toy code:

(cond (> 10 20) (+ 10 20)
      (> 20 10) (- 20 10)
      :else 200)

It happens to return 10 since the second condition fires. But suppose it stands for a much more complicated cond where it's not obvious which condition was taken and where the return value came from. How do we go about debugging this?

Well, we can always add a printfv into every result expression (possibly wrapping in a do form) and see what fires. This would work, but it's quite tiresome, especially for large conds. To do this automatically, we can write the following macro:

(defmacro condv
  [& clauses]
  (when clauses
     (first clauses)
     (if (next clauses)
       `(do (println (str "condv " '~(first clauses)))
            ~(second clauses))
       (throw (IllegalArgumentException.
               "cond requires an even number of forms")))
     (cons 'condv (next (next clauses))))))

It behaves just like cond, while also printing out the condition that fired. If we replace the cond in the original example with condv and evaluate it, we'll get:

debugging.core=> (condv (> 10 20) (+ 10 20)
            #_=>        (> 20 10) (- 20 10)
            #_=>        :else 200)
condv (> 20 10)

Note the printout before the return value of 10: condv (> 20 10) - it shows us exactly which condition was taken.


While beginning Clojure programmers may find the debugging experience challenging, I believe that with some effort and perseverance it's possible to get used to the unusual environment and even reach new levels of productivity by developing a set of debugging tools and techniques.

In this endeavor, Clojure's macro capabilities are an extremely powerful ally. Coupled with a fast edit-rerun cycle in the REPL, such tools can turn Clojure debugging into a much less painful activity.

[1]Alternatively, we can evaluate the same expression somewhere in our editor using a Clojure plugin (such as vim-fireplace for Vim).
[2]The astute reader will notice a slight discrepancy between our code and the output of trace-form. We don't have an if form, or do we? Quiz: what does cond expand to? Complex interactions between macros and functions is yet another reason debugging Clojure code is sometimes hard...
[3]In my professional life I spent far more time writing debuggers than actually using them.
[4]This method is only recommended when the debugging prinouts are destined to be eventually eliminated from the code. For more permanent logging with more verbosity controls, consider using a proper logging library like tools.logging.

Adventures in JIT compilation: Part 4 - in Python

This is part 4 in a series of posts on writing JIT compilers for BF.

Even though, for performance considerations, most JIT compilers are written in down-to-metal languages like C and C++, in some scenarios it could be useful to write them in higher-level languages as well [1].

This post intends to do just that, by presenting yet another JIT for BF - this time written in Python. It presents both a low-level approach to JITing native machine code, and using a higher-level library to perform instruction encoding. The JIT implemented here is not optimized (equivalent to the "simple" JIT from part 2), so I'm not going to discuss any performance results. The idea is just to show how JITing in Python works.

How to JIT from Python

To be able to JIT-compile BF, we should first be capable of JITing any code at all. It's worth showing how to do this in plain Python without any third-party libraries. In part 2 of the series we've used the same approach as presented in my older How to JIT post for basic JITing by copying machine code into an executable memory segment and jumping to it.

That was done in C and C++, but it turns out it's not much harder in Python due to the magic of ctypes. I've written about doing runtime calls to C code via ctypes before, and if you're not familiar with this wonderful tool, I encourage you to read more about it. What follows is a complete code sample that implements the JITing presented in the "How to JIT" post; it's heavily commented, so it should be reasonably clear what's going on:

import ctypes
import mmap

# Equivalent to dlopen(NULL) to open the main process; this means the Python
# interpreter, which links the C library in. Alternatively, we could open
# directly (for Linux).
libc = ctypes.cdll.LoadLibrary(None)

# Grab the mmap foreign function from libc, setting its signature to:
#     void *mmap(void *addr, size_t length, int prot, int flags,
#                int fd, off_t offset)
# Per the mmap(2) man page.
mmap_function = libc.mmap
mmap_function.restype = ctypes.c_void_p
mmap_function.argtypes = (ctypes.c_void_p, ctypes.c_size_t,
                          ctypes.c_int, ctypes.c_int,
                          ctypes.c_int, ctypes.c_size_t)

CODE_SIZE = 1024

# Allocate RWX memory with mmap. Using mmap from libc directly rather than
# Python's mmap module here because the latter returns a special "mmap object"
# and we just need the raw address of the mapping. However, we will use the
# PROT_* and MAP_* constants from the mmap module rather than duplicating them.
code_address = mmap_function(None, CODE_SIZE,
                             mmap.PROT_READ | mmap.PROT_WRITE | mmap.PROT_EXEC,
                             mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS,
                             -1, 0)

if code_address == -1:
    raise OSError('mmap failed to allocate memory')

# Emit code into the allocated address. This sequence of x86-64 machine code
# encodes:
#  mov %rdi, %rax
#  add $4, %rax
#  ret
code = b'\x48\x89\xf8\x48\x83\xc0\x04\xc3'
assert len(code) <= CODE_SIZE
ctypes.memmove(code_address, code, len(code))

# Declare the type for our JITed function: long (*JitFuncType)(long), and cast
# code_address (which is a void*) to this type so we could just call it.
JitFuncType = ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_long)
function = ctypes.cast(code_address, JitFuncType)

If you execute this code it will print -96, since the JITed function adds 4 to its argument.

From this point on, it should be easy to implement a full JIT-compiler for BF using hand-rolled instruction encoding, just like in part 2. Feel free to do it as an exercise :) You'll face the same issue discussed in that post - manually encoding instructions and resolving labels/branches is tedious, and it would be nice if a library could do it for us.


Enter PeachPy - an x86-64 assembler embedded in Python. It's a relatively new project mostly aimed at writing highly-optimized assembly kernels for numerical computations without leaving the domain of Python. It even has a cute logo:

PeachPy logo

PeachPy has some examples strewn around the web, and my code for this post can serve as another example. Let's start by replicating the simple JIT functionality shown above. PeachPy takes care of all the memory mapping behind the scenes [2], so the full code we need to write is just this:

import peachpy
import peachpy.x86_64

x = peachpy.Argument(peachpy.int64_t)

with peachpy.x86_64.Function("Add4", (x,), peachpy.int64_t) as asm_function:
    peachpy.x86_64.LOAD.ARGUMENT(peachpy.x86_64.rax, x)
    peachpy.x86_64.ADD(peachpy.x86_64.rax, 4)

abi = peachpy.x86_64.abi.detect()
encoded_function = asm_function.finalize(abi).encode()
python_function = encoded_function.load()

# The JIT call

PeachPy lets us write our code in assembly rather than directly in machine code, and it also handles the loading for us. As we'll see soon, it lets us use symbolic labels (just like asmjit) and takes care of jump target resolution automatically. In other words, it's just the tool we need to focus on our domain - compiling BF - without worrying too much about the low level details.

Note how PeachPy uses Python's context managers (a very common approach for DSLs embedded in Python). The with peachpy.x86_64.Function statement creates a context manager within which all assembly instructions (like ADD and RETURN) are appended to the function.

JIT-compiling BF via PeachPy

With the Add4 sample above, we actually already have most of the building blocks we need to JIT-compile BF. What remains is just a matter of digging in PeachPy's source and examples (sadly there's very little documentation) to figure out how to invoke and properly use its assembly primitives. The following is very similar to how simpleasmjit is implemented in part 2. The full code sample is available here; I'll just highlight the important snippets of code. First the function "declaration":

# Create a JITed function named "ppjit", with the C-style signature:
#   void ppjit(uint8_t* memptr)
memptr = peachpy.Argument(peachpy.ptr(peachpy.uint8_t))

with peachpy.x86_64.Function("ppjit",
                             result_type=None) as asm_function:
    # Use r13 as our data pointer; initially it points at the memory buffer
    # passed into the JITed function.
    dataptr = peachpy.x86_64.r13
    peachpy.x86_64.LOAD.ARGUMENT(dataptr, memptr)

In this code we'll be passing memory from the host side (from Python, in this case), so the function signature is void (*ppjit)(uint8_t*). We then give r13 the symbolic name dataptr. The usual instruction-by-instruction BF compilation loop follows:

for pc, instr in enumerate(parse_bf_program(bf_file), start=1):
    if instr == '>':
        peachpy.x86_64.ADD(dataptr, 1)
    elif instr == '<':
        peachpy.x86_64.SUB(dataptr, 1)
    elif instr == '+':
        peachpy.x86_64.ADD([dataptr], 1)
    elif instr == '-':
        peachpy.x86_64.SUB([dataptr], 1)

PeachPy dresses Python in assembly-like flavor. Registers placed in brackets like [dataptr] imply dereferencing. While dataptr refers to the value of the r13 register itself, [dataptr] refers to the value of the memory word whose address is held in dataptr - this is similar to the syntax of many assembly languages.

For emitting I/O operations, we resort to syscalls again [3]:

elif instr == '.':
    # Invoke the WRITE syscall (rax=1) with stdout (rdi=1).
    peachpy.x86_64.MOV(peachpy.x86_64.rax, 1)
    peachpy.x86_64.MOV(peachpy.x86_64.rdi, 1)
    peachpy.x86_64.MOV(peachpy.x86_64.rsi, dataptr)
    peachpy.x86_64.MOV(peachpy.x86_64.rdx, 1)
elif instr == ',':
    # Invoke the READ syscall (rax=0) with stdin (rdi=0).
    peachpy.x86_64.MOV(peachpy.x86_64.rax, 0)
    peachpy.x86_64.MOV(peachpy.x86_64.rdi, 0)
    peachpy.x86_64.MOV(peachpy.x86_64.rsi, dataptr)
    peachpy.x86_64.MOV(peachpy.x86_64.rdx, 1)

For emitting the loops, we use a stack of PeachPy Label objects with one label for the loop and another for the "after loop". Again, this is very similar to how it was done with asmjit in part 2.

elif instr == '[':
    # Create labels for the loop start and after-loop.
    loop_start_label = peachpy.x86_64.Label()
    loop_end_label = peachpy.x86_64.Label()
    # Jump to after the loop if the current cell is 0.
    peachpy.x86_64.CMP([dataptr], 0)
    # Bind the "start loop" label here.
        BracketLabels(loop_start_label, loop_end_label))
elif instr == ']':
    if not len(open_bracket_stack):
        die('unmatched closing "]" at pc={}'.format(pc))
    labels = open_bracket_stack.pop()
    # Jump back to loop if the current cell is not 0.
    peachpy.x86_64.CMP([dataptr], 0)
    # Bind the "after-loop" label here.

Finally, this is how the JITed function is invoked:

# Allocate memory as a ctypes array and initialize it to 0s. Then perform
# the JIT call.
memsize = 30000
MemoryArrayType = ctypes.c_uint8 * memsize
memory = MemoryArrayType(*([0] * memsize))

There's just a little bit of trickiness here in our usage of ctypes. Since we have to pass arguments to C functions, ctypes lets us declare C-like types like MemoryArrayType which is a uint8_t[30000]. The funky syntax on the following line is nothing more than Python's *args destructuring of a list of 30000 zeros. So memory is now an object we can safely pass to our JITed function which expects a uint8_t* argument:


This calls the JITed function, the result of which is I/O and modified memory cells in memory.

PeachPy - more features and some limitations

My usage of PeachPy in this post has been fairly limited, and the library has many more features. For example:

  1. Since PeachPy was mainly developed to write fast mathematical kernels, it has fairly good support for the newest vector extensions like AVX512.
  2. There's some support for automatic register allocation. In the BF JIT, the r13 register is used directly, but we don't really care which register it is. We could ask PeachPy for a "general purpose 64-bit register" and it would allocate one for us. When writing a much more complicated piece of assembly code, this can be very useful.
  3. There's also some support for generating ARM code, though I don't know how mature it is.

However, PeachPy is also a fairly new library which means there are still limitations and rough edges. When developing the JIT I ran into a bug and sent a PR - which was promptly accepted. PeachPy also doesn't do well with a large number of assembly instructions. It has some recursive analysis logic that blows up the Python stack when run on large code. In the BF JIT, I'm setting the stack to a higher limit artificially; with that, PeachPy doesn't crash but takes quite a while to assemble large programs like Mandelbrot.

According to PeachPy's maintainer, this is due to the design of PeachPy being aimed towards writing assembly code manually rather than generating it from some other language. Fair enough - but definitely something to keep in mind. All in all, the maintainer is responsive and the library seems to be improving quickly - so these limitations may go away in the future.

Python JIT alternative - llvmlite

A somewhat higher-level alternative to JITing from Python is using llvmlite, the new Python binding to LLVM. I wrote about llvmlite before and also ported the LLVM official tutorial to Python using it.

llvmlite would definitely do the job here (and most likely not have the limitations of PeachPy), but I wanted to go with something different in this part. After all, a BF JIT with LLVM was already covered in part 3, and llvmlite is a binding to the same library, just in Python. PeachPy offers an altenative approach for machine code generation from Python - with more direct control of the emitted instructions, though none of the optimizations LLVM provides automatically.

[1]For example Numba - a JIT compiler for numeric code written in Python; it takes Python code that uses Numpy and JIT-compiles it to optimized native code.
[2]No magic is involved here - in fact PeachPy uses precisely the same approach as I've shown to invoke mmap from Python via ctypes.
[3]As an exercise, modify this code to invoke putchar and getchar.

Book review: "Essentials of Programming Languages" by D. Friedman and M. Wand

[Note: this review is about the 3rd edition of the book]

This book is a detailed overview of some fundamental ideas in the design of programming languages. It teaches by presenting toy languages that demonstrate these ideas, with a full interpreter for every language (implemented in Scheme). After discussing some basic concepts like scoping, it gets into the ways state can exist in a language; then a whole chapter deals with continuation-passing style. This chapter also describes exceptions, coroutines and threads (all implemented with continuations). Other chapters discuss module systems and object-oriented programming.

The book was created as a companion/summary of a university course. When read stand-alone, it's not very easy to get through due to the huge number of concepts presented and discussed in fairly limited space (~350 pages). IMHO the authors could spend more time explaining and demonstrating the concepts before jumping into implementation - but maybe as a companion to lectures it's not really necessary. But this is definitely something to take into account if you're just reading the book; it's not for beginners.

I went through this book in a fairly detailed way (not quite like my SICP journey, but not very far from it). It took me several months of spending a couple of hours a week on it, and I wrote almost 10,000 lines of Clojure code implementing most of the interpreters (and some of the exercises) described in it. Clojure was a good choice. It's sufficiently similar to Scheme that the concepts translate without too much trouble; on the other hand, it is a different language so mindless copy-pasting doesn't work. I ran out of steam somewhere in the middle of the penultimate chapter (on Modules) and stopped implementing, since the concepts of Modules and OOP seemed more familiar and less interesting.

My favorite part was, no doubt, the chapter on continuations. It helped me understand concepts like CPS and trampolines to a deeper extent than ever before. For this, I'm forever thankful to the authors.

It's hard to say how beneficial a lithter skimming of this book can be. Without implementing the interpreters, the discussion is fairly weak. Implementing them takes time and code in advanced chapters builds on top of more code in earlier chapters. I hope this review helps folks decide whether the book is worth it, and feel free to ask in comments if I could clarify anything else.