Open and Closed Systems with Clojure (Draft)
Please Note: I accidentally made this post public after writing only a rough outline on June 18, 2021. I will leave this content here for now since it's already been noticed, but I will post a link on this page to the finished version of the article once I have completed it. Please excuse the mess and my oversight.
For the purposes of this article, I propose the following definitions:
- Members of a software system are data values and functions.
- An open system requires only extension of itself to support new members. Consumers can leverage new members at any point in time, including never.
- A closed system either doesn't support extension, or requires simultaneous extension of itself and its consumers when adding new members.
These definitions break down quickly if we dwell on the facts that our programs run on computers that are also running other programs, that they interact with hardware that is susceptible to physical forces of nature, that they run in time and yet there is no guarantee that the next instruction of our program will ever be executed.
But if we limit ourselves to the tamer, optimistic view of the universe that we employ when writing our programs, these concepts of open and closed systems prove useful tools for designing software systems.
Clojure—not only because it is a dynamically typed language, but also by virtue of its abstractions—encourages open systems. In this article I explore Clojure constructs that support openness and hope to highlight the benefits software engineers derive from this default.
For the Clojure examples that follow, I consider a construct open when it returns a value rather than halting or disrupting the execution of the program by throwing an exception.
Introspection
This comes up in many of the sections in this article. Need to make sure it's underscored, that these open systems are powerful because they can also be inspected by the userland program.
Truthiness
Closed true/false. Open truthy/falsey.
Functions with Infinite Arity
Arithmetic: +, -, *, /
map
n collections accepting function of n args.
Sequence Functions
Core Clojure functions return nil
or provide an arity for user-specified default values, rather than throwing exceptions when sought-after items are not found:
nth
as an oddball exception; make note of its arity that doesn't throw an exception.
Maps
Keys of arbitrary types. Go complex enough, and maps with maps as keys become a type of pre-indexed database.
Namespaces
Private functions still available via var.
Ability to mutate a namespace from multiple files.
Ability to list all known namespaces (e.g., see implementation of apropos
).
Metadata
Orthogonal to primary execution of your program, but choice of that is up to you.
Pattern of specialized members being adorned with metadata and then collected from arbitrary location by inspection of namespaces and metadata.
Multimethods
Open by default.
Use of :default
for categorical defaults, not fall-backs.
Inspection of methods
for enforcing comprehensive coverage over a particular domain of values.
Protocols
No methods have to be implemented, just has to be "marked" as extending the protocol.
Transducers
Completely agnostic to the collections (if any) they will work on. Composable at any level.
Closed Systems in Clojure
defstruct
Lesson learned, and defrecord
does more open thing of demoting itself to a hash-map when essential keys are removed.
Pattern of []
record definitions so they never lose their type.
Conditionals: case
vs. cond
Case throws an exception; cond returns nil.
Exceptions
ex-info
and ex-data
One area that perhaps a sanctioned closed-system approach would benefit. Perhaps propose one here...
with-redefs
Redefined but within a specified scope.
Reader Literals
Closed by design. Syntax of base language is rich but must be circumscribed to keep programs source readable across a wide audience. Tagged literals as an acceptable compromise.
TBD: Possible Additions
- Datalog systems inspired by Datomic?