I/O Operations in Common Lisp
Learn console output, reading input, file reading and writing, formatted output, and I/O error handling in Common Lisp with Docker-ready examples
Input and output are where a program meets the outside world. In Common Lisp, all I/O flows through streams — first-class objects that represent a source of characters (or bytes) to read from, or a destination to write to. The terminal, a file, and even an in-memory string are all streams, and the same functions work across all of them.
Because Common Lisp is a multi-paradigm language with a functional heart, I/O is treated as a deliberate side effect. Functions like format, read-line, and write-line are explicit about where they read and write: they take (or default to) a stream argument. This tutorial goes beyond the single format call you saw in Hello World and covers console output, reading input, working with files, formatted output, and handling I/O errors with the condition system.
The idiomatic tool for output is format, a miniature language for producing text. For file work, the with-open-file macro guarantees streams are closed even when errors occur — a clean, functional approach to resource management. Let’s explore each of these.
Console Output
Common Lisp offers several output functions, each with slightly different behavior. Knowing which to reach for keeps your output clean.
Create a file named output.lisp:
| |
princprints a human-readable representation (no quotes) and adds nothing after it.terpri(“terminate print”) emits one newline.printis designed for debugging: it prints a machine-readable form preceded by a newline.~&(fresh-line) prevents accidental blank lines by only emitting a newline when needed.
Reading Input
To read from the terminal you use read-line (a whole line as a string) or read (one parsed Lisp object). So this example is fully reproducible, we read from an in-memory string stream instead of the keyboard — the reading functions behave identically whether the stream is a terminal, a file, or a string.
Create a file named input.lisp:
| |
The key distinction: read-line gives you raw text, while read parses the input using the Lisp reader, turning 3 into the number 3. When you need numbers from text, parse-integer (and read-from-string) bridge the gap.
Reading and Writing Files
The with-open-file macro is the workhorse of file I/O. It opens a stream, binds it to a variable, runs your code, and guarantees the stream is closed afterward — even if an error is signaled. This example writes a file and then reads it back.
Create a file named file_io.lisp:
| |
Notice the loop for line = (read-line in nil nil) while line idiom — this is the canonical way to iterate over a file’s lines. The two nil arguments to read-line mean “at end of file, return nil rather than raising an error,” which lets the while clause stop the loop cleanly.
Formatted Output
The format function is a small text-generation language of its own. Its directives handle padding, number bases, pluralization, and iteration over lists — tasks that would need several lines in other languages.
Create a file named formatted.lisp:
| |
A few highlights:
~,4f— floating point rounded to 4 decimal places.~x/~o/~b— print integers in hexadecimal, octal, and binary.~5,'0d— decimal padded to width 5 using0as the pad character.~{...~}— loop over a list, with~^suppressing the trailing separator.~:p— automatic pluralization based on the previous argument.
Handling I/O Errors
File operations fail: a file may not exist, or a disk may be full. Common Lisp’s condition system handles these with handler-case, and stream functions can be told to return nil instead of signaling.
Create a file named errors.lisp:
| |
handler-case is the closest analog to try/catch, but the condition system is far more capable — it can inspect a condition, run restarts, and resume execution at the point of failure. For simple “does this file exist?” checks, the :if-does-not-exist nil option is often cleaner than catching a condition.
Running with Docker
Run each example with the official SBCL image. The -v $(pwd):/app flag mounts the current directory so SBCL can read your .lisp files (and so file_io.lisp can create poem.txt in your working directory).
| |
Expected Output
Running output.lisp:
Standard formatted output
princ: no quotes
write-line adds a newline
"print adds quotes"
text vs "text"
no newline here
Running input.lisp:
Name: Ada Lovelace
Next year you will be 43
Sum: 12
Running file_io.lisp:
Wrote data to poem.txt
--- File contents ---
Roses are red
Violets are blue
Lisp has parentheses
And so do you
--- Character count: 66 ---
Running formatted.lisp:
Name Score
Alice 95
Bob 87
Pi to 4 places: 3.1416
Hex: FF Octal: 377 Binary: 11111111
Padded number: 00042
In English: forty-two
In Roman: MMXXVI
Languages: Lisp, Scheme, Clojure
1 item
3 items
Running errors.lisp:
Could not open the file (handled the error)
File is missing, handled without an error
Key Concepts
- Everything is a stream — the terminal, files, and in-memory strings all use the same read/write functions, so code written for one works with another.
formatis a language — its directives (~a,~d,~,4f,~{~},~:p,~r) handle padding, number bases, iteration, and pluralization declaratively.with-open-fileguarantees cleanup — it closes the stream even if an error occurs, the functional answer to manual open/close bookkeeping.read-linevsread—read-linereturns raw text;readparses input into Lisp objects. Useparse-integerto convert numeric strings.- The
loop ... (read-line in nil nil)idiom — passingnilas the end-of-file value lets you iterate over lines without catching an end-of-file error. - I/O errors are conditions — recover with
handler-case, or ask stream functions to returnnilvia options like:if-does-not-exist nil. - Fresh-line vs newline —
~&avoids duplicate blank lines by emitting a newline only when the cursor isn’t already at the start of a line.
Running Today
All examples can be run using Docker:
docker pull clfoundation/sbcl:latest
Comments
Loading comments...
Leave a Comment