단점의 속성은 꼬리 재귀 모듈러스 단점을 제거 할 수 있습니까?


14

호출의 직접 결과를 반환하는 함수를 반복 루프로 다시 작성할 수있는 기본 꼬리 재귀 제거 아이디어에 익숙합니다 .

foo(...):
    # ...
    return foo(...)

또한 특수한 경우 재귀 호출이에 대한 호출로 래핑 된 경우에도 함수를 다시 작성할 수 있음을 이해합니다 cons.

foo(...):
    # ...
    return (..., foo(...))

어떤 속성 cons이 이것을 허용합니까? cons반복적으로 다시 작성할 수있는 능력을 파괴하지 않고 재귀 적 꼬리 호출을 감쌀 수있는 것 외에 다른 기능은 무엇입니까 ?

GCC (그러나 Clang은 아님)는 이 "꼬리 재귀 모듈로 곱셈 "예제 를 최적화 할 수 있지만 어떤 메커니즘이이를 감지하고 어떻게 변환하는지는 확실하지 않습니다.

pow(x, n):
    if n == 0: return 1
    else if n == 1: return x
    else: return x * pow(x, n-1)

1
Godbolt 컴파일러 탐색기 링크에서 함수는 if(n==0) return 0;(질문과 같이 1을 반환하지 않습니다) 있습니다. x^0 = 1버그입니다. 그러나 나머지 질문에는 중요하지 않습니다. 반복적 인 asm은 그 특별한 경우를 먼저 확인합니다. 그러나 이상하게도 반복 구현 1 * xfloat버전 을 만들더라도 소스에 존재하지 않는 배수를 소개합니다 . gcc.godbolt.org/z/eqwine (및 gcc는으로 만 성공 -ffast-math)
Peter Cordes

@PeterCordes 잘 잡아라. 이 return 0수정되었습니다. 1의 곱셈이 흥미 롭습니다. 무엇을 만들어야할지 모르겠습니다.
Maxpm 2015 년

루프로 돌릴 때 GCC가 변형되는 방식의 부작용이라고 생각합니다. 분명히 gcc는 매번 같은 값을 곱하더라도, 누락 된 최적화, 예를 들어 floatwithout없이 최적화를 누락했습니다 -ffast-math. (고집 포인트가 될 1.0f`를 제외하고?)
Peter Cordes

답변:


12

GCC는 임시 규칙을 사용하지만 다음과 같은 방법으로 규칙을 파생시킬 수 있습니다. 나는 pow당신이 foo너무 모호하게 정의되어 있기 때문에 설명 하기 위해 사용할 것입니다 . 또한, foo언어 Oz 가 가지고 있고 개념, 기술 및 컴퓨터 프로그래밍 모델 에서 논의 된 바와 같이 단일 할당 변수와 관련하여 마지막 호출 최적화의 인스턴스로 가장 잘 이해 될 수 있습니다 . 단일 할당 변수를 사용하면 선언적인 프로그래밍 패러다임 내에 남아있을 수 있다는 이점이 있습니다. 기본적으로 구조체 foo반환 의 각 필드 가 단일 할당 변수로 표시되도록 한 다음 foo추가 인수로 전달할 수 있습니다. foo그런 다음 꼬리 재귀가됩니다.void반환 기능. 이를 위해 특별한 영리함이 필요하지 않습니다.

로 돌아가서 pow먼저 연속 전달 스타일 로 변환하십시오 . pow된다 :

pow(x, n):
    return pow2(x, n, x => x)

pow2(x, n, k):
    if n == 0: return k(1)
    else if n == 1: return k(x)
    else: return pow2(x, n-1, y => k(x*y))

모든 통화는 이제 테일 통화입니다. 그러나 제어 스택은 연속을 나타내는 클로저의 캡처 된 환경으로 이동되었습니다.

다음 으로 연속 기능을 비활성화 하십시오. 재귀 호출이 하나만 있기 때문에 비 기능화 된 연속을 나타내는 결과 데이터 구조가 목록입니다. 우리는 얻는다 :

pow(x, n):
    return pow2(x, n, Nil)

pow2(x, n, k):
    if n == 0: return applyPow(k, 1)
    else if n == 1: return applyPow(k, x)
    else: return pow2(x, n-1, Cons(x, k))

applyPow(k, acc):
    match k with:
        case Nil: return acc
        case Cons(x, k):
            return applyPow(k, x*acc)

무엇을 하는가 applyPow(k, acc), 즉 무료 monoid와 같은 목록을 가져 k=Cons(x, Cons(x, Cons(x, Nil)))와서로 만드는 것 x*(x*(x*acc))입니다. 그러나 *연관적이고 일반적으로 unit과 monoid를 형성하기 때문에 1이것을 reassociate 할 수 ((x*x)*x)*acc있고 간단히하기 위해 a 1를 시작하여 생산할 수 (((1*x)*x)*x)*acc있습니다. 중요한 것은 실제로 결과를 얻기 전에도 결과를 부분적으로 계산할 수 있다는 것입니다 acc. 즉 k, 결국 "해석"할 불완전한 "구문"인 목록 으로 전달하는 대신 우리가 갈 때 "해석"할 수 있습니다. 결과적으로 우리는 이 경우에 Nilmonoid의 단위와 monoid 의 작동으로 대체 할 수 있으며 이제는 "실행중인 제품"을 나타냅니다.1Cons*kapplyPow(k, acc)그런 다음 k*acc다시 인라인 pow2하고 생산을 단순화 할 수있는 것입니다 .

pow(x, n):
    return pow2(x, n, 1)

pow2(x, n, k):
    if n == 0: return k
    else if n == 1: return k*x
    else: return pow2(x, n-1, k*x)

원본의 꼬리 재귀 누적 기 전달 스타일 버전입니다 pow.

물론 GCC가 컴파일 타임 에이 모든 추론을 수행한다고 말하지는 않습니다. 나는 GCC가 사용하는 논리를 모른다. 내 요점은이 추론을 한 번만 수행 한 것입니다. 패턴을 인식하고 원본 소스 코드를이 최종 형식으로 즉시 변환하는 것이 상대적으로 쉽습니다. 그러나, CPS 변환 및 기능 이상화 변환은 완전히 일반적이고 기계적이다. 거기서부터 융합, 삼림 벌채 또는 수퍼 컴파일 기법을 사용하여 통합 된 연속을 제거 할 수 있습니다. 확정 된 연속의 모든 할당을 제거 할 수없는 경우 추론 적 변환은 폐기 될 수 있습니다. 그러나 나는 그것이 전체적으로 전체적으로 수행하기에는 너무 비싸고 따라서 더 많은 임시 접근 방식이라고 생각합니다.

말도 안되게하려면 CPS와 연속체 표현을 데이터로 사용하지만 꼬리 재귀 모듈러스와 비슷하지만 다른 기능을하는 재활용 연속 용지를 참조하십시오 . 여기에서는 변환을 통해 포인터 역전 알고리즘을 생성하는 방법에 대해 설명합니다.

이 CPS 변형 및 비 기능화 패턴은 이해를위한 매우 강력한 도구이며 여기에 나열된 일련의 논문에서 좋은 효과를내는 데 사용됩니다 .


여기에 표시하는 연속 통과 스타일 대신 GCC가 사용하는 기술은 정적 단일 할당 양식이라고 생각합니다.
Davislor

@Davislor CPS와 관련하여 SSA는 프로 시저의 제어 흐름에 영향을 미치지 않으며 스택을 유지하거나 동적으로 할당해야하는 데이터 구조를 도입하지도 않습니다. SSA와 관련하여 CPS는 "너무 많이"수행되므로 ANF (Administrative Normal Form)가 SSA에 더 가깝습니다. 따라서 GCC는 SSA를 사용하지만 SSA는 제어 스택을 조작 가능한 데이터 구조로 볼 수 없습니다.
Derek Elkins가 SE를

권리. “GCC가이 모든 추론을 컴파일 타임에 수행한다고 말하는 것은 아닙니다. GCC가 사용하는 논리를 모르겠습니다.” 마찬가지로, 내 대답은 주어진 컴파일러가 사용하는 구현 방법이 아니라 변환이 이론적으로 정당하다는 것을 보여주었습니다. (아시다시피, 많은 컴파일러가 최적화 중에 프로그램을 CPS로 변환합니다.)
Davislor

8

나는 잠시 동안 덤불 주위를 때릴 것입니다. 그러나 요점이 있습니다.

반 그룹

답은 이진 축소 연산의 연관 속성입니다 .

그것은 매우 추상적이지만 곱셈이 좋은 예입니다. 경우 X , YZ는 어떤 자연수 (또는 정수 또는 유리수, 또는 실수 또는 복소수, 또는이다 N × N의 행렬, 또는 전체 무리의 더 이상 일을), 다음 X × Y는 같은 종류입니다 xy 모두 숫자 입니다. 우리는 두 개의 숫자로 시작하여 이항 연산이고 하나를 얻었으므로 우리는 숫자의 수를 하나씩 줄 였으므로 이것을 감소 ​​연산으로 만듭니다. 그리고 ( x × y ) × z 는 항상 x × ( y ×z )는 연관 특성입니다.

(이미 이미 알고 있다면 다음 섹션으로 건너 뛸 수 있습니다.)

동일한 방식으로 작동하는 컴퓨터 과학에서 자주 볼 수있는 몇 가지 사항 :

  • 곱하기 대신 이런 종류의 숫자를 더하는 것
  • 문자열을 연결하는 ( "a"+"b"+"c""abc"당신이 시작 여부 "ab"+"c"또는 "a"+"bc")
  • 두 목록을 함께 연결합니다. [a]++[b]++[c]마찬가지로 [a,b,c]앞뒤로 또는 앞뒤로입니다.
  • cons머리를 싱글 톤 목록으로 생각하면 머리와 꼬리에. 그것은 단지 두 개의 목록을 연결하는 것입니다.
  • 집합의 조합 또는 교차점을 복용
  • 부울 및 부울 또는
  • 비트 &, |그리고^
  • 함수 구성 : ( fg ) ∘ h x = f ∘ ( gh ) x = f ( g ( h ( x )))
  • 최대 및 최소
  • 추가 모듈로 p

그렇지 않은 것들 :

  • 1- (1-2) ≠ (1-1) -2이므로 뺄셈
  • tan (π / 4 + π / 4)가 정의되지 않았으므로 xy = tan ( x + y )
  • -1 × -1은 음수가 아니므로 음수에 대한 곱셈
  • 세 가지 문제가 모두있는 정수 나누기!
  • 논리적이 아닌 두 개의 피연산자가 아닌 하나의 피연산자가 있으므로
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); }print2( print2(x,y), z );print2( x, print2(y,z) );다른 출력을 갖는다.

우리가 명명 한 유용한 개념입니다. 이러한 속성을 가진 연산이 포함 된 집합은 semigroup 입니다. 따라서 곱셈의 실수는 반 그룹입니다. 그리고 당신의 질문은 이런 종류의 추상화가 현실 세계에서 유용하게되는 방법 중 하나 인 것으로 밝혀졌습니다. 원하는대로 세미 그룹 작업을 최적화 할 수 있습니다.

집에서 이것을 시도

내가 아는 한,이 기술은 1974 년 Daniel Friedman과 David Wise의 논문 인 “스타일 화 된 재귀를 반복으로 접는 방법” 에서 처음 설명 되었지만, 필요한 것보다 몇 가지 더 많은 속성을 가정했습니다.

Haskell은 Semigroup표준 라이브러리에 typeclass 가 있기 때문에 이것을 설명하기에 좋은 언어 입니다. 일반 Semigroup연산자의 연산자를 호출합니다 <>. 목록과 문자열은의 인스턴스 Semigroup이므로 해당 인스턴스 는 예를 들어 <>연결 연산자로 정의 됩니다 ++. 그리고 올바른 가져 오기 [a] <> [b]를 사용하면의 별칭 [a] ++ [b]이입니다 [a,b].

그러나 숫자는 어떻습니까? 우리는 단지 숫자 유형에 따라 반군 있습니다 본 중 하나를 추가 또는 곱셈! 어느 그래서 하나가 될 얻는다 <>A에 대한 Double? 둘 중 하나! 하스켈 유형 정의 Product Double, where (<>) = (*)또한 (즉 하스켈 실제의 정의이다)과 Sum Double, where (<>) = (+).

하나의 주름은 1이 곱셈이라는 사실을 사용했다는 것입니다. 아이덴티티가있는 세미 그룹을 모노 아이디라고하며 Haskell 패키지에 정의되어 있으며 Data.Monoid,이 클래스는 typeclass의 일반 아이덴티티 요소를 호출합니다 mempty. Sum, Product및 각리스트는 식별 소자 (0, 1 및 보유 []가 인스턴스 그래서 각각 등) Monoid뿐만 아니라이 Semigroup. ( 모나드 와 혼동하지 마십시오. 모니터를 키우는 것을 잊지 마십시오 .)

모노 이드를 사용하여 알고리즘을 Haskell 함수로 변환하기에 충분한 정보입니다.

module StylizedRec (pow) where

import Data.Monoid as DM

pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
 - itself n times.  This is already in Haskell as Data.Monoid.mtimes, but
 - let’s write it out as an example.
 -}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x      -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.

중요하게도 이것은 꼬리 재귀 모듈로 반 그룹입니다. 모든 경우는 값, 꼬리 재귀 호출 또는 두 그룹의 반 그룹 곱입니다. 또한이 예제 mempty는 사례 중 하나에 사용 되었지만, 필요하지 않은 경우보다 일반적인 typeclass로 수행 할 수 있습니다 Semigroup.

이 프로그램을 GHCI에로드하고 어떻게 작동하는지 봅시다 :

*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49

우리 pow가 제네릭 Monoid형식으로 선언 한 방식을 기억 a하십니까? 우리는 유형 것을 추론 할 GHCI 충분한 정보를 준 a여기 Product Integer인, instanceMonoid, 그 <>작업이 정수의 곱셈이다. 따라서 로 또는로 pow 2 4확장됩니다 . 여태까지는 그런대로 잘됐다.2<>2<>2<>22*2*2*216

그러나 우리의 함수는 일반적인 monoid 연산만을 사용합니다. 이전에, 나는 또 다른 인스턴스가 존재했다 Monoid라는 Sum누구의 <>작업입니다 +. 시도해 볼 수 있습니까?

*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14

이제 동일한 확장이 2+2+2+2대신에 우리 를 제공합니다 2*2*2*2. 지수는 곱셈이므로 곱셈은 더하기입니다!

그러나 나는 Haskell monoid : list의 다른 예를 제시했습니다.

*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]

쓰기는 [2],이 목록입니다 컴파일러를 알려줍니다 <>목록에 ++, 그래서 [2]++[2]++[2]++[2]이다 [2,2,2,2].

마지막으로, 알고리즘 (실제로 2 개)

단순히 교체함으로써 x와 함께 [x], 당신은 사용이 목록을 만듭니다 하나에 모듈 반군 재귀하는 일반적인 알고리즘을 변환합니다. 어느 목록입니까? 알고리즘이 적용되는 요소 목록 <>. 리스트도 가지고있는 세미 그룹 연산 만 사용했기 때문에 결과리스트는 원래 계산과 동형이됩니다. 그리고 원래의 작업은 연관성이 있었으므로 요소를 앞뒤로 또는 앞뒤로 동등하게 평가할 수 있습니다.

알고리즘이 기본 사례에 도달하고 종료되면 목록이 비어 있지 않습니다. 터미널 케이스가 무언가를 반환했기 때문에 목록의 마지막 요소가되므로 적어도 하나의 요소를 갖게됩니다.

목록의 모든 요소에 이진 축소 작업을 어떻게 순서대로 적용합니까? 그렇습니다. 따라서을 대신 [x]하고 x로 줄일 요소 목록을 <>가져온 다음 목록을 오른쪽 또는 왼쪽으로 접을 수 있습니다.

*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16

와 버전 foldr1실제로는 같은 표준 라이브러리에 존재 sconcat에 대한 Semigroupmconcat대한 Monoid. 목록에서 게으른 오른쪽 접기를 수행합니다. 즉,로 확장 [Product 2,Product 2,Product 2,Product 2]됩니다 2<>(2<>(2<>(2))).

이 경우에는 모든 용어를 생성 할 때까지 개별 용어로 아무 것도 수행 할 수 없으므로이 방법은 효율적이지 않습니다. (한 시점에서 오른쪽 접기를 사용할 때와 엄격한 왼쪽 접기를 사용할 때에 대해 논의했지만 너무 멀리 나갔습니다.)

버전 foldl1'은 엄격하게 평가 된 왼쪽 접기입니다. 즉, 엄격한 누산기를 갖춘 꼬리 재귀 함수입니다. 이 평가는으로 평가되며 (((2)<>2)<>2)<>2나중에 필요할 때 즉시 계산되지 않습니다. (적어도 내에 지연이없는 자체를 겹 :. 게으른 평가를 포함 할 수 있습니다 다른 기능에 의해 여기서 생성되는 접힌되는 목록) 그래서, 배 계산 (4<>2)<>2, 즉시 계산 8<>2한 후, 16. 이것이 우리가 연산을 연관시키는 것이 필요한 이유입니다 : 우리는 단지 괄호의 그룹을 변경했습니다!

엄격한 왼쪽 접기는 GCC가 수행하는 작업과 동일합니다. 이전 예에서 가장 왼쪽에있는 숫자는 누산기 (이 경우 실행중인 제품)입니다. 각 단계마다 목록의 다음 숫자가 곱해집니다. 그것을 표현하는 또 다른 방법은 곱할 값을 반복하여 실행중인 제품을 누산기에 유지하고 각 반복마다 누산기에 다음 값을 곱하는 것입니다. 즉, while변장 루프입니다.

때로는 효율적으로 만들 수도 있습니다. 컴파일러는 메모리에서 목록 데이터 구조를 최적화 할 수 있습니다. 이론적으로는 컴파일 타임에 여기에서 그렇게해야한다는 것을 알기에 충분한 정보 [x]가 있습니다. 싱글 톤이므로 [x]<>xs와 동일합니다 cons x xs. 함수를 반복 할 때마다 동일한 스택 프레임을 재사용하고 적절한 매개 변수를 업데이트 할 수 있습니다.

특정한 경우에는 오른쪽 접기 또는 엄격한 왼쪽 접기가 더 적절할 수 있으므로 원하는 것을 알고 있어야합니다. 올바른 접힘 만 수행 할 수있는 작업도 있습니다 (예 : 모든 입력을 기다리지 않고 대화식 출력 생성 및 무한 목록에서 작동). 그러나 여기서는 일련의 연산을 간단한 값으로 줄이고 있으므로 엄격한 왼쪽 접기가 우리가 원하는 것입니다.

보시다시피, 하나의 행에서 게으른 오른쪽 접힘 또는 엄격한 왼쪽 접힘에 대해 세미 반복 모듈로를 세미 그룹 (예 : 곱셈에서 일반적인 숫자 유형 중 하나)으로 자동 최적화 할 수 있습니다. 하스켈.

더 일반화

이진 연산의 두 인수는 초기 값이 결과와 동일한 유형 인 한 같은 유형일 필요는 없습니다. (물론 항상 접는 종류, 왼쪽 또는 오른쪽 순서와 일치하도록 인수를 뒤집을 수 있습니다.) 따라서 파일에 패치를 반복적으로 추가하여 업데이트 된 파일을 얻거나 초기 값으로 시작할 수 있습니다. 1.0은 정수로 나눠 부동 소수점 결과를 누적합니다. 또는 목록을 얻으려면 빈 목록 앞에 요소를 추가하십시오.

또 다른 유형의 일반화는 접기를 목록이 아닌 다른 Foldable데이터 구조 에 적용하는 것 입니다. 종종 불변 선형 링크리스트는 주어진 알고리즘에 대해 원하는 데이터 구조가 아닙니다. 위에서 언급하지 않은 한 가지 문제는 목록보다 요소를 목록보다 앞에 추가하는 것이 훨씬 효율적이며 작업이 정식 적이 지 않을 때 작업 x의 왼쪽과 오른쪽에 적용 하는 것이 효과적 이지 않다는 것입니다 똑같다. 따라서 x오른쪽 <>뿐만 아니라 왼쪽에도 적용 할 수있는 알고리즘을 나타내려면 목록 쌍 또는 이진 트리와 같은 다른 구조를 사용해야 합니다.

또한 연관 속성을 사용하면 나누기 및 정복과 같은 다른 유용한 방법으로 작업을 다시 그룹화 할 수 있습니다.

times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n    = y <> y
          | otherwise = x <> y <> y
  where y = times x (n `quot` 2)

또는 자동 병렬 처리. 각 스레드는 하위 범위를 값으로 축소 한 다음 다른 범위와 결합합니다.


1
우리는 연관성이 최적화 할 수있는 GCC의 능력의 핵심입니다 테스트 실험을 수행 할 수 있습니다 pow(float x, unsigned n)버전 gcc.godbolt.org/z/eqwine가 단지와 최적화를 -ffast-math의미한다 ( -fassociative-math. 엄격한 부동 소수점의 코스입니다 하지 다른 임시직 때문에 연관 = 다른 반올림). 소개는 1.0f * xC 추상 머신에는 없었지만 항상 동일한 결과를 제공합니다. 그렇다면 n-1 곱셈 do{res*=x;}while(--n!=1)은 재귀와 동일하므로 이것은 놓친 최적화입니다.
Peter Cordes
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.