When you’re dealing with Racket (indeed, all sorts of Lisps and Schemes), you’re probably all too used to code that, as it unfolds, gets increasingly indented. If you’re intending your code,
let and friends start pushing everything to the right. And as you cover more and more cases, your nested logic can push your code so far to the right that it can start to feel a bit silly.
Various countermeasures are available in Racket, such as
match and its allies (
match-define, for example),
struct-define, and surely others. These help to reduce some boilerplate and keep your code on the left-hand side of your editor. Below I’ll present another, escape continuations (
let/ec). But perhaps a comment on the overall situation:
Putting aside Racket, the practice of breaking such code down into smaller pieces using helper functions often helps. Function getting too big and hairy? Break it down. Doing so (probably) improves the readability and testability of your code. But if the task at hand is especially fussy, I’ve found that adding lots of little helper functions can lead to a new problem: it can slice up your code into lots of little things that don’t make much sense on their own, and which need to be stitched back together in your mind if you want to understand what’s really going on.
I don’t have a precise definition, but I might provisionally call a problem fussy if it has many cases that need to be addressed. Problems where there’s no short cut; you’ve just got to go through the drudgery. Depending on the skill of the programmer, a fussy problem can be tackled in more or less elegant ways. Maybe the solution can be so elegant that the apparent fussiness is appreciably diminished. But no matter how elegant, all the cases have to be dealt with at some point. There doesn’t seem to be any compact way to even talk about the problem, unless we elide or compress some steps which, ultimately, have to be spelled out. Fussiness, in this sense, isn’t a programming language-specific concept. Indeed, there might not even be terribly many cases, as such: a fussy problem might be one that has might be essentially just one case, but
arriving at that case requires multiple data extraction & validation steps before we reach we’ve reached a safe space where we can finally breathe freely and proceed with the computation we were really after. Failure to extract needed data or failure to validate it, at any of these preceding steps, requires us to bail out of the computation. That might take the form of raising an exception or perhaps returning a failure value (like how
#f is conventionally used in many Racket library functions).
Fussy problems call for fussy code, and fussy code leans right. In Racket, that means code that starts sneaking (or should I say jumping by leaps and bounds) up to the right-hand side of your editor window. I don’t know about you, but I can almost feel my mental load increasing as I face such heavily indented code. I easily get lost in my thinking in these heavily indented sessions, simply because the number of cases starts to become palpably too big. Worse, the precise state of the program might even be hidden up above where I am. I have to scroll up to mentally reconstruct the assumptions that hold, then scroll back down to continue writing my code. I might have even forgotten the name of the formal parameters to the function. I need to go 50 or more lines up to reconstruct the missing information.
Fussy code comes up in all sorts of contexts. In web programming, fussy code comes up naturally, especially in processing form submission requests. In systems programming, checking whether processes exited cleanly, whether a port is (still) open, or whether files and directories exist are the norm. When you write up this kind of defensive code, your code naturally starts getting increasingly indented.
To make matters worse, definitions of some values in a program only makes sense given a number of preconditions. You can’t define the newest file in a directory unless you’re sure that the directory exists, and that the list of files in it is not empty. You can’t meaningfully define the current user when processing an HTTP request if the request doesn’t have a session ID cookie, and that the user really exists in the DB. I’m sure you can imagine all sorts of cases like that.
Various tricks are available to you. Here’s one worth knowing about: escape continuations. They allow you to mimic C-style return statements while in the land of Lisp. You wrap a block of code in
let/ec and within that block you can bail out and return a value at any point. There’s not much official documentation for it, so here’s a schematic version of what an example looks like:
(let/ec return (unless (condition-one? thing) (return #f)) (define foo (do-it thing)) (unless (condition-two? thing foo) (return #f)) ; more extraction & validation ... ; looks good! 'ok)
(We’ll see a real-world example in a moment.) The way it works is that
let/ec gives us a function (the escape continuation, here called
return) that, when called, abandons the rest of the computation and returns its argument as the value. Here, we set up some data extraction and validation, returning
#f as a fallback value indicating that something failed.
Here’s a concrete example of handling the aforementioned web request, where we need to extract the user (a model in a database) based on an HTTP request. It uses Bogdan Popa’s redis-rkt package (for handling basic session data) and deta (a slick little ORM):
; request? -> (or/c response? user?) ; redirect the user to the login page ; if they are not yet logged in (define (ensure-logged-in-user req) (let/ec return (define sid (request->sessionid req)) (unless (string? sid) (return (redirect-to "/login"))) (define session-data (redis-hash-get sid)) (define uid/string (bytes->string (hash-ref session-data #"user.id" #f))) (unless (string? uid/string) (return (redirect-to "/login"))) (define uid (string->number uid/string)) (unless (integer? uid) (return (redirect-to "/login"))) (define logged-in? (bytes=? #"1" (hash-ref session-data #"user.logged_in" #"0"))) (unless logged-in? (return (redirect-to "/login"))) (define u (fetch-user uid)) (unless (user? u) (return (redirect-to "/login"))) u))
You may regard this as overly defensive, but I generally try to cover all my bases. This function checks, in order, that:
This is pretty tedious stuff. All of these checks need to pass before we can take further action. We are combining data validation & extraction, repeated a few times. If expressed in terms of
cond blocks, the indentation level would already be something like 10 (if we used two spaces for each new level of indentation) or worse. You’d be pushing the right-hand side of your editor display pretty quickly.
Thankfully, in this kind of situation (processing certain kinds of HTTP requests), one can safely tuck away the tedious checks into a function. This helps to delimit the need for this kind of code. To be clear, I’m not advocating for using escape continuations all over the place, emulating C-style
return-y thinking. Racket’s a pretty liberal place, but I’m pretty sure this kind of code style goes against the grain somewhat. But depending on what you’re up to with Racket, it’s worthwhile to keep
let/ec in your back pocket as a nice tool.