Особенности Scheme: минимализм, хвостовая рекурсия и call/cc на примерах

Особенности Scheme: минимализм, хвостовая рекурсия и call/cc на примерах Полезное

Scheme — это диалект Lisp с маленьким ядром: несколько базовых форм, лексическая область видимости, обязательная оптимизация хвостовых вызовов и продолжения как обычные значения. Язык придумали Гай Стил и Джеральд Сассман в MIT в 1975 году, а сегодня его описывают стандарты серии RnRS, актуальный из них — R7RS.

Ниже разберем, какие особенности отличают Scheme от других языков семейства, и каждую покажем на коде. Все примеры проверены 23.09.2026 в chibi-scheme 0.9.1 и GNU Guile 3.0.9 (Ubuntu 24.04).

Три термина, которые путают

Прежде чем смотреть код, разведем понятия, которые в статьях о Scheme часто смешивают.

Термин Что это Пример
Язык Scheme Спецификация: синтаксис и семантика Документ R7RS
Стандарт RnRS Версия спецификации (Revised^n Report) R5RS (1998), R6RS (2007), R7RS small (2013)
Реализация Программа, которая исполняет код chibi-scheme, GNU Guile, Chez Scheme, Chicken, Gambit
Производный язык Язык, выросший из Scheme, но со своими правилами Racket (до 2010 года — PLT Scheme)

Практический вывод: код из учебника по R7RS обычно запускается в любой реализации, которая заявляет поддержку R7RS. Расширения конкретной реализации (модули Guile, #lang в Racket) переносятся уже не всегда.

Минимальная программа на Scheme

Сначала полный пример, который можно скопировать в файл hello.scm и запустить командой chibi-scheme hello.scm.

(import (scheme base) (scheme write))

(define (square x) (* x x))

(define (factorial n)
  (if (= n 0)
      1
      (* n (factorial (- n 1)))))

(display (square 7)) (newline)
(display (factorial 20)) (newline)
(display (factorial 30)) (newline)
(display (map square '(1 2 3 4))) (newline)
(display (/ 1 3)) (newline)

Результат прогона:

49
2432902008176640000
265252859812191058636308480000000
(1 4 9 16)
1/3

Что здесь видно:

  • Префиксная запись. Любой вызов — список в скобках, первым идет операция: (* x x), (+ 3 5). Операторов с приоритетами нет, поэтому скобки заменяют и приоритет, и разделители.
  • define задает и переменную (define имя значение), и функцию (define (имя параметры) тело).
  • Числа без переполнения. 30! не влезает в 64-битное целое, но Scheme печатает его точно. Деление (/ 1 3) дает точную дробь 1/3, а не 0.333. Точные целые произвольной длины и дроби входят в полную числовую башню стандарта; в минимальных реализациях набор типов может быть уже.
  • import подключает библиотеки R7RS: (scheme base) с базовыми формами и (scheme write) с процедурой display.

Guile выполнит тот же файл командой guile hello.scm, но предупредит в stderr, что (scheme base) переопределяет встроенную map. Результат при этом тот же.

Ключевые особенности Scheme

Маленькое ядро и один синтаксис для кода и данных

Код Scheme записан теми же списками, с которыми работает программа. Отсюда простота разбора и макросы, которые получают код как данные. Ядро небольшое: производные формы (let, cond, case и другие) в стандарте определены через примитивные.

Scheme — язык с единым пространством имен (так называемый Lisp-1): функция и переменная живут в одной таблице имен. В Common Lisp пространства раздельные, поэтому там нужен funcall, а в Scheme функцию из переменной вызывают просто (f x).

Лексическая область видимости и замыкания

Имя ищется там, где функция записана в тексте, а не там, откуда ее вызвали. Поэтому функция может «унести» с собой локальную переменную — это замыкание. Блоки задаются формами let, let* и letrec, и разница между ними видна на одном примере.

(import (scheme base) (scheme write))

(define x 10)

(let ((x 1) (y x))
  (display (list x y)) (newline))

(let* ((x 1) (y x))
  (display (list x y)) (newline))

(letrec ((my-even? (lambda (n) (if (= n 0) #t (my-odd? (- n 1)))))
         (my-odd?  (lambda (n) (if (= n 0) #f (my-even? (- n 1))))))
  (display (my-even? 100)) (newline))

(define (make-counter)
  (let ((count 0))
    (lambda ()
      (set! count (+ count 1))
      count)))

(define c (make-counter))
(c) (c)
(display (c)) (newline)
(1 10)
(1 1)
#t
3

Разбор по строкам вывода:

Форма Когда видны привязки Результат в примере
let Все значения вычисляются до связывания, y видит внешний x (1 10)
let* Последовательно, y видит только что связанный x (1 1)
letrec Привязки видят друг друга, нужно для взаимной рекурсии #t
Замыкание count живет, пока жива возвращенная функция 3

Хвостовая рекурсия гарантирована стандартом

Главная практическая особенность. Вызов в хвостовой позиции (последнее действие функции, после которого ничего не надо досчитывать) не наращивает стек. Стандарт R7RS требует этого от любой реализации, поэтому циклы в Scheme пишут рекурсией, а do в стандарте тоже есть, но используется реже.

(import (scheme base) (scheme write))

(define (sum-to n)
  (let loop ((i 1) (acc 0))
    (if (> i n)
        acc
        (loop (+ i 1) (+ acc i)))))

(display (sum-to 10)) (newline)
(display (sum-to 10000000)) (newline)
55
50000005000000

Форма (let loop ...) называется named let: она создает локальную функцию loop и сразу ее вызывает. Вызов (loop ...) стоит в хвостовой позиции, сумма копится в аккумуляторе acc, и десять миллионов итераций проходят в постоянной памяти стека.

Для сравнения — та же сумма без аккумулятора, где после рекурсивного вызова еще нужно сложить n:

(import (scheme base) (scheme write))

(define (sum-naive n)
  (if (= n 0)
      0
      (+ n (sum-naive (- n 1)))))

(display (sum-naive 10000000)) (newline)

Здесь поведение зависит от реализации, и это важная граница. chibi-scheme 0.9.1 завершилась с ошибкой:

ERROR: out of stack space

GNU Guile 3.0.9 напечатал 50000005000000: он умеет расширять стек по мере надобности, пока хватает памяти. Стандарт гарантирует постоянную память только для хвостовых вызовов; нехвостовая рекурсия расходует память пропорционально глубине в любой реализации, различается лишь предел. Исправление — перенести отложенную работу в аккумулятор, как в sum-to.

Продолжения: call/cc

Продолжение — это «остаток вычисления» в данной точке программы. Процедура call-with-current-continuation (сокращенно call/cc) передает его в функцию как обычное значение. Если вызвать это значение, программа сразу вернется в точку захвата. Простейшее применение — досрочный выход из обхода списка.

(import (scheme base) (scheme write))

(define (find-first pred lst)
  (call-with-current-continuation
    (lambda (return)
      (for-each (lambda (x)
                  (when (pred x) (return x)))
                lst)
      #f)))

(display (find-first even? '(1 3 8 5 10))) (newline)
(display (find-first even? '(1 3 5))) (newline)
8
#f

В первом списке for-each доходит до 8, вызов (return 8) прерывает обход, и 10 уже не проверяется. Во втором четных нет, и функция возвращает #f. Это упрощенная демонстрация: тем же механизмом в Scheme строят исключения, генераторы и сопрограммы. В прикладном коде для ошибок удобнее guard и raise из R7RS, а call/cc оставляют для таких конструкций.

Гигиенические макросы

Макрос через syntax-rules описывает шаблон преобразования кода. Гигиена означает, что внутренние имена макроса не конфликтуют с именами пользователя.

(import (scheme base) (scheme write))

(define-syntax swap!
  (syntax-rules ()
    ((_ a b)
     (let ((tmp a))
       (set! a b)
       (set! b tmp)))))

(define tmp 1)
(define other 2)
(swap! tmp other)
(display (list tmp other)) (newline)
(2 1)

Внутри макроса есть своя переменная tmp, и пользователь передал переменную с тем же именем. Обмен все равно прошел верно: макрос-система переименовала внутреннее имя.

Типичная ошибка: лишняя скобка

Большая часть ошибок новичка в Scheme — скобки. Неверный код с преждевременно закрытым if:

(import (scheme base) (scheme write))

(display
  (let loop ((n 1))
    (if (> n 10))
        '()
        (cons n (loop (+ n 1)))))
(newline)

chibi-scheme 0.9.1 сообщает (строки трассировки ниже опущены):

ERROR on line 5 of file ex6.scm: not enough args to if: (if (> n 10))

Guile 3.0.9 пишет иначе:

Syntax error:
unknown location: source expression failed to match any pattern in form (if (> n 10))

Исправление — закрывающая скобка переезжает в конец if, чтобы обе ветки оказались внутри:

(import (scheme base) (scheme write))

(display
  (let loop ((n 1))
    (if (> n 10)
        '()
        (cons n (loop (+ n 1))))))
(newline)
(1 2 3 4 5 6 7 8 9 10)

Обратите внимание: (cons n (loop ...)) — не хвостовой вызов, cons выполняется после возврата. Для десяти элементов это неважно, для миллионов лучше накопить список в аккумуляторе и развернуть его через reverse.

Реализации Scheme в 2026 году

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

Задача Что взять Особенности
Учеба по SICP и R7RS chibi-scheme, Guile Маленькие, ставятся из пакетов Linux
Учебная среда с IDE Racket (DrRacket) Отдельный язык-наследник, Scheme-диалекты через #lang
Скриптинг и встраивание в C-программы GNU Guile Официальный язык расширений GNU, на нем описывают пакеты GNU Guix
Быстрый нативный код Chez Scheme С версии 8.0 основа Racket CS
Компиляция в C Chicken, Gambit, Bigloo Удобно для утилит и связки с C-библиотеками

Где Scheme используют на практике

Самая известная роль Scheme — обучение. Книга «Structure and Interpretation of Computer Programs» (SICP) построена на Scheme, и по ней до сих пор учат основам программирования и устройству интерпретаторов. Сам курс MIT, для которого книгу писали, позже перешел на Python.

Вне учебы Scheme встречается как язык расширений (Guile в GNU-проектах, Guix), как основа Racket и как язык исследований в области компиляторов и языков программирования. Массового прикладного рынка вакансий именно под Scheme нет: идеи языка обычно переносят в работу на других функциональных языках.

Выводы

  • Scheme — минималистичный диалект Lisp, актуальный стандарт R7RS; язык, стандарт и реализация — три разные вещи.
  • Хвостовые вызовы по стандарту не расходуют стек, поэтому циклы пишут рекурсией с аккумулятором; глубина нехвостовой рекурсии упирается в предел конкретной реализации.
  • call/cc делает продолжения значениями и позволяет строить досрочный выход, исключения и генераторы.
  • Лексическая область видимости дает замыкания, а syntax-rules — гигиенические макросы без конфликтов имен.
  • Для первых шагов хватит chibi-scheme или Guile из пакетов Linux и книги SICP.

Где применяется / связь с практикой

Scheme редко становится рабочим языком, но его приемы напрямую переносятся в прикладные Lisp-диалекты. Ближайший пример — Clojure на JVM: те же скобки и код как данные, неизменяемые структуры, функции высшего порядка и макросы. Отличается модель циклов: JVM не дает гарантированной оптимизации хвостовых вызовов, поэтому в Clojure для этого есть явная форма recur.

Освойте тему на практике

Если хочется применить функциональный подход в продакшене, посмотрите программу «Clojure Developer». Чтобы сначала познакомиться с форматом занятий, загляните на бесплатные открытые уроки Otus.

FAQ

Чем Scheme отличается от Common Lisp?
Scheme меньше и строже стандартизирован по ядру, имеет единое пространство имен и гарантирует хвостовые вызовы. Common Lisp — большой промышленный стандарт с раздельными пространствами имен, CLOS и негигиеническими макросами defmacro.

Racket — это Scheme?
Racket вырос из PLT Scheme и сохранил многое из него, но это самостоятельный язык со своим стандартом и библиотеками. Код R5RS и R6RS в нем запускается через соответствующие #lang, поддержка R7RS ставится отдельным пакетом.

Что такое R7RS-large?
Это продолжение R7RS small с большой стандартной библиотекой. Работа над ним ведется по частям, поэтому для переносимого кода сегодня ориентируются на R7RS small.

OTUS Журнал