블로그 메인으로
블로그 메인으로

흥미 위주로 알아보는 일반 재귀 함수

약 23분 분량 · 작성 · 수정 #TIL#계산이론

컴퓨터과학에서 이론적으로 다루는 계산 모형이 튜링 기계 말고 다른 것도 있다는 것을 알고 계셨나요?

처음 이 글을 작성했을 때는 어쩌다가 일반 재귀 함수라는 개념을 마주치게 되었는지 구질구질하게 모두 적었었는데, 핵심만 요약하자면 어쩌다가 위키백과에서 Model of computation 항목을 읽게 되었고 제가 몰랐던 수많은 계산 모형을 마주치게 되었다는 것이라고 하겠습니다. 아마 튜링 기계 말고도 람다 계산을 들어보신 분들이 계실 텐데, 튜링 완전하지 않은 것을 포함하면 위키백과에 등재된 계산 모형이 최소 18개나 되리라고 예상하셨던 분은 없을 것이라고 생각합니다. (저도 깜짝 놀랐습니다.)

이 글에서는 지금까지 제시되었던 수많은 계산 모형 중 일반 재귀 함수general recursive functions를 다룹니다. 한국어 위키백과에는 μ-재귀 함수μ-recursive functions라는 제목으로 등재되어 있고 재작성 이전에도 이 용어를 사용했지만, 이번에는 영문 용어를 그대로 번역해 사용합니다. 제가 확실히 이해하고 있는 개념이 아니기 때문에 잘못된 내용이 있을 수 있습니다.

참고로 해당 위키백과 항목에 등재되어 있는 계산 모형을 모두 적으면 다음과 같습니다. 특히 제가 어느 정도 알고 있는 것은 부연 설명을 적고, 튜링 완전하다고 확실히 확인한 것은 굵게 강조했습니다.

원시 재귀 함수

일반 재귀 함수를 통째로 이해하기 전에 더 작은 개념인 원시 재귀 함수primitive recursive functions를 먼저 다루어 보겠습니다. 이 계산 모형에서는 세 종류의 대상을 다룹니다.

다음에 해당하는 함수만을 원시 재귀 함수라고 합니다. 대문자는 함수, 소문자는 자연수를 나타냅니다.

세 가지 기본 함수는 설명하지 않고 넘어가도 될 것이라고 생각합니다. 두 가지 연산자는 바로 이해하기 쉽지 않을 수 있으니 부연 설명을 해보겠습니다.

합성 연산자

다변수 버전의 함수 합성입니다. 변수함수 개의 변수함수 에 대해 변수함수 은 다음과 같이 계산합니다.

학교에서 함수를 배울 때 아마 함수의 합성()을 같이 배울 것입니다. (설마 그동안 교육과정에서 빠지지는 않았겠죠?) '여러 가지 작업을 순차적으로 한다'는 것을 표현하는 것은 정말 중요하기 때문에 원시 재귀 함수에서도 합성 연산자를 포함하고 있습니다.

의 양쪽 피연산자가 1변수함수일 때는 보통의 함수 합성이 됩니다.

오른쪽 피연산자를 다변수함수로 일반화하는 것까지도 역시 문제가 없습니다.

그런데 왼쪽 피연산자를 확장하려고 하면 문제가 발생합니다. 변수함수인데, 위의 식을 그대로 두면 에 전달할 인자가 하나밖에 없게 됩니다.

그렇다고 해서 을 모든 인자에 똑같이 복제하면 모든 매개변수의 값이 똑같아지기 때문에 다변수함수를 쓰는 이유가 없어집니다.

그 대신 원시 재귀 함수 모형에서는 오른쪽 피연산자로 오는 함수 개수를 왼쪽 함수의 항수에 맞추는 해결책을 사용합니다. 이렇게 하면 인자의 개수도 일치하고 각 매개변수에 서로 다른 인자를 전달할 수도 있습니다.

그렇게 쓸모가 있는 것은 아니지만, 왼쪽 피연산자로 0변수함수가 오는 것도 가능합니다. 기존 문헌에서는 이 경우를 어떻게 표기하는지 모르겠는데, 이 글에서는 합성한 함수의 항수를 알 수 있도록 오른쪽 피연산자의 괄호 안에 을 표기합니다.

이론은 충분히 다뤘으니 합성 연산자를 사용해 간단한 함수를 만드는 예시를 살펴보겠습니다.

물론 지금 할 수 있는 것은 원하는 자연수 만들기, 정해진 값만큼 더하기, 자리 바꾸기뿐이기 때문에 이 이상 할 수 있는 것이 별로 없습니다. 그 대신 다음으로 알아볼 연산자는 합성보다 훨씬 강력합니다.

원시 재귀 연산자

제한적인bounded 반복을 수행하는 연산자입니다. 변수함수 변수함수 에 대해 변수함수 는 다음과 같이 계산합니다.

원하는 값()에 원하는 함수()를 매개변수로 주어지는 횟수()만큼 반복하고 싶다면 연산자를 어떻게 정의하는 것이 좋을까요? 아마 이런 정의가 가장 먼저 떠오르지 않을까 싶습니다. 아직 진짜 가 아니기 때문에 아래 첨자를 붙였습니다.

그런데 위와 같은 방식으로 정의하면 가능한 모든 에 대해 규칙이 하나씩, 즉 무한개 있는 꼴이니 예쁘지 않습니다. 다행히 다음 방법을 사용해 규칙을 2개로 줄일 수 있습니다.

첫 매개변수에 아무 자연수를 대입하면 첫 번째 정의와 같은 결과가 나온다는 것을 알 수 있습니다. 사실은 이것만으로 이미 진짜 와 똑같은 표현력을 가짐이 알려져 있지만, 사용의 편의성을 위해 몇 가지 기능을 추가해 보겠습니다.

우선 일 때의 베이스 케이스를 자체가 아니라 에 베이스 케이스 함수()를 적용한 값이 되도록 일반화할 수 있습니다.

이제 매개변수 가 항상 함수에 인자로 전달되기 때문에 매개변수의 개수를 원하는 만큼 늘릴 수 있습니다.

마지막으로 함수를 보다 쉽게 작성할 수 있도록 에도 전달하도록 수정할 수 있습니다.

바로 이 원시 재귀 연산자 입니다. 지금까지의 설명을 요약하자면 는 일종의 for문으로 생각할 수 있습니다. JavaScript로 표현하면 다음과 같습니다.

const rho = (B, R) => (n, ...args) => {
	let acc = B(...args);
	for(let i = 0; i < n; i++)
		acc = R(i, acc, ...args);
	return acc;
};

참고로 설명에서 사용한 함수 이름 은 각각 Base의 B, Recursive의 R입니다.

원시 재귀 함수의 예시

원시 재귀 연산자를 사용하는 가장 간단한 예시로는 덧셈을 들 수 있겠습니다. 보통 페아노 공리계를 사용하는 산술 체계에서는 덧셈을 다음과 같이 정의합니다.

이 정의를 그대로 로 옮길 수 있습니다.

로 옮기면 다음과 같습니다.

이 식이 실제로 성립하는지 2 + 7을 넣어서 확인해 보겠습니다.

정말 원하는 값이 나왔네요!

곱셈도 비슷하게 정의할 수 있습니다.

이대로 거듭제곱도 정의할 수 있지만, 방향을 조금 돌려 봅시다. 재귀 연산자를 사용할 때 간과하기 쉬운 점은 재귀를 분기로도 사용할 수 있다는 점입니다. 예를 들어서 Haskell에서 자료구조가 비어 있는지 확인하는 함수인 null은 다음과 같이 정의되어 있습니다. (foldr이 원시 재귀 연산자 역할을 합니다.)

null :: Foldable t => t a -> Bool
null = foldr (\_ _ -> False) True

베이스 케이스는 True이고, 재귀 함수는 인자를 무시하고 False를 반환하는 함수입니다. 즉, 원소가 없을 때는 \_ _ -> False를 단 한 번도 적용하지 않기 때문에 True, 원소가 있을 때는 \_ _ -> False가 적용되어 있기 때문에 그 아래에 뭐가 몇 층으로 있든 상관없이 False가 됩니다.

이 원리를 똑같이 응용해서 원시 재귀 함수로도 주어진 인자가 0인지 확인할 수 있습니다.

아니면 다음과 같이 일반적인 분기도 구현할 수 있습니다. 0은 거짓, 나머지는 참으로 판정합니다.

예를 들어서 , 입니다.

위와 같이 함수를 조합해서 사칙연산, 나머지, 거듭제곱, 비교, 제곱근, 소수 판정(!)과 번째 소수(!!) 등의 연산을 구현할 수 있다고 합니다. 원시 재귀 함수로 자연수 이외의 대상을 조작하고 싶다면 원하는 대상에 괴델 수를 부여해 그 수를 통해 간접적으로 조작하는 등의 방법을 사용할 수 있습니다.

굳이 왜 이렇게까지 하나요?

글을 재작성하기 전에는 솔직히 원시 재귀 함수 모형을 보면서 '람다 계산처럼 함수에 함수 전달도 못 하는 경직된 모형'이라는 인상을 받았는데, 좀 더 조사해보고 나니 이 모형은 수학책에 나오는 특정한 꼴의 재귀적인 함수 정의를 형식화한 것으로도 이해할 수 있었습니다. 위에서 소개했던 덧셈의 정의를 다시 예로 들어 보겠습니다.

위에서는 아무런 설명 없이 이 두 규칙을 그대로 로 옮겼는데, 잘 생각해보면 위의 정의와 원시 재귀 정의 사이에 연관성이 있다는 것을 알 수 있습니다.

이외에도 다른 식을 원시 재귀 함수로 옮긴 것들을 살펴보면 다음의 연관성을 찾아볼 수 있습니다.

이런 원리를 이해한다면 원시 재귀의 규칙을 만족하는 수식을 기계적으로 원시 재귀 정의로 바꿀 수 있습니다. 예를 들어 이 식을 변환해 볼까요? (로 주어져 있다고 가정합니다. 이 두 함수는 원시 재귀로 구현할 수 있습니다.)

  1. 왼쪽 인자에 대해 분기하므로 를 사용한다.
  2. 왼쪽 인자가 0인 경우에는 함숫값이 이므로 를 사용한다.
    • 의 규칙에 의해 현재 쓸 수 있는 인자는 이므로 입니다.
  3. 왼쪽 인자가 0이 아닌 경우에는 함숫값이 함수이므로 를 사용한다.
  4. 의 왼쪽 인자는 함수이므로 를 사용한다.
  5. 의 인자는 이전 단계의 재귀 결과 이므로 를 사용한다.
    • 의 규칙에 의해 현재 쓸 수 있는 인자는 , , 이고, 이 중 를 사용하므로 입니다.
  6. 의 오른쪽 인자는 함수이므로 를 사용한다.
  7. 의 왼쪽 인자는 이므로 를 사용한다.
    • 의 규칙에 의해 현재 쓸 수 있는 인자는 , , 이고, 이 중 를 사용하므로 입니다.
  8. 의 오른쪽 인자는 4~5에서 이미 다뤘으므로 같은 함수를 사용한다.

복잡하다고 하면 부정할 수는 없겠지만, 그래도 이게 바로 위에서 잠깐 언급했던 나머지 연산입니다! 재귀 함수 모형이 계산 모형 3대장 중 가장 먼저 나온 데는 이유가 있었네요.2

원시 재귀 함수의 한계

솔직히 저도 글 재작성 목적으로 조사를 하면서 그냥 소수 판정은 몰라도 번째 소수를 구하는 연산까지 원시 재귀일 것이라고는 상상도 하지 못했습니다. 이렇게 상수함수, 다음 수 함수, 사영함수, for문과 순차 실행만으로 생각보다 다양한 연산을 표현할 수 있지만, 모든 연산을 표현하는 것까지는 아쉽게도 불가능합니다.

튜링 기계를 기억하시나요? 어떤 튜링 기계든 모사할 수 있는, 즉 튜링 기계와 같은 표현력을 가지는 계산 모형을 튜링 완전하다고 합니다. 주어진 튜링 기계가 언젠가 정지하는지 판정하는 문제를 정지 문제halting problem라고 하는데, 이 문제를 일반적인 경우에서 풀 수 있는 알고리즘은 존재하지 않음3이 알려져 있습니다(이런 성질을 결정불가능undecidable이라고 합니다). 단적인 예로 상태가 개인 튜링 기계 중 정지하는 것만 추려서 가장 오래 동작하는 것을 찾는 바쁜 비버 게임이 있는데, 1962년에 처음 제안된 이후 증명일 기준으로 12년 뒤인 1974년에 4상태 바쁜 비버를, 62년 뒤인 2024년 7월이 되어서야 5상태 바쁜 비버를 찾을 수 있었습니다.

모든 튜링 기계를 모사할 수 있다면 그 연산이 언젠가는 정지하는지도 일반적으로 알 수 없어지기 때문에 튜링 완전한 계산 모형이라면 그 모형 버전의 정지 문제가 결정불가능해야 하는데, 잘 생각해보면 위에서 제시한 모든 기본 함수와 연산자는 유한한 자연수를 인자로 받고 유한 단계만큼 연산해 유한한 값을 반환하는 것을 알 수 있습니다. 즉, 원시 재귀 함수로는 모든 연산을 표현할 수 없습니다.

참고

원시 재귀 함수가 모든 경우에 값을 가진다면, 오른쪽 피연산자가 0일 때 정의되지 않는 는 도대체 어떻게 정의한 걸까요?

위에서 구한 함수에 오른쪽 피연산자로 0을 넣고 직접 계산해 보면 결과값으로 왼쪽 피연산자가 나오는 것을 알 수 있습니다. 일종의 쓰레기 값이나 무정의don't care으로 생각할 수 있으며, 나중에 사용할 나눗셈 연산도 위에서 정의한 나머지 연산을 사용하기 때문에 0으로 나누려고 하면 결과가 0이 됩니다.

마지막으로 사족을 붙이자면, 의외로 Gleam, Pony, Rocq(구 Coq), Lean 등 일부 프로그래밍 언어와 여러 증명 보조기가 0으로 나누기를 0으로 정의하고 있습니다.

계산 가능하지만 원시 재귀가 아닌 함수가 있을까요? 멀리 가지 않아도 아래와 같은 아주 간단한 정의를 가지는 아커만 함수가 원시 재귀가 아님이 알려져 있습니다.

프로그래밍을 배우셨다면 원하는 언어로 금방 구현할 수 있을 것이라고 생각합니다. 원본의 형태와 가장 비슷하게 정의할 수 있는 Haskell로는 다음과 같이 작성할 수 있습니다.4

ack :: Integer -> Integer -> Integer
ack 0 n = n + 1
ack m' 0 = ack (m' - 1) 1
ack m' n' = ack (m' - 1) (ack m' (n' - 1))

원시 재귀 함수로 표현할 수 없는 함수는 일반 재귀 함수로 표현할 수 있습니다. 다행히 원시 재귀 함수에서 연산자를 딱 하나만 추가하면 됩니다.

일반 재귀 함수

새로운 연산자를 바로 소개해 드리겠습니다. 아래 새로운 연산자 이외에 기존 정의가 바뀌는 점은 없습니다.

원시 재귀 연산자를 for문에 비유할 수 있다면, 최소화 연산자는 while문에 비유할 수 있습니다. 역시 JavaScript로 표현하면 다음과 같습니다.

const mu = F => (...args) => {
	for(let i = 0;; i++)
		if(F(i, ...args) != 0)
			return i;
};

구체적인 예시는 아니지만, 소수에 대해서만 0을 반환하는 1변수함수 이 있다면 의 값은 2가 됩니다.

위의 정의에서 꺼림칙한 점을 느낀 분들도 있을 것입니다. 만약에 주어진 가 어떤 경우에도 0을 반환하지 않는다면 어떻게 될까요? 이때는 함숫값이 없습니다!!! 🥳🥳🥳

🥳은 반어적인 의미의 🥳이기도 하지만, 축하할 일이기도 합니다. 일반 재귀 함수는 튜링 완전한 계산 모형임이 알려져 있고, 함숫값이 정의되지 않는 경우는 튜링 완전성에서 비롯한 영광의 상처라고 생각할 수도 있습니다. 이렇게 함숫값이 정의되지 않을 수도 있는 함수를 부분함수partial function라고 하고, 간단한 예를 들면 가 있습니다.

아커만 함수 만들기

글을 처음 작성했을 때는 일반 재귀 함수 모형에 대한 직관이 충분하지 않아서 예시를 직접 만들지 못했는데, 그동안 경험이 쌓여 실제로 시도해볼 수 있게 되었습니다. 위에서 원시 재귀 함수가 아닌 것으로 소개한 아커만 함수를 일반 재귀 함수로 만들어 보겠습니다. 아커만 함수의 정의를 다시 적어 보겠습니다.

구현을 위해 다음과 같은 계획을 세웠습니다.

  1. 주어진 깊이만큼만 재귀하는 원시 재귀 아커만 함수를 만든다.
  2. 주어진 깊이가 아커만 함수를 계산하는 데 충분한지 확인하는 함수를 만든다.
  3. 최소화 연산자에 2.의 함수를 적용해 필요한 깊이를 구한다.
  4. 구한 깊이를 1.에 전달해 함숫값을 구한다.

그런데 이렇게 계획을 세우고 다른 프로그래밍 언어로 개념 증명 코드까지 짰지만 정작 그걸 재귀 함수로 옮기는 것은 하지 못했습니다. 일상적으로 쓰는 재귀적으로 정의되는 함수와 달리 일반 재귀 함수에서는 재귀하면서 마음대로 인자를 바꿀 수 없다는 사실을 잊고 있었기 때문입니다... 설명을 위해 원시 재귀 연산자 의 정의를 한 번만 더 가져와 보겠습니다.

변수함수 변수함수 에 대해 변수함수 는 다음과 같이 계산합니다.

이 정의를 주의깊게 보면 추가로 전달되는 는 재귀를 진행하는 내내 고정되어 있으며, 값이 바뀌는 인자는 에 전달되는 처음 두 인자뿐입니다. 그 중에서도 첫 번째는 루프 인덱스의 역할을 하고, 두 번째는 에서 시작해서 을 거친 값만 사용할 수 있기 때문에 마음대로 조작할 수 없으며, 그나마 자유롭게 쓸 수 있는 두 번째 인자에 어떻게든 모든 정보를 욱여넣었다고 해도 계산이 '상향식'으로 이루어지기 때문에 '미래를 예측'해서 나중에 필요할 인자를 미리 계산하는 것 역시 불가능합니다. 이대로는 방법이 없는 걸까요?


네모바지 스폰지밥의 타임 카드. 밝은 노란색과 초록색의 배경 위에 'One Eternity Later'라는 문구가 있다.

오래 기다리셨죠...? 진짜로 구현이 가능한 계획을 세우고 무려 2달 뒤에 구현을 마쳤습니다. 저도 이렇게 오래 걸릴 줄은 몰랐습니다.

  1. 아커만 함수의 함숫값을 배열로 표현한다.
  2. 미완성된 아커만 함수 배열을 조금씩 확장하는 함수를 만든다.
  3. 아커만 함수 배열에 원하는 값이 있는지 확인하는 함수를 만든다.
  4. 최소화 연산자에 2.와 3.의 함수를 적용해 필요한 재귀 깊이를 구한다.
  5. 구한 깊이를 2.와 3.에 전달해 실제로 함숫값을 구한다.

괴델 수 부여하기

배열을 조작하기로 했다면 배열을 자연수로 나타낼 수 있어야 합니다. 우선 필요한 대상을 타입으로 표현해 봅시다.

-- Haskell 스타일 의사코드입니다.

-- 기본 불린 타입
data Bool
	= False
	| True

-- 기본 자연수 타입
data Nat;

-- 값이 없을 수도 있음
data Maybe a
	= Nothing
	| Just a

-- 두 개의 값
-- 하이라이터가 깨져서 부득이하게 이렇게 작성합니다. 원래 의도는 `(a, b)`입니다.
data Pair a b = Pair a b

-- 아래의 두 타입은 상호 재귀로 표현합니다.
-- 원소가 하나 이상 있는 배열
type NonEmpty a = Pair a (List a)
-- 원소가 없을 수도 있는 배열
type List a = Maybe (NonEmpty a)

은 그냥 , 을 쓰면 됩니다. 의 자연수 표현도 , 로 하면 됩니다. 는 좀 더 어렵네요. 자연수 2개를 자연수 1개로 나타내는 게 가능할까요?

다행히 위키백과에서 정확히 그 역할을 하는 짝짓기 함수pairing function를 찾을 수 있었습니다. 이 글에서 사용할 칸토어 짝짓기 함수는 다음과 같은 정의를 가집니다.

의 쌍은 다음과 같이 찾을 수 있습니다.

위의 함수를 사용해 타입의 값 로 표현할 수 있습니다. (참고로 제곱근을 버림한 값을 구하는 함수도 원시 재귀입니다.)

만 사용해 정의했으므로 두 타입의 자연수 표현을 그대로 가져와 쓸 수 있습니다. 예를 들어 타입의 = = 이 됩니다.

아커만 함수 배열

아커만 함수를 2차원 배열로 표현해 봅시다. 아커만 함수는 1번째 인자 에 더 민감하게 반응하므로 을 바깥 차원으로 두어서 으로 정의하는 편이 더 편리할 것입니다. 아커만 함수 배열을 조작할 때는 이전 원소의 값이 계산에 필요한 경우가 많기 때문에 대신 값이 하나라도 있음이 보장되는 타입을 사용하겠습니다.

배열의 초깃값은 = 이며(인 경우만 기록되어 있음), 이 값의 자연수 표현은 입니다.

첫째 줄 확장하기

아커만 함수 배열의 첫째 줄은 인 경우에 해당합니다. 배열의 끝에 (현재 배열의 길이 + 1)을 계속 삽입하는 로직은 쉽게 구현할 수 있지만, 맨 앞이 아니라 맨 뒤에 삽입하기 때문에 다소 비효율적입니다.

참고

의 실제 구현

구현이 정말 복잡하기 때문에 실제로 관심을 두고 꼼꼼하게 읽으실 분은 거의 없을 것이라고 생각해 참고 상자 안에만 작성합니다.

(여기까지는 위에서 언급했던 것과 같은 함수입니다.)

(와 비슷한 트릭인데 루프 인덱스를 사용해서 감산을 표현합니다.)

(이것도 위에서 언급했던 것과 같은 함수입니다.)

(제곱근 이하인 가장 큰 자연수를 찾는 함수입니다. 위키백과에서는 원시 재귀가 아닌 구현을 예시로 들고 있지만, 특정 조건을 만족하는 가장 작은 수를 찾는 방식으로 구현되어 있고 반환값이 인자 이하이기 때문에 로 바꿀 수 있습니다.)

(위에서 언급했던 의 역함수를 구하는 데 필요한 를 구하는 함수입니다.)

(이기 때문에 1을 더하고 를 받는 함수에 위임할 수 있습니다.)

열어본 걸 후회하시나요?

새로운 줄 만들기

다음 줄부터는 바로 이전 줄의 내용을 참조합니다. 다음 줄을 새로 만드는 경우는 , 확장하는 경우는 인 경우에 해당합니다.

새로운 줄의 첫 원소는 인 전자의 경우이므로, 이전 줄에 원소가 2개 이상 있는지 확인하고 2번째 원소를 그대로 가져와서 쓸 수 있습니다.

위에 있는 타입 서명이 왜 이 아니냐면, 그렇게 구현하기 귀찮아서 배열 전체를 수정하도록 구현했기 때문입니다. 지금 다시 짜라고 하면 이렇게 구현했을 것 같은데 너무 지쳐서 건드리기 싫네요.

참고

의 실제 구현

나머지 줄 확장하기

후자의 경우를 보면 현재 보고 있는 줄의 마지막 원소를 첨자로 삼아서 이전 줄에 접근하는 것을 알 수 있습니다. 새로운 줄을 만들 때와 비슷한 로직을 쓸 수 있습니다.

참고

의 실제 구현

값 추출하기

이건 그냥 아커만 함수 배열에서 원하는 첨자의 값을 뽑는 함수입니다. 이 함수에서 가 나오면 원하는 값을 구한 것이므로 그만 확장해도 됩니다.

참고

의 실제 구현

조립하기

위에서 정의한 아커만 함수 배열을 확장하는 함수 3개를 묶어서 한 사이클을 만들 수 있습니다.

위에서 언급한 초깃값 에 위의 사이클을 주어진 횟수만큼 돌리는 함수도 만들어 봅시다.

이 두 함수까지 만들면 진짜 아커만 함수를 만들 준비가 다 끝난 것입니다.

참고

의 실제 구현

(모든 줄에 를 적용하는 함수입니다. 보시다시피 분량 조절에 실패했습니다.)

완성된 함수

아커만 함수 배열이 충분히 찰 때까지 계속 재귀하다가 원하는 원소가 생기면 멈추고 그걸 반환하는 원리입니다.

솔직히 이걸 올바르게 구현한 건지는 별로 자신이 없습니다. 다른 프로그래밍 언어로 일반 재귀 연산자만 쓴 함수와 언어 기능을 최대한 활용해서 만든 함수를 만들어놓고 서로 비교하면서 짰는데 전자가 천문학적으로 느려서 기본 리스트 로직에 작은 리스트까지만 겨우 넣어서 테스트해볼 수 있었습니다. 혹시 버그가 있어도 뭐라고 하지 말아주세요.

참고

의 실제 구현

여기까지 왔으면 지금까지 정의한 모든 함수를 인라이닝해서 한 줄로 만들면 어떻게 될지가 궁금할 법도 하죠? 여기서 확인해보실 수 있습니다. 텍스트로만 230KB에 육박하는 파일이고 LaTeX로 렌더링하려고 하면 뻗어서 이렇게밖에 할 수가 없었습니다. 솔직히 너무 후회됩니다.

클레이니 표준형 정리

실제 구현체를 찬찬히 뜯어보면(권장드리지는 않습니다) 을 만드는 데 필요한 헬퍼 함수는 전부 원시 재귀로 구현하고 실제로 을 만들 때 딱 한 번 를 썼다는 것을 알 수 있습니다. 주어진 값으로 문제를 풀 수 있는지 판정하는 함수를 만들고, 이 판정을 만족하는 어떤 값을 일종의 '신탁oracle'으로 받은 뒤, 그 값으로 문제를 푸는 구조입니다.

사실은 아커만 함수 이외에 다른 함수에도 이 구조를 응용할 수 있습니다. 예를 들어 다음을 만족하는 함수족 가 원시 재귀임이 알려져 있습니다.5

그런데 이 함수족에 대응해 올바른 에서 계산 결과를 뽑아내는 원시 재귀 함수족 역시 존재함이 알려져 있습니다. 만 존재한다면 를 계산할 수 있겠네요!

가 존재한다면 로 구할 수 있습니다. 엄밀히 말해 이 글에서 소개한 는 결과가 0이 되는 자연수를 찾기 때문에 앞에 을 붙여줘야 하지만, 기존 문헌에서는 생략하는 듯합니다.

주어진 함수가 계산가능하다면 실제로 가 항상 존재한다는 것이 클레이니 표준형 정리Kleene's normal form theorem로 알려져 있습니다.

변수함수 가 계산가능할 경우 와 다음 함수가 동치가 되도록 하는 자연수 가 존재하고, 그 역도 성립한다.

제가 구현한 아커만 함수와 똑같이 에서 '신탁'을 받아 값을 구하는 구조입니다. 가 원시 재귀라고 했으니 모든 일반 재귀 함수는 를 한 번만 써서 만들 수 있다는 재미있는 사실 역시 보일 수 있습니다.

직접 써 보자!

이 글을 처음 썼을 때 "흥미 위주로 쓰기 시작한 글을 위키백과 번역글로 놔두기는 아까우니" 일반 재귀 함수를 체험할 수 있는 미니 프로그래밍 언어를 만들었던 적이 있는데, 이번에 재작성하면서 파서와 실행기를 바닥부터 직접 구현했습니다. (오류 메시지도 한국어로 나옵니다. 😉) 문법과 의미론은 아래 참고 상자에서 확인할 수 있습니다.

참고

문법 및 의미론 요약

프로그램은 0개 이상의 함수 매크로 정의, 1개의 본문 함수, 0개 이상의 인자로 이루어집니다.

  • 함수 매크로 정의는 이름 = 함수식;의 형태를 띱니다. 세미콜론은 필수입니다.
    • 다른 언어와 같이 이름은 영숫자와 밑줄(A-Z, a-z, 0-9, _)로만 이루어지고, 첫 글자로 숫자가 올 수 없습니다. 대소문자를 구분합니다.
    • 기존에 정의된 매크로나 기본 함수를 덮어쓰거나, 자기 자신을 정의에 포함할 수 없습니다.
  • 기본 함수는 다음과 같이 쓸 수 있습니다.
    • = =n/k
      • 값이 =n이라는 의미입니다.
    • = +
    • = #i/k
      • #i번째 인자를 사용한다는 의미입니다. 인자 번호는 0부터 시작합니다.
  • 연산자는 다음과 같이 쓸 수 있습니다.
    • = F.(G1, ..., Gk)
    • = F.G
    • = F.(k)
    • = ^(B, R)
      • R을 거듭제곱(^)한다는 뜻입니다.
    • = ?(F)
      • 이건 기호 선택이 적절한지 솔직히 잘 모르겠습니다.
  • 본문 함수와 인자는 함수식(인자, ..., 인자)의 형태를 띱니다.
  • 성능상의 이유로 JavaScript 엔진을 빌려서 연산하는 일부 간단한 함수를 미리 정의해 두었습니다. 아래의 빌트인 함수에서 확인할 수 있습니다.
  • 성능상의 이유로 지연 평가lazy evaluation를 사용하며, 중간 결과가 정의되지 않더라도 그 값을 사용하지 않으면 정의된 결과가 나올 수도 있습니다.
형식 문법

어휘 분석에 사용하는 말단 기호는 다음과 같습니다(소문자로 표기합니다). 정규식이라는 의미로 /으로 감쌌습니다. 말단 기호 사이의 공백 문자와 <comment>는 구문 분석에서 무시합니다.

  <ident> ::= /[A-Za-z_][A-Za-z0-9_]*/
    <nat> ::= /[0-9]+/
<comment> ::= /\/\/[^\n]*\n?/

구문 분석에 사용하는 비말단 기호는 다음과 같습니다(대문자로 표기합니다). BNF 문법을 사용하고, (...),+는 반점으로 구분해서 1개 이상, (...),*는 반점으로 구분해서 0개 이상이라는 의미이며, 맨 끝에 추가로 반점을 하나 더 달아도 됩니다.

 <Rec> ::= <Main>
         | <Def> <Rec>
 <Def> ::= <ident> "=" <Fn> ";"
  <Fn> ::= <Term>
         | <Fn> "." <Term>
         | <Fn> "." "(" <Fn>,+ ")"
         | <Fn> "." "(" <nat> ")"
<Term> ::= "=" <nat> "/" <nat>
         | "+"
         | "#" <nat> "/" <nat>
         | "^" "(" <Fn> "," <Fn> ")"
         | "?" "(" <Fn> ")"
         | <ident>
<Main> ::= <Fn> "(" <nat>,* ")"
빌트인 함수
  • 산술
    • add, sub, mul, div, mod, pow (2변수)
      • 작은 수에서 큰 수를 빼면 0이 됩니다.
      • div(x, 0)은 0입니다.
      • mod(x, 0)x입니다.
    • pred (1변수): 바로 전에 오는 자연수를 계산합니다. pred(0)은 0입니다.
  • 비교
    • eq, ne, lt, le, gt, ge (2변수)
  • 논리
    • and, or, not (2변수)
    • if (2변수)
      • 고전 논리의 에 대응합니다. if(x, y)!x || y와 같습니다.
    • 거짓은 0, 참은 1이며, 이외의 모든 값은 참으로 취급합니다.

생성형 AI 투명성

이 글을 작성하는 데 생성형 AI를 다음과 같이 사용했습니다.

간접 사용

다음 주제를 조사하는 데 Claude를 보조적으로 사용했습니다.

  • 튜링 완전한 세포 자동자의 종류
  • 0으로 나누기가 잘 정의되어 있는 프로그래밍 언어
각주

1

대한민국의 교육과정에서는 0을 자연수가 아닌 것으로 보지만, 여러 가지 이유로 0을 포함하는 자연수를 다루는 경우 역시 많습니다.

2

일반 재귀 모형은 1933년에, 튜링 기계와 람다 계산은 1936년에 처음 제안되었다고 합니다.

3

이때 '일반적인 경우'란 어떤 튜링 기계와 어떤 입력이 주어지든 유한 시간에 '그렇다'/'아니다'를 판정하는 알고리즘을 말합니다. 특수한 경우를 해결하는 알고리즘, 예를 들어 '그렇다'/'모른다'를 판정하는 알고리즘은 당연히 존재하지만, 이때도 정지하는 모든 경우를 유한 시간에 '그렇다'로 판정하는 것은 불가능합니다.

4

NPlusKPatterns 언어 확장을 켜면 좌변에 m' 대신 (m + 1)까지 똑같이 쓸 수 있지만, 이 기능은 거의 사용·권장되지 않고 있습니다.

5

기존 문헌에서는 이 함수족을 로 정의하지만, 이 글에서는 편의를 위해 의 순서를 바꿨습니다.