꼬리 재귀
"오늘의AI위키"의 AI를 통해 더욱 풍부하고 폭넓은 지식 경험을 누리세요.
1. 개요
꼬리 재귀는 함수가 자기 자신을 호출하는 재귀 호출의 한 종류로, 함수의 마지막 연산이 자기 자신을 호출하는 경우를 의미한다. 꼬리 재귀 호출은 호출 스택을 효율적으로 관리하기 위해 꼬리 호출 제거(tail call elimination)라는 최적화를 통해 스택 오버플로우를 방지하고 성능을 향상시킬 수 있다. 이러한 최적화는 함수형 프로그래밍 언어에서 중요하며, 꼬리 재귀를 지원하는 언어는 Scheme, Erlang, Haskell, Elixir, F#, JavaScript(ECMAScript 6.0) 등이 있다. 꼬리 재귀는 반복문으로 변환될 수 있으며, C 언어와 같은 언어에서는 컴파일러가 꼬리 호출을 최적화하여 성능을 개선하기도 한다.
함수가 호출될 때, 컴퓨터는 호출이 완료된 후 돌아갈 위치(반환 주소)를 호출 스택에 저장한다. 꼬리 호출의 경우, 호출자를 기억할 필요가 없다. 대신, 꼬리 호출 제거는 스택 프레임을 전달하기 전에 최소한의 변경만 수행하고,[3] 꼬리 호출된 함수는 ''원래'' 호출자에게 직접 반환된다. 꼬리 호출은 소스 코드의 다른 모든 문장 다음에 어휘적으로 나타날 필요는 없다. 중요한 것은 호출 함수가 꼬리 호출 직후에 즉시 반환하며, 최적화가 수행될 때 호출 함수가 우회되므로 꼬리 호출의 결과를 반환하는 것이다(있는 경우).
꼬리 호출은 함수의 구문적 끝 바로 앞에 위치할 수 있다. 다음은 그 예시이다.
Scheme, C, Prolog, 하스켈 등 다양한 프로그래밍 언어에서 꼬리 재귀 및 꼬리 재귀 모듈로 cons 예제를 살펴본다.
2. 설명
꼬리 호출 제거는 재귀 함수, 특히 상호 재귀의 경우 메모리 사용량을 크게 줄여준다. Scheme[4][5] 및 ML 계열 언어를 포함한 일부 프로그래밍 언어의 표준에서는 꼬리 호출 제거를 요구한다.[6] 꼬리 호출 제거를 통해 활성화된 꼬리 호출 수를 제한 없이 허용하는 구현은 '적절하게 꼬리 재귀'라고도 할 수 있다.[4]
C 언어와 같은 언어에서는 언어 처리 시스템에 의한 자동적인 꼬리 호출 변환 및 최적화가 어렵다. 자기 재귀를 주의해서 작성하면, 최적화를 통해 루프로 변환할 수 있는 컴파일러도 있다는 정도이다. 함수형 언어에서는 자신의 반환값에 다른 함수의 반환값을 사용하는 꼬리 호출을 통한 프로그래밍이 자연스럽다. 특히 Scheme은 최적화를 수행해야 하는 패턴을 형식적으로 규정하고, 최적화를 수행하도록 사양으로 정하고 있다.
대체 모델을 기준으로 생각하면, 어떤 절차가 반환하는 값은 그 안에서 마지막으로 호출한 절차의 결과와 같고, 그 외의 부분은 과정의 분기 또는 부작용을 가질 경우에만 의미를 가진다. 따라서 함수적인 관점에서는 절차의 꼬리 부분만 고려하면 되며, 여기서 재귀가 일어나는 경우를 꼬리 재귀라고 한다.
2. 1. 꼬리 호출 제거
꼬리 호출 제거는 스택 프레임을 전달하기 전에 최소한의 변경만 수행하고, 꼬리 호출된 함수가 원래 호출자에게 직접 반환되도록 하는 최적화 기법이다.[3] 꼬리 호출은 소스 코드의 다른 모든 문장 다음에 어휘적으로 나타날 필요는 없다. 중요한 것은 호출 함수가 꼬리 호출 직후에 즉시 반환하는 것이다.
비재귀적 함수 호출의 경우, 호출할 수 있는 서로 다른 함수가 많지 않으므로 이는 시간과 공간을 약간 절약하는 프로그램 최적화이다. 그러나 재귀 또는 상호 재귀 함수를 처리할 때 꼬리 호출을 통해 재귀가 발생하면 함수가 직접 또는 간접적으로 자체를 호출하여 매번 새로운 호출 스택 프레임을 생성하므로 스택 공간과 저장되는 반환 수가 매우 커질 수 있다. 꼬리 호출 제거는 종종 점근적 스택 공간 요구 사항을 선형, 즉 O(n)에서 상수, 즉 O(1)로 줄인다.
따라서 꼬리 호출 제거는 Scheme[4][5] 및 ML 계열 언어를 포함한 일부 프로그래밍 언어의 표준 정의에서 요구된다. Scheme 언어 정의는 꼬리 위치에 결과를 가질 수 있는 구문 형식을 지정하여 꼬리 위치에 대한 직관적인 개념을 정확하게 공식화한다.[6] 꼬리 호출 제거 덕분에 활성화된 꼬리 호출 수를 제한 없이 허용하는 구현은 '적절하게 꼬리 재귀'라고도 할 수 있다.[4]
공간 및 실행 효율성 외에도 꼬리 호출 제거는 연속 전달 방식으로 알려진 함수형 프로그래밍 관용구에서 중요하다.
C 언어와 같은 언어에서는 언어 처리 시스템에 의한 자동적인 꼬리 호출로의 변환 및 해당 최적화(꼬리 최적화)는 어렵다. 자기 재귀를 주의해서 작성하면, 최적화를 통해 루프로 변환할 수 있는 컴파일러도 있다는 정도이다. 일반적으로 재귀 호출이 가능한 언어에서는 서브루틴 호출 시마다 스택에 호출처로 되돌아오기 위한 정보를 저장한다. 따라서 재귀가 너무 깊어지면 스택 오버플로우로 프로그램이 비정상 종료될 수 있다.
함수형 언어에서는 자신의 반환값에 다른 함수의 반환값을 사용하는 꼬리 호출을 통한 프로그래밍이 자연스러운 스타일이다. 특히 Scheme은 최적화를 수행해야 하는 패턴을 형식적으로 규정하고, 최적화를 수행하도록 사양으로 정하고 있다. 함수형 언어의 처리계에는, 프로그램 전체를 지속 전달 스타일로 변환하여, 모든 호출을 꼬리 호출로 변환하는 기법도 있다.
대체 모델을 기준으로 생각하면, 어떤 절차가 반환하는 값은 그 안에서 마지막으로 호출한 절차의 결과와 같고, 그 외의 부분은 과정의 분기 또는 부작용을 가질 경우에만 의미를 가진다. 따라서 함수적인 관점에서는 절차의 꼬리 부분만 고려하면 되며, 여기서 재귀가 일어나는 경우를 꼬리 재귀라고 한다.
'''꼬리 재귀 최적화'''는 꼬리 호출 코드를 반환 위치를 저장하지 않는 점프로 변환하여 스택 누적을 없애고 효율을 향상시키는 기법이다. 꼬리 호출 제거라고도 한다. 함수 호출에서는 호출마다 호출 위치로 돌아가기 위한 정보를 스택에 저장한다. 이것을 점프로 최적화할 수 있다면, 겉보기 재귀가 아무리 깊어져도 스택이 넘치는 일이 없어진다. 이 최적화가 언어 처리 시스템에 의해 수행된다면, 프로그래머가 재귀적인 절차를 루프로 변환하지 않고도, 재귀를 위한 스택 오버플로우를 신경 쓸 필요가 없어진다.
이론적으로는 계속 전달에 의한 점프 명령과 동일한 점프 처리에서는 절차의 호출 위치 정보를 저장할 필요가 없다는 결론에 이른다. 이 경우 `return`은 점프 대상이 호출 위치와 같은 특수한 경우의 점프 명령이다. 꼬리 최적화가 있다면, 절차의 재귀를 수행할 때도 결과는 루프와 동등한 처리 절차가 되어, 얼마나 깊은 재귀를 하더라도 스택 오버플로우를 일으키지 않는다. 이러한 동작을 "올바른 종단 재귀"라고 한다. Scheme은 구현 사양으로 올바른 종단 재귀를 요구하는 언어이다. Scheme뿐만 아니라, 일부 C 언어 컴파일러 등에서도 조건이 충족되면 재귀 호출이 최적화되는 경우가 있다.
2. 2. 점근적 스택 공간 요구 사항
함수가 호출될 때, 컴퓨터는 호출이 완료된 후 돌아갈 위치, 즉 반환 주소를 호출 스택에 저장해야 한다. 꼬리 호출의 경우, 호출자를 기억할 필요가 없다. 대신 꼬리 호출 제거는 스택 프레임을 전달하기 전에 최소한의 필요한 변경만 수행하고,[3] 꼬리 호출된 함수는 ''원래'' 호출자에게 직접 반환된다.
비재귀적 함수 호출은 시간과 공간을 약간 절약하는 프로그램 최적화이다. 그러나 상호 재귀 함수를 처리할 때 꼬리 호출을 통해 재귀가 발생하면 스택 공간과 저장되는 반환 수가 매우 커질 수 있다. 꼬리 호출 제거는 점근적 스택 공간 요구 사항을 선형(O(n))에서 상수(O(1))로 줄인다. 따라서 꼬리 호출 제거는 Scheme[4][5] 및 ML 계열 언어를 포함한 일부 프로그래밍 언어의 표준 정의에서 요구된다. 꼬리 호출 제거 덕분에 활성화된 꼬리 호출 수를 제한 없이 허용하는 구현은 '적절하게 꼬리 재귀'라고도 할 수 있다.[4]
2. 3. 꼬리 호출 제거의 중요성
꼬리 호출 제거는 Scheme,[4][5] ML 계열 언어를 포함한 일부 프로그래밍 언어의 표준 정의에서 요구되는 중요한 기능이다.[6] 이는 함수가 호출될 때 호출 위치, 즉 반환 주소를 호출 스택에 저장하는 대신, 꼬리 호출된 함수가 원래 호출자에게 직접 반환되도록 하여 스택 공간을 절약한다.
꼬리 호출 제거는 특히 재귀 또는 상호 재귀 함수를 처리할 때 유용하다. 꼬리 호출을 통해 재귀가 발생하면 매번 새로운 호출 스택 프레임을 생성하는 대신, 꼬리 호출 제거는 점근적 스택 공간 요구 사항을 선형(O(n))에서 상수(O(1))로 줄인다.
또한 꼬리 호출 제거는 연속 전달 방식(CPS)과 같은 함수형 프로그래밍 관용구에서 스택 공간 소진을 방지하는 데 중요한 역할을 한다. CPS는 모든 호출을 꼬리 호출로 변환하는 기법으로, 꼬리 호출 제거 없이는 스택 오버플로우가 발생할 수 있다.
구현에서 활성화된 꼬리 호출 수를 제한 없이 허용하는 경우 '적절하게 꼬리 재귀'라고도 부를 수 있다.[4]
3. 구문 형식
```javascript
function foo(data) {
a(data);
return b(data);
}
```
여기서 `a(data)`와 `b(data)`는 모두 호출이지만, `b`는 프로시저가 반환하기 전에 실행하는 마지막 항목이므로 꼬리 위치에 있다. 그러나 모든 꼬리 호출이 반드시 서브루틴의 구문적 끝에 위치하는 것은 아니다.
```javascript
function bar(data) {
if (a(data)) {
return b(data);
}
return c(data);
}
```
여기서 `b`와 `c`에 대한 호출은 모두 꼬리 위치에 있다. 이는 각 호출이 if-분기의 끝에 위치하기 때문이며, 첫 번째 호출이 `bar` 본문의 구문적 끝에 있지 않더라도 마찬가지이다.
4. 예제 프로그램
'''꼬리 재귀 예제 (Scheme)'''[7]
Scheme을 사용한 꼬리 재귀 예시는 다음과 같다.
;; factorial : number -> number
;; to calculate the product of all positive
;; integers less than or equal to n.
(define (factorial n)
(if (= n 0)
1
(* n (factorial (- n 1)))))
위 코드는 곱셈 함수("*")가 꼬리 위치에 있지 않기 때문에 꼬리 재귀 형태가 아니다.
;; factorial : number -> number
;; to calculate the product of all positive
;; integers less than or equal to n.
(define (factorial n)
(fact-iter 1 n))
(define (fact-iter product n)
(if (= n 0)
product
(fact-iter (* product n)
(- n 1))))
위 코드에서 `fact-iter` 함수는 꼬리 위치에서 자신을 호출한다. 인터프리터나 컴파일러는 이 코드를 효율적으로 최적화할 수 있다.[7]
`factorial (4)`를 호출하면, `fact-iter (1 4)` -> `fact-iter (4 3)` -> `fact-iter (12 2)` -> `fact-iter (24 1)` 순서로 호출된 후 24를 반환한다. 이 과정에서 스택에 중간 결과 값을 저장하지 않고, 기존 호출 스택 프레임을 재사용하여 공간을 절약한다.
'''꼬리 재귀 모듈로 cons'''
데이비드 H. D. 워렌(David H. D. Warren)이 Prolog의 컴파일러 컴파일과 관련하여 도입한 꼬리 재귀 최적화의 일반화이다. 이는 1974년 다니엘 P. 프리드먼(Daniel P. Friedman)과 데이비드 S. 와이즈(David S. Wise)가 LISP 컴파일 기법으로 설명했다.[9] 재귀 호출 후에 수행해야 할 유일한 작업이 재귀 호출에서 반환된 리스트 앞에 알려진 값을 추가하는 경우(또는 일반적으로 상수 개수의 간단한 데이터 구성 연산을 수행하는 경우) 적용된다.
다음은 꼬리 재귀 모듈로 cons를 설명하는 Prolog, Haskell, C 예제 코드이다.
| Prolog | Haskell | ||
|---|---|---|---|
| x < p = (x:a,b) | otherwise = (a,x:b) | ||
| Prolog (비 꼬리 재귀 변환) | Prolog (꼬리 재귀 변환) | ||
- C를 이용한 연결 리스트 복제
| C | Scheme | Prolog |
|---|---|---|
| rowspan="2" | |
위 C 코드는 꼬리 재귀가 아니다. 재귀 호출 `duplicate(ls->next)`의 결과를 호출 후 `next` 필드에 연결해야 하기 때문이다.
- 꼬리 재귀 형태로 변환 (C)
| C | Scheme | Prolog |
|---|---|---|
| rowspan="2" | |
`next` 필드를 채우는 책임을 재귀 호출 자체에 넘겨 꼬리 호출이 되도록 한다.
- 반복적 구현으로 변환 (C)
| C | Scheme | Prolog |
|---|---|---|
| rowspan="2" | |
4. 1. 꼬리 재귀 예제 (Scheme)
다음은 Scheme을 사용한 꼬리 재귀 예시이다.[7]먼저 팩토리얼을 계산하는 일반적인 재귀 함수는 다음과 같다.
;; factorial : number -> number
;; to calculate the product of all positive
;; integers less than or equal to n.
(define (factorial n)
(if (= n 0)
1
(* n (factorial (- n 1)))))
이 코드는 곱셈 함수("*")가 꼬리 위치에 있지 않기 때문에 꼬리 재귀 형태가 아니다.
다음은 꼬리 재귀를 활용한 팩토리얼 계산 함수이다.
;; factorial : number -> number
;; to calculate the product of all positive
;; integers less than or equal to n.
(define (factorial n)
(fact-iter 1 n))
(define (fact-iter product n)
(if (= n 0)
product
(fact-iter (* product n)
(- n 1))))
위 코드에서 `fact-iter` 함수는 꼬리 위치에서 자신을 호출한다. 인터프리터나 컴파일러는 이 코드를 효율적으로 최적화할 수 있다.[7]
예를 들어 `factorial (4)`를 호출하면,
- `fact-iter (1 4)` 호출
- `fact-iter (4 3)` 호출
- `fact-iter (12 2)` 호출
- `fact-iter (24 1)` 호출
- 24 반환
과정을 거쳐 스택에 중간 결과 값을 저장하지 않고, 기존 호출 스택 프레임을 재사용하여 공간을 절약한다.
일반적으로 꼬리 재귀를 사용하면 깊은 재귀에서도 스택 오버플로우 걱정 없이 안전하게 코드를 실행할 수 있다.
4. 2. 꼬리 재귀 모듈로 cons
데이비드 H. D. 워렌(David H. D. Warren)이 Prolog의 컴파일러 컴파일과 관련하여 도입한 꼬리 재귀 최적화의 일반화이다. 이는 1974년 다니엘 P. 프리드먼(Daniel P. Friedman)과 데이비드 S. 와이즈(David S. Wise)가 LISP 컴파일 기법으로 설명했다.[9] 재귀 호출 후에 수행해야 할 유일한 작업이 재귀 호출에서 반환된 리스트 앞에 알려진 값을 추가하는 경우(또는 일반적으로 상수 개수의 간단한 데이터 구성 연산을 수행하는 경우) 적용된다. 따라서 이 호출은 ''cons'' 연산을 제외하면 ''꼬리 호출''이 된다. 그러나 재귀 호출에서 ''종료''될 때 리스트의 시작 부분에 값을 접두사로 추가하는 것은 재귀 호출에 ''입력''될 때 증가하는 리스트의 끝에 이 값을 추가하는 것과 같으므로, 암묵적인 누산기 매개변수처럼 부작용으로 리스트를 생성한다.다음은 꼬리 재귀 모듈로 cons를 설명하는 Prolog, Haskell, C 예제 코드이다.
| Prolog | Haskell | ||
|---|---|---|---|
| x < p = (x:a,b) | otherwise = (a,x:b) | ||
| Prolog (비 꼬리 재귀 변환) | Prolog (꼬리 재귀 변환) | ||
꼬리 재귀 변환에서 이러한 호출은 먼저 새로운 리스트 노드를 생성하고 해당 `first` 필드를 설정한 다음, 노드의 `rest` 필드에 대한 포인터를 인수로 사용하여 꼬리 호출을 수행하여 재귀적으로 채우는 방식으로 변환된다. 게으르게 평가되는 데이터 생성자 아래에서 재귀가 ''보호''될 때 동일한 효과가 달성되는데, 이는 하스켈과 같은 게으른 프로그래밍 언어에서 자동으로 달성된다.
다음은 연결 리스트를 복제하는 C로 작성된 재귀 함수이다.
| C | Scheme | Prolog |
|---|---|---|
| rowspan="2" | |
이 형태에서 함수는 꼬리 재귀가 아닌데, 재귀 호출이 입력 리스트의 나머지를 복제한 후에 제어가 호출자에게 반환되기 때문이다. 재귀 호출 전에 'head' 노드를 할당하더라도, 재귀 호출의 결과를 호출 ''후'' `next` 필드에 연결해야 한다.
그래서 함수는 ''거의'' 꼬리 재귀적이다. Warren의 방법은 `next` 필드를 채우는 책임을 재귀 호출 자체에 밀어넣어 꼬리 호출이 되도록 한다. 센티넬 헤드 노드를 사용하여 코드를 단순화하면 다음과 같다.
| C | Scheme | Prolog |
|---|---|---|
| rowspan="2" | |
이제 호출된 함수는 반환된 리스트의 시작 부분에 호출자가 추가하는 대신, 성장하는 리스트의 끝에 추가한다. 작업은 재귀 호출이 더 진행되기 ''전''에 리스트의 시작 부분에서 ''앞으로'' 진행되는 방식(그리고 그 결과가 반환된 후)이 아니라 재귀 호출이 결과를 반환한 ''후''에 리스트의 끝 부분에서 ''뒤로'' 진행되는 방식이다. 따라서 이는 재귀 계산을 반복적인 계산으로 바꾸는 누적 매개변수 기법과 유사하다.
이 기술의 특징은, 꼬리 호출 최적화가 있는 경우, 상위 프레임이 실행 호출 스택에 생성되어 꼬리 재귀 호출 함수가 자체 호출 프레임으로 재사용할 수 있다는 것이다.
꼬리 재귀 구현은 이제 명시적으로 반복적인 구현, 즉 누적 루프로 변환될 수 있다.
| C | Scheme | Prolog |
|---|---|---|
| rowspan="2" | |
5. 역사
1977년 시애틀에서 열린 ACM 컨퍼런스에서 발표된 논문에서 가이 L. 스틸은 프로시저 호출의 꼬리 위치에 있는 프로시저 호출을 직접적인 제어 이전으로 취급하여 불필요한 스택 조작 연산을 제거할 수 있다고 언급했다.[10] 이러한 "꼬리 호출"은 Lisp에서 매우 일반적이었기 때문에, 이러한 최적화는 프로시저 호출 비용을 상당히 줄여주었다. 스틸은 프로시저 호출의 부실한 구현이 GOTO가 프로시저 호출에 비해 저렴하다는 인식을 만들었다고 주장했다. 또한 "일반적으로 프로시저 호출은 매개변수를 전달하는 GOTO 문으로 생각할 수 있으며, [기계어] JUMP 명령어로 일관되게 코딩할 수 있다"고 주장하면서, 기계어 스택 조작 명령은 "최적화로 간주되어야 한다(그 반대가 아니라!)"고 말했다.[10] 스틸은 Lisp에서 잘 최적화된 수치 알고리즘이 당시 상업용 포트란 컴파일러에서 생성된 코드보다 더 빠르게 실행될 수 있음을 제시했는데, 이는 Lisp에서 프로시저 호출 비용이 훨씬 낮았기 때문이다.[10] 제럴드 제이 서스먼과 함께 개발한 Lisp 방언인 Scheme은 모든 인터프리터에서 꼬리 호출 제거를 구현하도록 보장한다.[11]
6. 구현 방법
꼬리 재귀는 함수형 프로그래밍, 논리 프로그래밍, Lisp 프로그래밍 언어 계열에서 반복을 구현하는 중요한 방법이다. 이러한 언어에서 꼬리 재귀는 반복을 구현하는 가장 일반적인 방법(때로는 유일한 방법)이다. Scheme 언어 명세는 꼬리 호출이 스택을 증가시키지 않도록 최적화되어야 한다고 요구한다.[12] Perl에서는 함수 이름을 사용하는 "goto"문의 변형을 통해 꼬리 호출을 명시적으로 만들 수 있다(goto &NAME;).[12]
하지만 함수 인자와 지역 변수를 호출 스택에 저장하는 언어 구현의 경우, 일반화된 꼬리 호출 최적화(상호 꼬리 재귀 포함)를 구현하기 어렵다. 호출된 함수의 활성 레코드 크기가 호출한 함수의 활성 레코드 크기와 다르면 스택 프레임을 추가로 정리하거나 크기를 조정해야 할 수 있기 때문이다. 따라서 꼬리 재귀 최적화는 비교적 간단하지만, 일반적인 꼬리 호출 최적화는 효율적으로 구현하기 어려울 수 있다.
예를 들어, 자바 가상 머신(JVM)에서는 꼬리 재귀 호출은 기존 호출 스택을 재사용하므로 제거할 수 있지만, 일반 꼬리 호출은 호출 스택을 변경해야 하므로 제거할 수 없다.[13][14] 결과적으로 JVM을 대상으로 하는 Scala와 같은 함수형 언어는 직접 꼬리 재귀는 효율적으로 구현할 수 있지만, 상호 꼬리 재귀는 구현할 수 없다.
GNU 컴파일러 모음(GCC), LLVM/Clang, 인텔 C 컴파일러(Intel) 컴파일러는 더 높은 최적화 수준에서 또는 -foptimize-sibling-calls 옵션을 사용하면 C (프로그래밍 언어) 및 다른 언어에 대해 꼬리 호출 최적화를 수행한다.[15][16][17] 컴파일러는 주어진 언어 구문이 명시적으로 지원하지 않더라도, 호출자와 호출된 함수의 반환 유형이 동일하고, 두 함수에 전달된 인자 유형이 같거나 호출 스택에서 동일한 양의 총 저장 공간을 요구하는 경우 이 최적화를 수행할 수 있다.[18]
다양한 구현 방법이 존재한다. 함수형 언어에서는 자신의 반환값에 다른 함수의 반환값을 사용하는 꼬리 호출을 통한 프로그래밍이 자연스럽다. 특히 Scheme은 최적화를 수행해야 하는 패턴을 형식적으로 규정하고, 최적화를 수행하도록 사양으로 정하고 있다.
함수형 언어 처리계에는 프로그램 전체를 지속 전달 스타일로 변환하여 모든 호출을 꼬리 호출로 변환하는 기법도 있다.
대체 모델 관점에서 보면, 어떤 절차가 반환하는 값은 그 안에서 마지막으로 호출한 절차의 결과와 같고, 그 외의 부분은 과정의 분기 또는 부작용을 가질 경우에만 의미를 가진다. 따라서 함수적인 관점에서는 절차의 꼬리 부분만 고려하면 되며, 여기서 재귀가 일어나는 경우를 꼬리 재귀라고 한다.
Common Lisp에서의 꼬리 재귀 예:
```lisp
(defun fact (n)
(labels ((ifact (n r)
(if (= n 0)
r
(ifact (- n 1) (* n r)))))
(ifact n 1)))
```
F#에서의 꼬리 재귀 예:
```lisp
// 비 꼬리 재귀
let rec fact n =
if n = 0 then 1 else fact (n - 1) * n
// 꼬리 재귀
let fact n =
let rec ifact n r =
if n = 0 then r else ifact (n - 1) (n * r)
ifact n 1
6. 1. 어셈블리에서 구현
컴파일러는 꼬리 호출을 최적화할 때 호출 연산 코드를 점프 연산 코드로 바꾸고, 스택의 매개변수를 수정하는 방식을 사용한다. 예를 들어, 컴파일러는 의사-어셈블리 언어 코드를 다음과 같이 변환할 수 있다. (이는 실제 x86 어셈블리 언어로도 유효하다.)```asm
foo:
call B
call A
ret
```
위 코드는 꼬리 호출 제거를 통해 다음과 같이 최적화할 수 있다.
```asm
foo:
call B
jmp A
```
이렇게 하면 서브루틴 `A`가 완료된 후 불필요한 `ret` 문을 거치지 않고 `foo`의 반환 주소로 바로 돌아갈 수 있다.
일반적으로 호출되는 서브루틴에 매개변수가 필요한 경우, 컴파일러는 꼬리 호출된 서브루틴으로 점프하기 전에 호출 프레임이 올바르게 설정되도록 코드를 생성해야 한다. 예를 들어 호출 스택에 반환 주소뿐 아니라 매개변수도 포함되는 컴퓨팅 플랫폼에서는 컴파일러가 호출 스택 조정을 위한 명령을 추가해야 할 수 있다.
예를 들어, 다음과 같은 코드를 보자.
```
'''function''' foo(data1, data2)
B(data1)
'''return''' A(data2)
```
여기서 `data1`과 `data2`는 매개변수이다. 컴파일러는 이 코드를 다음과 같이 변환할 수 있다.
```nasm
foo:
mov reg,[sp+data1] ; 스택(sp) 매개변수에서 data1을 임시 레지스터로 가져옵니다.
push reg ; data1을 B가 예상하는 스택에 넣습니다
call B ; B는 data1을 사용합니다
pop ; 스택에서 data1을 제거합니다
mov reg,[sp+data2] ; 스택(sp) 매개변수에서 data2를 임시 레지스터로 가져옵니다.
push reg ; data2를 A가 예상하는 스택에 넣습니다
call A ; A는 data2를 사용합니다
pop ; 스택에서 data2를 제거합니다.
ret
```
꼬리 호출 최적화기는 이 코드를 다음과 같이 더욱 효율적으로 변경할 수 있다.
```nasm
foo:
mov reg,[sp+data1] ; 스택(sp) 매개변수에서 data1을 임시 레지스터로 가져옵니다.
push reg ; data1을 B가 예상하는 스택에 넣습니다
call B ; B는 data1을 사용합니다
pop ; 스택에서 data1을 제거합니다
mov reg,[sp+data2] ; 스택(sp) 매개변수에서 data2를 임시 레지스터로 가져옵니다.
mov [sp+data1],reg ; data2를 A가 예상하는 곳에 넣습니다
jmp A ; A는 data2를 사용하고 즉시 호출자로 반환합니다.
```
이 최적화된 코드는 실행 속도와 스택 공간 사용 모두에서 더 효율적이다.[19]
6. 2. 트램펄린을 통한 구현
C 언어를 중간 대상 코드로 사용하는 많은 Scheme 컴파일러는 트램펄린을 사용하여 꼬리 재귀를 C 언어로 인코딩한다. 트램펄린은 함수를 반복적으로 호출하는 코드 조각이며, 모든 함수 호출은 트램펄린을 통해 이루어진다. 함수가 다른 함수를 꼬리 호출해야 할 때, 직접 호출하는 대신 호출할 함수의 주소와 호출 매개변수를 트램펄린에 반환한다. 그러면 트램펄린이 지정된 매개변수로 해당 함수를 호출하므로 C 스택이 증가하지 않고 반복이 무기한 계속될 수 있다.[21]6. 3. 가비지 수집을 이용한 구현 (Chicken Scheme)
Scheme 컴파일러는 중간 대상 코드로 C 언어를 사용하기 때문에, C 컴파일러가 꼬리 호출을 최적화하지 않더라도 꼬리 재귀는 스택이 증가하지 않도록 C 언어로 인코딩해야 한다. 많은 구현에서는 트램펄린이라고 하는 코드를 사용하여 이를 달성하는데, 이는 함수를 반복적으로 호출하는 코드 조각이다.Chicken은 헨리 베이커가 앤드루 애플의 미공개 제안에서 처음 설명한 기술을 사용한다.[21] 이 기술은 일반적인 C 호출을 사용하지만 모든 호출 전에 스택 크기를 확인한다. 스택이 허용된 최대 크기에 도달하면, 스택의 객체는 모든 라이브 데이터를 별도의 힙으로 이동하여 가비지 수집되며, 이때 체니 알고리즘이 사용된다. 그 후, 스택은 언와인드("팝")되고 프로그램은 가비지 수집 직전에 저장된 상태에서 다시 시작된다. 베이커는 "애플의 방법은 가끔 엠파이어 스테이트 빌딩에서 뛰어내림으로써 많은 수의 작은 트램펄린 바운스를 피한다"라고 말한다.[21] 가비지 수집은 상호 꼬리 재귀가 무기한으로 계속될 수 있도록 보장한다.
7. while 문과의 관계
꼬리 재귀는 명시적 반복문인 `while` 구문으로 변환될 수 있다. 예를 들어,
```
'''절차''' foo(''x'')
'''만약''' ''p''(''x'')
'''반환''' bar(''x'')
'''그렇지 않으면'''
'''반환''' foo(baz(''x''))
```
는
```
'''절차''' foo(''x'')
'''while''' '''참'''
'''만약''' ''p''(''x'')
'''반환''' bar(''x'')
'''그렇지 않으면'''
''x'' ← baz(''x'')
```
로 변환할 수 있다. 여기서 ''x''는 여러 변수를 포함하는 튜플일 수 있다. 이 경우 종속성을 고려하여 할당문 ''x'' ← baz(''x'')를 구현하는 데 주의해야 한다. 보조 변수를 도입하거나 ''스왑'' 구문을 사용할 수 있다.
더 일반적으로,
```
'''절차''' foo(''x'')
'''만약''' ''p''(''x'')
'''반환''' bar(''x'')
'''그렇지 않고 만약''' ''q''(''x'')
'''반환''' baz(''x'')
...
'''그렇지 않고 만약''' ''r''(''x'')
'''반환''' foo(qux(''x''))
...
'''그렇지 않으면'''
'''반환''' foo(quux(''x''))
```
는 다음과 같이 변환될 수 있다.
```
'''절차''' foo(''x'')
'''while''' '''참'''
'''만약''' ''p''(''x'')
'''반환''' bar(''x'')
'''그렇지 않고 만약''' ''q''(''x'')
'''반환''' baz(''x'')
...
'''그렇지 않고 만약''' ''r''(''x'')
''x'' ← qux(''x'')
...
'''그렇지 않으면'''
''x'' ← quux(''x'')
```
예를 들어, 다음 줄리아 프로그램은 팩토리얼의 비 꼬리 재귀적 정의 `fact`를 제공한다.
function factorial(n)
if n == 0
return 1
else
return n * factorial(n - 1)
end
end
실제로, `n * factorial(n - 1)`는 `factorial`에 대한 호출을 래핑한다. 그러나 '누산기'라고 하는 인수 `a`를 추가하여 꼬리 재귀적 정의로 변환할 수 있다.[7]
다음은 팩토리얼의 꼬리 재귀적 정의 `fact_iter`를 제공하는 줄리아 프로그램이다.
function factorial(n::Integer, a::Integer)
if n == 0:
return a
else
return factorial(n - 1, n * a)
end
end
function factorial(n::Integer)
return factorial(n, 1)
end
다음은 팩토리얼의 반복적 정의 `fact_iter`를 제공하는 줄리아 프로그램이다.
function fact_iter(n::Integer, a::Integer)
while n > 0
a = n * a
n = n - 1
end
return a
end
function factorial(n::Integer)
return fact_iter(n, one(n))
end
일반적으로 재귀 호출이 가능한 언어에서는 서브루틴 호출 시마다 스택에 호출처로 되돌아오기 위한 정보를 저장한다. 따라서 재귀가 너무 깊어지면 스택 오버플로우로 프로그램이 비정상 종료될 수 있다.
이러한 경우, 다음과 같이 루프로 변환하여 이를 피할 수 있다.
```ada
{ 변환 전 }
function F (a1:T1, a2:T2, ..., an:Tn) : T0
begin
P ;
return func (b1, b2, ..., bn) ;
end ;
{ 변환 후 }
function F (a1:T1, a2:T2, ..., an:Tn) : T0
begin
loop
P ;
a1 := b1 ;
a2 := b2 ;
:
an := bn ;
end loop ;
end ;
{
Ti는 자료형, P는 프로시저, bi는 값 또는 a1~an에 대한 부작용이 없는 식이다.
그 외의 식별자는 변수를 나타내며 P 안에 최소 1개의 return 문이 없으면
스택 오버플로우 또는 무한 루프가 발생한다.
}
8. 언어 지원
| 언어 | 꼬리 호출 최적화 지원 여부 | 비고 |
|---|---|---|
| 클로저 | 예 | `recur` 특수 형식 사용[22] |
| Common Lisp | 구현에 따라 다름 | 일부 구현에서 속도 최적화를 위해 컴파일 시 지원 |
| 엘릭서 | 예 | BEAM VM을 대상으로 하는 모든 언어에서 지원[23] |
| 엘름 | 예[24] | |
| 얼랭 | 예 | |
| F# | 예 | 가능한 경우 기본적으로 지원[25] |
| Go | 아니오[26] | |
| 하스켈 | 예[27] | |
| 자바스크립트 | 일부 지원 | ECMAScript 6.0 호환 엔진에서 지원, 사파리/WebKit에서 구현됨, V8과 스파이더몽키에서는 거부[28] |
| 코틀린 | 예 | 함수에 `tailrec` 수정자 사용[30] |
| Lua | 예 | 언어 정의에 의해 요구됨[31] |
| Objective-C | 일부 지원 | 컴파일러 옵션(-O1 이상) 필요, 자동 참조 카운팅(ARC)에 의해 방해받을 수 있음 |
| OCaml | 예 | |
| Perl | 예 | `goto &NAME;` 구문으로 명시적 지원[32] |
| PureScript | 예 | |
| 파이썬 | 아니오 | 타사 모듈을 통해 사용 가능, 언어 발명가 귀도 반 로섬은 디버깅 문제를 이유로 반대[33][34] |
| R | 예 | R.4.4.0부터 tailcall() 함수 도입[35] |
| 라켓 | 예[36] | |
| 루비 | 예 | 기본적으로 비활성화[37] |
| 러스트 | 제한적 지원 | 보장되지 않음[38] |
| 스칼라 | 예 | 컴파일러에 의해 자동 최적화, `@tailrec` 어노테이션으로 표시 가능[39] |
| Scheme | 예 | 언어 정의에 의해 요구됨[40][41] |
| 스위프트 | 일부 지원 | 2014년 기준[42] |
| Tcl | 예 | Tcl 8.6부터 tailcall 명령어 지원[43] |
| 지그 | 예[44] |
참조
[1]
서적
Advanced Compiler Design Implementation
https://archive.org/[...]
Morgan Kaufmann
1997-08-15
[2]
웹사이트
The LLVM Target-Independent Code Generator — LLVM 7 documentation
http://llvm.org/docs[...]
[3]
웹사이트
recursion - Stack memory usage for tail calls - Theoretical Computer Science
https://cstheory.sta[...]
Cstheory.stackexchange.com
2013-03-21
[4]
웹사이트
Revised^6 Report on the Algorithmic Language Scheme
http://www.r6rs.org/[...]
R6rs.org
2013-03-21
[5]
웹사이트
Revised^6 Report on the Algorithmic Language Scheme - Rationale
http://www.r6rs.org/[...]
R6rs.org
2013-03-21
[6]
웹사이트
Revised^6 Report on the Algorithmic Language Scheme
http://www.r6rs.org/[...]
R6rs.org
2013-03-21
[7]
서적
Structure and Interpretation of Computer Programs
https://archive.org/[...]
MIT Press
1984
[8]
간행물
DAI Research Report 141
University of Edinburgh
1980
[9]
간행물
Technical Report TR19: Unwinding Structured Recursions into Iterations
http://www.cs.indian[...]
Indiana University
1974-12
[10]
서적
Proceedings of the 1977 annual conference on - ACM '77
[11]
학술지
Revised5 Report on the Algorithmic Language Scheme
http://www.schemers.[...]
1998-08
[12]
웹사이트
goto
http://perldoc.perl.[...]
perldoc.perl.org
2013-03-21
[13]
뉴스
What is difference between tail calls and tail recursion?
https://stackoverflo[...]
Stack Overflow
[14]
뉴스
What limitations does the JVM impose on tail-call optimization
http://programmers.s[...]
Programmers Stack Exchange
[15]
웹사이트
LLVM Language Reference Manual, section: The LLVM Target-Independent Code Generator, sub: Tail Call Optimization
http://llvm.org/docs[...]
The LLVM Project
2018-06-24
[16]
웹사이트
Using the GNU Compiler Collection (GCC): Optimize Options
https://gcc.gnu.org/[...]
[17]
웹사이트
foptimize-sibling-calls
https://software.int[...]
[18]
웹사이트
Tackling C++ Tail Calls
http://www.drdobbs.c[...]
[19]
웹사이트
proper tail recursion for gcc
https://gcc.gnu.org/[...]
GCC Project
2015-03-10
[20]
뉴스
Bouncing on your tail
http://blog.function[...]
Functional Fun
2008-04-09
[21]
문서
"CONS Should Not CONS Its Arguments, Part II: Cheney on the M.T.A."
http://home.pipeline[...]
[22]
웹사이트
(recur expr*)
https://clojure.org/[...]
[23]
웹사이트
Recursion
http://elixir-lang.o[...]
[24]
웹사이트
Functional Programming in Elm: Tail-Call Elimination
https://functional-p[...]
[25]
웹사이트
Tail Calls in F#
https://blogs.msdn.m[...]
Microsoft
2011-07-08
[26]
웹사이트
proposal: Go 2: add become statement to support tail calls
https://github.com/g[...]
[27]
웹사이트
Tail recursion - HaskellWiki
https://wiki.haskell[...]
2019-06-08
[28]
웹사이트
Worth watching: Douglas Crockford speaking about the new good parts of JavaScript in 2014
http://bdadam.com/bl[...]
[29]
웹사이트
ECMAScript 6 in WebKit
https://www.webkit.o[...]
2015-10-13
[30]
웹사이트
Functions: infix, vararg, tailrec - Kotlin Programming Language
https://kotlinlang.o[...]
[31]
웹사이트
Lua 5.3 Reference Manual
https://www.lua.org/[...]
[32]
웹사이트
goto - perldoc.perl.org
http://perldoc.perl.[...]
[33]
웹사이트
baruchel/tco
https://github.com/b[...]
2022-03-29
[34]
웹사이트
Neopythonic: Tail Recursion Elimination
http://neopythonic.b[...]
2009-04-22
[35]
웹사이트
What's new in R 4.4.0?
https://www.jumpingr[...]
2024-04-28
[36]
웹사이트
The Racket Reference
https://docs.racket-[...]
[37]
웹사이트
Ruby Tail Call Optimisation
https://ruby-doc.org[...]
[38]
웹사이트
Rust FAQ
https://prev.rust-la[...]
[39]
웹사이트
Scala Standard Library 2.13.0 - scala.annotation.tailrec
https://www.scala-la[...]
2019-06-20
[40]
웹사이트
Revised^5 Report on the Algorithmic Language Scheme
http://www.schemers.[...]
[41]
웹사이트
Revised^6 Report on the Algorithmic Language Scheme
http://www.r6rs.org/[...]
[42]
웹사이트
Does Swift implement tail call optimization?
https://stackoverflo[...]
2024-03-13
[43]
웹사이트
tailcall manual page - Tcl Built-In Commands
http://www.tcl.tk/ma[...]
[44]
웹사이트
Documentation - the Zig Programming Language
https://ziglang.org/[...]
[45]
서적
bit 単語帳
共立出版
1990-08-15
[46]
웹인용
Annotations {{!}} Scala Documentation
https://docs.scala-l[...]
2018-03-04
본 사이트는 AI가 위키백과와 뉴스 기사,정부 간행물,학술 논문등을 바탕으로 정보를 가공하여 제공하는 백과사전형 서비스입니다.
모든 문서는 AI에 의해 자동 생성되며, CC BY-SA 4.0 라이선스에 따라 이용할 수 있습니다.
하지만, 위키백과나 뉴스 기사 자체에 오류, 부정확한 정보, 또는 가짜 뉴스가 포함될 수 있으며, AI는 이러한 내용을 완벽하게 걸러내지 못할 수 있습니다.
따라서 제공되는 정보에 일부 오류나 편향이 있을 수 있으므로, 중요한 정보는 반드시 다른 출처를 통해 교차 검증하시기 바랍니다.
문의하기 : help@durumis.com