[Kotlin] : tailrec 꼬리재귀

Murjune·2023년 11월 25일
1

kotlin/Java

목록 보기
3/5
post-thumbnail

Intro

프로그래밍을 할 때, 특정 조건이 충족될 때까지 반복적으로 로직을 처리해야 하는 상황이 종종 발생합니다. 이럴 때 while문을 사용하는 것이 일반적인데, 성능상의 이점을 제공하지만 코드의 가독성을 저하시킬 수 있습니다. 반면에, 재귀 함수를 사용하면 코드의 가독성이 개선되지만 성능 문제나 스택 오버플로우의 위험이 도사리고 있습니다 😩

예를 들어, InputView에서 입력값을 받아오되, 에러가 발생하면 계속해서 입력값을 요청하는 상황을 고려해 봅시다!

private val inputView = InputView()

class InputView() {
    fun inputMoney(money: String = readln()) = money.toInt()
}

private fun showMoneyInputView() {
    while (true) {
        try {
            println("${inputView.inputMoney()}원 입력 받음")
            break
        } catch (e: NumberFormatException) {
            println("숫자만 입력해주세요.")
        } catch (e: IllegalArgumentException) {
            println("잘못된 입력 형식입니다.")
        }
    }
}

@JvmStatic
fun main(args: Array<String>) {
    showMoneyInputView()
}

이 경우, while문을 사용하면 인덴트가 추가되며, 만약 while문 안에 여러 if문이 있다면 showMoneyInputView()의 가독성이 매우 저하될 것입니다. 따라서 재귀 함수 사용을 고려해볼 수도 있겠지만, 스택 오버플로우에 대한 걱정이 들 수도 있습니다.

이때, 코틀린의 강력한 도구 tailrec 키워드를 사용할 수 있습니다!

 private tailrec fun showMoneyInputView() {
        try {
            return println("${inputView.inputMoney()}원 입력 받음")
        } catch (e: NumberFormatException) {
            println("숫자만 입력해주세요.")
        } catch (e: IllegalArgumentException) {
            println("잘못된 입력 형식입니다.")
        }
        showMoneyInputView()
    }

좀 더 보기 편하지 않나요? ㅎ ㅎ
tailrec 키워드를 사용하면 가독성과 성능 2마리 토끼를 잡을 수 있답니다!

꼬리 재귀 함수와 tailrec 키워드

코틀린은 함수형 프로그래밍의 tail recursion을 tailrec 키워드를 통해 지원합니다.
tailrec을 사용하기 위해서 아래와 같이 필수적인 조건이 필요합니다.

tailrec 키워드가 붙은 함수는 함수의 마지막 작업이 자기 자신을 호출하는 형태여야 함
(이것만 기억하시면 끝입니다 :D)

1부터 n까지의 합을 계산하는 함수를 tailrec 키워드를 활용하여 구현해보겠습니다.

 tailrec fun calculateSum(n: Int, currentSum: Int = 0): Int {
	if (n == 0) return  currentSum 
	return calculateSum(n - 1, currentSum + n)
     // 중간에 조건문이 있어도 마지막에 자기 자신만 호출해주면 됩니다!
 }
 
 fun calculateSumUseWhile(a: Int): Int {
    var n = a
    var sum = 0
    while (n > 0) {
        sum += n
        n--
    }
    return sum
}

위에 calculateSum() 함수와 calculateSumUseWhile()는 완전히 동일한 동작을 하는 함수입니다. 어떤 함수가 더 직관적이고, 가독성이 높아 보이나요?? 저는 tailrec 를 사용한 calculateSum 함수가 훨씬 보기 편한 느낌입니다 😎

게다가 코틀린 compiler가 calculateSum를 컴파일 과정에서 알아서 아래와 같이 calculateSumUseWhile와 같이 변환해주기 때문에 스택오버플로우도 걱정없습니다!

그럼 재귀함수를 사용할 때마다 tailrec을 붙이기만 하면 될까요??
그럼 물론 좋겠지만, 항상 모든 재귀함수가 꼬리재귀 형태가 아니랍니다..

tailrec의 제한

tailrec은 코틀린에서 강력한 기능이지만, 모든 상황에 적합하지는 않습니다. 꼬리 재귀의 조건을 만족하지 못하는 예시를 살펴봅시다!

    private tailrec fun fiboNum(n: Int, total: Long = 0): Long {
        if (n <= 0) return 0
        if (n in 1..2) return 1
        return fiboNum(n - 1, total) + fiboNum(n - 2, total)
        // 마지막 반환값이 fiboNum()이 아님.. 😢
    }

이 피보나치 수열 함수는 재귀 호출 결과의 합을 반환하기 때문에 꼬리 재귀 조건을 만족하지 못합니다.

위의 함수도 아래와 같이 꼬리재귀함수 형태로 변환할 수는 있습니다.

tailrec fun tailfiboNum(n: Int, prev: Long = 0, cur: Long = 1): Long {
    if (n == 0) return prev
    if (n == 1) return cur
    return tailfiboNum(n - 1, cur, cur + prev)
}

그러나, 일반 재귀함수를 위와 같이 억지로 꼬리재귀함수로 바꾸는 것이 바람직할까요??

그럴 수도 있고 아닐 수도 있습니다.

굉장히 무책임한 말처럼 보이지만.. ㅋㅋㅋㅋ

예를 들어, 만약 n의 범위가 매우 크지 않거나 스택 오버플로우의 위험이 낮은 경우, 코드의 가독성과 간결성을 유지하는 기존의 재귀 방식이 더 적합할 수 있습니다.

반면에, n의 값이 매우 클 때나 재귀 호출이 깊어질 가능성이 있는 경우에는 꼬리 재귀 형태로의 변환이 성능 상 이점을 제공할 수 있습니다. 이 경우, 스택 오버플로우를 방지하고 메모리 사용을 최적화할 수 있겠습니다.

결론

정리를 하자면 tailrec은 강력하고 유용한 도구지만, 모든 상황에 적합한 만능 해결책은 아니며 재귀함수를 무조건적으로 tailrec 함수로 변환시키려는 노력을 할 필요는 없다는 것입니다. 때로는 while문 혹은 일반 재귀 함수가 더 적합할 수도 있습니다.
따라서, 개발자가 각 상황에 맞게 코드의 간결성, 가독성, 성능 요구 사항을 고려하여 상황에 맞는 도구를 선택하는 것이 중요할 것입니다!

Optional 1)

꼬리 재귀를 만족하지 못할 경우 IDE 에서 아래와 같이 경고문을 나타내주니 실수할 걱정은 안해도 좋겠습니다 😄

Optional 2) Intro예시 저는 이렇게 했어요!

private val inputView = InputView()

    class InputView() {
        fun inputMoney(money: String = readln()) = money.toInt()
    }

    // 입력값을 받고 예외가 발생하면 재입력을 받는 함수
    class InputErrorHandler() {
        tailrec fun handle(
            input: () -> Unit,
        ) {
            try {
                return input()
            } catch (e: NumberFormatException) {
                println("숫자만 입력해주세요.")
            } catch (e: IllegalArgumentException) {
                println("잘못된 입력 형식입니다.")
            }
            handle(input)
        }
    }

    private fun showMoneyInputView() {
        InputErrorHandler().handle{
			println("${inputView.inputMoney()}원 입력 받음")
        }
    }

    @JvmStatic
    fun main(args: Array<String>) {
        showMoneyInputView()
    }

intro에서 설명 드린 예시에서 에러 핸들링(에러시 재입력)에 대한 책임을 InputErrorHandler 클래스에게 부여하고 tailrec 키워드를 활용해서 스택오버플로우도 방지했습니다!

참고

https://kotlinlang.org/docs/functions.html#tail-recursive-functions

profile
열심히 하겠슴니다:D

0개의 댓글