Зачем такой синтаксис?

Принципиальное отличие LISP от других языков - запись кода в виде данных(списков). В прочих языках, вроде javascript, код записывается текстом. Это наглядно видно при использовании eval:

// javascript
const code = "1 + 2"
eval(code) //=> 3
;; clojure
(let [code (quote (+ 1 2))]
  ;; далее будет показано, что содержится в code
  (eval code)) ;;=> 3

quote - останавливает выполнение, и преобразует код в данные. Без quote произошло бы выполнение выражения (+ 1 2) и code был бы связан со значением 3:

(let [code (quote (+ 1 2))]
  (assert (not= 3 code)))

Существует краткая форма для записи quote - '. Далее я буду использовать именно на этот вариант:

(assert (= (quote (+ 1 2))
           '(+ 1 2)))

'(+ 1 2) представляет список из 3‑х элементов: символа + и 2‑х чисел:

(let [code '(+ 1 2)
      operator (first code)
      operand  (second code)]
  (assert (= clojure.lang.PersistentList (class code)))
  (assert (= clojure.lang.Symbol (class operator)))
  (assert (= java.lang.Long (class operand))))

Символ - специальный тип данных, символизирующий, например, функцию, макрос, значение. Символ some-name можно создать следующими способами: 'some-name, (symbol "some-name"). Если рассматривать следующий код как данные, то символами будут: let, x, y и +.

(let [x 1
      y 2]
  (+ x y))

Важно отметить, что ' останавливает вычисление всех выражений, в том числе вложенных. Если вам нужно просто создать список и вычислить некоторые его элементы, то следует использовать функцию list:

(let [x 1
      y 2
      code-1 (list '+ x y)
      code-2 '(+ x y)]
    (assert (not= code-1 code-2))
    (assert (= (list '+ 1 2) code-1))
    (assert (= (list '+ 'x 'y) code-2)))

Макросы

То обстоятельство, что код записан в виде структур данных, дает нам возможность модифицировать код с помощью этого же языка.

Например, мы можем написать функцию, которая дает возможность записывать арифметические выражения привычным способом.

;; (1 + 2) => (+ 1 2)
(defn infix-fn [infixed]
  (let [operand-1 (first infixed)
        operator  (second infixed)
        operand-2 (last infixed)]
      (list operator operand-1 operand-2)))

(let [infixed '(1 + 2)
      code    (infix-fn infixed)
      result  (eval code)]
  (assert (= 3 result)))

Но практической ценности от такой функции мало. Каждый раз нужно вызывать eval.

В LISP есть функции, выполняющиеся на этапе компиляции. Они используются для модификации кода. Т.е. принимают и возвращают код как данные. Это макросы:

;; просто вызываем нашу функцию
;; вместо вызова можно скопировать сюда ее тело
(defmacro infix [infixed]
  (infix-fn infixed))

(assert (= 3 (infix (1 + 2))))
;; после компиляции эта строчка станет такой:
;; (assert (= 3 (+ 1 2)))

;; macroexpand - это аналог eval для макросов
;; т.е. он разворачивает макрос в runtime
(let [code '(infix (1 + 2))
      compiled (macroexpand code)]
    (assert (= '(+ 1 2) compiled)))

Т.е. макрос выполняется на этапе компиляции и принимает в качестве аргументов куски кода как данные. Макрос возвращает структуру данных, которая затем будет исполнена при запуске программы.

Разделение ответственности

При таком подходе четко выделяются 2 ответственности:

  • то, что код делает
  • то, как код выглядит

Например, нам нужно сделать инкремент для каждого элемента, взять нечетные элементы и посчитать сумму элементов. Выглядит это устрашающе:

(reduce + (filter odd? (map inc [0 1 2 3])))

Можно заметить, что все эти функции принимают коллекцию последним аргументом. Чтобы код проще читался, воспользуемся макросом ->>:

(->> [0 1 2 3]
     (map inc)
     (filter odd?)
     (reduce +))

(macroexpand '(->> [0 1 2 3]
                   (map inc)
                   (filter odd?)
                   (reduce +)))
;;=> (reduce + (filter odd? (map inc [0 1 2 3])))

Этот макрос берет свой первый аргумент, подставляет его на последнее место в свой второй аргумент, результат вычисления, подставляет в третий, и так далее.

Важно понять, что этот макрос работает с любыми функциями. Например, в javascript нет макросов и для соединения функций в цепочки каждая библиотека реализует свой способ:

// https://lodash.com/docs/4.17.5#chain

var users = [
  { 'user': 'barney',  'age': 36 },
  { 'user': 'fred',    'age': 40 },
  { 'user': 'pebbles', 'age': 1 }
];

var youngest = _
  .chain(users)
  .sortBy('age')
  .map(function(o) {
    return o.user + ' is ' + o.age;
  })
  .head()
  .value();

Преимущества

При таком подходе ядро языка реализует минимальный набор специальных форм вроде:

  • (def symbol doc-string? init?)
  • (if test then else?)
  • (let [ binding* ] expr*)
  • (quote form)
  • (fn name? [params* ] expr*)

На самом деле их чуть больше, подробнее о них можно прочитать в разделе special_forms.

Все остальное - это функции, макросы или обертки для вызова java кода.

(macroexpand '(if-not false 1 2))
;; #=> (if (clojure.core/not false) 1 2)

;; а вот так определяется стандартная функция not
(defn not
  "Returns true if x is logical false, false otherwise."
  {:tag Boolean
   :added "1.0"
   :static true}
  [x] (if x false true))

;; у класса clojure.lang.Util вызывается статический метод identical
(defn identical?
  "Tests if 2 arguments are the same object"
  {:added "1.0"}
  ([x y] (clojure.lang.Util/identical x y)))

Parinfer

Расставлять и выравнивать скобки - неблагодарное занятие. Но есть плагин для множества редакторов, облегчающий редактирование lisp выражений. Это parinfer.

Заключение

LISP языки называют программируемыми языками программирования. Вы можете самостоятельно расширять язык новыми синтаксическими конструкциями. При этом ядро языка остается маленьким и простым.