Можно ли создать язык программирования, в котором нет синтаксиса? Кажется, что это чистое противоречие. Вся суть языков программирования заключается в синтаксисе, плюс немного в генерации и оптимизации кода, настройке сред выполнения и т.д. Но с точки зрения программиста — именно синтаксис самая важная часть языка. Когда вы приступаете к изучению нового языка программирования, вам обязательно придётся уделить время освоению синтаксиса.
Можно ли просто избавиться от синтаксиса или, как минимум, предельно его упростить? Другой вариант — можно ли сделать синтаксис произвольным, чтобы программист, пишущий код, мог сам для себя его определять?
Именно этих целей мы попытались достичь, создав язык Ouroboros. Его синтаксис максимально прост. Настолько, что в этом языке даже не предусмотрен синтаксический анализатор. В нём есть только лексический анализатор, код которого составляет 20 строк.
В то же время, на Ouroboros можно писать сложные программы и даже выражения со скобками и операторами, имеющими разный порядок следования — при условии, что в вашей программе вы напишете для этого ваш собственный синтаксис. Поэтому отсутствие регламентированного синтаксиса ещё не означает отсутствия всякого синтаксиса.
Эта статья познакомит вас с Ouroboros — языком программирования, лишённым синтаксиса. Это игрушечный язык, и его никогда не планировалось использовать в продакшене, но поупражняться с ним будет интересно, в особенности, если вы когда-либо задумывались написать собственный язык программирования.
Существуют языки программирования, обладающие минимальным синтаксисом. Одним из первых языков такого рода был LISP, в котором используются лишь скобки, позволяющие группировать команды в виде списков.
Если вы знакомы с TCL, то, возможно, припоминаете, как прост этот язык. Но в нём всё равно можно определять сложные выражения и управляющие структуры, которые естественным образом вписываются в язык.
Ещё один простой язык, заслуживающий упоминания – FORTH. Это стековый язык. Его синтаксис минимален. Вы или помещаете что-либо в стек, либо вызываете функцию, работающую со значениями в стеке. Язык FORTH также знаменит своим минимальным ядром, написанным на ассемблере, а также тем, что оставшаяся часть его компилятора написана на самом FORTH.
Именно этими языками вдохновлён проект Ouroboros. Язык LISP известен своим простейшим синтаксисом. Можно даже утверждать, что LISP обладает простейшим синтаксисом среди всех языков программирования, но это будет ошибкой. Оправдывая своё имя, он разграничивает списки при помощи скобок, а в списках могут содержаться как данные, так и программные структуры. Существует шутка, будто LISP следует понимать как «Lots of Irritating Superfluous Parentheses» (Куча бесячих избыточных скобок).
В Ouroboros так не делается. Язык унаследовал использование{ и } из TCL, но, в отличие от LISP, вы вынуждены прибегать к этим скобкам лишь когда они действительно нужны.
В свою очередь, Ouroboros, хотя и является интерпретируемым языком, может компилировать себя сам. Не совсем «компилировать», но в Ouroboros можно определять синтаксис для языка на уровне самого языка. Правда, это не тот случай, что с компиляторами — когда компилятор пишется на некоем исходном языке. Одним из первых в своём роде был компилятор для языка PASCAL, компилятор которого Никлаус Вирт написал прямо на языке PASCAL. Компилятор для C также написан на языке C, и далее появлялось всё больше и больше компиляторов именно на том языке, который они компилируют.
С интерпретируемыми языками ситуация немного иная. Это не отдельная программа, которая читает исходный код и на его основе генерирует исходный код. Нет, он выполняет код, то есть, саму прикладную программу, которая сама становится частью интерпретатора.
Таким образом, нельзя посмотреть на код и сказать: «это не Ouroboros». Код на Ouroboros может быть любым, в зависимости от того, какой синтаксис вы для него определите в самом начале программы.
Что в имени тебе моём...
Прежде, чем подробно разобрать, что представляет собой Ouroboros, давайте поговорим о названии этого языка. Ouroboros сплетается сам с собой в бесконечном цикле создания и воссоздания. Название «Ouroboros» столь же многогранное, как и сам язык. В нём заложено несколько уровней смысла, отражающих его уникальную природу и устремления.
Вечный цикл
По сути своей Ouroboros вдохновлён «Уроборосом» — древним символом в виде змеи, кусающей себя за хвост. Этот мощный символ иллюстрирует циклическую природу созидания и разрушения, что идеально согласуется с само-референциальной структурой нашего языка. Точно как змея-уроборос ест сама себя ради пропитания, язык Ouroboros определяется собственными конструкциями, образуя замкнутый цикл логики и функциональности.
UR: суть простоты
Концепция фундаментальной простоты Ouroboros передана и в его сокращённом названии «UR». В немецком языке приставка «ur-» означает «пра-», то есть, что-то первобытное, примитивное или предельно элементарную форму. Это определение также соответствует философии, заложенной в язык Ouroboros: это язык, усечённый до самой сути.
Ouroboros, в котором синтаксис упрощён до крайности, претендует на статус «праязыка» программирования, возвращающего нас к максимально примитивной форме вычислений. Конструкции Ouroboros подобны простейшим биомолекулам или элементарным частицам в физике. Ouroboros предоставляет минимальный набор «кирпичиков», из которых можно выстраивать более сложные структуры.
Такая радикальная простота — не недостаток, а фича языка. В таких условиях программист вынужден мыслить на самом фундаментальном уровне и, следовательно, глубоко понимать вычислительные процессы. В Ouroboros принципиально важна каждая конструкция, значим каждый символ. Это чистейший дистиллят программирования.
Кусачее название
В конечном счете, название Ouroboros подсказывает, что вы вынуждены мыслить рекурсивно, чтобы видеть и начало, и конец каждой части кода, и всю эту часть в целости. Это лингвистическая загадка, вплетённая в саму структуру описываемого языка программирования — сложного, самореференциального и бесконечно увлекательного.
Отправляясь знакомиться с Ouroboros, помните, что при этом вы не просто пишете код, а участвуете в древнем цикле творения, где каждый конец есть новое начало, и каждая строка кода — это добавка в общую более богатую сумму вычислительных возможностей.
Оговорка о синтаксисе Ouroboros
В начале статьи было заявлено, что Ouroboros — это язык программирования, не имеющий синтаксиса. Должен признаться: я слукавил. Языка программирования абсолютно без всякого синтаксиса не бывает. В Ouroboros есть одно синтаксическое правило, которое формулируется так:
Лексические элементы языка пишутся по порядку, друг за другом.
Синтаксис
Вот и весь синтаксис.
Приступая к выполнению кода, интерпретатор языка начинает читать лексические элементы в том порядке, в котором они записаны. Он прочитывает столько лексем, сколько нужно для выполнения определённого кода — не больше. Точнее говоря, прежде, чем приступить к выполнению, он читает один элемент. Как только будет завершено выполнение кода, начавшегося с этого элемента, интерпретатор переходит к чтению следующего элемента.
Сам процесс выполнения может запускать новые линии чтения, если команде требуются дополнительные элементы. Вскоре рассмотрим на примере, как это делается.
В качестве лексического элемента может выступать число, строка, символ или слово. Символы и слова могут и должны быть ассоциированы с командами, которые следует выполнить.
Например, команда puts напрямую заимствована из языка TCL и ассоциирована с командой, которая выводит на экран строку.
puts "Hello, World!"
Это простейшая команда на Ouroboros. Когда начинает выполняться команда за puts, интерпретатор должен прочитать следующий элемент и вычислить его. В данном примере речь о константной строке, и рассчитать ей несложно. Значением константной строки является сама эта строка.
Следующий пример немного сложнее:
puts add "Hello, " "World!"
В данном случае аргументом команде puts служит другая команда: add. Когда puts приказывает интерпретатору получить свой аргумент, интерпретатор читает следующий элемент, а затем приступает к выполнению. Поскольку эти аргументы — строки, add сцепляет их и возвращает результат.
Блоки
В Ouroboros есть специальная команда, обозначаемая символом {. Распознав этот символ, лексический анализатор попросит интерпретатор читать следующие элементы до тех пор, пока не встретится закрывающая скобка }. При наличии вложенных блоков этот вызов по определению является рекурсивным.
В результате получается блочная команда. В рамках неё выполняются все команды, содержащиеся внутри блока, и результатом выполнения блока является результат последней команды.
puts add {"Hello, " "World!"}
Если заключить в блок две эти строки, то на выходе получим только World!, без Hello, . Блок «выполнит» обе строки, но значением блока будет только вторая строка.
Команды
Реализованные в языке команды документированы в файле readme проекта, выложенном на GitHub. Сам набор команд не слишком интересен — ведь набор команд есть в каждом языке.
Самое интересное в случае Ouroboros — что между функциями и командами нет разницы. Например, puts или add — это команды или функции? А как насчёт if и while? Всё это – команды, но они входят в состав не языка как такового, а его реализации.
Команда if приказывает интерпретатору выбрать один аргумент в вычисленном виде. Это будет использовано как условие. После этого интерпретатор выберет два следующих элемента, не вычисляя их. Опираясь на булеву интерпретацию условия, команда попросит интерпретатор вычислить один из двух аргументов.
Аналогично, команда while также выберет два аргумента, не вычисляя их. Затем интерпретатор первый аргумент, и, если он результирует в true, то вычислит и второй аргумент, а затем вернётся обратно к условию. Условие выбирается в невычисленном виде, поскольку затем его потребуется вычислять снова и снова. При применении команды if условие вычисляется всего один раз, поэтому нам не требуется ссылка на его невычисленную версию.
Многие команды используют невычисленные версии аргументов. Поэтому появляется возможность использовать «бинарные» операторы как мультиаргументные. Если хотите сложить три числа, то можете написать add add 1 2 3, или add* 1 2 3 {}, или {add* 1 2 3}. Команда add выбирает первый аргумент в невычисленном виде и смотрит, является ли он . Если он является , то команда продолжит выбирать аргументы до тех пор, пока они не кончатся, либо пока она не встретит пустой блок.
Это щепотка синтаксического сахара, которая смотрится особенно причудливо в языке, где нет синтаксиса. На самом деле, она добавлена сюда, чтобы упростить как сам эксперимент, так и обращение с самим языком. С другой стороны, сахар нарушает чистоту языка. Кроме того, это просто техническая деталь, и я упоминаю о ней здесь только потому, что она помогает нам обсуждать метаморфическую природу этого языка. Такой синтаксический сахар нужен, чтобы был понятнее первый из приведённых здесь примеров.
Переменные
Ouroboros поддерживает переменные. Переменные — это строки, с которыми ассоциированы значения. Значением может служить любой объект.
Когда интерпретатор видит символ или голое слово (идентификатор), подлежащие вычислению, он проверит, какое значение с ним(и) ассоциировано. Если это значение — команда, то интерпретатор выполнит команду. В других случаях он вернёт значение.
У переменных есть область видимости. Если установить (set) переменную в блоке, то она будет видима только в пределах этого блока. Если одноимённые переменные есть в родительском блоке, то переменная из дочернего блока затенит переменную из родительского блока.
Обработка переменных и установка областей видимости — это детали реализации, строго говоря, не входящие в состав языка.
Реализация языка в описываемом виде поддерживает булевы значения, длинные числа, числа двойной точности, большие целые числа, большие десятичные дроби, а также строковые примитивы. Ещё списки и объекты.
Список — это список значений, его можно создать командой list. Аргументом этой команды служит блок. Команда list прикажет интерпретатору выбрать аргумент в невычисленном виде. После этого команда вычислит блок с самого начала, точно, как это делает блочная команда. Но она не станет выбрасывать все полученные в результате значения (кроме последнего), а вернёт список результатов.
Объект – это словарь значений. Его можно создать командой object. Аргументом для этой команды служит родительский объект. Поля родительского объекта копируются в новый объект.
Ещё у объектов есть методы. Это поля, принимающие команду как значение.
Интроспекция
Интерпретатор открыт как развороченный сейф. В язык ничего жёстко не вшито. Я написал, что интерпретатор языка распознаёт голые слова, символы, строки, т.д., но это справедливо лишь для исходной конфигурации языка. Реализованные лексические анализаторы — это команды Ouroboros, и они поддаются переопределению. Они ассоциированы с именами $keyword, $string, $number, $space, $block, $blockClose и $symbol. Пользуясь структурами переменных, интерпретатор находит эти команды. Есть ещё одна переменная $lex, в которой содержится список лексических анализаторов.
Интерпретатор пользуется этим списком, когда требуется прочитать следующий лексический элемент. Он вызывает первый элемент, затем второй и так до тех пор, пока очередной элемент не вернёт ненулевое значение — лексический элемент, который и будет командой.
Если вы измените этот список, то сможете модифицировать лексические анализаторы и, следовательно, менять синтаксис языка.
В качестве простейшего примера давайте изменим интерпретацию символа конца строки.
Наверное, вы помните, что бинарные операторы можно использовать с множественными аргументами, которые завершаются пустым блоком. Было бы хорошо иметь возможность пропускать этот блок и писать add* 1 2 3, просто добавляя в конце переход на новую строку. Это можно сделать, добавив лексический анализатор, который распознаёт символ конца строки, и именно это мы сделаем в следующем примере.
set q add* 3 2
1 {} puts q
insert $lex 0 '{
if { eq at source 0 "\n"}
{sets substring 1 length source source '{}}}
set q add* 3 2
1 {} puts q
Вставим новый лексический анализатор в начале списка. В текущем состоянии исходный код начинается прямо с символа перехода на новую строку, затем лексический анализатор съедает этот символ и возвращает пустой блок.
Команда source возвращает исходный код, который пока не разобран интерпретатором. Команда sets устанавливает в качестве исходного кода указанное строковое значение.
Первая команда puts q выведет 6, поскольку на момент первого расчёта символы перехода на новую строку просто игнорируются, и поэтому значение q — это add* 3 2 1 {}. Вторая команда puts q выведет 5, поскольку символ перехода на новую строку съедается лексическим анализатором, и значением q будет add* 3 2 {}. Здесь закрывающие {} получены в результате лексического анализа символа перехода на новую строку. Значения 1 и {} на следующей строке вычисляются, но не оказывают никакого эффекта.
Это очень простой пример. Если вы хотите попробовать ��то-то посложнее — посмотрите файл проекта src/test/resources/samples/xpression.ur. В нём содержится скрипт, определяющий парсер числовых выражений.
Есть особая команда, которая называется fixup. Она приказывает интерпретатору разобрать остаток исходного кода. После этого момента лексические анализаторы более не используются.
Выполнив эту команду, вы не выиграете в производительности, и она нужна не для этого. Скорее она декларирует, что все элементы исходного кода должны быть выполнены методом интроспекции, а также должны быть сделаны метаморфические вычисления. Есть особая реализация этой команды, принимающая распарсенный код и генерирующая исполняемый файл. Так интерпретатор превращается в компилятор.
Технические соображения
Актуальная версия языка реализована на Java. Но язык Ouroboros не предназначен для работы с JVM. Его исходный код не компилируется в байт-код Java. Код Java интерпретирует исходный код Ouroboros и выполняет его.
Здесь показана минимальная жизнеспособная реализация (MVP), акцент в которой сделан на метаморфической природе языка. Язык строго экспериментален. Вот почему в нём нет конструкций для работы с файлами, сетью и другими операциями ввода/вывода, не считая единственной команды puts, которая пишет результат в стандартный вывод.
Предусмотренная в Java возможность загрузки сервисов позволяет загружать команды и регистрировать их в интерпретаторе каждую со своим именем. Таким образом, реализовывать дополнительные команды не сложнее, чем создавать их. Для этого пишется класс, реализующий ContextAgent для регистрации команд (см. исходный код) и записи их в путь классов.
Весь код является опенсорсным и выложен на GitHub. Он распространяется по лицензии Apache License 2.0 (см. файл лицензии в репозитории). На момент подготовки оригинала этой статьи в языке было ровно 100 классов. Таким образом, исходный код прост, краток и лёгок для понимания. Если вам в вашем приложении нужен простой скриптовый язык — попробуйте Ouroboros. Но на использование в продакшене он не рассчитан.
Что дальше
В настоящее время не планируется расширять язык и дополнять его новыми командами. Хочется только обогатить язык новым метаморфическим кодом. Дело в том, что пока для языка не просматривается вариантов практического применения. Если он окажется полезным, у него сформируется пользовательская аудитория и появятся варианты применения — определённо, мы добавим в него команды для поддержки ввода/вывода, обработки файлов, работы с сетью и т.д.
Также есть планы реализовать этот интерпретатор на других языках, например, на Rust и Go. Предлагайте, какие команды можно разработать для большего удобства работы с языком, какие фичи добавить. Это может быть параллельный проект, а, возможно, он будет залит и в главную ветку, если это будет оправданно.
Заключение
Исследуя Ouroboros, мы подробно разобрали концепцию языка программирования, в котором синтаксис сведён к такому минимуму, как будто его почти не существует. Такой радикальный подход — это вызов традиционным представлениям о том, какими должны быть языки программирования. В этой системе не только отсутствует синтаксис — сам язык при этом поддаётся бесконечному настраиванию. Язы�� Ouroboros вдохновлён LISP, TCL и FORTH, его главные черты — простота и интроспекция. Программируя на этом языке, вы можете определять собственный синтаксис и собственные команды прямо в пределах языка.
Притом, что Ouroboros не предназначен для практического использования, он является захватывающим экспериментом в проектировании языков и метапрограммировании. Его самореференциальная природа и минималистичный дизайн позволят поэкспериментировать тем разработчикам, которые интересуются основами вычислений, проектирования синтаксиса и интерпретации языка. Остаётся догадываться, разовьётся ли он в более устойчивый инструмент или не выйдет за границы занимательного интеллектуального упражнения. Ouroboros расширяет наши представления о языках программирования, предлагая нам задуматься о языке, синтаксис которого настолько же изменчив и рекурсивен, как мифический змей Уроборос.