Переполнение стека из рекурсивного вызова функции в Лиспе

Я учусь шепелявить из книги" Земля шепелявить " Конрада Барски. Теперь я попал в свой первый камень преткновения, где автор говорит:

вызов себя таким образом не только разрешен в Lisp, но часто настоятельно рекомендуется

после показа следующего примера функции для подсчета элементов в списке:

(defun my-length (list)
  (if list
    (1+ (my-length (cdr list)))
    0))

когда я вызываю эту функцию my-length со списком, содержащим миллион элементов, я получаю переполнение стека ошибка. Поэтому либо вы никогда не ожидаете, что список будет таким длинным в Lisp (поэтому, возможно, мой вариант использования бесполезен), либо есть другой способ подсчета элементов в таком длинном списке. Можешь пролить свет на это? (Кстати, я использую GNU CLISP в Windows).

4 ответов


создание рекурсивных функций для работы с рекурсивными структурами данных действительно хорошо для lisp. И список (в lisp) определяется как рекурсивная структура данных, поэтому вы должны быть в порядке.

однако, как вы уже испытали, при прохождении datastructure миллион элементов глубоко с помощью рекурсии также выделит миллион кадров в стеке, и вы можете ожидать переполнения стека, если вы специально не попросите свою среду выполнения выделить огромное количество стекового пространства (у меня нет идея, Если или как вы могли бы сделать это в gnu clisp...).

прежде всего, я думаю, что это показывает, что list-datastructure на самом деле не является лучшим для всего, и в этом случае другая структура может быть лучше (однако вы, возможно, еще не пришли к векторам в вашей lisp-книге ;-)

другое дело, что для эффективности таких больших рекурсий компилятор должен оптимизировать хвостовые рекурсии и преобразовать их в итерации. Я не знаю, есть ли у clisp это функциональности, но вы должны изменить свои функции, чтобы на самом деле быть optimisable. (Если "оптимизация хвостовой рекурсии" не имеет смысла, пожалуйста, дайте мне знать, и я могу выкопать некоторые ссылки)

для других способов итерации взгляните на:

или другие datastructures:


TCO (оптимизация хвостового вызова) в CLISP на примере Криса Тейлора:

[1]> (defun helper (acc list)
       (if list
           (helper (1+ acc) (cdr list))
           acc))

(defun my-length (list)
  (helper 0 list))

HELPER

теперь скомпилировать его:

[2]> (compile 'helper)
MY-LENGTH
[3]> (my-length (loop repeat 100000 collect t))

*** - Program stack overflow. RESET

теперь, выше не работает. Давайте установим низкий уровень отладки. Это позволяет компилятору делать TCO.

[4]> (proclaim '(optimize (debug 1)))
NIL

снова скомпилировать.

[5]> (compile 'helper)
HELPER ;
NIL ;
NIL
[6]> (my-length (loop repeat 100000 collect t))
100000
[7]> 

строительство.

разрешение компилятору Common Lisp делать TCO чаще всего контролируется уровнем отладки. С высоким уровнем отладки компилятор генерирует код, который использует кадр стека для каждого вызова функции. Таким образом, каждый вызов может быть прослежен и будет виден в обратном направлении. При более низком уровне отладки компилятор может заменить хвостовые вызовы прыжками в скомпилированном коде. Эти звонки тогда не будет видно в backtrace-что обычно затрудняет отладку.


вы ищете хвостовая рекурсия. На данный момент ваша функция определена как

(defun my-length (list)
  (if list
    (1+ (my-length (cdr list)))
    0))

обратите внимание, что после my-length вызвал себя, ему нужно добавить один к результату, прежде чем передавать это значение своей вызывающей функции. Эта необходимость изменить значение перед возвращением означает, что вам нужно выделить новый кадр стека для каждого вызова, используемое пространство пропорционально длине списка. Это то, что вызывает переполнение стека на долго списки.

вы можете переписать его, чтобы использовать вспомогательную функцию

(defun helper (acc list)
  (if list
    (helper (1+ acc) (cdr list))
    acc))

(defun my-length (list)
    (helper 0 list))

вспомогательная функция принимает два параметра, элемент накопление параметр acc, который хранит длину списка до сих пор, а список list это список, длину которого мы вычисляем.

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

эта форма преобразования программы - от рекурсивного (но не-хвост-рекурсивного) определения до эквивалентного определения с использованием хвост-рекурсивной вспомогательной функции является важной идиомой в функциональном программировании-один что стоит потратить немного времени на понимание.


(DEFUN nrelem(l) 
    (if (null l)
        0
       (+ (nrelem (rest l)) 1)
))