Более подробная техническая статья о LQN здесь (PDF).
Какое-то время я хотел создать собственную утилиту для работы с текстовыми файлами. Что-то вроде Sed или AWK, или, может быть, даже .jq. И в конце концов я это сделал. Итак, вот первые 25 чисел Фибоначчи, вычисленные и напечатанные излишне сложным способом при помощи моего нового языка запросов — Lisp Query Notation (LQN)1:
❭ echo '(0 1)' | lqn -t "
(?rec (< (cnt) (1- 25))
(cat* _ (apply* + (tail* _ 2))))
#( (strcat #1=(fmt \"~2,'0d ~30,'0d.\" (cnt) _)
(seq* (reverse #1#) 1)) )"
⇒ 00 000000000000000000000000000000.000000000000000000000000000000 00
01 000000000000000000000000000001.100000000000000000000000000000 10
02 000000000000000000000000000001.100000000000000000000000000000 20
03 000000000000000000000000000002.200000000000000000000000000000 30
04 000000000000000000000000000003.300000000000000000000000000000 40
05 000000000000000000000000000005.500000000000000000000000000000 50
06 000000000000000000000000000008.800000000000000000000000000000 60
07 000000000000000000000000000013.310000000000000000000000000000 70
08 000000000000000000000000000021.120000000000000000000000000000 80
09 000000000000000000000000000034.430000000000000000000000000000 90
10 000000000000000000000000000055.550000000000000000000000000000 01
11 000000000000000000000000000089.980000000000000000000000000000 11
12 000000000000000000000000000144.441000000000000000000000000000 21
13 000000000000000000000000000233.332000000000000000000000000000 31
14 000000000000000000000000000377.773000000000000000000000000000 41
15 000000000000000000000000000610.016000000000000000000000000000 51
16 000000000000000000000000000987.789000000000000000000000000000 61
17 000000000000000000000000001597.795100000000000000000000000000 71
18 000000000000000000000000002584.485200000000000000000000000000 81
19 000000000000000000000000004181.181400000000000000000000000000 91
20 000000000000000000000000006765.567600000000000000000000000000 02
21 000000000000000000000000010946.649010000000000000000000000000 12
22 000000000000000000000000017711.117710000000000000000000000000 22
23 000000000000000000000000028657.756820000000000000000000000000 32
24 000000000000000000000000046368.863640000000000000000000000000 42
25 000000000000000000000000075025.520570000000000000000000000000 52
Что это такое?
LQN — это язык запросов, библиотека CL (Common Lisp) и утилита для терминала. Чтобы использовать язык запросов в терминале, есть три разные команды: tqn, lqn и jqn. Для ввода текста (например, CSV), данных Lisp (например, исходного кода) и JSON соответственно. Подробнее об этом позже.
LQN имеет много общего с .jq. Самое заметное — функциональный стиль и объединение команд в цепочки. Но я недостаточно хорошо знаю .jq, чтобы сказать, насколько они похожи2. Тем не менее я заметил, что в итоге создал несколько похожих функций. Это имеет смысл, учитывая область применения и стиль программирования, общие для инструментов.
Кроме того, я хотел, чтобы язык был кратким, но достаточно гибким, чтобы при необходимости я мог писать произвольный код на CL. Наконец, я хотел, чтобы компилятор был относительно простым.
Символы, строки и ключевые слова
Символ в CL (помимо прочего) используется для обозначения функций и переменных. В данном контексте нужно знать только то, что символы используются для обозначения имён функций и операторов LQN; reverse и ?rec в приведённом выше коде являются символами; reverse — встроенная функция CL, которая разворачивает последовательность в противоположном направлении, а ?rec — это оператор LQN для рекурсии.
Неудивительно, что строки в CL записываются так: "like this". Запись строк в командах терминала может быть непрактичной, поэтому LQN использует :keywords (ключевые слова), чтобы обозначать строки в нижнем регистре, где возможно. В ключевом слове может быть практически любой символ, вплоть до :this/is-@-valid-keyword!
Представление данных
Чтобы LQN работал с несколькими форматами данных, все входящие данные загружаются в нативные объекты CL. Чаще всего это векторы и хеш-табицы (далее сокращённо — kvs). Текстовые файлы считываются в вееторы строк, а JSON — в векторы и kvs в зависимости от структуры.
Вот пример структуры JSON, которая, я полагаю, вам знакома.
[{ "_id": "65679d23", "index": 0,
"things": [{ "id": 0, "name": "Chris" }],
"msg": "this is a message",
"fave": "strawberry" }]
А вот те же данные, записанные в том, что я здесь назвал нотацией данных Lisp (LDN), просто чтобы у меня была ещё одна аббревиатура, которой я могу пользоваться:
#(( (:_ID . "65679d23") (:INDEX . 0)
(:THINGS . #(( (:ID . 0) (:NAME . "Chris") )))
(:MSG . "this is a message")
(:FAVE . "strawberry") ))
Как видите, векторы CL записывается в виде #(..). Так CL выводит векторы в REPL. А ещё так вы можете записывать векторы в исходном коде CL. kvs — это списки ((:OF . "tuples") (:LIKE . "this")). Также они известны как алисты.
Со временем я могу добавить поддержку расширяемой нотации данных (edn), которая, на мой взгляд, выглядит приятнее. А пока подойдёт LDN.
Скорость терминала
Поскольку язык запросов одинаков для всех типов входных данных, начнём с tqn: его проще всего использовать в качестве примера. tqn может считывать файлы или данные со стандартного ввода:
tqn [options] <qry> [files ...]
echo 'some string' | tqn [options] <qry>
Все команды могут выводить данные в любом поддерживаемом формате, используя опции -t, -l либо -j. Чтобы вывести внутренние объекты CL, такие как kvs и векторы на терминал, они должны быть сериализованы тем или иным образом. Вы можете использовать -tj или -tl, чтобы сериализовать в текст, где внутренние объекты выводятся как JSON или LDN соответственно. Вот небольшой пример, где мы передаем две строки в lqn и выбираем все входные данные при помощи символа «текущее значение» _:
❭ echo 'a b c\ndef' | tqn _
⇒ a b c
def
Все строки считываются в вектор, затем каждый элемент выводится в отдельной строке. Если использовать опцию -l, то вместо этого получим ввод, сериализованный в виде LDN:
❭ echo 'a b c\ndef' | tqn _
⇒ #("a b c" "def")
Объединение операций в цепочку
Запрос может состоять из нескольких операций или «предложений». Чтобы явно объединить два предложения в цепочку, можно использовать оператор | (|| expr-1 .. expr-n) и передать результат каждого выражения в следующее выражение. Естественно, _ можно использовать, чтобы сослаться на входное значение. Ниже приведён запрос, который разбивает входящую строку на "x" и преобразует каждую новую строку в верхний регистр. Оператор splt по умолчанию удаляет все пробелы.
❭ echo 'a b c x def x 27'\
| tqn '(|| (splt _ :x) (sup _))'
⇒ A B C
DEF
27
Оператор || — это умолчание для любого запроса, так что вместо него мы можем написать:
❭ echo 'a b c x def x 27'\
| tqn '(splt _ :x) sup'
⇒ A B C
DEF
27
Также обратите внимание, что любое «голое» имя функции (sup) внутри оператора | вызывается для каждого отдельного элемента во входящем векторе. Это сокращение оператора map #(..). Вот то же выражение, записанное с явным map:
❭ echo 'a b c x def x 27'\
| tqn '(splt _ :x) #(sup)'
Как и следовало ожидать, #(..) также объединяет предложения в цепочку. Ниже приведён запрос, разбивающий подстроки по "B" и снова объединяющий их при помощи "-":
❭ echo 'abc x def x abcdef'\
| tqn '(splt _ :x) #(sup (splt _ "B") (join _ :-))'
⇒ A-C
DEF
A-CDEF
Если вы хотите отфильтровать вводные данные, то можете использовать строки или простые ключевые слова напрямую или применить оператор фильтрации для фильтрации сложнее. Например, можно найти все строки, содержащие подстроку "e!":
❭ echo 'one! x two! x three!'\
| tqn '(splt _ :x) :e!'
⇒ one!
three!
Или элементы, содержащие "ef" или являющиеся (разбирающимися как) целые числа:
❭ echo 'a b c x def x 27'\
| tqn '(splt _ :x) [:ef int!?]'
⇒ def
27
Фильтры также поддерживают инверсию. При инверсии все целые числа будут удалены:
❭ echo 'abc x def x 27'\
| tqn '(splt _ :x) [-@int!?]'
⇒ abc
def
Вот фильтр немного сложнее:
❭ echo 'abc x abcdef x abcdefghi'\
| tqn '(splt _ :x) [:+@ab :+@bc :-@gh]'
⇒ abc
abcdef
Поведение фильтров более подробно описывается в документации, но вот запрос, который вернёт тот же результат, написанный при помощи обычных логических операторов CL (and, not), а также функции поиска подстроки sub?. Чтобы продемонстрировать, что можно использовать обычный код CL, если вы хотите:
❭ echo 'abc x abcdef x abcdefghi'\
| tqn '(splt _ :x) [(and (sub? _ "ab")
(sub? _ "bc")
(not (sub? _ "gh")))]'
Теперь, когда мы имеем представление об основной функциональности, попробуем преобразовать какие-нибудь JSON.
Выборка из JSON
До сих пор мы рассматривали чтение текста из терминала. Работа с JSON отличается не сильно. Но в LQN есть несколько операторов, очень удобных при работе со структурированными данными, такими как JSON. Вы также можете передавать JSON в jqn по конвейеру, но в качестве примера мы воспользуемся следующим файлом JSON:
❭ cat data.json
⇒ [ { "id": "1",
"things": [ { "id": 4, "name": "Ball", "info": "round" } ],
"fave": "strawberry" },
{ "id": "2",
"things": [ { "id": 9, "name": "Scissor", "info": "sharp" },
{ "id": 7, "name": "Herring", "info": "frozen" },
{ "id": 3, "name": "Computer" } ],
"msg": "Nih!",
"fave": "strawberry" },
{ "id": "3",
"things": [ { "id": 2, "name": "Paper" },
{ "id": 8, "name": "Bottle", "info": empty } ],
"msg": "+++Banana, banana, banana!+++" } ]
Иногда из списка объектов JSON нужны только некоторые ключи. Выберем поля id и msg при помощи оператора #{..} 3:
❭ jqn '#{:id :msg}' data.json
⇒ [ { "id": "1", "msg": null },
{ "id": "2", "msg": "Nih!" },
{ "id": "3", "msg": "+++Banana, banana, banana!+++" } ]
Видно, что #{..} выбирает ключи из kvs в вектора в новый kvs в векторе. По умолчанию ключи включаются, даже если у них нет значения. Чтобы включить только те ключи, у которых есть значение, можно применить модификатор ?@. Он выбирает ключи только в том случае, если они существуют, а не равны nil:
❭ jqn '#{:id :?@msg}' data.json
⇒ [ { "id": "1" },
{ "id": "2", "msg": "Nih!" },
{ "id": "3", "msg": "+++Banana, banana, banana!+++" } ]
И если вы хотите преобразовать значения, то можете написать выражения наподобие этого:
❭ jqn '#{(:id (+ 10 (int!? _)))
(:?@msg sup)}' data.json
⇒ [ { "id": 11 },
{ "id": 12, "msg": "NIH!" },
{ "id": 13, "msg": "+++BANANA, BANANA, BANANA!+++" } ]
Мы снова видим, что голые символы интерпретируются как функция с одним аргументом — текущим значением, тогда как выражения вычисляются напрямую.
Прежде чем мы продолжим, вот пример немного сложнее. Код выбирает поле msg только в том случае, если у сообщения есть значение и оно длиннее 10 символов:
❭ jqn '#{ (:%@msg (?? _ (> (size? _) 10)
(sup _))) }
[is?]' data.json
⇒ [ { "msg": "+++BANANA, BANANA, BANANA!+++" } ]
Если бы у нас не было фильтра в конце, мы бы также увидели два null.
Вот ещё один пример, где используется несколько селекторов одновременно и результат выводится с разделителями строк JSON.
❭ jqn -tjm '#[( :things #[ (:name sdwn)
(:?@info sup) ] )]' code/sample.json
⇒ ["ball","ROUND"]
["scissor","SHARP","herring","FROZEN","computer"]
["paper","bottle","EMPTY"]
Хотя конкретно эти данные — просто бессмыслица, мне нравится, как мало кода нужно для преобразования.
Заключение
В LQN есть немало других полезных операторов и функций. (?rec ..), как вы могли заметить при первоначальном вычислении Фибоначчи, выполняет рекурсию. (?srch ..) может искать объекты внутри вложенной структуры. Аналогично (?txpr ..) может выполнять поиск и замену во вложенных данных. И, очевидно, есть несколько операторов для создания новых объектов, поиска отдельных путей и так далее. Если что-то из этого вас заинтересует, прочитать больше вы можете в readme.
В начале я сказал, что мне нужен относительно простой компилятор. Ядро компилятора LQN состоит примерно из 300 строк. Очевидно, что кода больше, но он не кажется непосильным.
LQN — это во многом эксперимент. Поскольку у меня не было возможности часто использовать его на практике, я не знаю, удобно ли поведение операторов и встроенных функций. В любом случае это, вероятно, будет зависеть от типичных сценариев использования. Тем не менее это интересный и, возможно, даже полезный небольшой язык для выполнения некоторых задач, с которыми можно столкнуться в терминале4.
Пожалуй, еще интереснее, я думаю, он может быть полезен при написании других DSL в будущем. В качестве инструмента выполнения кое-каких преобразований, которые часто нужны при реализации макросов CL.
- Написать компилятор, чтобы вычислять Фибоначчи на собственном языке — самый сложный способ, которым я когда-либо вычислял числа Фибоначчи.
- На практике я почти не использовал его. Так что в основном я знаю о нём из документации и распространённых примеров.
- Вывод был отформатирован, чтобы быть немного компактнее реального вывода на терминал.
- В CL можно писать очень эффективный код. Однако
LQNникогда не будет таким же быстрым, как, например,grep,SedиAWK. Помимо прочего, CL из терминала запускается дольше, чем эти небольшие утилиты.
Читайте также:
- Как освоить новый язык программирования или фреймворк
- Какой язык программирования используют самые счастливые разработчики?
- Стоит ли заменить Python на Julia?
Читайте нас в Telegram, VK и Дзен
Перевод статьи Anders Hoff: Lisp Query Notation (LQN)





