kbool: классы в духе Free Pascal, модули и библиотека классов для bash
Скрипт на bash начинается с двадцати строк. Через пару лет в нём две тысячи строк, три десятка функций, глобальные переменные с префиксами, чтобы они не столкнулись, source "$(dirname "$0")/lib/…" в начале каждого файла и $(…) вокруг каждого вызова, потому что функция «возвращает» значение через stdout. Переписать всё на Python можно не всегда: скрипт живёт там, где гарантированно есть только bash и базовые утилиты, или его поддерживают люди, которые пишут на bash.
Вторая половина проблемы — переносимость. sed -i, stat -c, date -d, grep -Z, \b в регулярном выражении, nc -l ведут себя по-разному на Ubuntu, на Alpine с busybox, на FreeBSD, на macOS и в Git-bash на Windows. Скрипт, который год работал на одной машине, ломается на соседней.
kbool — попытка дать таким скриптам привычки Free Pascal: модули, которые подключаются по имени через uses, классы, объявленные как class … end, и библиотеку классов со знакомыми именами: TStringList, TDictionary, TIniFile, TRegEx. Вышла первая версия, 0.1.0. Проект разработан с помощью ИИ, и об этом ниже есть отдельный раздел: кто что решал, как проверялся код и какие у этого цифры.
Репозиторий: https://github.com/Keksov/kbool, лицензия MIT.
Что внутри
kbool — это пять git-репозиториев: корневой и четыре подмодуля.
kkore — системные модули: загрузчик
kk.uses, конфигурация, обработчик ошибок.kklass — классы: поля, свойства с геттерами и сеттерами, конструкторы и деструкторы, наследование с
inherited, статические члены, секции видимости.kcl — библиотека классов, 29 модулей: порты классов FPC и Delphi (
TList,TStringList,TDictionary,TIniFile,TPath,TRegEx,DateUtils,Math…), обёртки над утилитами командной строки (TGrep,TSed,TAwk,TFind,THead,TTail) и небольшой HTTP-сервер.ktests — тестовый фреймворк и «гейт»: одна команда, которая прогоняет все наборы тестов.
Нужен bash 5.1 или новее. POSIX sh и старые bash не поддерживаются: kbool.sh проверяет версию первым делом и отказывается работать с кодом выхода 2 и одной строкой в stderr, а не падает на середине загрузки.
Класс
Класс объявляется в стиле паскалевского interface: сначала структура, потом тела методов как обычные функции bash, потом build:
source /path/to/kbool/kbool.sh || exit
kk.uses kklass_pascal || exit # Pascal DSL; ядро kklass он загрузит сам
class TCounter
public
var Name
var Count
constructor Create
proc Add
func Report
end
TCounter.Create() { Name="$1"; Count=0; }
TCounter.Add() { Count=$(( Count + ${1:-1} )); }
TCounter.Report() { RESULT="$Name: $Count"; }
build TCounter
TCounter.new basket apples
basket.Add
basket.Add 2
basket.Report; echo "$RESULT" # => apples: 3
basket.Count # => 3
basket.Count = 10
echo "$(basket.Report)" # => apples: 10
basket.delete
Этот пример взят из README и прогоняется тестами как есть. Несколько деталей:
тела методов — настоящие функции, поэтому редактор их подсвечивает, и не нужно экранировать кавычки внутри строк;
внутри тела поля — обычные переменные (
Name,Count);funcотдаёт значение в$RESULTи печатает его только внутри$( ). Почему так, видно в разделе о стоимости: подстановка$( )— это fork, а fork дорог.
Наследование и вызов родителя выглядят так, как ожидает человек, писавший на Delphi:
# TAnimal (Name, конструктор Create, Speak) объявлен и собран выше
class TDog : TAnimal
public
var Breed
constructor Create
override proc Speak
end
TDog.Create() { inherited; Breed="${2:-mutt}"; } # конструктор родителя, аргументы передаются
TDog.Speak() { echo "$Name barks"; inherited Speak; }
build TDog
Все методы виртуальные; override — это проверка при сборке, что у предка такой метод есть.
Библиотека
Модуль подключается по имени; повторное подключение ничего не делает:
source /path/to/kbool/kbool.sh || exit
kk.uses tstringlist tdictionary tinifile || exit
TStringList.new names
names.Add Wirth
names.Add Hejlsberg
names.Sort
names.Get 0; echo "$RESULT" # => Hejlsberg
TDictionary.new langs
langs.Add Wirth Pascal
langs.Add Wirth Oberon || echo "duplicate: rc=$?" # => duplicate: rc=1
langs.TryGetValue Wirth && echo "$RESULT" # => Pascal
TMemIniFile.new cfg settings.ini
cfg.WriteString main editor lazarus
cfg.UpdateFile
cfg.ReadString main editor vim; echo "$RESULT" # => lazarus
У всех модулей kcl один контракт: значение возвращается в RESULT, а не в stdout; код возврата 0 — успех, 1 — «нет» или «не найдено» (молча, без сообщений), 2 — неправильный вызов; модули работают под set -eu и не делают fork на горячих путях. Отклонения от FPC перечислены для каждого модуля в его README — например, TRegEx работает на POSIX ERE из bash, а не на PCRE, поэтому ленивых квантификаторов и lookaround нет.
Модули и uses
kk.uses tlist tdictionary загружает каждый модуль один раз, по имени, из пути поиска. Есть файл конфигурации и проверяемые пространства имён, а бухгалтерии вида source "$(dirname …)/…" нет. Файл модуля можно подключить и напрямую, source kcl/tlist/tlist.sh: заголовок модуля сам загрузит kbool.sh, если тот ещё не загружен, и модуль всё равно загрузится один раз, каким бы путём до него ни дошли. В файле с классами на Pascal DSL есть директива uses A B, она делает то же самое, а имена разрешает относительно этого файла.
Модуль сам объявляет, от чего зависит: kk.uses tstringlist подтянет и kklass, и всё, что нужно списку.
Демо: сервис заметок на bash
В examples/notes.sh — HTTP-сервис заметок: около 175 строк кода без комментариев и пустых строк. Он показывает три модуля библиотеки и одну идею: обработчики запросов выполняются в том же процессе bash, что и сервер, поэтому объекты живут между запросами.
Класс, методы которого — обработчики маршрутов:
class TNotes
public
constructor Create
destructor Destroy
proc Help
proc List
proc Add
proc Show
proc Remove
proc Search
proc Quit
proc NotFound
private
var _ini
func _Ids
end
Заметки хранятся в INI-файле через TMemIniFile, поэтому переживают перезапуск. Поиск ищет слово целиком, без учёта регистра, через TRegEx и \b:
TNotes.Search() {
local word ids id out=""
if ! $1.QueryField q || ! [[ "$RESULT" =~ ^[A-Za-z0-9_]+$ ]]; then
$2.Code = 400
$2.Write $'use /search?q=WORD (letters, digits, underscore)\n'
return 0
fi
word="$RESULT"
$this._Ids
ids="$RESULT"
for id in $ids; do
"$_ini.ReadString" notes "$id" ""
if TRegEx.isMatch "$RESULT" '\b'"$word"'\b' i; then
out+="$id: $RESULT"$'\n'
fi
done
$2.Write "$out"
}
Маршруты регистрируются в приложении (THttpApplication, наследник порта TCustomApplication):
App.RegisterRoute / GET Notes.Help
App.RegisterRoute /notes GET Notes.List
App.RegisterRoute /notes POST Notes.Add
App.RegisterRoute /notes/:id GET Notes.Show
App.RegisterRoute /notes/:id DELETE Notes.Remove
App.RegisterRoute /search GET Notes.Search
App.RegisterRoute /quit POST Notes.Quit
App.RegisterRoute /*rest ALL Notes.NotFound
examples/notes_session.sh запускает сервис на свободном порту и проигрывает короткую сессию curl. Так она выглядела при проверке «чистой установки» по README на Ubuntu:
$ curl -s localhost:39218/notes -d 'buy milk'
1
$ curl -s localhost:39218/notes -d 'call Alice about the milk'
2
$ curl -s localhost:39218/notes -d 'fix the bike'
3
$ curl -s 'localhost:39218/search?q=milk'
1: buy milk
2: call Alice about the milk
$ curl -s -X DELETE localhost:39218/notes/2
$ curl -s localhost:39218/notes
1: buy milk
3: fix the bike
$ curl -s -X POST localhost:39218/quit
bye
server exited: 0
Ограничения сервера честно записаны в его README: один запрос за раз, без keep-alive, около 10 запросов в секунду последовательно, только текстовые тела, обычный HTTP без аутентификации — держите его на 127.0.0.1. Слушает он через GNU netcat 0.7.1 или socat 1.7.4+.
Переносимость: «зоопарк»
Тесты kbool в CI идут на 13 конфигурациях на каждый push в main и каждый pull request: bash 5.1, 5.2 и 5.3 на Linux (glibc и musl), текущий bash на macOS, FreeBSD и Windows (Git-bash и MSYS2). До CI те же тесты гонялись в лабораторных виртуальных машинах. Вот что они нашли — каждый пункт закреплён тестом, полный список в docs/portability.md.
mawk — awk по умолчанию в Debian и Ubuntu. Движок вещественной арифметики модуля
Mathнаписан на awk, и в mawk он молча не работал: парсер переполнялся на длинной цепочкеelse if, на запросы через пайп mawk отвечает только с-W interactive, аnan == 0у него истинно. Теперь там плоская диспетчеризация, сравнения защищены от NaN, и одна программа работает на gawk, mawk, BWK awk и busybox awk.bash 5.1.
declare -fпечатает>&"$fd"как&> "$fd". После такого круга команда пишет stdout и stderr в файл с именем дескриптора, а kklass пропускает черезdeclare -fкаждое тело метода. Ещё в 5.1unset a["$k"]раскрывает ключ дважды, и ключ@очищает весь массив. Код обходит обе формы, а статическая проверка в каждом репозитории их не пропускает.Десятичная запятая. В локали
ru_RUилиde_DEbash печатаетEPOCHREALTIMEкак1788676658,546051, а$(( ))читает запятую как оператор. Замеры времени берут только цифры.\<,\>,\bпо-разному пишутся (или отсутствуют) в glibc, musl, libc FreeBSD, macOS и newlib в cygwin.TRegExодин раз проверяет C-библиотеку и переписывает шаблон в её написание, сохраняя номера групп.FreeBSD. В pkg-сборке bash
/dev/fd/Nэмулируется самим bash, а<( )— это FIFO, писатель которого блокируется до прихода читателя. Быстрый захват тел методов там отключается сам, а FIFO открывается и удаляется явно.Linux под нагрузкой иногда теряет код выхода производителя в
<( )(около 0,3 % быстрых производителей при восьмикратной нагрузке).TPipeвозвращает его через отдельный пайп статуса.netcat. У GNU netcat, OpenBSD nc, ncat и busybox nc флаг
-cзначит разное, а OpenBSD nc терял каждого второго клиента. Сервер работает на GNU netcat или socat, остальные отклоняет по имени.CRLF. Утилиты cygwin в текстовом режиме отбрасывают CR, утилиты Linux и BSD его оставляют.
TGrep,TSedиTAwkотбрасывают CR в текстовом режиме везде.
Разделение такое. Ядро (kkore, kklass и модули kcl, кроме обёрток и HTTP-сервера) не требует GNU утилит и даёт одинаковые результаты на всех конфигурациях CI. Обёртки дают поведение GNU-утилиты везде или понятную ошибку: они ищут gsed, gawk, ggrep и т. д., потом обычное имя, если это GNU, а без них каждый вызов возвращает код 2 с причиной, где назван пакет для установки.
Сколько это стоит
Все цифры — из docs/benchmarks.md, медианы трёх прогонов на одной машине (AMD Ryzen 7 8845H): Ubuntu 24.04 в VirtualBox (bash 5.2.21) и Git-bash на Windows-хосте (bash 5.3.15). Сравнивать столбцы между собой не стоит: VM добавляет свои накладные расходы, и версии bash разные.
что | VM | Windows |
|---|---|---|
вызов пустой функции bash | 7.018 µs | 5.032 µs |
один fork, | 750.395 µs | 16008.55 µs |
вызов метода | 266.6 µs | 212.9 µs |
чтение свойства | 45.9 µs | 34.8 µs |
| 337 µs | 235 µs |
| 12.6 ms | 32.8 ms |
| 152.4 ms | 171.7 ms |
| 431.5 ms | 383.7 ms |
Главное здесь — строка с fork. Тот же GetItem из словаря, вызванный как v=$(d.GetItem k), стоил 2756 µs на Linux и 31617 µs на Windows, а прямой вызов в том же прогоне — 370 µs и 253 µs. Поэтому члены классов возвращают значение в переменной, а внешняя утилита запускается один раз на вызов, а не на каждую запись.
Обёртки над утилитами добавляют фиксированную цену: от 2.9 до 6.4 ms на вызов. На Linux это делает обёртку в 2–7 раз медленнее прямого вызова утилиты, на Windows, где доминирует запуск процесса, — в 1.19–1.38 раза.
Когда kbool не нужен: если скрипт перемалывает тысячи записей, bash медленный сам по себе. count у THead по 5000 строкам занял 420.83 ms на Linux против 2.94 ms у head -n 5000 | wc -l. Для числа или отфильтрованного файла правильный инструмент — конвейер из grep, awk, sort. kbool окупается там, где скрипт тратит время на логику, структуру и обработку ошибок.
Тесты
Все наборы тестов написаны на ktests и запускаются «гейтом», ktests/tools/gate.sh: одна команда, одна итоговая строка __GATE__:… и код выхода. Каждый набор идёт в своей «песочнице» (свой HOME, временный и рабочий каталоги, окружение «запрещено всё, что не разрешено»), а гейт проверяет, не оставил ли тест после себя процессов. Случай, который на платформе выполнить нельзя, засчитывается как SKIP: <причина> или N/A: <причина>, поэтому число тестов в наборе одинаково на всех конфигурациях, и изменившееся число что-то значит.
Чего нет и что ограничено
Только bash 5.1+, не POSIX
sh; на macOS нужен bash из Homebrew.TRegEx— POSIX ERE из bash: нет ленивых квантификаторов, lookaround и именованных групп.Обёртки требуют GNU-утилиты.
HTTP-сервер — демонстрация, а не веб-сервер для продакшена.
Загрузка с модулем стоит 150–430 ms на скрипт: для утилиты, которую вызывают в цикле, это заметно.
fpjsonзапланирован, но не реализован.
Как это сделано: с помощью ИИ, открыто
kbool разработан с помощью ИИ, и это часть истории проекта, а не сноска. Роли:
Мейнтейнер (я) решает: границы проекта, каждый вопрос дизайна, у которого больше одного разумного ответа, и что выпускать. В
mainизменения попадают только через меня; коммиты, написанные с Claude, несут трейлерCo-Authored-By: Claude ….Сессия-супервизор (Claude) планирует фазу. На каждый шаг она пишет карточку задачи: цель, файлы, которые можно трогать, проверки приёмки — и отдаёт её исполнителю: Claude Opus для дизайна, ядра и текстов, Claude Sonnet для хорошо специфицированных задач. Отчёт исполнителя супервизор сверяет с реальным деревом до коммита.
Независимый критик — свежая сессия без контекста исполнителя — проверяет изменения ядра.
Каждый коммит проходит гейт на двух рантаймах bash: Git-bash 5.3.15 и MSYS2 5.3.9. Полный прогон гейта от 31 октября 2026 года: 30 наборов тестов, 9694 теста на 5.3.15 и 9695 на 5.3.9, все прошли, всего 19389. С 30 октября каждый push в
mainи каждый pull request дополнительно проходит 13 конфигураций CI.
Гейты действительно ловят проблемы. Первый прогон на Linux (Ubuntu, Debian, Fedora и Debian под de_DE) дал 111 упавших тестовых файлов (по всем машинам и локалям вместе), прогон на Alpine и FreeBSD — 240, первые запуски CI упали на 5 конфигурациях из 13: 72 упавших случая с 14 причинами. Всё это разобрано, исправлено и закреплено тестами, а матрица CI потом прошла дважды подряд на всех 13 конфигурациях.
Ревью находит ошибки и в самой системе проверки: в одном из раундов выяснилось, что прерванный тестовый файл мог отчитаться зелёным, и ktests это исправил. Ревью kklass закрыло 12 найденных проблем; .new после него стал быстрее в 4,8 раза.
Вся история открыта: планы, каждое решение с причиной и 27 машиночитаемых журналов фаз лежат в репозиториях в docs/dev/ (оглавление). Можно не верить тексту на слово, а прочитать, как принималось конкретное решение и каким тестом оно закреплено.
Статус и как попробовать
Версия 0.1.0 — первая, одна для всех пяти репозиториев. В линейке 0.x API может меняться между минорными версиями.
git clone --recurse-submodules https://github.com/Keksov/kbool.git
cd kbool
bash examples/notes_session.sh # нужны curl и socat (или GNU netcat 0.7.1)
bash ktests/tools/gate.sh all # все тесты под вашим bash
Ничего не собирается и не устанавливается: скрипт делает source kbool.sh оттуда, где лежит дерево, и называет нужные модули. На macOS понадобится brew install bash (системный /bin/bash 3.2 kbool отклонит), на FreeBSD — pkg install bash.
Особенно интересна обратная связь от тех, кто писал на Free Pascal или Delphi: насколько точно перенесены классы и где поведение расходится с ожиданиями. Issues и pull requests — в https://github.com/Keksov/kbool.
P.S. kbool посвящён памяти Никлауса Вирта (1934–2024), создателя Pascal, Modula-2 и Oberon.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.