Альтернатива Emacs Lisp'у +24

- такой же как Forbes, только лучше.

image


Вы когда-нибудь искали альтернативу Emacs Lisp'у? Давайте попробуем добавить в Emacs ещё один язык программирования.


В этой статье:


  • Потенциальные преимущества, которые будут получены при возможности расширять Emacs на Go;
  • Определим способы взаимодействия Go и Emacs Lisp;
  • Затронем некоторые детали реализации описанного транскомпилятора;

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


В самом конце статьи представлена ссылка на work in progress проект, который позволяет конвертировать Go в Emacs Lisp.


Выбираем Emacs Go


Как и любой другой язык программирования, Emacs Lisp имеет ряд "недостатков", которые мы будем политкорректно назвать "design tradeoffs". Говорить "лучше" или "хуже" о тех или иных свойствах языка программирования на объективном уровне достаточно сложно, потому что почти всегда найдутся защитники противоположных позиций. Нам же, как пользователям языков программирования, можно стараться выбрать тот язык, чьи компромиссы нам принять проще в силу наших задач или личных предпочтений. Ключевой момент — возможность выбора.


Предположим, что мы выбрали Go. Как вы будете использовать Go для взаимодействия с редактором?
Ваши варианты:


  1. Использовать Emacs модули для запуска Go функций. Вдохновение можно черпать из проекта go-emacs.
  2. Найти (или написать) интерпретатор Go, встроить его в Emacs путём патчинга или теми же C модулями, а затем вызывать eval из редактора.
  3. Транслировать Go в Emacs Lisp байт-код.

Способов может быть больше, но ни один из них не будет ближе к "нативному" лиспу, чем (3). Он позволяет на уровне исполнения иметь ту же виртуальную машину, что и обычный Emacs Lisp.


Это, в свою очередь, означает, что:


  • Emacs Lisp код сможет вызывать транслированный Go код;
  • FFI бесплатен. Вызов уже определённой в Emacs функции из Go максимально эффективен;
  • Легко распространять сконвертированные пакеты (родной для Emacs формат);

Если вы в первый раз слышите о байт-коде Emacs'а, ознакомьтесь со статьёй Chris Wellons.


Почему Go?


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


Есть несколько причин из-за которых сделанный выбор становится более обоснованным. Основные из них:


  • Компилятор языка внутри стандартной библиотеки;
  • Лаконичная спецификация;
  • Скромный runtime;
  • Tooling;

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


  • Go — достаточно популярный язык с С-подобным синтаксисом (т.е. это тебе не Scheme);
  • Статическая типизация;

Компилятор языка внутри стандартной библиотеки


Пакеты go/* значительно упрощают написание инструментов для Go.


Не нужно писать parser, typechecker и прочие прелести frontend'а компилятора. За 20 строк кода мы можем получить AST и информацию о типах для целого пакета.


Документация, по большей части, хороша. А для go/types на мой взгляд — образцовая.


Изначально для меня это было убийственным аргументом. Задача казалась на 90% решённой благодаря этому секретному оружию: "осталось только преобразовать AST в байт-код Emacs'а".


Ложка дёгтя

На практике возникали сложности с теми или иными нюансами.


В первую очередь — запутанность API и дублирование разными пакетами похожих сущностей, да ещё и под одинаковыми именами. Часто одно и то же можно сделать через go/ast и go/types; не редко вам нужно перемешивать сущности из обоих пакетов (да-да, в том числе те, что с одинаковыми именами).


Ещё на удивление неудобной оказалась работа с import'ами и декларациями (ох уж этот ast.GenDecl).


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


Лаконичная спецификация


Создать реализацию, которая в большей степени (~80%) конформна спецификации — вполне посильная задача для одного человека. Спецификацию Go легко читать, её можно осилить за вечер.


Особенности спецификации:


  1. Некоторые моменты вызывают сомнения в однозначности трактовки. Цена краткости;
  2. Кроме спецификации есть ещё Effective Go. Без него в спецификации останутся белые пятна;

Скромный runtime


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


Если хотя бы временно выбросить за борт горутины и каналы, то останется компактное ядро, которое вполне можно реализовать в терминах Emacs Lisp без потери производительности.


Tooling


Это чертовски приятно, когда многие привычные фичи работают в нескольких твоих любимых редакторах, причём единообразно.


Для Go многие функции, которые обычно переизобретаются для каждой IDE отдельно, реализованы в виде отдельных утилит. Самый простой пример, известный каждому Go разработчику — gofmt. В немалой степени этому способствуют упомянутые выше go/types,
go/build и остальные пакеты из группы go/*.


Sublime text, Emacs, Visual Studio Code — выбираешь любой из них, ставишь плагин(ы), и наслаждаешься рефакторингом через gorename, множеством линтеров и автоматическими import'ами. А автодополнение… превосходит company-elisp во многих аспектах.


Рефакторить и поддерживать проект на Emacs Lisp уже после 1000 строк кода лично мне уже некомфортно. Программировать Emacs на Go чуть ли не удобнее, чем на Emacs Lisp.


Как выглядит Emacs Go


Пофантазируем на тему того, как мог бы выглядеть Go для Emacs'а. Насколько он был бы удобен и функционален?


Мост типов


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


С примитивными типами вроде int, float64, string и другими всё более-менее просто. И в Go, и в Emacs Lisp эти типы присутствуют.


Интерес представляют слайсы, символьные типы (symbol) и беззнаковые целочисленные типы фиксированной разрядности (uintX).


  • Слайсы реализуем в рантайме (например, на том же Emacs Lisp);
  • Символы представляем в виде opaque типа;
  • Беззнаковую арифметику с детерминированным переполнением — эмулируем;

Тип, который может выразить "объект произвольного типа", который возвращается Emacs Lisp функцией, назовём lisp.Object. Его определение дано под спойлером lisp.Object: детали реализации.


Go slices

Для аналогии: слайсы в Go по своему "интерфейсу" — это std::vector из C++, но с возможностью брать полноценный subslice без копирования элементов.


Начнём с интуитивного представления {data, len, cap}.
data будет вектором, len и cap — числами. Чтобы хранить атрибуты выбираем improper list, где у нас нет финального nil, чтобы немного экономить память:
(cons data (cons len cap))


Почему список, а не вектор?

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


Более развёрнутый ответ на этот вопрос поможет найти дизассемблер (или таблица опкодов). Доступ к спискам из 2-3 элементов — очень эффективный. Чем ближе к голове списка, тем более ощутима разница. Атрибут data используется чаще всего, поэтому он в самом начале списка.


При N=4 можно считать, что список начинает уступать по эффективности в случае считывания последнего элемента, но остальные три атрибута всё так же более эффективны в доступе => даже для объектов из четырёх атрибутов я склонен полагать, что список является более удачной структурой, чем вектор.


Оговорка: это всё справедливо для виртуальной машины Emacs'а, её набора инструкций. Вырывать из контекста не стоит.


Операции slice-get/slice-set будут очень эффективными. У нас будет тот же aset/aget, но с одной дополнительной инструкцией car для извлечения атрибута data.


Но что будет, когда нам нужен subslice?


В C можно было бы data сделать указателем и сместить его, куда нужно. Адресация была бы такой же, 0-based. В нашем случае это невозможно, что приводит к необходимости хранить ещё и offset:
(cons data (cons offset (cons len cap)))


Для каждого slice-get/slice-set теперь нужно к индексу прибавлять offset.


Сравним байт-код для операции slice-get.


;; Обычный вектор
<vector> ;; [vector]
<index>  ;; [vector index]
aref     ;; [elem]

;; Slice без offset (не поддерживаем subslice)
<slice> ;; [slice]
car     ;; [data]
<index> ;; [data index]
aref    ;; [elem]

;; Slice с поддержкой subslice
<slice>     ;; [slice]
dup         ;; [slice slice] (1)
car         ;; [slice data]
stack-ref 1 ;; [slice data slice]
cdr         ;; [slice data slice.cdr]
car         ;; [slice data offset]
<index>     ;; [slice data offset index]
plus        ;; [slice data real-index]
aref        ;; [slice elem]
stack-set 1 ;; [elem] (2)

;; (1) Поскольку <slice> может быть дорогим выражением, мы
;; вычисляем его единожды.

;; (2) Нам требуется восстановить инвариант стека и удалить
;; лишний slice со стека. 

С помощью нотации <X> выделены выражения, которые могут быть произвольно сложными (от обычного stack-ref, до call со множеством аргументов). Справа от кода отображено состояние стека данных.


Opaque types

Некоторые типы мы не захотим/не сможем выразить как Go структуры. К таким типам относятся lisp.Object, lisp.Symbol и lisp.Number.


Главной целью opaque типа для нас является запрет на произвольное создание объектов через литералы. С этим отлично справляются интерфейсные типы с неэкспортируемым методом.


type Symbol interface {
    symbol()
}

type Object interface {
    object()
    // Другие методы...
}

// Для создания объектов должна использоваться специальная функция-конструктор.

// Intern returns the canonical symbol with specified name.
func Intern(name string) Symbol

Функция Intern обрабатывается компилятором по-особому. Другими словами она — intrinsic функция.


Теперь мы можем быть уверены, что у этих особых типов такое API, которое мы хотим им придать, а не то, какое возможно по законам Go.


lisp.Object

Если lisp.Object представляет "любое значение", то почему мы не используем interface{}?


Вспомним, что такое interface{} в Go — это структура, хранящая в себе динамический тип объекта, плюс сам объект — "данные".


Это не совсем то, чего хотелось бы, потому что для Emacs'а такое представление "чего угодно" не является эффективным. lisp.Object нужен для того чтобы хранить unboxed Emacs Lisp значения,
которые легко можно передавать в функции лиспа и получать в качестве результата.


Для того, чтобы можно было получить из lisp.Object значение конкретного типа, можно добавить дополнительные методы в его интерфейс.


type Object interface {
    object()

    Int() int
    Float() float64
    String() string
    // ... etc.

    // Можно также предоставить следующие методы:
    IsInt() bool                // Предикат для проверки типа
    GetInt() (val int, ok bool) // Для "comma, ok"-style извлечения
    // ... аналоги для оставшихся типов.
}

Каждый вызов генерирует проверку типа. Если внутри lisp.Object хранится значение отличного от запрошенного типа, должен быть вызван panic. Чем-то напоминает API reflect.Value, не так ли?


Emacs Lisp из Go


Если сигнатура функции неизвестна, то единственное, что нам остаётся — это принимать вариативное количество аргументов произвольного типа, а возвращать lisp.Object.


pair := lisp.Call("cons", 1, "2")
a, b := lisp.Call("car", pair), lisp.Call("cdr", pair)
lisp.Call("insert", "Hello, Emacs!")
sum := lisp.Call("+", 1, 2).Int()

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


part := "c"
lisp.Insert("Hello, Emacs!")     // Возвращает void
s := lisp.Concat("a", "b", part) // Возвращает string, принимает ...string

FFI DSL

DSL для аннотирования функций можно написать на макросах.


;; Пример описания функций, доступных через FFI.
(ffi-declare
 (concat Concat (:string &parts) :string)
 (message Message (:string format :any &args) :string)
 (insert Insert (:any &args) :void)
 (+ IntAdd (:int &xs) :int))

;; Разворачивается макрос, например, в Go сигнатуры.

Разворачиваться такой макрос должен в Go сигнатуры функций. Нужно оставлять комментарий-директиву для сохранения информации о том, какую Lisp функцию следует вызывать.


// IntAdd - ... <комментарий функции + из Emacs>
//$GO-ffi:+
func IntAdd(xs ...int) int

// ... Остальные функции

Документацию можно подтягивать из Emacs'а функцией documentation. Получаем функции с известной арностью и при этом не теряем ценные docstrings.


Go из Emacs Lisp


Результатом транскомпиляции будет Emacs Lisp пакет, в котором все символы из Go имеют преобразованный вид.


Схема преобразования идентификаторов может быть, например, такой:


package "foo" func "f"       => "$GO-foo.f"
package "foo/bar" func "f"   => "$GO-foo/bar.g"
package "foo" func (typ) "m" => "$GO-foo.typ.m"
package "foo" var "v"        => "$GO-foo.v"

Соответственно для того, чтобы вызвать функцию или воспользоваться переменной, нужно знать какому Go пакету она принадлежала (и её название, разумеется). Префикс $GO позволяет избежать конфликтов с уже определёнными в Emacs именами.


Тонкости транскомпиляции


Bytecode или lapcode?


В качестве выходного формата можно выбирать среди трёх вариантов:


  1. Emacs Lisp код (source-to-source compilation)
  2. Bytecode
  3. Lapcode (Lisp Assembly Program)

Первый вариант сильно проигрывает остальным вариантам, потому что он не позволит эффективно транслировать return statement, а ещё в нём сложнее реализовать оператор goto (который есть в Go).


Второй и третий варианты по возможностям практически эквивалентны.


  • Bytecode — это аналог машинного кода, самый низкий уровень;
  • Lapcode — это язык ассемблера виртуальной машины со стековой архитектурой;

Компилятор Emacs'а умеет оптимизировать на уровне исходного кода и lapcode представления.
Если выбираем lapcode, то можем дополнительно применять низкоуровневые оптимизации,
реализованные Emacs разработчиками.


Недостатки lapcode

Lisp assembly program — это внутренний формат компилятора Emacs'а (IR). Документации по нему ещё меньше, чем по байт-коду.


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


Я так и не нашёл точного описания формата инструкций. Здесь помогает метод проб и ошибок, а также чтение исходников компилятора Emacs Lisp'а (вам понадобятся стальные нервы).


Производительность генерируемого кода


Go, который "бегает" внутри Emacs VM не может быть быстрее Emacs Lisp.


Или может?


В Emacs Lisp есть динамический scoping для переменных. Если вы заглянете в "emacs/lisp/emacs-lisp/byte-opt.el", то сможете найти множество отсылок к этой особенности языка; из-за неё некоторые оптимизации либо невозможны, либо значительно затруднены.


Констант в Emacs Lisp нет. Имена, объявленные с помощью defconstant менее неизменяемые, чем те, что определены через defvar. В Go константы встраиваются в место использования, что позволяет сворачивать больше константных выражений.


Оптимизировать Go код проще, поэтому можно ожидать как минимум не уступающую обычному Emacs Lisp коду производительность. Потенциально, обгон в плане быстродействия реален.


Трудности реализации


Даже без горутин есть такие возможности Go, которые не имеют очевидной и/или оптимальной реализации внутри Emacs VM.


Наиболее интересной трудностью являются указатели.


В контексте задачи мы можем выделить две категории значений в Emacs Lisp:


  • Ссылочные типы (string, vector, list/cons)
  • Типы-значения (integer и float)

Для ссылочных типов задача решается попроще.
Взятие адреса от переменной типа int или float требует обработки
большего количества граничных случаев.


Ещё не следует забывать, что для указателей определён оператор =,
поэтому предлагаемым решением должно соблюдаться тождество адресов объектов.


Заворачивая number в cons, пролетаем по семантике,
потому что значение, у которого взяли адрес,
не будет меняться в случае изменения данных, хранимых в cons.


Если все числа изначально создавать в boxed виде (внутри cons),
сильно повысится количество аллокаций.
Распаковка будет требовать дополнительную инструкцию car при каждом считывании значения.


У реализации указателей через cons есть значительный изъян: &x != &x,
потому что (eq (cons x nil) (cons x nil)) всегда ложно.


Корректная эмуляция семантики указателей — это открытый для обсуждения вопрос.
Буду рад услышать ваши идеи для их реализации.


Seems like Go-ism inside Emacs


Проект goism — это инструмент, который позволяет получать из Go пакетов близкий к оптимальному Emacs Lisp байт-код.


Библиотека времени выполнения изначально была написана на лиспе, но с недавних пор полностью переписана на транслируемом в lapcode Go.
emacs/rt на данный момент — один из самых крупных пакетов, написанных с помощью goism.


На данный момент goism не особо дружелюбен по отношению к конечному пользователю,
придётся работать руками, чтобы правильно его собрать и настроить
(guick start guide должен упростить задачу).


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


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

Теги:



Комментарии (25):

  1. Rathil
    /#10270268 / +1

    Я бы не сказал, что синтаксис Си подобный. Имхо он как раз не Си подобный.

    • Rathil
      /#10270270

      Но это только мое мнение.

    • quasilyte
      /#10270282

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

      Тем не менее, поделитесь, к какому синтаксическому семейству вы бы отнесли Go?

      • Sirikid
        /#10270368 / +1

        Своему собственному — смесь C и Pascal или ML, представители — Kotlin, Rust, Swift и, собственно, Go.

  2. Luke0208
    /#10270330 / +2

    Emacs Go? Альтернатива Lisp'у? Lisp очень мощный и выразительный язык, по этому и альтернатива должна быть мощной и выразительной, иначе в ней просто нет потребности. Go сравнивают и с Rust, кто-то умудряется с С++, есть еще D. В чем уникальность Gо?

    • quasilyte
      /#10270350 / -1

      Столько вопросов… Не смогу на каждый из них дать стоящий ответ.

      Для снижения градуса категоричности: в моём понимании альтернатива —
      это не то же самое, что полная замена.

      Уникальность Go, если брать конкретно мой проект, в том, что он был easy pick'ом в самом начале.
      За один вечер уже был на коленке написанный транслятор, который преобразовывал Go код в
      S-выражения.

      • Luke0208
        /#10271982

        Любовь и знание языка делают многое. Например хороший С++ программист так же мог сделать подобное за такое же время.

  3. acmnu
    /#10270696 / +2

    Не ясно зачем тащить Go, но без рутин, без рантайма, без компиляции. Только ради синтаксиса? Вам он правда так нравится?


    На мой взгляд никакой проблемы с Lisp в Emacs нет, в отличии от VimScript, который все ненавидят.


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

    • BelBES
      /#10270798

      Хоткейность UI у Emacs — это его основной плюс, просто надо проникнуться таким workflow, ну и с мелкой моторикой пальцев должно быть все в порядке)
      Вот де факто однопоточность Emacs'а и в связи с этим подвисания при работе некоторых плагинов — вот это действительно проблема. И как-то пока с асинхронностью туго в нем...

      • acmnu
        /#10270814

        Вот де факто однопоточность Emacs'а и в связи с этим подвисания при работе некоторых плагинов — вот это действительно проблема. И как-то пока с асинхронностью туго в нем...

        Ну тут автор ничего нового не предлагает, на сколько я понял.


        Хоткейность UI у Emacs — это его основной плюс

        Это если не быть знакомым с Vim. Нет, у меня не синдром утенка, и я понимаю что за пределами vim жизнь есть, но мне очень не нравится то, что переход на следущую строку это ctrl-n, а на предыдущую — ctrl-p. Запомнить лекго, но кнопки не рядом, а это для быстрого перемещения неприятно.

        • BelBES
          /#10270828

          но мне очень не нравится то, что переход на следущую строку это ctrl-n, а на предыдущую — ctrl-p. Запомнить лекго, но кнопки не рядом, а это для быстрого перемещения неприятно.

          'p' лежит аккурат под мизинце, 'n' под указательным пальцем. Если вместо обычной клавиатуры пользоваться эргономичной (типа тех, что выпускает Microsoft), то там правильное положение рук для работы в Emacs будет by design.


          p.s. собственно модальность в Vim тоже на любителя)

          • acmnu
            /#10270846

            Именно ей и пользуюсь, но например при моей руке, я не могу держать без напряга руку сразу на двух позициях, как это с jk в вим, ну или просто со стрелочками. Я уж не говорю, что горизонтальное перемещение будет вообще на другой руке по большей части.


            p.s. собственно модальность в Vim тоже на любителя)

            Да я и не спорю. С модальностью много проблем. Но все же там именно на эрогомику упор, а в емаксе — на запоминаемость. Если бы переходы были хотябы ctrl-n, ctrl-h, то было бы уже заметно лучше. (да-да можно заремапить, я в курсе)

            • BelBES
              /#10270922

              Есть ErgoEmacs, который из коробки ремапит стандартные хоткеи Emacs на "общепринятые" стандартные кнопки. Ну а стрелочки из коробки работают в стандартном Emacs.


              А в чем эргономика Vim — вопрос открытый, т.к. разница между Vim и Emacs по большому счету только в принципе: либо мы сочетание клавиш вводим в рантайме, либо мы переходим в режим команд и набираем это сочетание в нем. Первое удобно для тех, кто владеет слепым 10-пальцевым набором, второе для тех, кто такой способ ввода не осилил и набирает текст двумя пальцами. Остальное — детали и вопрос конфига.

              • acmnu
                /#10270946

                Первое удобно для тех, кто владеет слепым 10-пальцевым набором, второе для тех, кто такой способ ввода не осилил и набирает текст двумя пальцами.

                Эээ. Чего? В vim делать нечего без слепой печати

                • BelBES
                  /#10270980

                  В каком месте?:-)
                  Переключился в режим набора и погнал двумя пальчиками стучать набирая текст, переключился в режим команд и давай также одним пальчиком набирать заклинания :q!.. у меня коллега так в нем работает и вроде бы всем доволен)

                  • acmnu
                    /#10270994

                    Ну не надо делать выводы по одному коллеге. При таком раскладе именно в emacs он будет сносно существовать. И при чем здесь комадный режим, который через: ?

                    • BelBES
                      /#10271008

                      Не, проблема с хоткеями у некоторых людей в том-же, в чем и пробелма со слепым набором — не достаточно развита мелкая моторика пальцев. Т.е. набить команду в "оффлайн" режиме человек может, а вот на лету нажать хоткей — уже проблема. Поэтому Vim в таком случае Vim лучше подходит.

        • BelBES
          /#10270832

          Ну тут автор ничего нового не предлагает, на сколько я понял.

          Увы, но да. Хотя, язык Go вроде бы позиционируется как спроектированный для многопоточности.

          • quasilyte
            /#10270894

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

            Если более локально, то есть несколько вариантов.
            Некоторые даже не требуют патчинга Emacs'а.

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

            • BelBES
              /#10270942 / +1

              "Жаль только — жить в эту пору прекрасную
              Уж не придется — ни мне, ни тебе" ©


              Давно уже народ штурмует этот бастион, но как-то безрезультатно)


              По сути единственная проблема этого редактора… вообще не круто, когда multiply cursors начинает дико виснуть, если в момент набора текста Emacs решает кодец поформатировать попутно....

              • acmnu
                /#10270960

                Ну, в условном Eclipse ситуация не то чтобы радикально лучше. Да многопоточность есть, но потребление ресурсов такое бешенное, что это не очень то и заметно.

                • BelBES
                  /#10270988

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

                  • acmnu
                    /#10270998

                    В эклипсе он скорее всего не откроется.

                    • BelBES
                      /#10271014

                      Но при этом gedit его откроет без особых проблем и ворочать будет его достаточно бодро) Да и Sublime с такими файлами справляется лучше.