Перейти к основному содержимому

Введение в Clojure

· 19 мин. чтения

1. Введение

Clojure — это функциональный язык программирования, полностью работающий на виртуальной машине Java, аналогично Scala и Kotlin. Clojure считается производным от Lisp и будет знаком всем, кто имеет опыт работы с другими языками Lisp.

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

2. Установка Clojure

Clojure доступен в виде установщиков и удобных сценариев для использования в Linux и macOS . К сожалению, на данном этапе в Windows такого установщика нет.

Однако сценарии Linux могут работать в чем-то вроде Cygwin или Windows Bash. Существует также онлайн-сервис, который можно использовать для тестирования языка , а в более старых версиях есть автономная версия, которую можно использовать.

2.1. Автономная загрузка

Отдельный файл JAR можно загрузить с Maven Central . К сожалению, версии новее 1.8.0 больше не работают таким образом, поскольку файл JAR разбит на более мелкие модули.

Как только этот JAR-файл загружен, мы можем использовать его как интерактивный REPL, просто рассматривая его как исполняемый JAR:

$ java -jar clojure-1.8.0.jar
Clojure 1.8.0
user=>

2.2. Веб-интерфейс к REPL

Веб-интерфейс к Clojure REPL доступен по адресу https://repl.it/languages/clojure , чтобы мы могли попробовать, не загружая ничего. В настоящее время это поддерживает только Clojure 1.8.0, а не более новые версии.

2.3. Установщик на MacOS

Если вы используете macOS и у вас установлен Homebrew, то последнюю версию Clojure можно легко установить:

$ brew install clojure

Это будет поддерживать последнюю версию Clojure — 1.10.0 на момент написания. После установки мы можем загрузить REPL, просто используя команды clojure или clj :

$ clj
Clojure 1.10.0
user=>

2.4. Установщик на линукс

Для нас доступен самоустанавливающийся сценарий оболочки для установки инструментов в Linux:

$ curl -O https://download.clojure.org/install/linux-install-1.10.0.411.sh
$ chmod +x linux-install-1.10.0.411.sh
$ sudo ./linux-install-1.10.0.411.sh

Как и в случае с установщиком macOS, они будут доступны для самых последних выпусков Clojure и могут быть выполнены с помощью команд clojure или clj .

3. Введение в Clojure REPL

Все вышеперечисленные варианты дают нам доступ к Clojure REPL. Это прямой эквивалент Clojure инструмента JShell для Java 9 и выше, который позволяет нам вводить код Clojure и сразу же видеть результат. Это отличный способ поэкспериментировать и узнать, как работают определенные функции языка.

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

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

user=>

В остальной части этой статьи предполагается, что у нас есть доступ к Clojure REPL, и все будет работать непосредственно в любом таком инструменте.

4. Основы языка

Язык Clojure сильно отличается от многих других языков, основанных на JVM, и, возможно, поначалу покажется очень необычным. Он считается диалектом Лиспа и имеет очень похожий синтаксис и функциональность на другие языки Лиспа.

Большая часть кода, который мы пишем на Clojure — как и на других диалектах Лиспа — выражается в форме списков . Затем списки можно оценивать для получения результатов — либо в виде большего количества списков, либо в виде простых значений.

Например:

(+ 1 2) ; = 3

Это список, состоящий из трех элементов. Символ «+» указывает на то, что мы выполняем этот вызов — добавление. Остальные элементы затем используются с этим вызовом. Таким образом, это оценивается как «1 + 2».

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

(+ 1 2 3 4 5) ; = 15

И это оценивается как «1 + 2 + 3 + 4 + 5».

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

4.1. Простые типы

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

Например:

123 ; Long
1.23 ; Double
"Hello" ; String
true ; Boolean

Мы можем указать и более сложные типы, используя специальные префиксы или суффиксы:

42N ; clojure.lang.BigInt
3.14159M ; java.math.BigDecimal
1/3 ; clojure.lang.Ratio
#"[A-Za-z]+" ; java.util.regex.Pattern

Обратите внимание, что вместо java.math.BigInteger используется тип clojure.lang.BigInt . Это связано с тем, что тип Clojure имеет некоторые незначительные оптимизации и исправления.

4.2. Ключевые слова и символы

Clojure дает нам концепцию как ключевых слов, так и символов . Ключевые слова относятся только к самим себе и часто используются для таких вещей, как ключи карты. С другой стороны, символы — это имена, используемые для обозначения других вещей. Например, определения переменных и имена функций являются символами.

Мы можем создать ключевые слова, используя имя с префиксом двоеточия:

user=> :kw
:kw
user=> :a
:a

Ключевые слова имеют прямое равенство сами с собой, а не с чем-либо еще:

user=> (= :a :a)
true
user=> (= :a :b)
false
user=> (= :a "a")
false

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

user=> (def a 1)
#'user/a
user=> :a
:a
user=> a
1

4.3. Пространства имен

Язык Clojure имеет концепцию пространств имен для организации нашего кода. Каждый фрагмент кода, который мы пишем, живет в пространстве имен.

По умолчанию REPL запускается в пространстве имен пользователя , как видно из подсказки «user=>».

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

user=> (ns new.ns)
nil
new.ns=>

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

Мы можем получить доступ к определениям в пространствах имен, полностью уточнив их . Например, пространство имен clojure.string определяет функцию в верхнем регистре .

Если мы находимся в пространстве имен clojure.string , мы можем получить к нему прямой доступ. Если это не так, нам нужно квалифицировать его как clojure.string/upper-case :

user=> (clojure.string/upper-case "hello")
"HELLO"
user=> (upper-case "hello") ; This is not visible in the "user" namespace
Syntax error compiling at (REPL:1:1).
Unable to resolve symbol: upper-case in this context
user=> (ns clojure.string)
nil
clojure.string=> (upper-case "hello") ; This is visible because we're now in the "clojure.string" namespace
"HELLO"

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

clojure.string=> (require '[clojure.string :as str])
nil
clojure.string=> (str/upper-case "Hello")
"HELLO"

user=> (require '[clojure.string :as str :refer [upper-case]])
nil
user=> (upper-case "Hello")
"HELLO"

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

4.4. Переменные

Как только мы узнаем, как определять простые значения, мы можем присваивать их переменным. Мы можем сделать это, используя ключевое слово def :

user=> (def a 123)
#'user/a

Как только мы это сделали, мы можем использовать символ a везде, где мы хотим представить это значение:

user=> a
123

Определения переменных могут быть как простыми, так и сложными, как мы хотим.

Например, чтобы определить переменную как сумму чисел, мы можем сделать:

user=> (def b (+ 1 2 3 4 5))
#'user/b
user=> b
15

Обратите внимание, что нам никогда не нужно объявлять переменную или указывать ее тип. Clojure автоматически определяет все это для нас.

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

user=> unknown
Syntax error compiling at (REPL:0:0).
Unable to resolve symbol: unknown in this context
user=> (def c (+ 1 unknown))
Syntax error compiling at (REPL:1:8).
Unable to resolve symbol: unknown in this context

Обратите внимание, что вывод функции def немного отличается от ввода. Определение переменной a возвращает строку 'user/a . Это связано с тем, что результатом является символ, и этот символ определен в текущем пространстве имен.

4.5. Функции

Мы уже видели пару примеров того, как вызывать функции в Clojure . Мы создаем список, который начинается с вызываемой функции, а затем со всеми параметрами.

Когда этот список оценивается, мы получаем возвращаемое значение из функции. Например:

user=> (java.time.Instant/now)
#object[java.time.Instant 0x4b6690c0 "2019-01-15T07:54:01.516Z"]
user=> (java.time.Instant/parse "2019-01-15T07:55:00Z")
#object[java.time.Instant 0x6b8d96d9 "2019-01-15T07:55:00Z"]
user=> (java.time.OffsetDateTime/of 2019 01 15 7 56 0 0 java.time.ZoneOffset/UTC)
#object[java.time.OffsetDateTime 0xf80945f "2019-01-15T07:56Z"]

Мы также можем вкладывать вызовы функций, когда мы хотим передать результат вызова одной функции в качестве параметра другой:

user=> (java.time.OffsetDateTime/of 2018 01 15 7 57 0 0 (java.time.ZoneOffset/ofHours -5))
#object[java.time.OffsetDateTime 0x1cdc4c27 "2018-01-15T07:57-05:00"]

Кроме того, мы также можем определить наши функции , если захотим. Функции создаются с помощью команды fn :

user=> (fn [a b]
(println "Adding numbers" a "and" b)
(+ a b)
)
#object[user$eval165$fn__166 0x5644dc81 "user$eval165$fn__166@5644dc81"]

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

user=> (def add
(fn [a b]
(println "Adding numbers" a "and" b)
(+ a b)
)
)
#'user/add

Теперь, когда мы определили эту функцию, мы можем назвать ее так же, как и любую другую функцию:

user=> (add 1 2)
Adding numbers 1 and 2
3

Для удобства Clojure также позволяет нам использовать defn для определения функции с именем за один раз .

Например:

user=> (defn sub [a b]
(println "Subtracting" b "from" a)
(- a b)
)
#'user/sub
user=> (sub 5 2)
Subtracting 2 from 5
3

4.6. Пусть и локальные переменные

Вызов def определяет символ, который является глобальным для текущего пространства имен . Обычно это не то, что требуется при выполнении кода. Вместо этого Clojure предлагает вызов let для определения переменных, локальных для блока . Это особенно полезно при использовании их внутри функций, когда вы не хотите, чтобы переменные просачивались за пределы функции.

Например, мы могли бы определить нашу подфункцию:

user=> (defn sub [a b]
(def result (- a b))
(println "Result: " result)
result
)
#'user/sub

Однако использование этого имеет следующий неожиданный побочный эффект:

user=> (sub 1 2)
Result: -1
-1
user=> result ; Still visible outside of the function
-1

Вместо этого давайте перепишем его, используя let :

user=> (defn sub [a b]
(let [result (- a b)]
(println "Result: " result)
result
)
)
#'user/sub
user=> (sub 1 2)
Result: -1
-1
user=> result
Syntax error compiling at (REPL:0:0).
Unable to resolve symbol: result in this context

На этот раз символ результата не виден за пределами функции. Или, действительно, за пределами блока let , в котором он использовался.

5. Коллекции

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

  • Вектор — это упорядоченный список значений — в вектор можно поместить любое произвольное значение, включая другие наборы.
  • Набор — это неупорядоченный набор значений, который никогда не может содержать одно и то же значение более одного раза.
  • Карта — это простой набор пар ключ/значение. Очень часто в качестве ключей на карте используются ключевые слова, но мы можем использовать любое значение, которое нам нравится, включая другие коллекции.
  • Список очень похож на вектор. Разница такая же, как между ArrayList и LinkedList в Java. Как правило, вектор предпочтительнее, но список лучше, если мы хотим добавлять элементы в начало или если мы хотим получить доступ к элементам только в последовательном порядке.

5.1. Создание коллекций

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

; Vector
user=> [1 2 3]
[1 2 3]
user=> (vector 1 2 3)
[1 2 3]

; List
user=> '(1 2 3)
(1 2 3)
user=> (list 1 2 3)
(1 2 3)

; Set
user=> #{1 2 3}
#{1 3 2}
user=> (hash-set 1 2 3)
#{1 3 2}

; Map
user=> {:a 1 :b 2}
{:a 1, :b 2}
user=> (hash-map :a 1 :b 2)
{:b 2, :a 1}

Обратите внимание, что примеры Set и Map не возвращают значения в том же порядке. Это связано с тем, что эти коллекции по своей природе неупорядочены, и то, что мы видим, зависит от того, как они представлены в памяти.

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

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

Списки считаются последовательностями . Это означает, что класс реализует интерфейс ISeq . Все остальные коллекции можно преобразовать в последовательность с помощью функции seq : ``

user=> (seq [1 2 3])
(1 2 3)
user=> (seq #{1 2 3})
(1 3 2)
user=> (seq {:a 1 2 3})
([:a 1] [2 3])

5.2. Доступ к коллекциям

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

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

user=> (my-vector 2) ; [1 2 3]
3

Мы можем сделать то же самое, используя тот же синтаксис, и для карт :

user=> (my-map :b)
2

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

user=> (first my-vector)
1
user=> (last my-list)
3
user=> (next my-vector)
(2 3)

Карты имеют дополнительные функции для получения всего списка ключей и значений:

user=> (keys my-map)
(:a :b)
user=> (vals my-map)
(1 2)

Единственный реальный доступ, который у нас есть к наборам, — это посмотреть, является ли конкретный элемент членом.

Это очень похоже на доступ к любой другой коллекции:

user=> (my-set 1)
1
user=> (my-set 5)
nil

5.3. Идентификация коллекций

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

Каждая из наших коллекций имеет определенную функцию для определения того, относится ли заданное значение к этому типу — list? для списков, установить? для наборов и так далее. Кроме того, есть seq? для определения того, является ли данное значение последовательностью любого вида и ассоциативно? чтобы определить, допускает ли заданное значение ассоциативный доступ любого рода, что означает векторы и карты:

user=> (vector? [1 2 3]) ; A vector is a vector
true
user=> (vector? #{1 2 3}) ; A set is not a vector
false
user=> (list? '(1 2 3)) ; A list is a list
true
user=> (list? [1 2 3]) ; A vector is not a list
false
user=> (map? {:a 1 :b 2}) ; A map is a map
true
user=> (map? #{1 2 3}) ; A set is not a map
false
user=> (seq? '(1 2 3)) ; A list is a seq
true
user=> (seq? [1 2 3]) ; A vector is not a seq
false
user=> (seq? (seq [1 2 3])) ; A vector can be converted into a seq
true
user=> (associative? {:a 1 :b 2}) ; A map is associative
true
user=> (associative? [1 2 3]) ; A vector is associative
true
user=> (associative? '(1 2 3)) ; A list is not associative
false

5.4. Мутирующие коллекции

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

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

Добавление новых элементов в вектор, список или набор выполняется с помощью conj . Это работает по-разному в каждом из этих случаев, но с одним и тем же основным намерением:

user=> (conj [1 2 3] 4) ; Adds to the end
[1 2 3 4]
user=> (conj '(1 2 3) 4) ; Adds to the beginning
(4 1 2 3)
user=> (conj #{1 2 3} 4) ; Unordered
#{1 4 3 2}
user=> (conj #{1 2 3} 3) ; Adding an already present entry does nothing
#{1 3 2}

Мы также можем удалить записи из набора, используя disj . Обратите внимание, что это не работает со списком или вектором, потому что они строго упорядочены:

user=> (disj #{1 2 3} 2) ; Removes the entry
#{1 3}
user=> (disj #{1 2 3} 4) ; Does nothing because the entry wasn't present
#{1 3 2}

Добавление новых элементов на карту осуществляется с помощью assoc . Мы также можем удалить записи с карты, используя dissoc:

user=> (assoc {:a 1 :b 2} :c 3) ; Adds a new key
{:a 1, :b 2, :c 3}
user=> (assoc {:a 1 :b 2} :b 3) ; Updates an existing key
{:a 1, :b 3}
user=> (dissoc {:a 1 :b 2} :b) ; Removes an existing key
{:a 1}
user=> (dissoc {:a 1 :b 2} :c) ; Does nothing because the key wasn't present
{:a 1, :b 2}

5.5. Конструкции функционального программирования

Clojure по своей сути является функциональным языком программирования. Это означает, что у нас есть доступ ко многим традиционным концепциям функционального программирования, таким как map, filter и reduce . Обычно они работают так же, как и в других языках . Однако точный синтаксис может немного отличаться.

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

user=> (map inc [1 2 3]) ; Increment every value in the vector
(2 3 4)
user=> (map inc #{1 2 3}) ; Increment every value in the set
(2 4 3)

user=> (filter odd? [1 2 3 4 5]) ; Only return odd values
(1 3 5)
user=> (remove odd? [1 2 3 4 5]) ; Only return non-odd values
(2 4)

user=> (reduce + [1 2 3 4 5]) ; Add all of the values together, returning the sum
15

6. Структуры управления

Как и во всех языках общего назначения, в Clojure используются стандартные управляющие структуры, такие как условные операторы и циклы.

6.1. Условные

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

user=> (if true 1 2)
1
user=> (if false 1 2)
2

Наш тест может быть любым, что нам нужно — это не обязательно должно быть значение true/false . Это также может быть блок, который оценивается, чтобы дать нам нужное значение:

user=> (if (> 1 2) "True" "False")
"False"

Здесь можно использовать все стандартные проверки, включая =, > и < . Также есть набор предикатов, которые можно использовать по разным причинам — некоторые из них мы уже видели при просмотре коллекций, например:

user=> (if (odd? 1) "1 is odd" "1 is even")
"1 is odd"

Тест вообще может возвращать любое значение — ему не обязательно быть только истинным или ложным . Однако считается истинным , если значение не равно false или nil . Это отличается от того, как работает JavaScript, где есть большой набор значений, которые считаются «истинными», но не истинными :

user=> (if 0 "True" "False")
"True"
user=> (if [] "True" "False")
"True"
user=> (if nil "True" "False")
"False"

6.2. Зацикливание

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

Помимо этого, зацикливание выполняется полностью с использованием рекурсии . Мы можем писать рекурсивные функции или использовать ключевые слова loop и recur для написания рекурсивного цикла:

user=> (loop [accum [] i 0]
(if (= i 10)
accum
(recur (conj accum i) (inc i))
))
[0 1 2 3 4 5 6 7 8 9]

Вызов цикла запускает внутренний блок, который выполняется на каждой итерации и начинается с установки некоторых начальных параметров. Повторяющийся вызов затем возвращается в цикл, предоставляя следующие параметры для использования в итерации . Если recur не вызывается, цикл завершается.

В этом случае мы зацикливаемся каждый раз, когда значение i не равно 10, а затем, как только оно становится равным 10, мы вместо этого возвращаем накопленный вектор чисел.

7. Резюме

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

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