Это перевод небольшой книги о языке Janet за авторством Иана Генри (Ian Henry). В этой небольшой книге подробно раскрываются различные аспекты работы с языком, объяснение синтаксиса и некоторых приемов программирования.

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


Список глав:

  • Глава 1 - Значения и ссылки

  • Глава 2 - Компиляция и создание образов (исполняемых файлов)

  • Глава 3 - Макросы и метапрограммирование

  • Глава 4 - PEG-выражения

  • Глава 5 - Параллелизм и корутины

  • Глава 6 - Управление ходом исполнения программы

  • Глава 7 - Модули и пакеты

  • Глава 8 - Таблицы (ассоциативные массивы) и Полиморфизм

  • Глава 9 - Ксенофункции

  • Глава 10 - Встраивание Janet

  • Глава 11 - Тестирование и отладка

  • Глава 12 - Скриптинг

  • Глава 13 - Забавы с макросами

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

(def one 1)
(def two 2)
(def three (+ one two))

(print one)

(defn main [&]
  (print three))

(print two)

Конструкция [&] после имени mainозначает, что эта функция может принимать любое количество аргументов, не обрабатывая их. Когда мы запустим скрипт, Janet передаст все аргументы командной строки в эту основную функцию, и мы можем получить несоответствие принимаемым параметрам и параметрам функции, если наша основная функция не является переменной, как здесь.

Если скопировать код выше в файл и запустить в интерпретаторе Janet, то мы получим следующее:

$ janet example.janet
1
2
3

Надеюсь, что мы получили предполагаемый результат. В сущности, программа пробежалась по объявлениям верхнего уровня, затем выполнила main и вернула управление интерпретатору.

Но программы на Janet можно также и компилировать. Обычно это означает их полную компиляцию в машинный код с помощью инструмента под названием jpm, который является версией npm, или Cargo, или чего-то еще, предложенного Janet. Но чтобы создать нативный (машинный) код, jpm на самом деле:

  1. Компилирует программу на Janet в "образ".

  2. Встраивает этот образ в файл .c с которым связывает среду исполнения и интерпретатор Janet.

  3. Компилирует получившийся .c файл компилятором C.

Но пока мы не будем подробно говорить о jpm. Поэтому сосредоточимся на том, что умеет сам Janet - компилировать образы программ.

Что же такое образ программы? Давайте сделаем такой и разберемся на примере:

$ janet -c example.janet example.jimage
1
2

Итак, при создании образа Janet выполнил вычисления верхнего уровня, но не запустил main. Также был создан файл example.jimage, который мы можем передать Janet для исполнения:

$ janet -i example.jimage
3

А вот и результат исполнения main. При этом, обратите внимание, что больше объявления верхнего уровня не исполнялись (не вычислялись). Но мы все равно получили результат. Как это произошло?

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

Давайте попробуем что-то более сложное, что бы проверить это:

(def skadi @{:name "Skadi" :type "German Shepherd"})
(def odin @{:name "Odin" :type "German Shepherd"})

(def people
  [{:name "ian" :dogs [skadi odin]}
   {:name "kelsey" :dogs [skadi odin]}
   {:name "jeffrey" :dogs []}])

(pp people)

(defn main [&]
  (set (odin :type)
    "Well mostly German Shepherd but he's mixed with some collie so his ears are half-flops")
  (pp people))

Предполагается, что pp означает «выведи на печать красиво», хотя на самом деле это не так, поэтому немного переформатируем вывод вручную. Если мы скомпилируем эту программу, то увидим, как этот список выглядел во время компиляции:

$ janet -c dogs.janet dogs.jimage
({:dogs (@{:name "Skadi" :type "German Shepherd"}
         @{:name "Odin" :type "German Shepherd"})
  :name "ian"}
 {:dogs (@{:name "Skadi" :type "German Shepherd"}
         @{:name "Odin" :type "German Shepherd"})
  :name "kelsey"}
 {:dogs () :name "jeffrey"})

А теперь запустим и посмотрим на то, что происходит, когда мы меняем состояние переменной:

$ janet -i dogs.jimage
({:dogs (@{:name "Skadi" :type "German Shepherd"}
         @{:name "Odin" :type "Well mostly German Shepherd but he's mixed with some collie so his ears are half-flops"})
  :name "ian"}
 {:dogs (@{:name "Skadi" :type "German Shepherd"}
         @{:name "Odin" :type "Well mostly German Shepherd but he's mixed with some collie so his ears are half-flops"})
  :name "kelsey"}
 {:dogs () :name "jeffrey"})

Давайте подведем промежуточные итоги:

  1. Когда вы печатаете кортежи, они печатаются в круглых скобках, даже если мы объявляем их в квадратных скобках.

  2. Таблицы и структуры не сохраняют порядок вставки ключей.

  3. Ссылки существуют как во время компиляции, так и во время выполнения.

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

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

В Janet этот процесс называется "маршаллинг" (marshaling), в других языках вы можете знать это под именем "консервации" (pickling). Если честно, это не имеет никакого отношения к теме, но мне просто не нравится термин консервация.

Итак, давайте поразмышляем как работает маршаллинг.

Возможно, когда мы компилируем программу на Janet, мы на самом деле производим два процесса: проводим «обычный» этап компиляции, когда мы берем код Janet высокого уровня и превращаем его в байт-код более низкого уровня, который интерпретатор Janet знает, как выполнить, просто как обычный компилятор байт-кода. Но есть еще и второй шаг: мы берем значения, вычисленные во время компиляции и сериализуем их в байты. И тогда образ — это комбинация этих двух вещей. Похоже на правду?

И да, и нет. Потому что эти два шага на самом деле не последовательные или отдельные процессы: образ это не "данные" + "часть кода". Это просто данные. Фактически, весь образ составляет одно маршаллированное значение: окружение нашей программы.

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

Это некоторая таблица, в которой сопоставлены символы (например, skadi и main) со значениями, которые мы для них определили. И это достаточно интересно. То есть, это буквально таблица @{...}, и это «корневое» значение, которое Janet сериализует для формирования нашего образа.

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

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

И когда мы «возобновляем» или «выполняем» образ с помощью janet -i, Janet сначала десериализует эту среду, затем ищет символ с именем main, а затем выполняет эту функцию. Но давайте рассмотрим это на примере (возможно будут проблемы с форматированием кода на Хабре):

repl> (load-image (slurp "dogs.jimage"))
@{main @{:doc "(main)\n\n"
         :source-map ("dogs.janet" 11 1)
         :value <function main>}
  odin @{:source-map ("dogs.janet" 1 1)
         :value @{:name "Odin" :type "German Shepherd"}}
  people @{:source-map ("dogs.janet" 4 1)
           :value ({:dogs (@{:name "Skadi" :type "German Shepherd"} @{:name "Odin" :type "German Shepherd"}) :name "ian"}
                   {:dogs (@{:name "Skadi" :type "German Shepherd"} @{:name "Odin" :type "German Shepherd"}) :name "kelsey"}
                   {:dogs () :name "jeffrey"})}
  skadi @{:source-map ("dogs.janet" 2 1) :value @{:name "Skadi" :type "German Shepherd"}}
  :current-file "dogs.janet"
  :macro-lints @[]
  :source "dogs.janet"}

slurp - это функция, которая возвращает содержимое файла в виде строки, а spit - это функция, записывающая строку в файл. Я думаю, что эти имена взяты из Clojure, и я их ненавижу.

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

Но тем не менее вы можете видеть, что образ - это всего лишь снимок среды вашей программы, замороженный во времени. И теоретически вы можете сделать снимок среды вашей программы в любой момент времени…

repl:1> (def greeting "hello world")
"hello world"
repl:2> (defn main [&] (print greeting))
<function main>
repl:3> (def image (make-image (curenv)))
@"\xD4\x05\xD8\x08root-env\xCF\x01_\xD3\x01\xD0\x05value\xD7\0\xCD\0\x98\0\0\x02\0\0\xCD\x7F\xFF
\xFF\xFF\x02\x05\xCE\x04main\xCE\x04repl\xCE\vhello world\xD8\x05print,\0\0\0*
\x01\0\0/\x01\0\0*\x01\x01\04\x01\0\0\x02\x01\0\x10\0\x10\0\x10\0\x10\xCF\x05image
\xD3\x01\xD0\nsource-map\xD2\x03\0\xDA\x07\x03\x01\xCF\x08greeting\xD3\x02\xDA\f
\xD2\x03\0\xDA\x07\x01\x01\xDA\x04\xDA\x08\xCF\x04main\xD3\x03\xDA\f\xD2\x03\0\xDA
\x07\x02\x01\xDA\x04\xDA\x05\xD0\x03doc\xCE\n(main &)\n\n\xD8\r*macro-lints*\xD1\0"
repl:4> (spit "repl.jimage" image)
nil
$ janet -i repl.jimage
hello world

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

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

Вместо этого я буду говорить об образах так, как если бы они были не чем иным, как результатом этапа «компиляции» Janet. Потому что даже если строго разделить время компиляции и время исполнения, Janet все равно позволит вам делать много интересных вещей за счет возможностей образов.

На самом деле, я думаю, что термин «компиляция» не совсем хорошо подходит Janet. Когда я слышу слово «компиляция», я думаю о преобразовании кода высокого уровня в код нижнего уровня, возможно, с некоторой оптимизацией по пути.

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

До сих пор мы рассматривали только действительно надуманные, искусственные примеры. Думаю, пришло время поговорить о чем-то реальном.

В OpenGL есть концепция под названием «шейдеры» - небольшие мини-программы, которые работают на графическом процессоре и выполняют такие действия, как вычисление цвета для каждого пикселя вашего чайника или что-то подобное.

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

Итак, есть несколько способов сделать это: мы могли бы просто распространять шейдеры как отдельные файлы вместе с игрой и загружать их во время выполнения относительно пути к нашему исполняемому файлу. И это будет отлично работать!

Но предположим, что мы не хотим этого делать. Допустим, мы хотим распространять игру в виде одного двоичного файла.

Мы могли бы встроить в исходный код нашего шейдера такой код:

(def gamma-shader `
  #version 330

  in vec3 fragColor;
  out vec4 outColor;

  void main() {
    outColor = vec4(pow(fragColor, vec3(1.0 / 2.2)), 1.0);
  }`)

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

Вместо этого, что, если мы сохраним шейдеры в отдельных файлах, но загрузим их в программу во время компиляции?

(def gamma-shader (slurp "gamma.fs"))

(defn main [&]
  (print gamma-shader))

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

$ janet -c shader-example.janet shader-example.jimage
$ rm gamma.fs # теперь оригинальный файл не требуется
$ janet -i shader-example.jimage
#version 330

in vec3 fragColor;
out vec4 outColor;

void main() {
  outColor = vec4(pow(fragColor, vec3(1.0 / 2.2)), 1.0);
}

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

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

(def f (file/open "gamma.fs"))
(def gamma-shader (file/read f :all))
(file/close f)

(defn main [&]
  (print gamma-shader))

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

$ janet shader-example2.janet
#version 330

in vec3 fragColor;
out vec4 outColor;

void main() {
  outColor = vec4(pow(fragColor, vec3(1.0 / 2.2)), 1.0);
}

Но если попытаться собрать образ:

$ janet -c shader-example2.janet shader-example2.jimage
error: cannot marshal file in safe mode
  in marshal [src/core/marsh.c] on line 1480
  in make-image [boot.janet] on line 2637, column 3
  in c-switch [boot.janet] (tailcall) on line 3873, column 36
  in cli-main [boot.janet] on line 3909, column 13

Мы не можем. В скрипте у нас есть ссылка на абстрактный тип core/file на верхнем уровне и когда Janet пытается маршаллировать среду, она поднимает панику на это значение. Потому что, это на самом деле странно так и есть: нельзя сериализовать на диск дескриптор файла, сетевое соединение или что-то в этом роде.

Из этого мы можем вывести несколько вещей:

  1. Все окружение маршаллированно в нашем образе
    Мы не ссылаемся на f напрямую в main , поэтому вы можете представить, как Janet выполняет какое-то сложное встряхивание дерева, чтобы определить, что это значение недостижимо и не требуется в конечном исполняемом файле (хотя в таком динамичном языке, как Janet, такая оптимизация в принципе невозможна).

    Но это противоречило бы духу того, что такое образ. Изображение - это среда, вся среда, и вы можете загружать его и взаимодействовать с ним разными способами, помимо простого выполнения его основной функции. Хотя, возможно, и не будете.

    Обратите внимание, что большинство областей видимости в Janet не являются объектами первого класса, поэтому Janet может делать что-то для оптимизации их представления, о чем вы даже не сможете сказать. Но окружающая среда верхнего уровня особенная.

  2. Janet не выделяет дескрипторы закрытых файлов в особый случай.
    Вы можете представить себе мир, в котором Janet позволяет нам избежать наказания за это (чтение закрытого файла) только один раз, поскольку демаршаллинг закрытого дескриптора файла может быть четко определен. Но это также и полностью бесполезно.

  3. Существуют программы, которые Janet умеет выполнять, но которые невозможно скомпилировать в изображения.

На практике вам вообще не придется об этом думать.

Мне пришлось немного повозиться, чтобы написать эту «сломанную» программу. Правильный способ чтения из файла, если у вас аллергия на ввод слова slurp, будет следующим:

(def gamma-shader
  (with [f (file/open "gamma.fs")]
    (file/read f :all)))

(defn main [&]
  (print gamma-shader))

Что, конечно, компилируется нормально - f не является переменной верхнего уровня, поэтому не является частью среды.

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

Вся ваша работа будет происходить на этапе «компиляции», и Janet вообще никогда не будет пытаться сконструировать образ, и вам действительно не придется об этом думать.

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

Наконец, я думаю, стоит прямо указать: то, что мы не можем маршаллировать core/file, не означает, что мы не можем маршаллировать другие абстрактные типы. Многие абстрактные типы в стандартной библиотеке (например, core/peg) прекрасно маршаллируются, и когда мы определяем наши собственные абстрактные типы, мы можем при желании предоставить собственные процедуры маршаллинга. Мы поговорим об этом подробнее в девятой главе.

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

Комментарии (7)



  1. Demon416
    02.01.2024 14:14

    Что за дискриминация? Где язык для бессмертных?


  1. dyadyaSerezha
    02.01.2024 14:14
    +7

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

    Кстати, весь язык в 1М? Forth помещается в 5К, ну и что? Без ясных целей непонятно, как относиться с этому 1М.


    1. Ales_Nim Автор
      02.01.2024 14:14

      Так вы можете просто не читать такие статьи.

      Более того, в предыдущей статье, вроде бы, в комментариях был ответ на ваш вопрос.

      Если очень кратко: clojure-подобный язык с компилляцией и/или возможностями для встраивания в C-программы.


      1. dyadyaSerezha
        02.01.2024 14:14

        Я смотрел начало первой статьи, но ничего про цели языка не нашёл. Но даже и сейчас непонятно, может ли язык взаимодействовать с материнской С-программой, может ли взаимодействовать с внешним миром кроме вывода на консоль? В общем, опять не понятны цели.


        1. Ales_Nim Автор
          02.01.2024 14:14

          Про встраивание Janet в C будет в 10 главе (статье), это же перевод, иду по порядку.

          Если все еще интересно, то отсылаю вас к документации: https://janet-lang.org/capi/index.html


          1. dyadyaSerezha
            02.01.2024 14:14

            То есть, только в 10 главе будет объявлена цель языка. Неа, не буду смотреть документацию)