Должен ли я избегать хвостовой рекурсии в Prolog и вообще?

Я работаю через" узнать Пролог сейчас " онлайн книгу для удовольствия.

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

addone([],[]).
addone([X|Xs],[Y|Ys]) :- Y is X+1, addone(Xs,Ys).

но я читал, что лучше избегать такого типа рекурсии по соображениям производительности. Это правда? Считается ли "хорошей практикой" всегда использовать хвостовую рекурсию? Стоит ли прилагать усилия для использования аккумуляторов чтобы приобрести хорошую привычку?

Я попытался изменить этот пример на использование аккумуляторов,но он меняет список. Как мне этого избежать?

accAddOne([X|Xs],Acc,Result) :- Xnew is X+1, accAddOne(Xs,[Xnew|Acc],Result).
accAddOne([],A,A).
addone(List,Result) :- accAddOne(List,[],Result).

4 ответов


короткий ответ: рекурсия хвоста желательна,но не подчеркивайте ее.

ваша оригинальная программа настолько рекурсивна, насколько вы можете получить в Prolog. Но есть более важные вопросы: правильность и прекращения.

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

но ваша попытка оптимизации имеет некоторый смысл. На крайней мере с исторической точки зрения.

еще в 1970-х годах основным языком ИИ был LISP. И соответствующее определение было бы

(defun addone (xs)
  (cond ((null xs) nil)
    (t (cons (+ 1 (car xs))
         (addone (cdr xs))))))

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

In Пролог, однако, вы можете создать минусы, прежде чем знать фактические значения, благодаря логическим переменным. Так много программ, которые не были хвост-рекурсивными в LISP, переведены в хвост-рекурсивные программы в Prolog.

последствия этого все еще можно найти во многих учебниках пролога.


ваша процедура addOne уже is хвост рекурсивной.

между головкой и последним рекурсивным вызовом нет точек выбора, потому что is / 2 является детерминированным.

аккумуляторы иногда добавляются, чтобы разрешить хвостовую рекурсию, более простой пример, который я могу придумать, - reverse/2. Вот наивный реверс (nreverse/2), не хвост рекурсивный

nreverse([], []).
nreverse([X|Xs], R) :- nreverse(Xs, Rs), append(Rs, [X], R).

если мы добавим аккумулятор

reverse(L, R) :- reverse(L, [], R).
reverse([], R, R).
reverse([X|Xs], A, R) :- reverse(Xs, [X|A], R).

теперь reverse/3 является хвостовым рекурсивным: рекурсивный вызов является последним, и точка выбора не осталось.


О. П. сказал:

но я читал, что лучше избегать [хвостовой] рекурсии по соображениям производительности. Это правда? Считается ли "хорошей практикой" всегда использовать хвостовую рекурсию? Это стоит ли прилагать усилия, чтобы использовать аккумуляторы, чтобы войти в хорошую привычку?

Это довольно простая оптимизация для преобразования хвостовой рекурсивной конструкции в итерацию (цикл). Поскольку хвостовой (рекурсивный) вызов является последним, кадр стека может повторно используйте рекурсивный вызов, делая рекурсию, для всех намерений и целей, циклом, просто перейдя к началу предиката / функции/метода / подпрограммы. Таким образом, хвост рекурсивный предикат не будет переполнять стек. Хвост-рекурсивная конструкция, с применяемой оптимизацией, имеет следующие преимущества:

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

возможные минусы?

  • потеря полезной трассировки стека. Не проблема, если TRO применяется только в версии / оптимизированной сборке, а не в отладочной сборке, но...
  • разработчики напишут код, который зависит от TRO, что означает, что код будет работать нормально с tro applied потерпит неудачу без применения TRO. Это означает, что в приведенном выше случае (TRO только в сборках release/optimized) между сборками release и debug существует функциональное изменение, по существу означающее, что выбор параметров компилятора генерирует две разные программы из идентичного исходного кода.

Это, конечно, не проблема, когда стандарт языка требует оптимизации хвостовой рекурсии.

цитирую Википедию:

хвостовые вызовы значительны, потому что они могут быть реализованы без добавления новый кадр стека в стек вызовов. Большая часть рамок нынешней процедуры больше не требуется, и его можно заменить рамкой хвостового вызова, изменено соответствующим образом (аналогично наложению для процессов, но для функции телефонные переговоры.) Затем программа может перейти к вызываемой подпрограмме. Производить такой код вместо стандартной последовательности вызовов называется tail Call elimination, или tail оптимизация звонков.

Читайте также:

Я никогда не понимал, почему больше языков не реализуют оптимизацию хвостовой рекурсии


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

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

Итак, как вы можете реализовать рабочую хвост рекурсивной версии addone? Это может быть обман, но предполагая, что reverse реализован с хвостовой рекурсией (например, см. здесь), то он может быть использован для решения вашей проблемы:

accAddOne([X|Xs],Acc,Result) :- Xnew is X+1, accAddOne(Xs,[Xnew|Acc],Result).
accAddOne([],Acc,Result) :- reverse(Acc, Result).
addone(List,Result) :- accAddOne(List,[],Result).

это крайне неуклюже, хотя. :-)

кстати, я не могу найти простое решение. Это может быть по той же причине, что и foldr в Haskell обычно не определяется с помощью хвостовой рекурсии.