In order to learn Clojure, and start participating in the Athens Research community, I signed up for the ClojureFam program. I'm part of Team Phanes and will be using this journal to document my learning journey over the coming weeks π
Valuable references:
Summary: I read the first 2 chapters of Clojure from the Ground up, and then completed the first 10 problems in 4Clojure.
Clojure is a dialect of Lisp, and has a grammar that is composed entirely of lists (Lisp == "List processing"). Lists are simply S-expressions, that start with a verb (a function), and then specify zero or more arguments (e.g. (inc 4)
, (+ 5 5)
).
Lists can be "nested", which allows you to create complex expressions, that are ultimately composed into entire applications (e.g. (inc (inc 4))
). Lists are evaluated left-to-right, but innermost lists evaluate before outermost lists.
In Clojure, most types evaluate to themselves (e.g. numbers, boolean, maps, keywords), however, there are two that evaluate to something else: lists and symbols.
All languages express the text of their programs in different ways, but internally all construct a tree of expressions. Lisp simply makes it explicit.
Clojure is built on top of the JVM (it's a "guest language"), and as a result, is able to interop with the entire/existing ecosystem of JVM classes and Java libraries. However, Clojure has its own dev tool (Leiningen), to simplify development (e.g. scaffolding, building, REPL, etc.).
By default, all lists in Clojure are evaluated, in order to actually execute the program. However, if you want to treat an expression as "data", then you can quote it by prefixing it with a '
(e.g. '(inc 5)
).
While functions may be the primary "verb" in a Clojure list/expression, the language also supports using other types in the verb-position.
Type | Behavior | Example |
---|---|---|
Function | Evaluate the function | (inc 3) |
Keyword | Access a member of a map | (:foo mapObject) |
Set | Determine if the set contains a member | (setObject :key) |
List/Vector | Access a member of the vector by index | (listObject 3) |
Like all languages, Clojure's type system includes support for numeric types (integers, decimals), strings, booleans, and has a base library of functions for operating on them (e.g. (and true true true)
, (> 3 2 1)
).
Furthermore, Clojure includes support for composite/collections data structures, such as lists (tuples), sets, vectors (arrays), and maps (associative arrays). Each of these types have both a functional construction form (e.g. (list 1 2 3)
), as well as a syntactic/literal form.
Type | Literal Syntax |
---|---|
List | '(1 2 3) |
Vector | [1 2 3] |
Set | #{ 1 2 3} |
Map | {:name "mittens" :weight 9} |
In order to access and mutate collection types, you can use any of the built-in functions that are provided by the Clojure standard library. The following are some of the most common ones, which apply to all collection types:
conj
- Append an element to a collectionfirst
- Retrieve the first element of a collectionsecond
- Retrieve the second element of a collectionnth
- Retrieve the nth element of a collectioncount
- Get the number of memebers in the collection
Summary: I read chapter 3 of Clojure from the Ground up, and then completed problems 11-15 in 4Clojure.
Let expressions allow you to bind immutable values to symbols, that are visible the context of an expressions (e.g. (let [+ -] (+ 2 3))
). You can specify n-number of let bindings as part of a let
expression, and they are evaulated from left-to-right.
Functions allow you to define expressions, that reference dynamically provided bindings (e.g. (fn [x] (+ x 1))
). Functions can be defined using a "normal" form, that explicitly defined named parameters, or a "shorthand" form that references parameters based on their position.
Form | Example |
---|---|
Normal | (fn [x] (+ x 1)) |
Shorthand | #(+ % 1) |
Vars allow you to define symbols that are globally accessible and mutable (e.g. (def cats 5)
). Aside from defining arbitrary symbols, vars allow you to give functions a name:
Form | Example |
---|---|
def |
(def half (fn [number] (/ number 2))) |
defn |
(defn half [number] (/ number 2)) |
In order to describe the behavior of a function, you can specify a "doc string" as part of its definition:
(defn launch
"Launches a spacecraft into the given orbit by initiating a
controlled on-axis burn. Does not automatically stage, but
does vector thrust, if the craft supports it."
[craft target-orbit]
"OK, we don't know how to control spacecraft yet.")
You can then easily retrieve the documentation a function by running the following command: (doc launch)
.
In order to retrieve arbitrary information about a function, you can use the built-in meta
(e.g. (meta launch)
). Additionally, you can retrieve the actual source code of a function using the source
function (e.g. (source launch)
)
Summary: I read chapter 4 of Clojure from the Ground up, and then completed problems 16-25 in 4Clojure.
As a functional programming language, Clojure makes heavy use of recursion as opposed to relying on iteration statements (e.g. for, while). Furthermore, as with most languages, it includes a built-in map
function that allows you to apply a "transform" function across every member of a collection (e.g. (map inc [1 2 3 4])
).
While you can define collections statically (e.g. (list 1 2 3)
), Clojure also supports "sequences", which are "lazy" collections, that are evaluated on-demand when a consumer actually asks for more members. This is useful for capturing potentially large (or infinite) collections, without needing to pay the cost of it (compute or memory), unless/until it's actually needed.
Function | Description | Example |
---|---|---|
iterate |
Creates a sequence by calling a provided function | (iterate inc 0) |
repeat |
Repeats a value n-number of times | (repeat 3 :foo) |
range |
Create a sequence based on a range of numbers | (range 2 10) |
cycle |
Creates a sequence by "cycling" through the values of a specific collection | (cycle [1 2 3]) |
Function | Descrpition | Example |
---|---|---|
take |
Retrieves n-number of values from a sequence | (take 5 sequence) |
Summary: I read chapter 5 of Clojure from the Ground up.
π Chapter 5 - Macros
Macros allow you to define custom functions, which re-write code before it's actually evaluated. As a result, "macro expansion" represents a stage in Clojure's evaluation model, that occurs before expressions are evaluated. A macro is defined using the defmacro
function, which looks almost identical to the defn
function.
Since Clojure's syntax is equivalent to it's internal AST ("code as data"), writing macros is easier than in other languages, since you're not operating on text (as in C) or some new/unfamiliar data structure (as in other languages like JavaScript). You're literally writing functions that operate on lists, which is already what you're used to do in Clojure for writing applications. However, the key difference with macros is in what the function returns: code.
Since macro expressions return code and not values, they allow you to return "templates" strings, that is made up of a Clojure expression, with some values interpolated into it. You define a "syntax quoted" expression by prefixing it with a tilde.
Within the template string, you can use the following special "operators" to interpolate and/or expand the meaning of symbols:
| Syntax | Description |
|-|-|-|
| ~
| Replace the value of a symbol |
| ~@
| Replace the "spreaded" value of a symbol (e.g. if the symbol points at a list then return 1 2 3
not (1 2 3)
) |
| #
| When appended to a symbol name, it will generate a unique version of that symbol name, so that it doesn't conflict with other symbols in the outer expression |
Summary: I read chapter 6 of Clojure from the Ground up.
As with all functional languages, Clojure references are immutable by default, and therefore can't be changed. However, Clojure allows you to create mutable references using the following types: vars, atoms, and refs.
Note: In order to refer to a var, atom or ref, you need a symbol. And therefore, these types don't "replace" symbols. However, they provide an extra layer of indirection between the symbol and it's "underlying" value.
Furthermore, Clojure is built to enable concurrent programming, and therefore, provides some powerful primitives to delay, parallelize and synchronize computation: delay, future and promise. The following table explains the differences between these types of references and concurrency primitives:
Type | Mutability | Reads | Updates | Evaluation | Scope |
---|---|---|---|---|---|
Symbol | Immutable | Transparent | Lexical | ||
Var | Mutable | Transparent | Unrestricted | Global/Dynamic | |
Delay | Mutable | Blocking | Once only | Lazy | |
Future | Mutable | Blocking | Once only | Parallel | |
Promise | Mutable | Blocking | Once only | ||
Atom | Mutable | Nonblocking | Linearizable | ||
Ref | Mutable | Nonblocking | Serializable |
Legend:
- Transparent - Value is returned simply by referencing the symbol, as opposed to requiring the use of
deref
or@
. - Blocking - Attempting to deref will cause the current thread to block until the referenced state is available (e.g. the promise has been delivered)
- Lazy - Evaluation doesn't occur until a consumer attempts to dereference it.
- Parallel - Evaluation happens immediately (as opposed to lazily), but occurs on a separate thread.
Summary: I read chapter 7 of Clojure from the Ground up.
Clojure projects are composed of a project.clj
file, which defines its build properties: name, description, version and dependencies. Furthermore, it includes a src
directory for source code, a test
directory for tests, and a docs
directory for documentation.
Within *.clj
file, you can define a new namespace, which allows you to group a collection of related symbols together. Furthermore, a namespace expression can also define a set of other namespaces that it depends on, and therefore, can access symbols from. This allows namespaces to decompose a project into smaller pieces/files, while retaining reusability.
(ns scratch.core)
; Import another namespace
(ns user (:require [scratch.core]))
; Import and alias another namespace
(ns user (:require [scratch.core :as c]))
; Import a specific symbol from another namespace
(ns user (:require [scratch.core :refer [foo]]))
In order to add a dependency to external/3rd-party packages, you simply add a reference to the project.clj
file, that specifies the name and version of the package you want to use. Behind the scenes, packages are pulled from Clojars, which is the package manager community for Clojure.