Haskell에 꼬리 재귀 최적화 기능이 있습니까?


89

저는 오늘 유닉스에서 "time"명령을 발견했고, Haskell에서 tail-recursive와 normal recursive 함수 사이의 런타임 차이를 확인하는 데 사용할 것이라고 생각했습니다.

다음 기능을 작성했습니다.

--tail recursive
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
    fac' 1 y = y
    fac' x y = fac' (x-1) (x*y) 

--normal recursive
facSlow :: (Integral a) => a -> a
facSlow 1 = 1
facSlow x = x * facSlow (x-1)

이것들은이 프로젝트에서만 사용하기위한 것이었기 때문에 0이나 음수를 확인하는 것을 귀찮게하지 않았습니다.

그러나 각각에 대한 주요 메소드를 작성하고 컴파일하고 "time"명령을 사용하여 실행하면 둘 다 꼬리 재귀 함수를 제거하는 일반 재귀 함수 와 유사한 런타임을 가졌습니다 . 이것은 lisp의 tail-recursive 최적화와 관련하여들은 것과는 반대입니다. 그 이유는 무엇입니까?


8
TCO는 일부 호출 스택을 절약하기위한 최적화라고 생각하지만 CPU 시간을 절약 할 수 있다는 의미는 아닙니다. 틀렸다면 정정하십시오.
제롬

3
lisp로 테스트하지는 않았지만 내가 읽은 튜토리얼은 스택을 설정하면 자체적으로 더 많은 프로세서 비용이 발생하는 반면 컴파일 된 반복적 테일 재귀 솔루션은이 작업을 수행하는 데 에너지 (시간)를 소비하지 않았으므로 더 효율적이었습니다.
haskell rascal

1
TCO는 일반적으로 ..뿐만 아니라 빠른 프로그램을 생성 할 수 있도록 @Jerome은 잘 또한 플레이에 와서 캐시하여 일반적으로 많은 것들에 따라 다르지만
크리스토퍼 Micinski

그 이유는 무엇입니까? 한마디로, 게으름.
Dan Burton

흥미롭게도 facghc product [n,n-1..1]가 보조 함수를 사용하여 계산하는 방법은 다소 차이가 prod있지만 물론 product [1..n]더 간단합니다. 나는 이것이 ghc가 간단한 누산기로 컴파일 할 수 있다는 것을 매우 확신한다는 근거로 두 번째 주장에서 엄격하게 만들지 않았다고 가정 할 수 있습니다.
AndrewC

답변:


168

Haskell은 lazy-evaluation을 사용하여 재귀를 구현하므로 필요한 경우 값을 제공하겠다는 약속으로 모든 것을 처리합니다 (이를 썽크라고 함). 썽 크는 진행하는 데 필요한만큼만 줄어 듭니다. 이것은 수학적으로 표현을 단순화하는 방법과 유사하므로 그렇게 생각하는 것이 도움이됩니다. 평가 순서가 코드에 의해 지정 되지 않는다는 사실로 인해 컴파일러는 예전의 꼬리 호출 제거보다 훨씬 더 똑똑한 최적화를 수행 할 수 있습니다. 최적화를 원한다면 컴파일 -O2하세요!

facSlow 5사례 연구로 평가하는 방법을 살펴 보겠습니다 .

facSlow 5
5 * facSlow 4            -- Note that the `5-1` only got evaluated to 4
5 * (4 * facSlow 3)       -- because it has to be checked against 1 to see
5 * (4 * (3 * facSlow 2))  -- which definition of `facSlow` to apply.
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120

따라서 걱정했던 것처럼 계산이 발생하기 전에 숫자가 쌓여 있지만 걱정 과 달리facSlow 종료를 기다리는 함수 호출 스택이 없습니다. 각 감소가 적용되고 사라지고 스택 프레임 이 그대로 유지됩니다. wake (즉, (*)엄격 하기 때문에 두 번째 인수의 평가를 트리거합니다).

Haskell의 재귀 함수는 매우 재귀적인 방식으로 평가되지 않습니다! 주위에 걸려있는 유일한 호출 스택은 곱셈 자체입니다. (*)가 엄격한 데이터 생성자로 간주되는 경우 이를 보호 된 재귀라고합니다 (일반적으로 엄격 데이터 생성자에서 이와 같이 언급되지만 추가 액세스에 의해 강제되는 경우 데이터 생성자가 데이터 생성자입니다).

이제 tail-recursive를 살펴 보겠습니다 fac 5.

fac 5
fac' 5 1
fac' 4 {5*1}       -- Note that the `5-1` only got evaluated to 4
fac' 3 {4*{5*1}}    -- because it has to be checked against 1 to see
fac' 2 {3*{4*{5*1}}} -- which definition of `fac'` to apply.
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}}        -- the thunk "{...}" 
(2*{3*{4*{5*1}}})        -- is retraced 
(2*(3*{4*{5*1}}))        -- to create
(2*(3*(4*{5*1})))        -- the computation
(2*(3*(4*(5*1))))        -- on the stack
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120

따라서 꼬리 재귀 자체가 시간이나 공간을 절약하지 못한 방법을 알 수 있습니다. 전체적으로 더 많은 단계를 수행 할뿐만 아니라 facSlow 5중첩 된 썽크 (로 표시됨)를 구축합니다 {...}. 추가 공간이 필요합니다. 이는 향후 계산, 수행 할 중첩 곱셈을 설명합니다.

이 썽크이어서 이송하여 풀린다 스택 계산을 재현 하단. 두 버전 모두 매우 긴 계산으로 스택 오버플로가 발생할 위험도 있습니다.

이를 수동으로 최적화하려면 엄격하게 설정하기 만하면됩니다. 엄격한 애플리케이션 연산자 $!를 사용하여

facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
    facS' 1 y = y
    facS' x y = facS' (x-1) $! (x*y) 

이것은 facS'두 번째 주장에서 엄격해야합니다. ( facS'적용 할 정의를 결정하기 위해 평가해야하므로 첫 번째 인수는 이미 엄격 합니다.)

때로는 엄격함이 큰 도움이 될 수 있으며, 게으름이 더 효율적이기 때문에 때로는 큰 실수입니다. 여기에 좋은 생각이 있습니다.

facSlim 5
facS' 5 1
facS' 4 5 
facS' 3 20
facS' 2 60
facS' 1 120
120

당신이 달성하고 싶었던 것이 무엇인지 나는 생각한다.

요약

  • 코드를 최적화하려면 첫 번째 단계는 다음으로 컴파일하는 것입니다. -O2
  • 꼬리 재귀는 썽크 축적이 없을 때만 유용하며, 엄격함을 추가하면 일반적으로 적절한 경우이를 방지하는 데 도움이됩니다. 이것은 나중에 필요한 결과를 한꺼번에 만들 때 발생합니다.
  • 때때로 꼬리 재귀는 나쁜 계획이고 보호 된 재귀가 더 적합합니다. 참조 이 질문 에 대해 foldr그리고 foldl예를 들어, 서로에 대해 그들을 테스트합니다.

다음 두 가지를 시도하십시오.

length $ foldl1 (++) $ replicate 1000 
    "The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000 
    "The number of reductions performed is more important than tail recursion!!!"

foldl1꼬리 재귀이지만 foldr1보호 된 재귀를 수행하여 추가 처리 / 액세스를 위해 첫 번째 항목이 즉시 표시되도록합니다. (한 번에 왼쪽의 첫 번째 "괄호" (...((s+s)+s)+...)+s는 입력 목록을 완전히 끝까지 강제하고 전체 결과가 필요한 것보다 훨씬 빨리 미래 계산의 큰 덩어리를 구축합니다. 두 번째 괄호는 점차 오른쪽으로 이동 s+(s+(...+(s+s)...))하여 입력을 모든 것이 최적화를 통해 일정한 공간에서 작동 할 수 있습니다).

사용중인 하드웨어에 따라 0의 수를 조정해야 할 수도 있습니다.


1
@WillNess 훌륭합니다, 감사합니다. 후퇴 할 필요가 없습니다. 이제 후손들에게 더 나은 대답이라고 생각합니다.
AndrewC

4
이것은 훌륭하지만 엄격 성 분석에 고개를 끄덕여도 될까요? 합리적으로 최근 버전의 GHC에서 꼬리 재귀 팩토리얼에 대한 작업을 거의 확실하게 수행 할 것이라고 생각합니다.
dfeuer 2014 년

16

fac함수는 보호 된 재귀에 적합한 후보가 아니라는 점을 언급해야합니다 . 꼬리 재귀는 여기로가는 길입니다. 게으름으로 인해 fac'누산기 인수가 큰 덩어리를 계속 빌드하기 때문에 함수 에서 TCO의 효과를 얻지 못하며 , 평가시 막대한 스택이 필요합니다. 이를 방지하고 원하는 TCO 효과를 얻으려면 이러한 누산기 인수를 엄격하게 만들어야합니다.

{-# LANGUAGE BangPatterns #-}

fac :: (Integral a) => a -> a
fac x = fac' x 1 where
  fac' 1  y = y
  fac' x !y = fac' (x-1) (x*y)

사용하여 -O2(또는 그냥 -O) 컴파일하는 경우 GHC는 엄격 성 분석 단계 에서이 작업을 자체적으로 수행 할 것입니다 .


4
를 사용하는 $!것보다 더 명확하다고 생각 BangPatterns하지만 이것은 좋은 대답입니다. 특히 엄격 성 분석에 대한 언급.
singpolyma

7

Haskell의 tail recursion에 대한 위키 기사를 확인해야합니다 . 특히 표현식 평가로 인해 원하는 재귀 종류는 보호 된 재귀입니다. 내부에서 무슨 일이 벌어지고 있는지 (Haskell의 추상 기계에서) 세부 사항을 계산하면 엄격한 언어의 꼬리 재귀와 같은 종류의 결과를 얻을 수 있습니다. 이와 함께 지연 함수에 대한 통일 된 구문이 있습니다 (꼬리 재귀는 엄격한 평가에 연결되는 반면 보호 된 재귀는 더 자연스럽게 작동합니다).

(그리고 Haskell을 배우면서 나머지 위키 페이지도 훌륭합니다!)


0

올바르게 기억하면 GHC는 일반 재귀 함수를 꼬리 재귀 최적화 함수로 자동으로 최적화합니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.