I/O Operations in Scheme
Learn console input, ports, formatted output, and file reading and writing in Scheme with Docker-ready GNU Guile examples
Scheme is a mostly functional language, and input/output is where its pragmatic side shows: I/O operations are side effects, performed for their consequences rather than their return values. Scheme makes no attempt to hide this behind a monad the way Haskell does — instead, it organizes all I/O around a single elegant abstraction: the port. A port is a first-class value representing a source or sink of data. The console, a file, even an in-memory string can all be ports, and the same display, write, and read procedures work uniformly on any of them.
That last procedure hints at something uniquely Lisp. Because Scheme code and Scheme data share the same S-expression syntax, read doesn’t just read characters — it reads an entire datum: a number, a string, or a whole nested list in one call. Pair it with write, which prints data in machine-readable form, and you get structured data persistence with no serialization library at all. This write/read round-trip has been Lisp’s native “file format” since the 1960s.
In this tutorial you’ll direct output to specific ports (including standard error and in-memory string ports), read lines and numbers from standard input, write and append to files, read them back line by line, round-trip a Scheme data structure through a file, and handle a missing file gracefully. The examples run on GNU Guile, the Scheme implementation in our Docker image.
Output and Ports
You met display and newline in Hello World. What that page didn’t show is that every output procedure accepts an optional final argument: the port to write to. When you omit it, output goes to (current-output-port) — but you can just as easily target (current-error-port) to keep diagnostics out of piped output, or a string port to build text in memory.
Guile’s built-in format covers the everyday directives: ~a inserts a value in human-readable form (like display), ~s in machine-readable form (like write), and ~% emits a newline. The first argument #t means “write to the current output port.”
Create a file named output_ports.scm:
| |
Notice that redirecting output required no new procedures — display writes to whatever port you hand it. For the full set of Common Lisp-style directives (padding, number bases, iteration), Guile offers (use-modules (ice-9 format)), which upgrades format in place.
Reading from Standard Input
Input mirrors output: read-line pulls one line from (current-input-port), returning the line without its trailing newline. In Guile, read-line lives in the (ice-9 rdelim) module, so scripts start with a use-modules form. Converting text to a number is a separate, explicit step — string->number returns the number, or #f if the string isn’t one, so bad input announces itself as a false value rather than a mysterious crash later.
Note the let* rather than let: Scheme deliberately leaves the evaluation order of plain let bindings unspecified, and when each binding performs a side effect like reading a line, the sequential guarantee of let* matters.
Create a file named read_input.scm:
| |
When input runs out, read-line returns a special sentinel called the end-of-file object, which you detect with eof-object? — the next example uses exactly that to know when a file is finished.
File I/O: Lines, Data, and Errors
The idiomatic way to touch a file in Scheme is call-with-output-file and call-with-input-file. Each opens the file, passes the port to a procedure you supply, and closes the port when that procedure returns — the functional answer to forgetting to close a file handle. For modes the R7RS procedures don’t cover, such as appending, Guile provides open-file with a mode string, paired with an explicit close-port.
The middle of this example is the payoff of homoiconicity: write saves an association list to a file as literal S-expression text, and one call to read later reconstructs the entire structure. The ending shows Guile’s catch, which runs a thunk and hands any system-error (like a missing file) to a handler instead of aborting the program.
Create a file named file_io.scm:
| |
The line-reading loop is a named let — the same tail-recursive pattern Scheme uses everywhere iteration is needed, here driven by the end-of-file object as its stopping condition. No special “read loop” construct exists because none is needed.
Running with Docker
| |
The stdin example needs Docker’s -i flag so the piped input reaches the program; without it, read-line immediately sees the end-of-file object.
Expected Output
Running output_ports.scm (in a terminal, the stderr line appears interleaved with stdout):
to standard output
diagnostics go to standard error
Scheme is 51 years old
display-style: text, write-style: "text"
Hello from a string port
Running read_input.scm with the piped input Ada, 2, 40:
What is your name?
Hello, Ada! Enter two numbers, one per line.
Their sum is 42
Running file_io.scm:
1: day 1: learned display
2: day 2: learned lambda
3: day 3: learned ports
retries: 3
could not read missing.txt
Key Concepts
- Ports unify all I/O — the console, files, and in-memory strings are all ports, and the same
display,write,read, andread-lineprocedures work on any of them - Output procedures take an optional port — omit it for standard output, pass
(current-error-port)for diagnostics, or a string port to build text in memory readparses a whole datum — combined withwrite, Scheme data structures round-trip through files with no serialization library; the S-expression is the file formatcall-with-input-fileandcall-with-output-fileclose the port for you — the port lives exactly as long as the procedure you pass in, even if it exits early- The end-of-file object signals exhausted input — test for it with
eof-object?; it drives the named-letloops that replace imperative read loops string->numberreturns#fon bad input — parsing failures surface as an ordinary false value you can test, not an exceptionlet*guarantees order,letdoes not — when bindings perform I/O side effects, sequentiallet*is the correct choice- Guile fills the standard’s gaps —
read-linecomes from(ice-9 rdelim), append mode fromopen-file, and error handling fromcatch, all layered on the portable core
Running Today
All examples can be run using Docker:
docker pull weinholt/guile:latest
Comments
Loading comments...
Leave a Comment