원문: Effective Scala - Control structures
프로그램을 함수형 스타일로 작성할 경우 일반적으로 전통적인 제어 구조가 더 적게 필요하게 되며, 선언형 스타일로 작성할 때 가독성이 더 좋아집니다. 이는 일반적으로 당신이 작성한 로직을 여러 개의 작은 메소드나 함수로 나누고, 이를 match 표현식으로 연결하는 것을 의미합니다. 함수형 프로그램은 종종 표현 중심적이 되는 경향이 있습니다. 조건문의 각 분기는 동일한 타입의 값을 계산하고, for (..) yield 표현은 comprehension을 계산하며, 흔하게 재귀 구조가 사용됩니다.
Recursion (재귀)
문제를 재귀적으로 표현하면 종종 더 간단하게 표현될 때가 있습니다. 심지어 꼬리 재귀 최적화가 적용되면 (이는 @tailrec 어노테이션으로 확인할 수 있습니다) 컴파일러는 재귀 코드를 일반적인 반복문으로 변환시켜줍니다.
일반적인 FIX-DOWN 힙 수정 코드를 확인해 봅시다.
def fixDown(heap: Array[T], m: Int, n: Int): Unit = {
var k: Int = m
while (n >= 2 * k) {
var j = 2 * k
if (j < n && heap(j) < heap(j + 1))
j += 1
if (heap(k) >= heap(j))
return
else {
swap(heap, k, j)
k = j
}
}
}
매번 while 루프에 진입할 때마다, 우리는 이전 iteration에서 더럽혀진 상태를 통해 작업을 합니다. 각 변수의 값은 어느 분기가 실행되었는지에 따라 결정되며, 만약 올바른 위치가 발견된다면 루프 중간에 반환하게 됩니다 (이와 비슷한 논증은 다익스트라의 "Go To Statement Considered Harmful"에서도 찾을 수 있습니다).
이를 대신해 꼬리 재귀로 구현된 버전을 확인해 봅시다.
@tailrec
final def fixDown(heap: Array[T], i: Int, j: Int) {
if (j < i * 2) return
val m = if (j == i * 2 || heap(2 * i) < heap(2 * j + 1)) 2 * i else 2 * i + 1
if (heap(m) < heap(i)) {
swap(heap, i, m)
fixDown(heap, m, j)
}
}
위의 코드는 매 iteration마다 잘 정의된 깔끔한 상태로 시작하게 되며, 심지어 참조 셀도 존재하지 않고, 모두가 불변을 지킵니다. 이러한 코드는 이해하기 쉽고 읽기도 훨씬 쉽습니다. 또한, 어떠한 성능 저하도 없습니다. 메소드는 꼬리 재귀이기 때문에 컴파일러가 이를 일반적인 반복문으로 변환시켜줍니다.
Returns (반환문)
위의 이야기는 명령형 구조가 가치가 없다는 것을 의미하지는 않습니다. 대부분의 경우 명령형 구조는 모든 종료 조건을 위한 분기를 두기보다 일찍 계산을 종료하는데 잘 맞습니다. 실제로 위의 fixDown에서는 힙 끝에 도달했을 때 일찍 종료하기 위해 return이 사용됩니다.
반환문은 분기를 줄이고 불변성을 지키는 데 유용합니다. 이를 통해 중첩을 줄여 (이 분기에는 어떻게 도달하게 되었을지?) 독자가 코드의 정확성을 더 쉽게 추론할 수 있게 만들어줍니다 (예: 이 시점 이후 배열은 범위를 벗어나지 않는다는 것을 확신할 수 있습니다). 이는 특히 "방어(guard)" 절에서 유용합니다.
def compare(a: AnyRef, b: AnyRef): Int = {
if (a eq b)
return 0
val d = System.identityHashCode(a) compare System.identityHashCode(b)
if (d != 0)
return d
// slow path...
}
반환문을 사용해서 가독성을 높이고 의도를 명확히 할 수 있지만, 명령형 언어에서처럼 결과를 반환하기 위해 사용하지는 마세요. 예를 들자면 다음과 같습니다.
// 이렇게 하지 마세요
def suffix(i: Int) = {
if (i == 1) return "st"
else if (i == 2) return "nd"
else if (i == 3) return "rd"
else return "th"
}
// 이렇게 하세요
def suffix(i: Int) =
if (i == 1) "st"
else if (i == 2) "nd"
else if (i == 3) "rd"
else "th"
match 표현식을 사용하는 방식이 위의 어떤 방식보다도 좋습니다.
def suffix(i: Int) = i match {
case 1 => "st"
case 2 => "nd"
case 3 => "rd"
case _ => "th"
}
반환문에는 숨겨진 비용이 있을 수 있습니다. 클로저 내부에서 사용하는 예를 봅시다.
seq foreach { elem =>
if (elem.isLast)
return
// process...
}
위의 코드는 bytecode로 컴파일하면 예외를 던지고 잡는 방식으로 구현되며, 이 방식이 자주 호출되는 코드에서 성능에 영향을 미칠 수 있습니다.
for loops and comprehensions (for 루프와 comprehension)
for 문법은 반복과 집계를 간결하고 자연스럽게 표현할 수 있는 수단을 제공합니다. 이는 특히 여러 sequence를 평탄화(flatten)할 때 유용합니다. for 구문은 내부 메커니즘을 숨기고 있지만 실제로는 클로저를 할당하고 dispatch합니다. 이는 예상치 못한 비용과 의미를 가져올 수 있습니다. 예를 들자면 아래와 같습니다.
for (item <- container) {
if (item != 2) return
}
위의 코드는 컨테이너가 지연된 계산을 사용할경우 비지역적인 반환으로 인식되어 runtime error를 발생시킬 확률이 있습니다!
이러한 이유로 인해 직접 foreach, flatMap, map, filter를 사용하는 것이 선호되지만 가독성을 높일 수 있다면 for을 사용해도 괜찮습니다.
require and assert (require와 assert)
require와 assert는 둘 다 실행 가능한 문서의 역할을 합니다.둘 다 타입 시스템이 필요한 불변식을 표현할 수 없는 상황에서 유용합니다(여기서의 불변식은 어떤 객체가 정상적으로 작동하기 위해 항상 참이어야 하는 식을 말합니다).
assert는 코드가 가정하는(내/외부를 모두 포함합니다) 불변식을 나타내는데 사용됩니다.
val stream = getClass.getResourceAsStream("someclassdata")
assert(stream != null)
require는 API가 지켜야 하는 계약을 나타내는데 사용됩니다.
def fib(n: Int) = {
require(n > 0)
// ...
}