Scala

Scala에서 꼬리재귀와 트램폴린, Stream

partner_jun 2017. 2. 9. 15:29

함수형 = 재귀함수는 아니지만 재귀함수를 많이 사용한다는 것은 틀림없을 것이다.

하지만 컴퓨터 프로그램의 구조상 함수를 호출할 때마다 스택에 값들이 쌓여 StackOverFlow를 피할 수 없다. 

이 친숙한 스택오버플로를 피하기 위한 방법들이 있다.

꼬리재귀와 트램폴린, 지연 콜렉션 Stream이다. 사실 스트림은 스택오버플로를 피하기 위한 방법은 아니다.

하지만 스택오버플로가 발생할 수 있는 문제에서 스트림을 사용, 

더 이른 스택 복귀를 통해(혹은 다른 방법으로의 접근) 문제를 해결한 경험이 있어 함께 적어둔다.



1. 꼬리재귀(Tail Recursive)

꼬리재귀는 어떤 함수가 자기 자신을 호출하되 그 호출이 함수의 마지막 연산인 경우를 말한다.

꼬리재귀는 아주 쉽게 반복문으로 변경할 수 있다. 

많은 함수형 프로그래밍 언어들의 컴파일러는 꼬리재귀를 반복문으로 최적화해준다고 한다. 

JVM 자체에서는 꼬리 재귀에 대한 최적화를 지원하지 않지만 스칼라 컴파일러의 @tailrec 어노테이션을 통해 꼬리재귀를 최적화할 수 있다.


// StackOverFlow
def factorial(n: BigInt): BigInt = {
if(n == 1) 1
else n * factorial(n - 1)
}


@tailrec
def factorial(n: BigInt, result: BigInt = 1): BigInt = {
if(n == 1) result
else factorial(n-1, n * result)
}


모든 재귀를 꼬리재귀로 바꿀 수 있느냐에 대한 의문은 항상 남는다.





2. 트램폴린(Trampoline)

트램폴린은 여러 함수가 서로 다른 함수를 호출하면서 이어져 나가는 반복을 말한다.

함수 A가 함수 B를 호출하고, 함수 B는 다시 함수 A를 호출하는 재귀일 때 트램폴린을 이용해 반복문으로 변환할 수 있다.

TailCalls Object에 있는 요소들을 사용하여 최적화하는데 이를 사용하기 위해 패키지를 import하여야 한다.


import scala.util.control.TailCalls._
def funcA(n: Int): TailRec[Int] = {
funcB(n+1)
}

def funcB(n: Int): TailRec[Int] = {
if(n == 100) done(100)
else funcA(n+1)
}

결과를 래핑된 TailRec 형태가 아닌 자료형 그대로 사용하기 위해서는 result 메소드를 사용하면 된다.




3. Stream

자바8에 추가된 Stream과 비슷한 스칼라의 Stream은 지연 평가(lazy evalution)를 통해 무한한 값을 표현할 수 있다. 그 뿐 아니라 head를 제외한 값의 계산을 미룸으로써 정말 필요한 값만을 계산하도록 도와준다.


scala> Stream(1, 2, 3, 4, 5)

res1: scala.collection.immutable.Stream[Int] = Stream(1, ?) 

(head만을 표시한다)


Scala Standard Library의 Stream 문서를 살펴보면 피보나치 수열을 통해 스트림의 강력함을 맛볼 수 있다. (편의상 약간 수정했다)


val fibs: Stream[Int] = 
0 #:: 1 #:: fibs.zip(fibs.tail)
.map(n => n._1 + n._2)
fibs.take(5).foreach(println)

이렇게 메소드를 호출하여 5번째까지의 피보나치 수열을 출력한다고 생각해 보자.


식의 가장 오른쪽 fibs.zip(fibs.tail)... 부분이 #::로 되어 있는 것을 통해 가장 오른쪽 식이 Stream이라는 사실을 알 수 있다.

Stream(0, 1) #::: x

0, 1 다음 x의 값은 

x = [0, 1] zip [1] map n._1 + n._2,

  = (0, 1) map n._1 + n._2,

  = [1], Stream은 (0, 1, 1)


하지만 고정된 콜렉션이 아닌 fibs로 자기 자신을 호출했기 때문에 

x가 1이 되는 순간 Stream의 값은 [0, 1]이 아닌 [0, 1, 1]이 된다. 따라서 tail은 [1, 1]이 된다.

따라서 [0, 1, 1] zip [1, 1] map n._1 + n._2 의 계산이 필요하다. 

x는 [1, 2], Stream은 (0, 1, 1, 2)


하지만 2가 추가되었으므로 다시,

[0, 1, 1, 2] zip [1, 1, 2] map n._1 + n._2

x는 [1, 2, 3], Stream은 (0, 1, 1, 2, 3)


같은 작업이 계속 이어짐으로써 무한히 계산되는 피보나치 수열이 만들어진다.



또, Stream은 이전 계산을 기억하는 메모이제이션도 지원한다.

val fibs: Stream[Int] =
0 #:: 1 #:: fibs.zip(fibs.tail).map(n => {
println("Adding %d and %d".format(n._1, n._2))
n._1 + n._2
})

fibs take 5 foreach println
println("And then prints")
fibs take 6 foreach println


// 0
// 1
// Adding 0 and 1
// 1
// Adding 1 and 1
// 2
// Adding 1 and 2
// 3

// And then prints
//
// 0
// 1
// 1
// 2
// 3
// Adding 2 and 3
// 5

앞서 살펴본 코드에 println 구문이 추가되어 있다.

주석으로 표시한 것처럼 fibs 함수를 다시 실행했을 때 새로 계산하지 않고

이전 계산했던 결과를 사용한다는 것을 알 수 있다. 이런 특징은 장점으로도 볼 수 있지만

때로 너무 많은 계산 결과를 가지고 있어 OutOfMemory를 발생시킬 수 있다. 


Stream은 굉장히 강력한 콜렉션이다. 

하지만 예시로 든 피보나치 수열처럼 쿨하게 만들 수 있는 경우는(혹은 방법은) 흔치않고 어렵다. 

이런 방법만이 아니라 이전 계산까지의 결과를 파라미터로 가지고 다음 값을 유도하는 재귀문으로도 사용할 수 있다. 

def fibs2: Stream[BigInt] = {
def fibRecu(n2: BigInt = 0, n1: BigInt = 1): Stream[BigInt] = n2 #:: fibRecu(n1, n2+n1)

fibRecu()
}

왼쪽 결과값을 오른쪽에 넣어 호출하는 오른쪽 재귀의 형태다. 

이런 형태의 활용법은 개미수열(읽고 말하기 수열) 문제와 Ninety-Nine Scala Problems의 91번 Knight's Tour 문제를 통해 익힐 수 있다.

'Scala' 카테고리의 다른 글

Scala로 풀어본 개미수열(읽고 말하기 수열)  (0) 2017.02.15
Scala에서의 map/flatmap, for  (0) 2017.02.11
Scala의 지연 콜렉션 Stream 기본 문법  (0) 2017.02.10
SBT Jar Build  (0) 2016.12.29
JavaFX with Scala?  (0) 2016.12.29