Wy

LISP-диалект Python

С 2024 года я пишу на собственном LISP-подобном языке программирования Wy, который полностью интегрирован и обратно-совместим с Python:

  • Wy-код может вызывать все функции/библиотеки Python
  • Из Python можно вызывать Wy-код

What not to like?

Но чтобы понять, что такое и в чём смысл Wy, сначала надо раскрыть тему ЛИСПов…

Дошашняя страница Wy: Wy on Github

Как opensource проект, Wy полностью задокументирован и готов к использованию.

ЛИСПы

God must know all languages, and a few I’ll never name.
The Lord made sure, when each sparrow falls, that its flesh will be reclaimed.
But He couldn’t count all grains of sand with a 32-bit word.
Who knows where we would go to if LISP weren’t what he preferred?

LISP традиционно считается только самими же ЛИСПерами самым мощным из существующих языков программирования, которым при этом практически никто не пользуется (ЛИСП-подобные языки болтаются в районе 30..50 места по популярности среди всех языков программирования).

Сегодня под ЛИСПами понимают группу языков, в частности LISP, Scheme, Racket, Clojure и пр.

Макросы и метапрограммирование в ЛИСПе

То, что делает ЛИСП ЛИСПом — это в первую очередь его макросы:

  • В ЛИСПе универсальный простой синтаксис:
    • Вызов функции выглядит как список: (function arg1 arg2 ...)
    • Вложенный вызов выглядит как вложенный список: (f2 arg1 (f1 arg1 arg2 ...) ...)
    • И так весь код и выглядит — как один большой вложенный список функций (или дерево)
  • Над этим самым вложенным списком (т.е. над собственным кодом) ЛИСП может совершать операции — это и называется макросами.

Примерно так выглядит LISP-код

Примерно так выглядит LISP-код

💡

Макрос — функция/процедура, преобразующая собственный код программы. Макросы ЛИСПа на самом же ЛИСПе и пишутся.

Т.е. в ЛИСПе всего одна (ОДНА!) основная структурная синтаксическая форма — код пишется как вложенный список из функций. Для сравнения, в Python порядка 200 синтаксических форм.

Ладно, на самом деле в ЛИСПах синтаксических форм всё-таки больше одной — например, есть ещё хотя бы комментарии. Но базовая идея (вложенные списки) всегда остаётся.

Поэтому макросы в ЛИСП — это first-class citizens (макросы возвращают просто ЛИСП код, и с этим кодом можно делать что угодно). В других же языках для метапрограммирования надо использовать обходные пути, плюс оно обычно происходит в специально под это выделенных загонах, а не распределяется на язык в целом.

💡

Метапрограммирование — способ программирования, при котором программа работает с собственным кодом как с данными. Т.е. макросы — один из способов метапрограммирования.

Применение макросов

Примеры ниже даются в условном LISP-like псевдокоде.

Макросы часто используются для уменьшения boilerplate-кода.

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

Пример 1 — автонаписание функций

Пусть у нас есть функция plus2 для вычисления 2a+b:

1
2
3
(defn plus2
      (a b)
      (+ (* a 2) b)) 

Пример макроса, создающего функции 3a+b, 4a+b с соответствующими именами:

1
2
3
4
5
6
7
8
9
; создание макроса defplusN:
(defmacro defplusN (n)
  `(defn (strjoin 'plus' (str ~n))
         (a b)
         (+ (* a ~n) b)))

; при вызове макроса defplusN:
(defplusN 3) ; раскроется в: (defn plus3 (a b) (+ * a 3) b)
(defplusN 4) ; раскроется в: (defn plus4 (a b) (+ * a 3) b)

Пример 2 — автосоздание классов

У меня есть класс AnimalDog, и мне надо создать 5 похожих классов, у которых отличается только имя.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
; Базовые класс:
(defclass () AnimalDog (Parent)
    (let name "dog")
    (defmeth print (self, x, y)
        (print x y))
    (defmeth minus (self, x, y)
        (- x y)))

; макрос defAnimal (код не привожу)
; превратил бы 5 определений в однострочники:
(defAnimal "dog") ; определит класс AnimalDog
(defAnimal "cat") ; определит класс AnimalCat
(defAnimal "rat") ; и т.д.
(defAnimal "mouse")
(defAnimal "yourmamaisfat")

Макросы для повышения эргономики кода:

Пример 1 — Threading macro

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
; в базовом ЛИСПе вложенный вызов делается вот так:
(f4 (f3 (f2 (f1 a) b) c) d)

; в ЛИСПах популярен макрос "->", который этот вызов делает так:
(-> a f1 (f2 b) (f3 c) (f4 d)) 
; при вызове макроса "->" он просто
; преобразует этот код в код из строчки выше

; итого, мы фактически на ровном месте
; получили эмуляцию (ни много ни мало) partial application

Пример 2 — Infix arythmetic

1
2
3
4
5
6
7
; В базовом ЛИСПе арифметическая операция a + 2*b 
; выглядит вот так:
(+ a (* 2 b))

; Можно написать макрос "arythm", который будет
; вычислять арифметику в традиционной (инфиксной) форме:
(arythm a + 2 * b) 

Макросы, расширяющие синтаксис языка до неузнаваемсти:

Пример 1 — for loop

1
2
3
4
5
6
; цикл в ЛИСП записывается примерно так:
(for (x xs) (do (print x)))

; В теории, ничто не мешает написать макрос "cycle",
; который бы понимал следующий код:
(cycle for x in xs : print x)

Пример 2 — эмуляция другого языка

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
; Вся программа может быть помещена в макрос "c-like"
; который понимал бы следующий код:
(c-like
    - x = 3 
    - y = 4
    - z = 3 + 4
    - print(z)
    - for x in xs : print(x)
)

; Например, этот код мог бы раскрываться в ЛИСП-код:

(do (setv x 3) (setv y 4) (setv z (+ 3 4))
    (print z)
    (for [x xs] (print x)))

LISP Curse

Ситуация, при которой ЛИСП одновременно признаётся самым мощным из существующих языков, и при этом им мало кто пользуется для industry-level проектов, романтично называеся LISP Curse (проклятие ЛИСПа).

Невероятная сила ЛИСПа в том, что всего за пару макросов его можно превратить в новый язык программирования (как показано в последних примерах выше).

Но в этом же и обратная сторона ЛИСПа:

LISP — по-настоящему СВОБОДНЫЙ™ язык, но с этой свободой приходят и соблазны решать все проблемы своими уникальными способами.

Отсюда ключевые проблемы LISP как командного инструмента разработки:

Т.е. чтобы LISP был командным инструментом — нужна очень строгая дисциплина документирования и внедрения расширений в код своего проекта. И если это ещё можно сделать для небольшой команды, то вряд ли это можно сделать для коммунити в целом (не пожертвовав СВОБОДОЙ™ языка).

LISP очень хорош для небольших соло-хобби-проектов. Но при доведении проекта до industry-level с недисциплинированным использованием макросов — проблем может возникнуть сильно больше, чем их было бы при использовании более традиционных языков (хотя и в них тоже можно писать код плохо).

Про язык Wy

Возвращаясь к Wy, цели при его создании были следующие:

Как оказалось, первые 2 пункта уже покрывал другой LISP-подобный язык (hy), поэтому Wy решает только последнюю проблему, опираясь на язык hy.

💡

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

Часто противопоставляется объектно-ориентированному программированию, в котором код пишется как иерархия объектов, хранящих функционал каждый в самом себе.

В английской версии сайта есть подробная заметка о том, что такое функциональный стиль программирования: Definition of FP

hy — ЛИСП-диалект Python

hy — это:

Пример кода на hy

Python:

1
2
3
4
with open('file.txt', 'rt') as o:
    buffer = []
    while len(buffer) < 10:
        buffer.append(next(o))

Тот же код на hy:

1
2
3
4
(with [o (open "file.txt" "rt")]
      (setv buffer [])
      (while (< (len buffer) 10)
             (.append buffer (next o))))

💡

AST (Abstract Syntax Tree) — представление кода программы в виде дерева. Узлы дерева — это переменные, функции, контрольные структуры (if/then/else) и т.п.

Чистый Python код при запуске сам по себе тоже трансформируется в Python AST.

По сути ЛИСП-код пишется почти напрямую в виде AST.

Что означает «hy полностью совместим с Python»:

Недостатки hy:

Эти проблемы типичны для нишевых языков, и могут быть частично нивелированы использованием, например, Vim-плагинов и библиотек динамической проверки типов (pydantic).

Ну и плюс LLMки хотя и знают hy, но только на совсем базовом уровне:

Зачем создавать Wy, когда уже есть hy?

Хотя под мои потребности и желания hy и ложился идеально — оставалась последняя проблема. Мне не нравится то же, что отпугивает многих людей, впервые видящих ЛИСП — нагромождение вложенных скобочек, выходящее из под контроля при первой же возможности.

1
2
3
4
5
6
7
8
9
((lambda (n)
    ((lambda (fact)
        ((lambda (steps)
            (if (= steps 0)
                1
                (* steps (funcall fact (dec steps)))))
         n))
     (lambda (x) (funcall fact x))))
 5)

Wy решает эту проблему и в нём этот код выглядит так:

1
2
3
4
5
6
7
8
9
: lambda : n
     : lambda : fact
          : lambda : steps
              if : = steps 0
                   1
                   * steps : funcall fact : dec steps
           \n
       lambda : x :: funcall fact x
  5

Т.е. единственная задача, которую решает Wy — это убрать скобочки из hy, заменив их на indent-based syntax, прямо как в исходном Python. Всё. Никак других задач Wy не решает.

Кстати, создание LISP-синтаксиса без скобочек — это давно известная и нерешённая до конца в LISP коммунити задача.

💡

А стоит ли создавать новый язык только для того, чтобы просто убрать лишние скобочки?

Я это понимаю так.

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

(да, есть только 2 варианта — либо прогать, либо лежать на диване)

Что такое Wy как язык (техническое описание)

Wy — это LISP-диалект Питона, транспилирующийся в промежуточный (уже существующий) язык hy, который затем трансформируется в Python AST.

Если совсем формально, то Wy — это не отдельный язык, а синтаксический слой над hy.

Полная цепочка запуска Wy-кода выглядит так: Wy → Hy → Python

Wy реализован в виде транспилятора wy2hy, написанного на hy.

💡

При транспиляции код на одном языке программирования преобразуется в код на другом языке программирования (на близком уровне абстракции).

Самый популярный пример транспилируемого языка: TypeScript → JavaScript

Транспиляцию принято отличать от компиляции, при которой human-readable код переводится в машинный или байт код.

Имя «Wy» появилось от сокращения «h[Y] [W]ithout parenthesis».

Поскольку Wy-код транспилируется в hy-код, Wy наследует все свойства hy (т.е. он, равно как и hy, полностью совместим с экосистемой Python).

Об опыте создания своего языка

Баланс между настройкой под себя и связью с большим миром

Если бы Wy писался с нуля и обособленно (а не как надстройка над Python) — это был бы абсолютно неприкладной проект (не считая образовательных целей).

Создание прикладного языка — это создание полной экосистемы вокруг него:

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

Поскольку мне всё-таки хотелось иметь не только удобный, но ещё и прикладной инструмент — то Wy как раз и выдерживает этот баланс:

Опыт разработки Wy

Создание Wy вылилось в 2 отдельные активности:

Разработка синтаксиса — это относительно небольшая часть создания Wy. Надо было посмотреть на существующие наработки, и придумать дополнительные управляющие символы, повышающие эргономику синтаксиса (больше всего Wy основан на WISP).

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

Основной же объём работы — это написание транспилятора как программного продукта.

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

Если первая рабочая версия транспилятора всего в 1000 строк hy-кода была создана за 2..3 недели, то полноценный релиз Wy доводился до ума уже около полугода (сейчас Wy — это 5000 строк кода, документации и тестов).

Заключение

Как выясняется, если запаразитировать на экосистеме уже существующего языка (Python), то можно одновременно:

ЛИСП — сила, Питон — могила. У меня всё.