Как это возможно, что функция может вызывать себя - PullRequest
1 голос
/ 12 марта 2019

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

(def (pow (x, y))
     (cond ((y = 0) 1))
           (x * (pow (x , y-1))))

Программа выше на языке Lisp.Я не уверен, что синтаксис правильный, так как я придумал его в своей голове, но это подойдет.В программе я определяю функцию pow, а в pow она сама себя вызывает.Я не понимаю, как это может сделать это.Из того, что я знаю, компьютер должен полностью проанализировать функцию, прежде чем ее можно будет определить.Если это так, то компьютер должен выдавать неопределенное сообщение, когда я использую pow, потому что я использовал его до того, как он был определен.Принцип, который я описываю, тот, который используется при использовании x в x = x + 1, когда x не был определен ранее.

Ответы [ 4 ]

2 голосов
/ 13 марта 2019

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

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

2 голосов
/ 13 марта 2019

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

(defun pow (x y)
  (cond ((zerop y) 1)
        (t (* x (pow x (1- y))))))

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

Disassembly of function POW
(CONST 0) = 1
2 required arguments
0 optional arguments
No rest parameter
No keyword parameters
12 byte-code instructions:
0     L0
0     (LOAD&PUSH 1)
1     (CALLS2&JMPIF 172 L15)              ; ZEROP
4     (LOAD&PUSH 2)
5     (LOAD&PUSH 3)
6     (LOAD&DEC&PUSH 3)
8     (JSR&PUSH L0)
10    (CALLSR 2 57)                       ; *
13    (SKIP&RET 3)
15    L15
15    (CONST 0)                           ; 1
16    (SKIP&RET 3)

Если бы это было более сложнымрекурсивная функция, которую компилятор не может развернуть в цикле, он просто вызовет функцию снова.

1 голос
/ 13 марта 2019

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

Как одна функция вызывает другую, сохраняя адрес следующей команды в этой функции в стеке, возможно, добавляя аргументы в стек, а затем переходя к расположению адреса функции. Сама функция переходит к найденному адресу возврата, так что управление возвращается к вызываемому объекту. Существует несколько соглашений о вызовах, реализованных языком, на чьей стороне что делать. Процессоры на самом деле не поддерживают функции, так что, как и в цикле while, в эмуляции функций процессоров нет ничего, что называется циклом while.

Точно так же, как функции имеют имена, аргументы также имеют имена, однако они просто указатели, как и адрес возврата. При вызове самого себя он просто добавляет новый адрес возврата и аргументы в стек и переходит к самому себе. Верхняя часть стека будет отличаться, и поэтому одинаковые имена переменных являются уникальными адресами для вызова, поэтому x и y в предыдущем вызове находятся где-то еще, чем текущие x и y. На самом деле для вызова самого себя не требуется никакого особого подхода, кроме вызова чего-либо еще.

Исторически первый язык высокого уровня, Fortran, не поддерживал рекурсию. Он будет вызывать сам себя, но когда он вернется, он вернется к исходному вызываемому абоненту, не выполняя оставшуюся часть функции после самостоятельного вызова. Сам Фортран невозможно было бы написать без рекурсии, поэтому, хотя сам он использовал рекурсию, он не предлагал ее программисту, который ее использовал. Это ограничение является причиной, по которой Джон Маккарти открыл Лисп.

0 голосов
/ 13 марта 2019

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

Давайте представим, как компилятор может превратить эту функцию в машинный код:

(defun foo (x)
  (+ x (bar x)))

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

  1. Он может скомпилировать foo таким образом, что вызов bar переводится как набор инструкций, которые говорят: «найдите определение функции, хранящееся под именем bar, каким бы оно ни было в настоящее время, и упорядочите чтобы вызвать эту функцию с правильными аргументами ".
  2. Он может скомпилировать foo таким образом, что есть функция функции на уровне машины для функции, но адрес этой функции остается в качестве заполнителя некоторого вида. И затем он может присоединить некоторые метаданные к foo, которые говорят: «перед вызовом этой функции вам нужно найти функцию с именем bar, найти ее адрес, объединить ее в код в нужном месте, и удалить это метаданные .

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

  • Первый механизм означает, что каждый раз, когда вызывается foo, ему нужно выполнить какой-то динамический поиск для bar, что потребует некоторых издержек (но это может быть довольно мало):
    • в результате этого первый механизм будет немного медленнее, чем мог бы быть;
    • но, как следствие этого, если bar будет переопределено, то будет выбрано новое определение, что очень желательно для интерактивного языка, которым обычно являются реализации на Лиспе.
  • Второй механизм означает, что после того, как foo имеет все ссылки на другие функции, связанные с ним, вызовы происходят на уровне машины:
    • это значит, что они будут быстрыми;
    • но это переопределение будет, в лучшем случае, более сложным или, в худшем случае, вообще невозможным.

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

Очень простая система Lisp может работать полностью по первому механизму (например, я уверен, что именно так работает Python). Более продвинутый компилятор, вероятно, будет работать по некоторой комбинации первого и второго механизма. В качестве примера этого CL позволяет компилятору делать предположения о том, что кажущиеся самостоятельные вызовы в функциях действительно являются самостоятельными вызовами, и поэтому компилятор вполне может скомпилировать их как прямые вызовы (по сути, он скомпилирует функцию а затем связать его на лету). Но при компиляции кода в целом он может вызывать функцию «через имя».

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


Обратите внимание, что компиляторы и компоновщики для современных систем на самом деле делают нечто более сложное, чем я описал, из-за разделяемых библиотек & c: то, что я описал, более или менее то, что происходило до разделяемой библиотеки.

...