Scheme — один из самых чистых диалектов Lisp: маленький синтаксис, лексические замыкания, гарантированная оптимизация хвостовых вызовов и гигиенические макросы. На нём приятно учить функциональное программирование и писать небольшие утилиты, которые не тянут за собой гигабайты зависимостей. В статье — практический старт на GNU Guile: установка, REPL, списки, рекурсия, работа с файлами, тесты и компиляция.
Почему Scheme, а не Common Lisp
Common Lisp — большой промышленный язык с множеством встроенных форм и библиотек. Scheme сознательно минималистичен: в основе несколько специальных форм, а остальное вынесено в стандартные библиотеки SRFI. Это делает язык предсказуемым и удобным для встраивания. Guile дополнительно используется как язык расширения в GnuCash и других проектах GNU, поэтому навык пригодится не только в учебных задачах.
Стандарт R7RS определяет переносимый набор возможностей; реализации добавляют свои модули. Код на чистом R7RS будет работать в Guile, Racket, Chicken и Chez с минимальными правками.
Установка Guile
В Debian и Ubuntu пакет называется guile-3.0, в Fedora — guile30. После установки проверьте версию и запустите простейшее выражение.
sudo apt install guile-3.0
guile --version
guile -c '(begin (display (+ 1 2)) (newline))'
guile
Последняя команда открывает REPL. В нём можно вводить выражения и сразу видеть результат; выход — (exit) или Ctrl+D. REPL — главный инструмент отладки: не нужно создавать файл, чтобы проверить одну функцию.
База: define, lambda, let
Определение функции — это define с параметрами. Тело может состоять из нескольких выражений, возвращается последнее.
(define (square x) (* x x))
(define cube (lambda (x) (* x x x)))
(define (hypotenuse a b)
(sqrt (+ (square a) (square b))))
(display (hypotenuse 3 4))
(newline)
let создаёт локальные привязки, let* позволяет ссылаться на предыдущие, letrec — на взаимно рекурсивные функции. set! меняет существующую переменную, но злоупотреблять им не стоит: код с мутациями сложнее тестировать. В функциональном стиле результат собирают из возвращаемых значений, а не изменяют общий контейнер.
Списки и рекурсия
Список строится из пар (car . cdr), где car — первый элемент, а cdr — остаток. Пустой список обозначается '(). Основные операции: cons, car, cdr, null?, pair?.
(define xs '(1 2 3 4 5))
(display (car xs))
(display (cdr xs))
(display (map square xs))
(display (filter odd? xs))
(newline)
Хвостовая рекурсия в Scheme не растёт по стеку, поэтому цикл принято записывать через именованный let. Это идиоматичнее, чем do, и легче читается.
(define (sum-to n)
(let loop ((i 0) (acc 0))
(if (> i n)
acc
(loop (1+ i) (+ acc i)))))
(display (sum-to 100))
(newline)
Практический скрипт: частота слов
Соберём небольшой анализатор текста. Он читает файл построчно, разбивает строки на слова и считает повторы в хеш-таблице. Для работы со списками и строками подключим SRFI-1, SRFI-13 и SRFI-14.
#!/usr/bin/guile -s
!#
(use-modules (ice-9 rdelim)
(ice-9 format)
(srfi srfi-1)
(srfi srfi-13)
(srfi srfi-14))
(define (read-lines path)
(call-with-input-file path
(lambda (port)
(let loop ((line (read-line port)) (acc '()))
(if (eof-object? line)
(reverse acc)
(loop (read-line port) (cons line acc)))))))
(define (count-words path)
(let ((table (make-hash-table)))
(for-each
(lambda (line)
(for-each
(lambda (word)
(hash-set! table word (1+ (hash-ref table word 0))))
(string-tokenize line char-set:letter)))
(read-lines path))
table))
(hash-for-each
(lambda (word count) (format #t "~a ~a~%" word count))
(count-words "text.txt"))
Запуск: guile words.scm. Шебанг #!/usr/bin/guile -s и строка !# позволяют сделать файл исполняемым и запускать его как обычный скрипт. read-line возвращает объект конца файла, а не пустую строку, поэтому проверка идёт через eof-object?.
Обратите внимание на hash-ref с третьим аргументом: это значение по умолчанию для отсутствующего ключа. Без него программа упала бы на первом же новом слове. Такая мелочь типична для Scheme: библиотека не угадывает намерения, а требует явности.
Модули и тесты
Код удобно выносить в модуль. Файл words.scm объявляет экспорт, а основной скрипт подключает его через use-modules. Для тестов подойдёт SRFI-64: он не требует внешних зависимостей.
;; words.scm
(define-module (words)
#:export (count-words))
;; test-words.scm
(use-modules (srfi srfi-64) (words))
(test-begin "words")
(test-equal 4 (+ 2 2))
(test-end "words")
Запускайте тесты отдельным файлом и держите их рядом с кодом. Даже несколько проверок на граничные случаи — пустой файл, строка без слов, повторяющиеся разделители — экономят время при рефакторинге. В Scheme легко случайно изменить порядок аргументов, а тест поймает это сразу.
Компиляция и альтернативы
Guile компилирует байткод в .go-файлы и кэширует их. Для утилиты, которая запускается часто, имеет смысл собрать файл заранее.
guild compile -o words.go words.scm
guile words.scm
chmod +x words.scm
./words.scm
Если нужен самодостаточный бинарник, посмотрите на Chicken: csc -O2 hello.scm -o hello собирает нативный исполняемый файл. Racket предлагает мощную среду и raco exe, Chez Scheme славится скоростью запуска. Guile хорош балансом: быстрый старт, нормальная стандартная библиотека и тесная интеграция с GNU-системой.
Не ждите от Scheme экосистемы Python. Библиотек меньше, часть задач придётся решать через SRFI, FFI или вызов внешних утилит. Зато язык остаётся маленьким, а поведение программы предсказуемым: синтаксис и область видимости не меняются от контекста, как в языках с множеством неявных правил.
Чек-лист
- Установлен Guile, REPL запускается, версия видна.
- Функции определены через
define, локальные привязки — черезlet. - Циклы записаны хвостовой рекурсией, а не глубокой вложенностью.
- Для списков подключены SRFI-1, для строк — SRFI-13.
- Скрипт имеет шебанг и проверен запуском из терминала.
- Есть хотя бы минимальные тесты SRFI-64.
Итог
Scheme — язык, который учит думать о данных и функциях, а не о синтаксическом сахаре. На Guile можно за вечер написать парсер логов, преобразователь файлов или небольшой генератор отчётов, не устанавливая ничего лишнего. Начните с REPL и хвостовой рекурсии, затем добавьте модули и тесты. Минимализм здесь не ограничение, а способ держать всю программу в голове — качество, которое в больших языках встречается всё реже.