Рекурсия
Некоторые задачи удобно описывать через них самих. Факториал числа n — произведение всех чисел от 1 до n. Его определяют двумя правилами:
- факториал нуля равен
1; - факториал
nравенn, умноженному на факториалn - 1.
Функция на Go повторяет это определение дословно, вызывая саму себя:
package main
import "fmt"
func factorial(n int) int {
if n == 0 {
return 1
}
return n * factorial(n-1)
}
func main() {
fmt.Println(factorial(5)) // 120
}Вернёмся к кухонному прибору и посмотрим, как он считает factorial(5):
- Прибор
factorialполучил5, но сразу ответа не знает. - Он ставит рядом такой же прибор, отдаёт ему
4и ждёт блюдо, чтобы умножить его на5. - Второй прибор так же поручает работу третьему с
3, и так до прибора, который получил0. - Этот последний отвечает
1сразу, никого не вызывая.
Две ветки рекурсивной функции
Рекурсия — это вызов функцией самой себя. Каждый вызов получает собственную копию параметра n, как каждый прибор — свою порцию. Поэтому factorial(5), factorial(4) и остальные вызовы друг другу не мешают.
В рекурсивной функции всегда две ветки. Разберём каждую.
Базовый случай
Ветку, в которой функция завершается без рекурсии, называют базовым случаем. В factorial это if n == 0 { return 1 }: вызов factorial(0) возвращает 1 сразу, без нового вызова.
Без базового случая вызовы никогда не закончатся. Он должен быть достижим: каждый шаг рекурсии обязан приближать аргумент к нему.
Рекурсивный шаг
Строка return n * factorial(n-1) вызывает ту же функцию с аргументом на единицу меньше. Вызов factorial(5) ждёт результата factorial(4), тот ждёт factorial(3), и так до factorial(0).
Затем результаты возвращаются по цепочке обратно:
factorial(0)возвращает1.factorial(1)возвращает1 * 1, то есть1.factorial(2)возвращает2 * 1, то есть2.factorial(3)возвращает3 * 2, то есть6.factorial(4)возвращает4 * 6, то есть24.factorial(5)возвращает5 * 24, то есть120.
Здесь аналогия перестаёт работать: настоящих приборов понадобилось бы шесть, а функция в программе одна. Для каждого вызова отдельно хранятся только его данные — параметр n и место, куда вернуть результат.
Стек вызовов
Пока вызов не завершился, в памяти хранятся его параметры и место, куда вернуть результат. Эта область памяти называется стеком, и каждый незавершённый вызов занимает в нём свою запись.
Стек похож на стопку тарелок:
- новый вызов кладёт тарелку сверху;
- завершённый вызов снимает верхнюю тарелку.
Нажмите «Следующий шаг» и следите, как стопка рамок растёт до factorial(0), а потом разбирается сверху вниз. Виджет считает factorial(3), чтобы стопка поместилась на экране.
1–4package main · import "fmt"5func factorial(n int) int {6 if n == 0 {7 return 18 }9 return n * factorial(n-1)10}11 12func main() {13 fmt.Println(factorial(3))14}
main вызывает factorial(3) и ждёт результат, чтобы его напечатать.
В самой глубокой точке factorial(5) на стеке лежат шесть записей вызовов factorial. Стек в Go растёт по мере надобности, но не бесконечно: на 64-битных системах его предел — 1 ГБ.
Хвостовые вызовы
Некоторые языки превращают в цикл рекурсивный вызов, стоящий последним действием функции, и стек не растёт. Такую замену называют оптимизацией хвостовых вызовов. Go её не делает: каждый рекурсивный вызов добавляет на стек новую запись.
Этот вопрос часто задают на собеседовании: «Есть ли в Go оптимизация хвостовых вызовов?» Ответ состоит из трёх частей:
- нет, Go её не делает;
- поэтому рекурсия расходует стек на каждом уровне;
- когда глубина может дойти до миллионов вызовов, вместо рекурсии пишут цикл.
Цикл вместо рекурсии
Тот же факториал циклом:
func factorialLoop(n int) int {
result := 1
for i := 2; i <= n; i++ {
result = result * i
}
return result
}Цикл не вызывает новых функций: переменные result и i меняются на месте. Сколько бы ни было шагов, стопка тарелок не растёт.
Частая ошибка
Рекурсивную функцию вызвали с аргументом, для которого базовый случай недостижим:
fmt.Println(factorial(-1)) // ✗fatal error: stack overflowОт -1 цепочка идёт к -2, -3 и дальше, а n == 0 не наступает никогда. Каждый вызов добавляет запись, пока стек не упрётся в предел, и программа аварийно завершается. Проверяйте, что базовый случай достижим из любого допустимого аргумента, а недопустимые аргументы отсекайте до рекурсии.
Главное
- Рекурсивная функция вызывает саму себя, и ей нужен базовый случай, который завершается без нового вызова.
- Каждый незавершённый вызов занимает запись на стеке, а стек в Go ограничен.
- Go не делает оптимизацию хвостовых вызовов, поэтому для очень глубокой рекурсии пишут цикл.
Подпишись, чтобы пройти курс
Курс бесплатный. Войди через Telegram и подпишись на канал — откроются все уроки.