Все части

Оглавление

Эта глава расскажет о REPL — фундаментальном свойстве Clojure. Так называют интерактивную работу с языком, когда программу наращивают постепенно. Вы узнаете, что такое REPL-driven development и почему, однажды познав, от него трудно отказаться.

Аббревиатура REPL происходит от четырех слов: read, eval, print и loop. Дословно они означают прочитать, выполнить, напечатать и повторить. REPL — устойчивый термин, под которым понимают интерактивный режим программы.

Многие современные языки поддерживают интерактивный режим. Как правило, он запускается, если вызвать интерпретатор без параметров. Например, команды python или node запустят интерактивные сеансы Python и Node.js. В Ruby для этого служит утилита irb (где i означает interactive). REPL поддерживают не только интерпретаторы, но и языки, которые компилируются в байт-код (Java, Scala) или машинный код (Haskell, SBCL).

Несмотря на это разнообразие, именно в Лисп-системах REPL имеет решающее значение. Если в Python или Node.js его рассматривают как приятное дополнение, то в Лиспе он необходим. Разработка на любом Лиспе зависит от того, насколько хорошо вы взаимодействуете с REPL. На REPL так или иначе опираются все инструменты и практики, примеры из документации.

В мире Лиспа ходит понятие REPL-driven development. Это стиль разработки, когда код пишут малыми порциями и запускают в REPL. С таким подходом сразу видно поведение программы. Легче проверить неочевидные случаи, например вызвать функцию с nil или обратиться к ресурсу, которого не существует.

Другой полезный сценарий для REPL — извлечь данные из сети и исследовать их. Эта задача идеально ложиться на интерактивный режим. Как правило, HTTP-запрос предполагает несколько этапов: его подготовку, отправку, чтение тела и поиск нужных полей. Эти шаги проходят интерактивно методом проб и ошибок. Ниже мы рассмотрим пример HTTP-запроса в сеансе REPL.

Исторический экскурс

REPL отсчитывает свою историю от первых Лисп-машин. Это были мейнфреймы с запущенным на них интерпретатором Лиспа. Подобные машины использовали в Xerox для печати, обработки изображений, управления оборудованием, решения задач, связанных с машинным обучением и искусственным интеллектом. Разработку Лисп-машин поддерживал отдел DARPA, в том числе потому, что их применяли в военной отрасли.

Золотой век Лисп-машин пришелся на период с 1977 по 1985 год, после чего последовал спад их популярности. Из-за особенностей архитектуры они не могли конкурировать с процессором x86 и компилируемыми языками, — как в плане цены, так и производительности. В итоге Лисп-машины полностью ушли с рынка, но подход REPL, придуманный полвека назад, навсегда остался в индустрии.

Подход и вправду был инновационным. До него программу набирали в редакторе, компилировали и только потом запускали (для краткости опустим этап с перфокартами). Процесс был долгим и дорогим. Наоборот, Лисп-машина принимала код и выполняла его мгновенно. Для своего времени Лисп был очень высокоуровневым языком. На нем легко выразить сложную логику, не отвлекаясь на низкоуровневые проблемы. Именно в Лиспе появился автоматический контроль за памятью и сборка мусора. Все это делало Лисп-машину идеальной площадкой для экспериментов.

Интерпретатор Лисп-машины был не просто программой по запуску кода. Фактически он был ее операционной системой, потому что имел доступ к регистрам процессора, оперативной памяти и устройствам ввода-вывода. В REPL можно было просмотреть все переменные и функции, переопределить и удалить их. Это свойство — полный контроль системой — тоже стало неотъемлемой частью REPL.

Язык Clojure, как и другие диалекты Лиспа, активно поддерживает REPL и все связанное с ним. Без знания REPL работа с Clojure будет неэффективна. Классический подход, когда сначала вы пишете программу, а потом запускаете, здесь не работает. Цель этой главы — показать практическую, REPL-ориентированную разработку, принятую в Clojure.

Пробуем REPL

Чтобы познакомиться с REPL, запустим его. Это можно сделать несколькими способами.

Первый — установить утилиту Leiningen для управления проектами на Clojure. Инструкции по установке вы найдете на официальном сайте. Когда утилита установлена, выполните в терминале:

> lein repl

Второй способ — установить набор утилит Clojure CLI. На официальном сайте Clojure представлены команды установки для Linux и MacOS. После установки в системе появятся команды clojure и clj для запуска проекта. Если вызвать любую из них, запустится REPL:

> clj
> clojure

Старые версии Clojure (до 1.8 включительно) можно запустить напрямую из jar-файла командой java:

> java -jar clojure-1.8.0.jar

или явно указав класс clojure.main:

> java -cp clojure-1.8.0.jar clojure.main

С версии 1.9 Clojure состоит из нескольких jar-файлов. Библиотека clojure.spec, которую мы рассмотрели в первой книге, поставляется отдельно. Скачайте jar-файлы из репозитория Maven в разделах org.clojure/clojure и org.clojure/spec.alpha. Далее выполните команду:

> java -cp clojure-1.11.1.jar:spec.alpha-0.3.218.jar clojure.main

Когда REPL запущен, вы увидите приглашение:

user=>

Слово user означает текущее пространство имен. Если не задано иное, REPL запускается в пространстве user. Позже мы узнаем, как задать другое пространство или переключить его.

Введите любое выражения на Clojure: число, строку в двойных кавычках или кейворд. Эти значения вычисляются сами в себя:

user=> 1
1

user=> :test
:test

user=> "Hello REPL!"
"Hello REPL!"

Задайте глобальную переменную:

user=> (def amount 3)
#'user/amount

и сошлитесь на нее в выражении:

user=> (+ amount 4)
7

Более сложный пример. Определите функцию add, которая складывает два числа. Введите ее в одну строку:

user=> (defn add [a b] (+ a b))
#'user/add

и проверьте вызов:

user=> (add 5 -4)
1

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

(defn add [a b]
  (+ a b))

Если напечатать (defn add [a b] и нажать ввод, по незакрытой скобке REPL определит, что выражение неполное. Ошибки не произойдет, и следующая строка дополнит исходную. Как только скобки станут сбалансированы, REPL выполнит форму.

user=> (defn add [a b]
  #_=> (+ a b))
#'user/add

Подключите любой из модулей Clojure, например clojure.string для работы со строками:

user=> (require '[clojure.string :as str])
nil

С его помощью разбейте строку или выполните автозамену:

user=> (str/split "one two three" #"\s+")
["one" "two" "three"]
user=> (str/replace "Two minutes, Turkish!", #"Two" "Five")
"Five minutes, Turkish!"

Модуль clojure.inspector предлагает примитивный графический отладчик. Его функция inspect-tree принимает структуру данных и выводит окно с деревом папок. Значок папки означает коллекцию; если его раскрыть, появятся дочерние элементы с иконками файлов.

Чтобы изучить переменные среды, выполните:

(require '[clojure.inspector :as insp])
(insp/inspect-tree (System/getenv))

Содержимое окна будет примерно таким:

{}
└─ JAVA_MAIN_CLASS_68934=clojure.main
└─ LC_TERMINAL=iTerm2
└─ COLORTERM=truecolor
└─ LOGNAME=ivan
└─ TERM_PROGRAM_VERSION=3.3.12
└─ PWD=/Users/ivan/work/book-sessions
└─ SHELL=/bin/zsh

Опробуйте случай с ошибкой. Поделите число на ноль или сложите число с nil. REPL не завершится, но выведет исключение на экран:

user=> (/ 1 0)
Execution error (ArithmeticException) at user/eval554...
Divide by zero

Это правильное поведение: в процессе работы ошибки случаются часто, и нам бы не хотелось завершать JVM. Однако это справедливо только для сеанса REPL. В боевом запуске программы на Clojure ведут себя как обычно: если исключение не поймано, программа завершается.

По умолчанию REPL выводит краткое сообщение об ошибке. Объект исключения остается в переменной *e. Исследуем ее:

user=> *e

#error {
 :cause "Divide by zero"
 :via
 [{:type java.lang.ArithmeticException
   :message "Divide by zero"
   :at [clojure.lang.Numbers divide "Numbers.java" 188]}]
 :trace
 [[clojure.lang.Numbers divide "Numbers.java" 188]
  [clojure.lang.Numbers divide "Numbers.java" 3901]
  [user$eval8888 invokeStatic "form-init3207458389100610076.clj" 1]
  [user$eval8888 invoke "form-init3207458389100610076.clj" 1]
  ...
  [nrepl.middleware.session$session_exec$main_loop__1048 invoke "session.clj" 201]
  [clojure.lang.AFn run "AFn.java" 22]
  [java.lang.Thread run "Thread.java" 829]]}

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

Когда REPL что-то вычислил, результат остается в переменной *1. С ней легко избежать повторных вычислений. Предположим, мы ввели код, который дает объемный результат:

(into {} (System/getenv))

{"HOME" "/Users/ivan"
 "LC_TERMINAL_VERSION" "3.3.12"
 "USER" "ivan"
 ...}

Чтобы сослаться на результат, не вычисляя повторно, введите:

(get *1 "USER")
;; "ivan"

Переменная *1 полезна для записи в файл. Предположим, мы хотим сохранить результат, чтобы исследовать позже. Для этого введите:

(spit "dump.edn" (pr-str *1))

На диске появится файл dump.edn с данными. Позже мы прочтем их комбинацией slurp и read-string:

user=> (read-string (slurp "dump.edn"))

{"HOME" "/Users/ivan"
 "LC_TERMINAL_VERSION" "3.3.12"
 "USER" "ivan"
 ...}

Вам доступны три переменных результата: *1, *2 и *3. С каждым вычислением результаты смещаются: последний будет в *1, предпоследний в *2 так далее. Покажем это на примере:

user=> 1
1

user=> 2
2

user=> 3
3

user=> (println *1 *2 *3)
3 2 1

Чтобы загрузить в REPL несколько определений, используйте функцию load-file. Она принимает один аргумент — путь к файлу с кодом на Clojure:

(load-file "my_functions.clj")

Эффект аналогичен тому, как если бы вы скопировали содержимое файла и вставили в REPL. Функция не влияет на текущее пространство имен, как это делают формы ns или require. В боевом коде load-file не используют, потому что такая загрузка делает код неочевидным: неясно, откуда взялась та или иная переменная. Однако для экспериментов load-file подходит идеально.

REPL предлагает макросы для интроинспеции. Выражение (doc ...) напечатает справку к указанной функции, например:

(doc assoc)
-------------------------
clojure.core/assoc
([map key val] [map key val & kvs])
  assoc[iate]. When applied to a map, returns a new map of the
    same (hashed/sorted) type, that contains the mapping of key(s) to
    val(s). When applied to a vector, returns a new vector that
    contains val at index. Note - index must be <= (count vector).

А форма (source ...) — ее исходный код (приведем в сокращении):

(source assoc)
(def
 ^{:arglists '([map key val] [map key val & kvs])
   :doc "..."
   :added "1.0"
   :static true}
 assoc
 (fn ^:static assoc
   ([map key val] (clojure.lang.RT/assoc map key val))
   ([map key val & kvs]
    (...)))) ;; truncated

Doc и source работают с любыми загруженными модулями. Вот как получить справку по разбиению строк из модуля clojure.string:

(doc clojure.string/split)
-------------------------
clojure.string/split
([s re] [s re limit])
  Splits string on a regular expression.  Optional argument limit is
  the maximum number of splits. Not lazy. Returns vector of the splits.

REPL поддерживает и другие возможности, о которых мы поговорим позже. Пока что завершите сеанс нажатием Ctrl+D или выполните (quit) или (exit).

Более сложный сценарий

REPL подходит не только для быстрых экспериментов; опытные разработчики проводят в нем часы и дни. Одна из причин этого в том, что REPL — лучший способ разведать ситуацию, когда вы не знаете точно, какие данные приходят из внешних источников.

Предположим, мы пишем бота для Telegram, который публикует шутки для программистов. Понадобится сервис, который бы выступил в роли источника шуток. Быстрый поиск дает нам сервис Joke API с удобным API по протоколу HTTP.

Прежде чем писать бота, убедимся в работе сервиса. Для этого понадобятся HTTP-клиент и парсер JSON. Если вы запускаете REPL при помощи lein, создайте файл project.clj с содержимым:

(defproject repl-chapter "0.1.0-SNAPSHOT"
  :dependencies [[org.clojure/clojure "1.10.0"]
                 [clj-http "3.9.1"]
                 [cheshire "5.8.1"]])

Для утилит Clojure CLI файл deps.edn выглядит так:

{:deps
 {clj-http/clj-http {:mvn/version "3.9.1"}
  cheshire/cheshire {:mvn/version "5.8.1"}}}

Запустите REPL. Обе библиотеки, если еще не были установлены локально, скачаются на ваш компьютер. Подключите их в сеансе:

(require '[clj-http.client :as client])
(require 'cheshire.core)

Подготовим словарь запроса:

(def request
  {:url "https://v2.jokeapi.dev/joke/Programming"
   :method :get
   :as :json})

Получим ответ:

(def response
  (client/request request))

Каждое выражение мы связываем с переменной формой def, чтобы позже сослаться на нее. Такой стиль не подходит для промышленного кода, но приемлем в REPL. Поместим тело в отдельную переменную и напечатаем его:

(def data
  (:body response))

{:category "Programming"
 :delivery "They only like chicken NuGet."
 :type "twopart"
 :setup ".NET developers are picky when it comes to food."
 :lang "en"
 :id 49
 :error false
 :safe true
 :flags
 {:nsfw false
  :religious false
  :political false
  :racist false
  :sexist false
  :explicit false}}

Чтобы лучше понять структуру ответа, исследуем его в инспекторе:

(require '[clojure.inspector :as insp])
(insp/inspect-tree data)

Из данных видно, что шутка состоит из двух частей: setup и delivery (термины можно перевести как “подводка” и “разрешение”). Такая структура полезна, когда разрешение показывают не сразу, а после паузы или под тегом спойлера. Так у читателя будет шанс придумать свой вариант. В нашем случае мы просто объединим обе фразы:

(def joke
  (let [{:keys [setup
                delivery]} data]
    (format "%s %s" setup delivery)))

".NET developers are picky when it comes to food. They only like chicken NuGet."

Если передать необязательный параметр contains, получим шутку на заданную тему. Например, если это чат о языке Python, будем шутить про JavaScript:

(def request
  {:url "https://v2.jokeapi.dev/joke/Programming"
   :method :get
   :query-params {:contains "javascript"}
   :as :json})

...

"Why was the JavaScript developer sad?
Because they didn't Node how to Express themself!"

Итак, мы написали фрагменты кода под каждый шаг. Составим функцию, которая принимает язык, про который нужно шутить, и возвращает текст шутки:

(defn get-joke [lang]
  (let [request
        {:url "https://v2.jokeapi.dev/joke/Programming"
         :method :get
         :query-params {:contains lang}
         :as :json}

        response
        (client/request request)

        {:keys [body]}
        response

        {:keys [setup delivery]}
        body]

    (format "%s %s" setup delivery)))

В действии:

(get-joke "python")
"Why did the Python programmer not respond to the foreign mails he got?
Because his interpreter was busy collecting garbage."

(get-joke "javascript")
"How did you make your friend rage?
I implemented a greek question mark in his JavaScript code."

На этом этапе еще рано завершать REPL: данные, что мы получили с сервера, пригодятся в тестах. Сохраним их json-файл. Для этого выполним:

(spit "joke-ok.json"
      (cheshire.core/generate-string
       data {:pretty true}))

;; or

(require '[clojure.java.io :as io])
(cheshire.core/generate-stream
  data (io/writer "joke-ok.json") {:pretty true})

Обратите внимание на параметр :pretty. С ним JSON будет красиво оформлен, то есть записан с отступами и переносами строк, а не в одну строку.

Важно знать, как ведет себя сторонний сервис в случае ошибки. Если пошутить о Clojure, получим неприятный результат:

(get-joke "clojure")
"null null"

Чтобы понять, почему так получилось, исследуем ответ сервера:

(def data
  (-> {:url "https://v2.jokeapi.dev/joke/Programming"
       :method :get
       :query-params {:contains "clojure"}
       :as :json}
      (client/request)
      (:body)))

Данные:

{:error true
 :internalError false
 :code 106
 :message "No matching joke found"
 :causedBy ["No jokes were found that match your provided filter(s)."]
 :additionalInfo
 "Error while finalizing joke filtering: No jokes were found that match your provided filter(s)."
 :timestamp 1651054623055}

Сервер не нашел подходящих записей и вернул структуру с полем {:error true}. Перепишите функцию так, чтобы в случае ошибки мы получили nil, а не строку с null.

Негативный ответ тоже пригодится в тестах. Запишите его в файл с понятным именем:

(cheshire.core/generate-stream
  data (io/writer "joke-err.json") {:pretty true})

Итак, разведка выполнена. Мы убедились, что сервис отвечает на запросы. Мы получили данные, которые позже используем в тестах. Стало ясно, как ведет себя сервис в случае ошибки. Во время экспериментов появились наброски кода, из которых легко составить конечную версию.

Важно, что эти наброски опираются на реальные данные, а не документацию или систему классов. И то и другое может устареть и не давать реальной картины — что именно передается по сети. В случае с REPL подобной ошибки быть не может.

Только теперь, с багажом опыта и данных, можно садиться за промышленный код. Мы уже проделали большую часть работы, и остальная часть не потребует усилий. Все это благодаря REPL, который выполнил роль черновика, отладчика и поля экспериментов.

Свой REPL

Чтобы лучше понять устройство REPL, напишем свою реализацию. Подготовьте минимальный проект: создайте файл project.clj или deps.edn и структуру папок src/<project>. Назовем проект my-repl. Поместите в файл src/my_repl/core.clj следующий код:

(ns my-repl
  (:gen-class))

(defn -main [& args]
  (repl))

Форма (:gen-class) в макросе ns означает, что при компиляции пространства получится одноименный класс. Это же пространство должно быть указано главным в файле project.clj:

:main my-repl

Функция -main — точка входа в будущий класс — запускает сеанс REPL. Что находится внутри (repl), пока неизвестно. Подготовим ее черновик:

(defn repl []
  (loop []
    (let [input (read-line)
          expr (read-string input)
          result (eval expr)]
      (println result)
      (recur))))

Это бесконечный цикл, где на каждом шаге происходит следующее:

Read

Функция read-line читает строку из стандартного канала ввода (stdin). Если канал пуст, система ожидает ввода с клавиатуры. Пользователь набирает текст в терминале и жмет Enter. В переменной input окажется строка.

Функция read-string читает объект Clojure из строки. Числа, строки и другие примитивы выражаются сами в себя. Например, из строки “1” получим единицу. Символы остаются невычисленными: строка "(foo bar)" вернет список с символами foo и bar. В переменной expr окажется объект Clojure: символ, строка, число или их коллекция.

=> (read-string "(foo [1 false {:foo BAZ}])")
;; (foo [1 false {:foo BAZ}])

Eval

Функция eval принимает выражение и вычисляет его. В отличии от других языков, в Clojure eval принимает не строку, а форму, то есть примитив (число, символ, кейворд) или коллекцию. Примитивы вычисляются сами в себя:

=> (eval 1)
1

=> (eval :foo)
:foo

Символы вычисляются в переменные текущего пространства. Например, символ + связан с функцией clojure.core/+, и его вычисление вернет объект функции:

=> (eval '+)
#function[clojure.core/+]

В вычислении можно сослаться на любую переменную, в том числе созданную вами:

(def some-text "Hello")
(eval '(str some-text " World!"))
"Hello World!"

Вариант посложнее со словарем и update-in:

(def data {:a {:b 0}})
(eval '(update-in data [:a :b] inc))
{:a {:b 1}}

Если переменной, на которую ссылается символ, нет, получим исключение:

(eval 'dunno)
Syntax error compiling at ...
Unable to resolve symbol: dunno in this context

Print

Следующий шаг — печать. Результат eval выводится на экран обычной функцией println:

(println result)

Loop

Оператор loop переносит нас к первому шагу — чтению с клавиатуры — и все повторяется.

Хоть это и крайне сырая версия REPL, она работает. Скомпилируйте проект командой lein uberjar и запустите jar-файл:

java -jar target/uberjar/repl-chapter-0.1.0-SNAPSHOT-standalone.jar

Введите несколько выражений на Clojure, отделяя каждое клавишей Enter. После каждого появится результат вычисления. Приведем фрагмент сеанса:

=> (+ 1 2)
3

=> (assoc {:one 1} :two 2)
{:one 1, :two 2}

=> (defn add [a b] (+ a b))
#'clojure.core/add

=> (add 4 5)
9

=> (require '[clojure.string :as str])
nil

=> (str/split "1 2 3" #"\s")
[1 2 3]

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

Улучшения

Предлагаем читателю доработать наш самописный REPL — это будет отличная практика.

Выход из цикла

На текущий момент нельзя завершить REPL без нажатия Ctrl+C, что неудобно. Сделайте так, чтобы какое-то выражение было особым и означало остановку. Например, если пользователь ввел кейворд :repl/exit, REPL завершается. Проверка может выглядеть так:

(let [input (read-line)
      expr (read-string input)]
  (when-not (= expr :repl/exit)
    ...))

При вводе :repl/exit оператор loop не сработает, и REPL выйдет из цикла.

Перехват исключений

Если выражение приведет к ошибке, программа завершится аварийно. Как мы выяснили, исключения часто случаются в REPL, потому что в нем экспериментируют. Ошибка — не повод завершать программу. Оберните каждый шаг цикла в try/catch с классом Throwable, чтобы перехватывать все возможные ошибки:

(defn repl []
  (loop []
    (let [[result e]
          (try
            [(-> (read-line)
                 (read-string)
                 (eval))
             nil]
            (catch Throwable e
              [nil e]))]
      (if e
        (binding [*out* *err*]
          (println (ex-message e)))
        (println result))
      (recur))))

Скомпилируйте проект заново и запустите. На этот раз программа не “вывалится” в случае исключения, а напечатает сообщение:

=> 1
1

=> (/ 0 0)
Divide by zero

Обратите внимание: форма recur не может быть внутри try/catch, поэтому идем на уловку. Выражение try возвращает пару, где первый элемент — результат, если не было ошибок, а второй — nil или ошибка, если что-то пошло не так. Прием с парой мы рассмотрели в первой книге в главе об исключениях.

В зависимости от того, что мы получили — результат или ошибку, — выводим результат в stdout или stderr при помощи связывания binding. В данном примере мы только печатаем текст исключения. Для более детальной информации можно взять функцию print-stack-trace из модуля clojure.stacktrace.

Еще одно замечание касается строки (if e ...). Мы проверяем исключение, а не результат, потому что результат вполне может быть nil.

Обработчик исключения

Чтобы сделать REPL более гибким, поместим обработку исключений в отдельную функцию:

(defn exception-handler [e]
  (binding [*out* *err*]
    (println (ex-message e))))

(loop []
  (...
   (if e
     (exception-handler e))))

Пусть функция repl принимает параметр, чтобы задать свой обработчик исключения. Если он не задан, сработает функция по умолчанию, которую мы объявили выше. Для ясности переименуем ее в default-exception-handler. Далее перепишем repl:

(defn repl [& [{:keys [exception-handler]}]]
  (let [ex-handler
        (or exception-handler
            default-exception-handler)]
    (loop []
      (...
        (if e
          (ex-handler e))))))

Еще до того как мы вступим в цикл, переменной ex-handler назначен обработчик — переданный или заданный по умолчанию. Эта оптимизация нужна для того, чтобы не вычислять обработчик на каждом шаге.

Запустите repl с обработчиком, который печатает тип исключения:

(defn -main [& args]
  (repl {:exception-handler
         (fn [e] (println (type e)))}))

Результат:

=> (/ 0 0)
java.lang.ArithmeticException

Красивая печать

Функция println выводит данные в одну строку, что не подходит для вложенных коллекций. Чтобы вывод был понятен, нужны переносы строк и отступы. Воспользуйтесь функцией pprint из модуля clojure.pprint:

(ns my-repl
  (:require
   [clojure.pprint :as pprint]))

(defn repl [...]
  (loop []
    (let [...]
      (pprint/pprint result)
      (recur))))

Опробуем красивую печать в действии. Понадобится большая коллекция, например словарь переменных среды:

(pprint/pprint
  (into {} (System/getenv)))

{"LEIN_VERSION" "2.9.5",
 "HOME" "/Users/ivan",
 "LC_TERMINAL_VERSION" "3.3.12",
 "USER" "ivan"
 ...
 }

Данные будут напечатаны построчно, что гораздо удобней для чтения. Обратите внимание, что красивая печать работает только для типов Clojure. Поэтому мы приводим результат (System/getenv), экземпляр класса UnmodifiableMap, к словарю функцией into.

В первой книге мы упоминали, что на pprint влияют глобальные динамические переменные *print-length* и *print-level* — максимальные длина и глубина коллекции. Пусть наш REPL принимает значения этих переменных. Если они заданы, цикл запускается в форме binding с переопределением длины и глубины. Минимальные правки:

(defn repl [& [{:keys [print-level
                       print-length]}]]
  (binding [*print-level*
            (or print-level *print-level*)
            *print-length*
            (or print-length *print-length*)]
    ...))

(defn -main [& args]
  (repl {:print-length 3}))

Запустив новый REPL, введите коллекцию длиннее трех элементов. При печати вы увидите ее усеченную версию:

=> [1 2 3 4 5]
[1 2 3 ...]

Приглашение

Стандартный REPL показывает приглашение вида user=>. Это удобно по двум причинам. Во-первых, ясно, в каком пространстве мы находимся сейчас — в процессе работы пространство переключают. Во-вторых, стрелка подсказывает, что от нас ожидают ввод.

Добавьте печать приглашения перед вводом с клавиатуры (вызовом read-line). Для начала ограничимся статичной строкой:

(loop []
  (print "repl=> ")
  (flush)
  (let [...]
    ...))

Вызов (flush) необходим, чтобы отправить вывод в терминал, не дожидаясь заполнения буфера. С приглашением REPL выглядит живее:

repl=> :hello/repl
:hello/repl

repl=> {:foo "test"}
{:foo test}

Логично, чтобы за приглашение отвечала функция get-prompt, которая принимает текущее пространство:

(defn get-prompt [this-ns]
  (format "%s=> " (ns-name this-ns)))

(defn repl []
  (binding [*ns* *ns*]
    (loop []
      (print (get-prompt *ns*))
      ...)))

При смене пространства в REPL меняется и приглашение:

clojure.core=> (in-ns 'repl-test)

repl-test=> (clojure.core/refer-clojure)
nil

repl-test=> (+ 1 2)
3

Обратите внимание на форму (binding [*ns* *ns*] ...) перед loop. Без нее не получится сменить пространство: функция in-ns меняет переменную *ns* формой (set! *ns* ...), что невозможно вне макроса binding.

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

18:12=> ...
18:14=> ...

Переменная результата

Еще одна полезная доработка — хранить последний результат в переменной, чтобы позже ссылаться на него. Назовем переменную -r (result).

Исправим REPL: поместим цикл в форму with-local-vars. Макрос задает локальные переменные, которые напоминают атомы. Чтобы изменить переменную, вызывают var-set. Для значения служит функция var-get или оператор @ (deref). Новая версия REPL:

(defn repl []
  (with-local-vars [-r nil]
    (loop []
      (let [input (read-line)
            expr (read-string input)
            result
            (case expr
              -r (var-get -r)
              (eval
               `(let [~'-r ~(var-get -r)]
                  ~expr)))]
        (var-set -r result)
        (println result)
        (recur)))))

Обратите внимание на то, как вычисляется форма expr. С помощью оператора case мы проверяем: если поступил символ -r, вернем значение переменной -r. Ввод -r считается особенным, потому что вычисляются без eval.

Чтобы сослаться на -r в выражении, например (+ -r 3), идут на трюк. Форма expr погружается в макрос let, где символ -r связан со значением -r. Дело в том, eval не учитывает локальные переменные, и без let мы получим ошибку, что символ -r неизвестен.

Другими словами: вычисление (eval '(+ -r 3)) вернет ошибку, потому что символ -r неизвестен. Однако (eval '(let [-r 4] (+ -r 3))) сработает без ошибки, потому что -r связан с 4.

Запустите REPL и проверьте, что в -r остается результат, при этом на него можно сослаться в выражении:

=> (+ 1 2 3)
6

=> -r
6

=> (* -r 3)
18

=> -r
18

Доработайте REPL так, чтобы, кроме результата, он хранил последнее исключение в переменной -e. Например:

(/ 1 0)
;; ... Stacktrace ...

-e
;; Execution error (ArithmeticException)...
;; Divide by zero

Для этого добавьте в макрос with-local-vars привязку [... -e nil]. При помощи try/catch перехватывайте ошибку. Если что-то поймано, запишите исключение в -e:

(with-local-vars [-r nil -e nil]
  ...
  (try
    ...
    (catch Throwable e
      (var-set -e e))))

Многострочный ввод

До сих пор мы вводили выражения под одной строке. Теперь мы хотим задать функцию с переносом после сигнатуры:

=> (defn add [a b]
    (+ a b))

Если нажать ввод после b], произойдет следующее. Клавиша Enter завершит прием символов, и в переменной окажется строка "(defn add [a b]". Функция read-string не сможет ее разобрать и бросит исключение:

=> (read-string "(defn add [a b]")
Execution error at ...
EOF while reading

Кроме того, у read-string еще одна особенность: она читает только один объект из строки, что видно из примеров:

=> (read-string "1 2")
1

=>  (read-string "(+ 1 2) (- 1 2)")
(+ 1 2)

Вторая форма была отброшена.

Чтобы исправить эти неприятности, перед вызовом read-line мы должны убедиться, что форма завершена. Для этого проверим строку на баланс скобок: на каждую открывающую приходится закрывающая того же типа (круглая, квадратная, фигурная). Если в строке незакрытые скобки, мы запрашиваем еще одну строку и продолжаем учет скобок. Как только все скобки закрыты, накопленные строки вычисляются как одно выражение.

Чтобы выделить многострочный ввод визуально, каждая следующая строка предваряется отступом:

(defn add [a b]
..(let [c (+ a b)]
....(* c 3)))

Длина отступа (число точек) равна уровню формы — числу вложенных скобок, умноженному на два.

Для учета скобок подойдет стек — структура данных, которая работает по принципу FILO (First In Last Out, первым пришел — последним ушел). В стек добавляют и извлекают из него элементы. Ограничение в том, что извлечь их можно только в обратном порядке. Например, если добавить в стек символы (, [, {, то при извлечении получим {, [, (.

При анализе строки мы перебираем ее символы. Открывающие скобки попадают в стек. Как только мы встретили закрывающую скобку, происходит следующее:

  • убираем с вершины стека последнюю скобку;
  • убеждаемся, что она парная к найденной. Если это не так, бросаем исключение.

Например, если в стеке содержатся элементы (, [, { и мы встретили закрывающую фигурную скобку, все в порядке: она относится к элементу на вершине {. Если же попалась квадратная закрывающая, это говорит об ошибке в синтаксисе.

;; ok
"(..[..{..}......"
          ^

;; error
"(..[..{..]......"
          ^

Для начала опишем стек. Это функция, которая порождает функцию, замкнутую на атоме. Внутренняя функция принимает различные зные команды. Оператор case определяет их логику. У функции два тела: команды без аргументов и с одним аргументом.

Стек поддерживает команды :count (узнать число элементов), :empty? (проверка на пустоту), :pop (извлечь последний элемент) и :push (добавить элемент). От других команд :push отличается тем, что принимает аргумент — значение, которое добавляют в стек. Поэтому :push описан во втором теле.

(defn make-stack []
  (let [-stack (atom nil)]
    (fn stack
      ([cmd]
       (case cmd
         :count (count @-stack)
         :empty? (zero? (count @-stack))
         :pop (let [item (first @-stack)]
                (swap! -stack rest)
                item)))
      ([cmd arg]
       (case cmd
         :push
         (swap! -stack conj arg))))))

Атом хранит обычный список. Если точнее, изначально он хранит nil, при добавлении элемента к которому получится список. Функция conj добавляет элемент в голову списка. Вот почему вершину стека получают функцией first, а усекают функцией rest.

Стек в действии:

(def s (make-stack))

(s :count)  ;; 0
(s :empty?) ;; true
(s :push 1) ;; (1)
(s :push 2) ;; (2 1)
(s :push 3) ;; (3 2 1)
(s :count)  ;; 3
(s :pop)    ;; 3
(s :pop)    ;; 2
(s :pop)    ;; 1
(s :empty?) ;; true

Наш стек является изменяемым объектом. Более “кложурный” способ в том, чтобы сделать стек неизменяемым: каждая операция возвращала бы его копию подобно функциям assoc или update. Но для разнообразия мы решили поработать с изменяемым стеком.

Анализ строки сводится к тому, чтобы сперва наполнить стек открывающими скобками, а затем опустошить закрывающими. Если в итоге стек пуст, строка сбалансирована.

Напишем функцию multi-input, которая читает ввод с клавиатуры до тех пор, пока выражение не сбалансировано. Считанные строки объединяются в одну пробелом. При вводе очередной строки покажем уровень вложенности отступами. Вот алгоритм функции:

(defn multi-input []
  (let [stack (make-stack)]
    (loop [result ""]
      (print (make-indent stack))
      (flush)
      (let [line (read-line)
            result (str result " " line)]
        (consume-line stack line)
        (if (stack :empty?)
          result
          (recur result))))))

Код довольно короткий и опирается на две служебные функции. Первая make-indent строит отступ из точек; его длина равна двойному числу элементов в стеке:

(defn make-indent [stack]
  (str/join (repeat (* (stack :count) 2) ".")))

Вторая функция consume-line сложнее. Она принимает стек и строку, проходит по символам и корректирует стек. Для корректировки нужен словарь парных скобок. Его ключи и значения — символы типа Char:

(def brace-pairs
  {\( \)
   \[ \]
   \{ \}})

Понадобится зеркальная копия этого словаря, чтобы по закрывающей скобке найти открывающую:

(def brace-oppos
  (into {} (for [[k v] brace-pairs]
             [v k])))

Код насыщения стека:

(defn consume-line [stack line]
  (doseq [char line]
    (cond
      (contains? brace-pairs char)
      (stack :push char)

      (contains? brace-oppos char)
      (let [char-oppos
            (get brace-oppos char)
            char-lead
            (stack :pop)]
        (when-not (= char-lead char-oppos)
          (throw
           (new Exception
                (format "Unbalanced expression: %s...%s"
                        char-lead char))))))))

Логика следующая: если символ — открывающая скобка (входит в brace-pairs), добавить ее в стек. Если закрывающая (входит в brace-oppos), найти по ней открывающую (переменная char-oppos). Далее получить элемент с вершины стека методом :pop (переменная char-lead). Если переменные не равны, бросить исключение.

Вернитесь в функцию repl и замените (read-line) на (multi-input). Скомпилируйте проект и опробуйте REPL в действии. Вот что получилось у автора:

=> (+ 1 2 3
=> ..3 4 5)
18

=> (defn add [a b]
=> ..(+ a b))
#'clojure.core/add

=> (assoc-in {:foo 1
=> ....:bar 2
=> ....:baz 3
=> ....}
=> ..[:test :hello]
=> ..3
=> ..)
{:bar 2 :baz 3 :foo 1 :test {:hello 3}}

Очевидно, с таким подходом легче ввести длинное выражение. Проверьте код с неверными скобками. В исключении сказано, какие именно скобки были причиной.

=> (+ 1 2 3]
java.lang.Exception: Unbalanced expression: (...]

Заметим, что попытка извлечь элемент из пустого стека тоже считается ошибкой. Это может случится при работе со строкой "(+ 1 2 3))". Предпоследняя скобка очистит стек, но последняя обратится к пустому стеку, что говорит о нарушении. Без доработок мы получим сообщение с null, что понятно:

Unbalanced expression: null...)

Перепишем команду :pop так, чтобы учесть пустой стек:

:pop (if (empty? @-stack)
       (throw (new Exception "Stack is empty!"))
       (let [item (first @-stack)]
         (swap! -stack rest)
         item))

Если ввести код с лишними скобками на конце, получим исключение:

java.lang.Exception: Stack is empty!

Подсчет скобок может дать осечку для внутренних строк. Предположим, мы объявили строку со смайликом внутри:

(def text "Hello Clojure :-)")

Наш анализатор не знает, что нужно игнорировать скобки внутри строки. В результате получим ошибку о пустом стеке. Доработайте (multi-input) так, чтобы при переходе через двойную кавычку включался режим “в строке”, когда скобки не добавляются в стек. При выходе из строки флаг отключается.

REPL в REPL

Еще одна интересная задача: что случится, если вызвать в сеансе функцию (repl)? Другими словами, запустить REPL в REPL?

Ответ — вы запустите новый сеанс, а прежний повиснет в его ожидании. В новом REPL доступны изменения, что вы сделали раньше. При завершении вы вернетесь в прежний REPL и продолжите работу. Покажем это на примере (напомним, что ввод :repl/exit завершает REPL):

=> (def x (+ 1 2))
#'my-repl/x

=> (repl)

=> (* x x)
9

=> :repl/exit
nil

=> (println "still in the REPL")
still in the REPL
nil

Видно, что переменная x, объявленная во внешнем сеансе, доступна во внутреннем. Ввод :repl/exit возвращает нас в прежний REPL, не завершая программу. Усложним сценарий, поместив (repl) в середину кода:

=> (let [a 1 b 2] (repl) (+ a b))

=> (+ 3 4)
7

=> :repl/exit
3

В этом примере форма let повиснет до тех пор, пока не завершится внутренний REPL. Пребывая в нем, мы ввели выражение (+ 3 4). После выхода получим результат всей формы — тройку.

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

Доступ к локальным переменным

Должно быть, вы догадались: вложенный REPL удобен в качестве отладчика. Он прерывает код и поэтому работает как точка останова. С его помощью мы бы остановились на сложном участке кода, чтобы исследовать переменные.

Недостаток нашего REPL в том, у него нет доступа локальным переменным. Предопложим, мы запустили REPL внутри формы let или функции с аргументами:

(let [a 1 b 2]
  (my-repl/repl)
  (+ a b))

;; or

(defn add [a b]
  (my-repl/repl)
  (+ a b))

(add 1 2)

Логично ожидать, что при вводе a или b получим единицу и двойку. Однако это не так — сославшись на локальную переменную, получим исключение о том, что символ неизвестен:

=> a
java.lang.RuntimeException: Unable to resolve symbol: a in this context

Дело в том, что функция eval учитывает только глобальные переменные, то есть заданные при помощи def. Предупреждая ваше огорчение, скажем — все-таки можно сделать так, чтобы локальные переменные были доступны. В середине главы мы напишем свой отладчик, где эта проблема решена. Пока что предложим читателю подумать над этим вопросом.

Полезные функции REPL

Модуль clojure.repl содержит функции и макросы для интерактивного сеанса. В основном они служат для того, чтобы получить информацию о модулях переменных, не покидая REPL. Чтобы подключить модуль, введите команду use:

(use 'clojure.repl)

Содержимое clojure.repl объединится с текущим пространством имен, и не понадобится псевдоним.

Функция apropos ищет определение по строке или регулярному выражению. Для слова "update" найдутся следующие кандидаты:

(apropos "update")

(clojure.core/update
 clojure.core/update-in
 clojure.core/update-keys
 clojure.core/update-proxy
 clojure.core/update-vals)

Если объявить переменную со словом "update" в названии:

(def updated-result 42)

и выполнить поиск еще раз, в выборке окажется символ user/updated-result.

Макрос dir выводит все публичные переменные пространства:

(dir clojure.string)

blank?
capitalize
ends-with?
escape
...

Знакомый макрос (doc ...) печатает справку определения. Если это функция или макрос, вы увидите возможные параметры вызова.

(defn add
  "Add two numbers."
  [a b]
  (+ a b))
=> (doc add)
-------------------------
user/add
([a b])
  Add two numbers.

Если возникло исключение, REPL напечатает только его класс и сообщение, чтобы не тратить место на стек-трейс. Функция pst (сокращение от print stack trace) принудительно выводит полное исключение.

=> (/ 1 0)
Execution error (ArithmeticException) at user/eval175 (REPL:1).
Divide by zero
=> (pst)
ArithmeticException Divide by zero
	clojure.lang.Numbers.divide (Numbers.java:190)
	clojure.lang.Numbers.divide (Numbers.java:3911)
	user/eval175 (NO_SOURCE_FILE:1)
    ...
	clojure.main/repl/fn--9215 (main.clj:458)
	clojure.main/repl (main.clj:458)

Эти и другие возможности пригодятся вам в долгих сеансах REPL. Ознакомьтесь с ними в документации к модулю clojure.repl.

Все части