
Lua — симпатичный и хороший язык — умещает богатый функционал в очень маленькой реализации (интерпретатор и библиотека — это всего один исполнимый файл на 300кб) — и притом изначально ориентирован на «человечный» синтаксис. Хотя он не в топе популярности, но за простоту встраивания (помимо человечности и функциональности) он используется в ряде популярных проектов — от Roblox до Tarantool, например.
Однако есть мелочи которые несколько снижают радость от его использования. Когда я встроил его у себя на сайте для того чтобы сделать несколько «игровых» задач на программирование, пользователи высказывали неодобрение по трём пунктам:
знак неравенства в виде
~=
вместо привычных!=
из C / Pythonотсутствие «комбинированного присваивания» (удобные
+=
,*=
и т. п.)массивы начинающиеся с
1
вместо0
Я попробовал сделать маааленький патч который добавляет немного «сахара» в синтаксис Lua — буквально несколько десятков строчек. Хотя это больше из любопытства и в качестве разминки — результат получился вполне рабочий (вы сможете его протестировать живьём). Возможно кто‑то предложит (или попробует сделать по аналогии) и другие интересные доработки.
Уточнение задачи
Вообще «кастомных» реализаций Lua существует много — и во многих из них есть и обсуждаемые синтаксические возможности. К сожалению эти реализации в основном достаточно далеко ушли от оригинального кода и подтягивать появляющиеся в оригинале изменения становится затруднительно.
Хочется сделать именно маленький патч, чтобы легко было подмёрживать «апстрим» при появлении новых версий и апдейтов. И при этом не ломать имеющийся функционал языка а сделать «добавления» к нему:
сделаем дополнительный оператор неравенства и оператор для логической инверсии
внедрим операцию «комбинированного присваивания» (с небольшими оговорками)
сделаем «alias» для ключевого слова
function
(уж очень оно громоздко особенно для лямбда‑выражений) — это попросили на форуме когда я показал результат двух предыдущих пунктов
Сразу поясню — я «дополнял» только синтаксис и поэтому за индексацию массивов не брался (пока). Всё равно 0й индекс использовать можно поэтому проблема не очень острая. Но в планах и с этим что‑то сделать. Проблема не столько в реализации сколько в идеологии, как это органично хотелось бы добавить не сломав дефолтные массивы — идеи приветствуются.
Исходный код: http://github.com/rodiongork/lua‑plus (все изменения — в последнем коммите)
Онлайн‑песочница где можно попробовать: https://rodiongork.github.io/lua‑emcc/
Итак, приступим!
Оператор неравенства
Комичная ситуация — Lua написана на C и в первую очередь для встраивания в C‑шные программы — но авторы решили сделать оператор неравенства не‑Сишным. Недавняя дискуссия в мэйллисте прояснила — авторам просто кажется что это «quite natural» а восклицательный знак «bewildering» (см. ответ Роберто — одного из ведущих разработчиков сейчас). Типичный пример когда очень субъективные авторские идеи влияют на развитие языка, кажется, негативно.
Здесь всё оказалось очень просто. Небольшой поиск по исходникам обнаруживает и сам оператор ~=
и соответствующий ему токен TK_NE
в функции llex(...)
которая непосредственно участвует в разборе каждого следующего токена. Код внутри оператора switch
проверяет очередной пришедший символ. В оригинале там вот так:
case '~': {
next(ls);
if (check_next1(ls, '=')) return TK_NE; /* '~=' */
else return '~';
}
То есть просто проверка — если сразу за тильдой следует равенство — то опознаём как логическое неравенство. В противном случае как бинарную инверсию.
В общем, не очень удачная мешанина логических и бинарных операций. Зато легко исправить — копируем эти строчки и вставляем сразу ниже со следующей модификацией:
case '!': {
next(ls);
if (check_next1(ls, '=')) return TK_NE; /* '~=' */
else return TK_NOT;
}
Здесь «бонусом» одиночный восклицательный знак будет парситься как логическая инверсия — это не очень нужно (в языке есть оператор not
) но генерировать вместо этого ошибку кажется нелогичным. Да и удобно для записи !!
— операция «унифицирующая» значения соответствующие «истине» — не для кода а чтобы вспомнить как «логически» воспринимается пустая строка или nil — в режиме интерпретатора (актуально если пользуешься разными скриптовыми языками):
> !!nil
false
> !!""
true
Комбинированное присваивание
Здесь ситуация сложнее — она тоже когда‑то обсуждалась в мэйллисте (не могу сейчас найти) — дело в том как реализован парсер. Исторически Lua старается разобрать исходник «в один проход» и команды байткода старается генерировать как можно скорее. Поэтому разбирая строчку языка она определяет — следует ли за первым выражением знак присваивания или нет (т. е. это присваивание или вызов функции). И сразу генерит опкоды для этого выражения — а потом уже разбирает выражение справа. Получается, если мы встретили комбинированное присваивание, нужно будет обработать его особым образом — либо на уровне генерации байткода, либо даже тюнинговать сам интерпретатор байткода.
Мы не хотим менять интерпретатор байткода, это точно. И даже его генерацию — т.к. это вещь «приватная» для проекта и может подвергаться радикальным переделкам со стороны авторов (он и был переделан между 4 и 5 версией). Поэтому зададимся целью попроще:
реализовать «комбинированные» операторы так чтобы они «превращались» в обычные присваивания на уровне парсера, и генерировали тот же в точности код
пусть работают только с одиночными присваиваниями (не с кортежами)
То есть если парсер встречает a += b
, пусть просто превратит его в a = a + b
хотя у этого подхода есть недостатки, которые обсудим ниже.
Легко сказать! Но что значит «превратит»? Как упомянуто, лексер и парсер не «держат в уме» весь текст программы. Когда они встретят оператор, то L‑value (выражение которому присваивается значение) уже распарсено и превращено в байткод.
Первой мыслью было попытаться запомнить во «входном потоке» символов позицию с которой вообще начался парсинг «строки» кода — т. е. перед началом L‑value. Входной поток реализуется в файле zio.c
но к сожалению сразу становится очевиден нюанс — код парсится немного «забегая вперед» — и когда мы только начинаем разбирать строку (в функции exprstat(...)
— оказывается что во входном потоке уже отнюдь не начало выражения!
Другая идея была в том чтобы переиспользовать уже разобранное L‑value ещё раз, после оператора присваивания — в качестве первого операнда выражения, следующего за присваиванием. Просто вызвать на нём нужные функции ещё раз чтобы сгенерировать требуемый байткод. Эта попытка оставлена в ветке attempt-1
— она прекрасно работает для одиночных переменных, но не справляется с индексированными — в этом случае генерируется более витиеватый байткод и, проще говоря, переиспользовать L‑value правильно не получается.
Третий вариант (после того как провозился день‑другой с предыдущими) оказался более работоспособным. Он аналогичен первому, но «запоминать» мы будем не символы из входного потока, а распарсенные уже токены. Их «выплёвывает» функция luaX_next(...)
и даже с учётом небольшого опережения её несложно тюнинговать так чтобы:
при старте разбора L‑value начать «запоминание токенов»
при окончании разбора L‑value прекратить его
если дальше определён комбинированный оператор, включить «воспроизведение» записанного участка
По коду это работает так — во‑первых, в структуру LexState
(отвечающую за текущее состояние лексера/парсера) мы добавим массив для запоминания токенов и несколько полей (счетчик, указатель) для работы с этим массивом.
typedef struct {
//...
Token *record; // буфер для токенов
int recmax; // текущий размер буфера для токенов
int recon; // флаг, включена ли запись
int reccnt; // сколько токенов в буфере
int recptr; // следующий токен (при воспроизведении)
} LexState;
Можно бы выделить в отдельную структурку и вложить её — но непринципиально.
Дальше добавляем функцию luaX_record(...)
управляющий включением записи, и используем её в exprstat(...)
вокруг парсинга L‑value:
luaX_record(ls, 1);
suffixedexp(ls, &v.v);
luaX_record(ls, 0);
Дальше идёт определение операции — добавляем вслед за проверкой на простое присваивание проверку на «комбинированное» (она вынесена в отдельную функцию т.к. позволяет использовать разные операторы):
if (ls->t.token == '=' || ls->t.token == ',') { /* stat -> assignment ? */
v.prev = NULL;
restassign(ls, &v, 1);
}
else if (iscompassign(ls->t.token)) { // добавляем начиная отсюда
v.prev = NULL;
amendassign(ls, &v);
}
Итак старый метод restassign(...)
парсит обычные присваивания (в т.ч. «кортежные») — а мы добавили новый, amendassign(...)
— в общем‑то в нём просто включается «воспроизведение» токенов а в остальном он основан на коде предыдущего.
Тут сразу возникает нюанс — как должно парситься выражение:
a /= 2 + 1
Нам кажется логичным — вычисляем правую часть (3) и делим левую на неё. Однако если просто «развернуть» выражение в обычное присваивание — получается вот что:
a = a / 2 + 1
По правилам приоритета деление произойдёт раньше сложения. Чтобы это поправить мы вносим небольшое дополнение в функцию subexpr(...)
так чтобы при передаче определенного значения параметра (-1) можно было сигнализировать что первый оператор должен иметь самый низкий приоритет вместо своего обычного.
В общем‑то с этой частью все — мы получили работоспособный оператор, но у него есть некоторые особенности поведения:
Это не один оператор всё‑таки, а два — и между ними может быть пробел (нестрашно)
В случае если L‑value содержит индекс в качестве которого используется выражение с посторонним эффектом (вызов функции и пр) — результат может быт неожиданным т.к. на деле это выражение будет вычислено дважды
Если L‑value содержит индекс в качесте которого используется лямбда, внутри которой есть ещё присваивание — будет «запомнено» L‑value из лямбды, что конечно неправильно.
Впрочем второй и третий случаи — это довольно экстремальное программирование и можно считать что в адекватно написанном коде мы такого не встретим. Главное предупредить пользователя.
Что же, можно испытать результат — попробуйте скопировать и вставить нижеприведённый код в «песочницу» и нажмите «Execute»
res = ''
for i = 1,10 do
t = i
t *= t+1
t //= 2
res ..= t
end
print(res)
Если повезёт — вы увидите таинственный результат 13610152128364555
— склейку десяти первых «треугольных» чисел:)
Добавляем "алиас" зарезервированного слова
Удивительно что в столь лаконичном и компактном языке как Lua нашлось такое длинное слово. Я подобные припоминаю только в Pascal — современные же языки явно на стороне сокращённых ключевых слов (def
, func
) либо позволяют альтернативный синтаксис (как в Javascript). Мне например интересно сделать обучающие веб‑страницы которыми можно пользоваться с телефона — и там набирать по 8 букв явно ни к чему!
Токен, который генерируется по слову function
находится легко — это TK_FUNCTION
. Один из самых простых подходов — сделать чтобы такой токен генерировался, например, по символу $
— нужно лишь добавить обработку этого символа в switch, по аналогии с тем как мы добавляли оператор неравенства.
К сожалению такой вариант, кажется, нарушает идею «человечного» синтаксиса. Не будем торопиться превращать язык в APL
:)
Легко найти что зарезервированные слова перечислены в массиве в llex.c
:
/* ORDER RESERVED */
static const char *const luaX_tokens [] = {
"and", "break", "do", "else", "elseif",
"end", "false", "for", "function", "global", "goto", "if",
"in", "local", "nil", "not", "or", "repeat",
"return", "then", "true", "until", "while",
"//", "..", "...", "==", ">=", "<=", "~=",
"<<", ">>", "::", "<eof>",
"<number>", "<integer>", "<name>", "<string>"
};
Однако просто заменить function
на fn
не годится, т.к. сломается совместимость. Добавить же в массив нельзя т.к. порядок в нём строго соответствует энуму из llex.h
:
enum RESERVED {
/* terminal symbols denoted by reserved words */
TK_AND = FIRST_RESERVED, TK_BREAK,
TK_DO, TK_ELSE, TK_ELSEIF, TK_END, TK_FALSE, TK_FOR, TK_FUNCTION,
TK_GLOBAL, TK_GOTO, TK_IF, TK_IN, TK_LOCAL, TK_NIL, TK_NOT, TK_OR,
TK_REPEAT, TK_RETURN, TK_THEN, TK_TRUE, TK_UNTIL, TK_WHILE,
// ...
}
И генерация кода токена по ключевому слову происходит так — когда прочтён идентификатор, мы ищем его в luaX_tokens
— и если находим — возвращаем соответствующий по порядку элемент из энума (это в «дефолтной» ветке того же свитча):
if (lislalpha(ls->current)) { /* identifier or reserved word? */
TString *ts;
do {
save_and_next(ls);
} while (lislalnum(ls->current));
// ...
if (isreserved(ts)) /* reserved word? */
return ts->extra - 1 + FIRST_RESERVED;
// ...
}
Что тут поделать? Снова вариантов масса — например заменить энум на массив интов, чтобы допустить повторения — либо ввести дополнительный токен и какое‑то средство мэпить его в существующий. Всё это хорошие‑красивые способы но они менее отвечают нашей цели создания «минимального» патча. Минимальным способом будет слегка «грязноватый» хак — просто добавим в это место (после while) проверку на сокращённое зарезервированное слово:
if (ls->buff->n == 2 && strncmp("fn", ls->buff->buffer, 2) == 0)
return TK_FUNCTION;
Если захочется сделать «алиасы» для более чем одного слова, имеет смысл сложить их в какой‑то массив и проверять здесь же в собственном цикле.
Заключение
В качестве демонстрации — вот небольшой код рисующий «сердечки» в полярных координатах, и использующий все три созданных нами «кусочка сахара». Вы, возможно, видели эти кривые (и сам код) на титульной картинке данной статьи.
https://rodiongork.github.io/lua‑emcc/example5.html
Конечно, это очень незначительные дополнения к синтаксису языка и без них вполне можно обходиться. С другой стороны удобство использования языка — и следовательно привлекательность для разработчиков — часто зависят именно от таких мелочей, и потому авторам языка не стоит игнорировать «глас народа»:)
Мы же этим «упражнением» показали несколько мест в исходниках Lua которые могут и вам помочь кастомизировать этот прекрасный язык, если случится встраивать его в свои приложения — и захочется что‑то добавить по вкусу.
P. S. на вопрос «можно ли сделать то же в LuaJIT» — я заглядывал в код и по‑моему, хотя он аккуратно переписан, всё же очень близко повторяет функционал в данной части (лексер/парсер) — так что не должно вызвать затруднений.
iklin
>> за индексацию массивов не брался
У Tsoding-а на одном из его бесчисленных стримов это было, где-то на ютубе лежит. Смотреть не смотрел, просто на глаза попадалось.