Может ли хвостовая рекурсивная функция получить переполнение стека? - PullRequest
1 голос
/ 19 июня 2020

Я решал некоторые задачи на сайте codesignal.com, используя C -Lisp, чтобы изучить его, и я избегал использования циклов для создания кода в стиле lisp. дает вам массив int a, который может быть очень большим, и просит вас вернуть массив / список {sumOfEvenIndexedElements, sumOfOddIndexedElements}) я получал ошибку переполнения стека с этим кодом:


(defun alternatingSums(a &optional (index 0) (accumulated '(0 0)))

    (cond ((= index (length a)) 
                accumulated)
          ((evenp index)
                (alternatingSums 
                  a
                  (1+ index)
                 `(,(+ (svref a index ) (elt accumulated 0)) ,(elt accumulated 1))) 
          )
          ((oddp index)
                (alternatingSums 
                  a
                  (1+ index)
                 `(,(elt accumulated 0) ,(+ (svref a index ) (elt accumulated 1))))
          )
    )

)

не так ли хвостовая рекурсия или могут ли хвостовые рекурсивные функции по-прежнему переполняться?

Ответы [ 2 ]

3 голосов
/ 19 июня 2020

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

Я избегал использования циклов для создания кода стиля lisp.

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

Хотя Common Lisp реализации не требуются для исключения хвостовых вызовов, многие это делают. Clisp поддерживает ограниченное исключение хвостовых вызовов, но только в скомпилированном коде и только для саморекурсивных хвостовых вызовов. Это плохо документировано, но здесь можно найти некоторые обсуждения @ Renzo . Опубликованный код OP будет подлежать исключению хвостового вызова при компиляции в Clisp, поскольку функция alternatingSums вызывает себя из хвостовой позиции. Это охватывает большинство случаев, в которых вас может заинтересовать исключение хвостового вызова, но обратите внимание, что в этом случае устранение хвостового вызова выполняется не для взаимно рекурсивных определений функций в Clisp. См. Пример в конце этого ответа.

Определение функции из REPL или загрузка определения из исходного файла приведет к интерпретируемому коду. Если вы работаете в среде разработки, такой как SLIME, ее легко скомпилировать: из буфера исходного файла выполните команду Ctrl - c Ctrl - k , чтобы скомпилировать весь файл и отправить его в REPL, или поместите точку внутри или сразу после определения функции и выполните Ctrl - c Ctrl - c, чтобы скомпилировать одно определение и отправить его в REPL.

Вы также можете скомпилировать исходный файл перед его загрузкой, например, (load (compile-file "my-file.lisp")). Или вы можете загрузить исходный файл и скомпилировать функцию после этого, например (load "my-file.lisp"), затем (compile 'my-function).

Как уже упоминалось, более вероятно, что код idiomati c Common Lisp будет в любом случае не используйте рекурсию для такого рода функций. Вот определение с использованием макроса loop, которое некоторые сочли бы более ясным и кратким:

(defun alternating-sums (xs)
  (loop for x across xs
     and i below (length xs)
     if (evenp i) sum x into evens
     else sum x into odds
     finally (return (list evens odds))))

Случай взаимно рекурсивных функций в Clisp

Вот простая пара взаимно рекурсивных определений функций:

(defun my-evenp (n)
  (cond ((zerop n) t)
        ((= 1 n) nil)
        (t (my-oddp (- n 1)))))

(defun my-oddp (n)
  (my-evenp (- n 1)))

Ни одна из функций не вызывает себя напрямую, но my-evenp имеет вызов на my-oddp в хвостовой позиции, а my-oddp имеет вызов my-evenp в хвостовой позиции. Хотелось бы, чтобы эти хвостовые вызовы были устранены, чтобы избежать взрыва стека для больших входных данных, но Clisp этого не делает. Вот разборка:

CL-USER> (disassemble 'my-evenp)

Disassembly of function MY-EVENP
14 byte-code instructions:
0     (LOAD&PUSH 1)
1     (CALLS2&JMPIF 172 L16)              ; ZEROP
4     (CONST&PUSH 0)                      ; 1
5     (LOAD&PUSH 2)
6     (CALLSR&JMPIF 1 47 L19)             ; =
10    (LOAD&DEC&PUSH 1)
12    (CALL1 1)                           ; MY-ODDP
14    (SKIP&RET 2)
16    L16
16    (T)
17    (SKIP&RET 2)
19    L19
19    (NIL)
20    (SKIP&RET 2)

CL-USER> (disassemble 'my-oddp)

Disassembly of function MY-ODDP
3 byte-code instructions:
0     (LOAD&DEC&PUSH 1)
2     (CALL1 0)                           ; MY-EVENP
4     (SKIP&RET 2)

Сравните с хвостовой рекурсивной функцией, которая вызывает сама себя. Здесь нет вызова factorial при разборке, но вместо этого была вставлена ​​инструкция перехода: (JMPTAIL 2 5 L0).

(defun factorial (n acc)
  (if (zerop n) acc
      (factorial (- n 1) (* n acc))))
CL-USER> (disassemble 'factorial)

Disassembly of function FACTORIAL
11 byte-code instructions:
0     L0
0     (LOAD&PUSH 2)
1     (CALLS2&JMPIF 172 L15)              ; ZEROP
4     (LOAD&DEC&PUSH 2)
6     (LOAD&PUSH 3)
7     (LOAD&PUSH 3)
8     (CALLSR&PUSH 2 57)                  ; *
11    (JMPTAIL 2 5 L0)
15    L15
15    (LOAD 1)
16    (SKIP&RET 3)

Некоторые реализации Common Lisp поддерживают исключение хвостовых вызовов для взаимно рекурсивных функции. Вот разборка my-oddp из SBCL:

;; SBCL
; disassembly for MY-ODDP
; Size: 40 bytes. Origin: #x52C8F9E4                          ; MY-ODDP
; 9E4:       498B4510         MOV RAX, [R13+16]               ; thread.binding-stack-pointer
; 9E8:       488945F8         MOV [RBP-8], RAX
; 9EC:       BF02000000       MOV EDI, 2
; 9F1:       488BD3           MOV RDX, RBX
; 9F4:       E8771B37FF       CALL #x52001570                 ; GENERIC--
; 9F9:       488B5DF0         MOV RBX, [RBP-16]
; 9FD:       B902000000       MOV ECX, 2
; A02:       FF7508           PUSH QWORD PTR [RBP+8]
; A05:       E9D89977FD       JMP #x504093E2                  ; #<FDEFN MY-EVENP>
; A0A:       CC10             INT3 16                         ; Invalid argument count trap

Это немного сложнее для чтения, чем в предыдущих примерах, потому что SBCL компилируется на язык ассемблера вместо байтового кода, но вы можете видеть, что инструкция перехода заменен звонок на my-evenp:

; A05:       E9D89977FD       JMP #x504093E2                  ; #<FDEFN MY-EVENP>
3 голосов
/ 19 июня 2020

Компиляторы Common Lisp не требуются для оптимизации хвостовых вызовов. Многие это делают, но не все реализации компилируют ваш код по умолчанию; вы должны скомпилировать файл, используя compile-file, иначе функция индивидуально с (compile 'alternatingsums).

CLISP содержит как интерпретатор, который обрабатывает представление исходного кода Lisp в виде вложенного списка, так и компилятор байтового кода . Компилятор поддерживает хвостовую рекурсию, а интерпретатор - нет:

$ clisp -q
[1]> (defun countdown (n) (unless (zerop n) (countdown (1- n))))
COUNTDOWN
[2]> (countdown 10000000)

*** - Program stack overflow. RESET
[3]> (compile 'countdown)
COUNTDOWN ;
NIL ;
NIL
[4]> (countdown 10000000)
NIL

Немного заглядывать под капот:

[5]> (дизассемблировать 'обратный отсчет)

Disassembly of function COUNTDOWN
1 required argument
0 optional arguments
No rest parameter
No keyword parameters
8 byte-code instructions:
0     L0
0     (LOAD&PUSH 1)
1     (CALLS2&JMPIF 172 L10)              ; ZEROP
4     (LOAD&DEC&PUSH 1)
6     (JMPTAIL 1 3 L0)
10    L10
10    (NIL)
11    (SKIP&RET 2)
NIL

Мы видим, что виртуальная машина имеет примитив JMPTAIL.

Другой подход к хвостовому вызову - через макросы. Годы a go, я взломал макрос под названием tlet , который позволяет вам определять (как выглядят) лексические функции, используя синтаксис, подобный меткам . Конструкция tlet компилируется в форму tagbody , в которой хвостовые вызовы среди функций являются формами go. Он не анализирует вызовы на предмет нахождения в хвостовой позиции: все вызовы являются безусловными передачами, которые не возвращаются независимо от их положения в синтаксисе. Тот же исходный файл также обеспечивает основанную на трамплине реализацию хвостового вызова среди глобальных функций.

Здесь tlet в CLISP; примечание: выражение не было скомпилировано, но оно не закончилось из стека:

$ clisp -q -i tail-recursion.lisp 
;; Loading file tail-recursion.lisp ...
;; Loaded file tail-recursion.lisp
[1]> (tlet ((counter (n) (unless (zerop n) (counter (1- n)))))
       (counter 100000))
NIL

tlet не является оптимизатором. Вызов counter всегда является семантическим goto; это не вызов процедуры, который иногда может превратиться в переход при определенных обстоятельствах. Посмотрите, что происходит, когда мы добавляем print:

[2]> (tlet ((counter (n) (unless (zerop n) (print (counter (1- n))))))
       (counter 100000))
NIL

Верно; ничего! (counter (1- n)) никогда не возвращается, поэтому print никогда не вызывается.

...