Все части

Оглавление

Эта глава расскажет о 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
#or
> clojure

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

> java -jar clojure-1.8.0.jar

Другой вариант, когда архив находится в CLASSPATH и явно указан класс 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 2 3)
5

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 принимает данные и выводит окно Swing с деревом папок. Значок папки означает коллекцию; если его раскрыть, появятся дочерние элементы с иконками файлов. Чтобы изучить переменные среды, выполните:

(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

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/my_repl.clj поместите следующий код:

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

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

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

:main my-repl

Функция -main — точка входа в будущий класс — запускает функцию 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 выйдет из цикла.

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

Сейчас, если выражение содержит ошибку, программа завершится аварийно. Исключение — не повод завершать эксперимент: просто напечатаем его и продолжим. Оберните каждый шаг цикла в 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/recur станет чище:

(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) к словарю функцией into. Без этого получим экземпляр класса UnmodifiableMap, на который красивая печать не действует.

В первой книге мы упоминали, что на 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=> ...

00:00:05=> ...
00:03:34=> ...

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

Еще одна полезная доработка — хранить последний результат в переменной, чтобы позже ссылаться на него. Назовем переменную -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 неизвестен. Эту проблему мы подробно изучим в разделе про отладку, а пока что ограничимся минимально рабочим вариантом.

Запустите 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-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. Но для разнообразия мы решили поработать с изменяемым стеком. Читатели, близко знакомые с Java, могут взять на вооружение класс java.util.Stack со схожими возможностями.

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

Напишем функцию 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]
  (repl)
  (+ a b))

;; or

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

(add 1 2)

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

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

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

На этом мы закончим работу над собственным REPL и двинемся дальше.

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

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

(use '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.

Все части