Переписал ядро языка целиком. Ни один из 444 эталонов не сдвинулся

от автора

Свой язык программирования пишут все. Обычно это калькулятор с переменными, пересказ главы про рекурсивный спуск и заброшенный репозиторий. Я тоже написал — и интересно в нём не то, что он считает выражения.

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

Код открыт под MIT: https://github.com/BOTIROFF-D/sable

Язык на 5 669 строк без единой зависимости

Sable написан на TypeScript и работает на Node 22.6+. Сборки нет вообще: Node исполняет .ts нативно, tsc нужен только для проверки типов. В рантайме — ноль пакетов. Весь тулчейн выглядит так:

node src/cli.ts file.sable

Конвейер обычный: лексер → парсер → AST → выполнение. Необычно то, во что превратилась последняя стрелка, но об этом ниже. Сам язык вышел таким:

// частота слов: строки, словари, сортировкаfn word_frequency(text) {  let counts = {}  for word in text.lower().split(" ") {    if word == "" { continue }    counts.set(word, counts.get(word, 0) + 1)  }  return counts}let ranked = word_frequency(TEXT).entries().sort((a, b) -> b[1] - a[1])for pair in ranked {  print("  ${pair[0].pad_end(12, '.')} ${pair[1]}")}

Структуры с методами, замыкания, модули, try/catch, строковые вставки, около шестидесяти встроенных функций. Дальше про язык почти не будет — будет про то, как держать такое в руках.

Пять замков, и каждый закрывает то, что гниёт молча

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

Ошибка выполнения: имя «countr» не определено — возможно, имелось в виду «counter»  --> pay.sable:7:22  |7 |   let remaining = countr - amount  |                   ^  в withdraw (pay.sable:12:16)  в main (pay.sable:18:5)

Этот вывод — часть контракта. Поменяется хоть одно слово — тест покраснеет.

Почему так. Формулировка ошибки — это интерфейс, которым пользуются чаще любого API. Она портится незаметно: рефакторинг задел строку, сообщение стало на полтона глупее, никто не заметил, потому что «тесты же зелёные». Если текст под замком — не портится.

По той же логике заперто ещё четыре вещи, и все они из категории «гниёт молча».

Замок на документацию

tests/docs.ts вытаскивает из markdown пары «блок кода → блок вывода», выполняет код во временной папке и сверяет с напечатанным. Документация, где вывод придуман, ломает сборку.

Это единственный известный мне способ не врать читателю. Пример в статье или в README живёт своей жизнью: язык поехал, вывод изменился, а в тексте остался прежний. Читатель повторяет и получает другое — и перестаёт верить всему остальному.

Замок на подсветку синтаксиса

tests/grammar.ts сверяет списки слов в TextMate-грамматике для VS Code с настоящими списками: ключевые слова берутся из token.ts, встроенные функции — прямо из интерпретатора.

Подсветка — чемпион по тихому гниению. Добавили ключевое слово, оно просто перестало подсвечиваться, и это может не всплыть годами: никто не смотрит на подсветку, все смотрят сквозь неё. Тест окупился в первый же запуск — поймал, что \p{Cyrillic} понимает движок VS Code, но не JavaScript, где нужно \p{Script=Cyrillic}.

Замок на форматтер

Четыре свойства на каждом файле репозитория: format(format(x)) совпадает с format(x); дерево после форматирования то же самое без учёта позиций; список комментариев сохранён; программа печатает ровно то же. 328 проверок.

Замок на статический анализатор

Каждая проверка покрыта двумя кейсами: «ловит» и «не срабатывает ложно». Второй важнее первого, и почему — отдельный разговор ниже.

Что замок поймал на самом деле

Теория стоит ровно столько, сколько поймала на практике. Считаю.

Два полных переименования. Проект успел побывать под тремя именами, прежде чем стать Sable. Каждый раз менялось всё: папка, репозиторий, пакет, команда, расширение семидесяти четырёх файлов, классы значений, переменные окружения, грамматика редактора, документация. Оба раза ломался ровно один эталон — тот, где имя языка лежало внутри тестовых данных и проходило через .upper(). Больше ничего. Это и есть работающий замок: он молчит там, где всё хорошо, и говорит ровно про то место, где надо принять решение.

Полная перестройка ядра. Триста пятьдесят строк: обход дерева заменён компиляцией узлов в замыкания. Все 444 проверки прошли, ни один эталон не пришлось трогать. Без такого набора я бы просто не решился на эту правку — а точнее, решился бы и потом месяц ловил последствия.

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

Три икса, и ни одной догадки

Скорость языка сначала не мерил никто, включая меня. Поэтому первым делом появился не оптимизатор, а стенд: одиннадцать программ разной нагрузки, прогоны с прогревом, сравнение с сохранённым базовым замером. Правило простое — ни одной правки без замера.

Профиль назвал виновника, которого я бы не заподозрил. Сигналы return, break и continue были сделаны исключениями — это дёшево писать и невозможно забыть протащить через промежуточные узлы. Замер отдельным тестом: пятьсот тысяч вызовов с return — 0,26 с, без return — 0,16 с. Около двухсот наносекунд на бросок, половина стоимости самого вызова функции.

Сигналы стали значениями: execute возвращает число, а значение return кладётся в поле. Дальше по профилю пошли двухъярусная область видимости (пустой не нужно хранилище, первое имя лежит прямо в поле объекта), счётчик глубины вместо массива кадров, прямой вызов метода без создания промежуточного объекта-функции, обход диапазона счётчиком без разворачивания в список, индекс строки без нарезки её на символы.

Последним ушёл сам диспетчер. На каждое вычисление уходил switch по виду узла — 22–40% времени по профилю. Теперь каждый узел один раз превращается в замыкание, и в горячем цикле остаётся вызов готовой функции. Тело функции компилируется на объявление, а не на создание: fn() { … } в цикле порождает много значений-функций, но скомпилированная часть у них общая.

Замер

Было, мс

Стало, мс

Выигрыш

fib

155,20

23,83

6,5×

calls

257,45

58,14

4,4×

closures

109,59

34,67

3,2×

forloop

85,40

27,06

3,2×

structs

140,21

47,27

3,0×

lists

89,25

30,47

2,9×

strindex

186,55

72,55

2,6×

maps

48,75

27,09

1,8×

strings

39,65

21,45

1,8×

Суммарно

1 325,71

436,21

3,0×

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

А теперь про то, что эти оптимизации сломали

После них fn d(n) { return d(n + 1) } стал падать с сообщением о глубокой вложенности вычислений вместо честного «слишком глубокая рекурсия: больше 900 вложенных вызовов». То есть вместо ошибки языка пользователь снова видел срыв стека JavaScript — ровно то, от чего этот предел и был поставлен.

Причина: добавленные локальные переменные раздули кадр JS. А запас, как оказалось, был не тем, что я думал. Я замерил реальную ёмкость двоичным поиском по переменной окружения — и она оказалась около 950 вызовов при пороге 900. Запас был пятьдесят вызовов, а в документации у меня значилось «около 1100». Оптимизации съели его до 887 — ниже порога.

Урок дороже самого бага: я мерил ёмкость тривиальной рекурсивной функцией. Это самый лёгкий случай. Чем больше в теле функции вложенных выражений, литералов и структур, тем больше кадров JS уходит на один вызов языка — и тем раньше кончается стек.

Сейчас ёмкость — около 1980 вызовов на простой рекурсии и около 1580 на тяжёлой. Порог остался 900, хотя запас вырос почти вдвое: поднять до 1500 значило бы оставить в худшем случае семьдесят восемь вызовов. Не поднимаю без нового замера. А сторож в стенде замеров теперь проверяет инвариант намеренно тяжёлой рекурсией — прежний брал лёгкую и такую регрессию пропустил бы.

Фаззер нашёл двенадцать дефектов. Два из них — мои же тесты к его находкам

Генератор строит синтаксически корректные программы: следит за областями видимости, объявленными именами, арностью функций, глубиной циклов. Это принципиально — иначе фаззер тестирует парсер, а не язык. Оракул ловит семь классов сбоев, и любая находка автоматически сокращается: программа из 97 строк ужимается до четырёх. Отчёт про стостроконную случайную программу бесполезен, про четырёхстрочную — золото.

Самое злое из найденного.

Наружу лез undefined — в языке, где его нет

let xs = [1, 2, 3]let ys = xs.map(x -> xs.pop())print(ys, len(ys), ys[2], type(ys[2]))[3, 2, ] 3 undefined unknown

Колбэк укорачивал список прямо во время обхода, а Array.prototype.map честно оставлял в результате пустую ячейку. В языке нет значения undefined и нет типа unknown — но оба вылезли наружу. to_json превращал дыру в null, арифметика ругалась на тип, которого не существует.

Равенство перестало быть симметричным

Продолжение той же дыры, но злее. Сравнение шло через Array.prototype.every, а every пропускает пустые ячейки — то есть дыра «равна» чему угодно.

print(ys == [3, 2, 99])   trueprint([3, 2, 99] == ys)   false

Базовый закон языка ломался молча. Починка — обход по индексу, а не через every, и копия списка на входе в методы с колбэком: ровно то же правило, по которому уже работал цикл for.

+= вычислял цель дважды

let счётчик = 0fn next() { счётчик += 1; return счётчик - 1 }let xs = [10, 20]xs[next()] += 5print(xs, счётчик)        [10, 15] 2

Прочитали xs[0], записали в xs[1]. Парсер разворачивал a[i] += v в a[i] = a[i] + v, и цель попадала в дерево дважды. Починка потребовала правки AST, парсера, интерпретатора, анализатора и форматтера — оператор теперь живёт в узле присваивания.

И у неё был неожиданный побочный эффект. Форматтер приводил x = x + 1 к x += 1 как к канонической записи — на том основании, что парсер строит из них одно дерево. После починки это перестало быть правдой: a[i()] += 1 считает индекс один раз, a[i()] = a[i()] + 1 — два. Переписывание убрано. Форматтер выравнивает запись, а не меняет смысл.

Бесконечный цикл, который невозможно заметить глазами

for i in 0..1e308 { … }

Это не «долго». За пределом 2^53 прибавление единицы перестаёт двигать счётчик — цикл не кончится никогда, сколько бы вы ни ждали. Теперь границы диапазона проверяются, и такая запись сразу ошибка.

Там же нашлась несогласованность, которую я сам себе обещал не допускать: деление на ноль было ошибкой, а 0 ^ -1, pow(0, -1) и round(1e308, 2) молча возвращали inf. Бесконечность расползалась по программе и всплывала где-нибудь в JSON как null. Правило стало общим: в языке нет ни бесконечности, ни «не числа», любое переполнение — ошибка.

Два дефекта из двенадцати нашёл не фаззер, а я — когда писал тесты к его находкам. Лексер подавлял переводы строк внутри фигурных скобок в аргументах вызова, и тело функции, записанное прямо в аргументе, слипалось в одну инструкцию. А собственный проход форматтера по исходнику не видел строк внутри ${…} и портил корректный код. Это лучший аргумент за то, чтобы каждую находку запирать тестом, а не просто чинить: пока пишешь тест, находишь соседей.

Инструмент, который врёт, хуже отсутствующего

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

  • Маркеры «утёкших значений» искались в выводе команды форматирования — а её вывод и есть исходный код. Литерал "unknown", написанный автором программы, выглядел как дыра в рантайме.

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

  • Вывод исходной и отформатированной программы сравнивался вместе с именем файла — а они по определению лежат в разных файлах.

  • Поток вывода дочернего процесса декодировался кусками, и многобайтовый символ рвался на границе куска: «вторая» превращалась в «в?орая».

Последнее — мой любимый экземпляр. Инструмент проверки сам портил данные и предъявлял испытуемому. Тот же дефект нашёлся и в первом фаззере.

Я довёл оба до нуля ложных срабатываний, вместо того чтобы оставить «ну там пять находок, четыре из них шум». Причина простая: инструмент, который выдумывает дефекты, учит не верить собственным находкам. После этого он бесполезен — им перестают пользоваться ровно тогда, когда он наконец находит настоящее.

Бонусом оттуда же: в один из файлов затесался настоящий нулевой байт вместо экранированного \0. Из-за него grep считал файл двоичным и молча пропускал. Минут десять я не мог понять, почему поиск не находит строки, которые в файле точно есть.

Решения, за которые пришлось спорить с собой

Статический анализатор не встроен в запуск — намеренно

sable --check находит неизвестные имена, запись в const, недостижимый код, неверную арность, опечатки в полях структур. Логично было бы гонять его перед каждым запуском. Я этого не делаю.

Потому что он ошибается. Вот на этом коде:

try { print(имя_которого_может_не_быть) } catch e { print("нет") }

Статически имя действительно неизвестно. Программа при этом рабочая. Цена ложной тревоги здесь — рабочая программа отказывается запускаться; цена пропуска — сообщение об ошибке на секунду позже. Асимметрия очевидная, поэтому проверка — отдельная команда.

Та же логика внутри самого анализатора: он молчит везде, где не уверен. Тип значения выводится только из очевидного вроде let p = Point(1, 2), и если имени где-то в программе присваивают заново — тип считается неизвестным.

И это не теория. Фаззер поймал у анализатора ложную тревогу: тела функций проверялись на границе блока, где функция объявлена, — а имя в этом языке ищется в момент вызова. Код вида «функция внутри блока зовёт то, что объявлено ниже по файлу» работал, а анализатор называл его ошибкой. Худший класс дефекта для такого инструмента.

Форматтер, который никто не стал бы применять

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

// былоfn area() { return self.w * self.h }struct Num { value }// сталоfn area() {  return self.w * self.h}struct Num {  value}

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

Встроенные имена переехали в отдельную область

Когда стандартная библиотека доросла до sum, log, zip, exp, обычная строка let sum = 0 начала падать с «уже объявлено». Верхнеуровневые объявления лежали в той же области, что и встроенные имена. Теперь встроенные — отдельная родительская область: своё объявление их затеняет, а присваивание встроенному имени даёт понятную ошибку с подсказкой.

Чего в языке нет

Целочисленного типа — всё числа с плавающей точкой, как в JavaScript. Глубина рекурсии упирается в стек JS и ограничена девятьюстами вызовами. Это не байткод и не виртуальная машина: следующий крупный выигрыш по замерам — разрешение имён в слоты, поиск по цепочке областей до сих пор занимает около десятой доли времени. Документация, учебник и все сообщения об ошибках на русском, и это сознательно сужает аудиторию.

Пишу это не из скромности. Статья, в которой всё получилось, читается как реклама и не стоит ничего.

Что из этого стоит забрать себе

  1. Запирайте то, что гниёт молча. Вывод примеров в документации, списки слов в подсветке, формулировки ошибок. Обычные тесты этого не ловят, потому что «функционально всё работает».

  2. Стенд замеров строится до первой оптимизации. Иначе «стало быстрее» — это вера. Мой главный виновник оказался не тем, на кого я думал.

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

  4. Ложная тревога дороже пропуска. Верно и для анализаторов, и для самих тестовых инструментов. Из этого следует, куда встраивать проверку, а куда нет.

  5. Каждую находку запирайте тестом, а не просто чините. Пока пишешь тест, находишь соседей: у меня так нашлись два дефекта из двенадцати.

  6. Сомневайтесь в своём инструменте раньше, чем в испытуемом. Четыре из пяти находок оказались враньём фаззера.

Код открыт под MIT: https://github.com/BOTIROFF-D/sable. Там же учебник, справочник языка и разбор устройства интерпретатора. Буду рад разносу в комментариях — особенно по местам, где я не заметил, что мой инструмент мне врёт.


Дониёр Ботиров — основатель THE BOTIROFF LLC (США), компании по разработке программного обеспечения, и холдинга BOTIROFF.

Строю продукты, которые каждый день работают у живых клиентов и держат их деньги, расписания и переписку: THE CRM — CRM с искусственным интеллектом для учебных центров, ULTRATHINK — учебный центр по ИИ, BOTIROFF SPACE — 3D-конфигуратор мебели с подбором через ИИ, dbit — разработка на заказ.

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

botiroff.com · github.com/BOTIROFF-D · t.me/mrdoniyor

ссылка на оригинал статьи https://habr.com/ru/articles/1074456/