본문 바로가기

스터디/다재다능 코틀린 프로그래밍

11. 내부 반복과 지연 연산

다재다능 코틀린 프로그래밍 책으로 코틀린 스터디를 진행하면서 발표를 위해 준비했던 글입니다.


외부 반복자 vs 내부 반복자

숫자 list에서 짝수만 2배로 만들어서 다른 콜렉션에 추가하는 예제를 외부 반복자와 내부 반복자로 구현해보자.

먼저 외부 반복자는 아래와 같다.

val numbers = listOf(10, 12, 14, 17, 18, 19)

val doubled = mutableListOf<Int>()
for (num in numbers) {
    if (num % 2 == 0) {
        doubled.add(num * 2)
    }
}
println(doubled)    // [20, 24, 28, 36]

내부 반복자는 아래와 같다.

val numbers = listOf(10, 12, 14, 17, 18, 19)

val doubled = numbers.filter { e -> e % 2 == 0 }.map { e -> e * 2 }
println(doubled)    // [20, 24, 28, 36]

외부 반복자는 뮤터블 리스트를 정의해야 했단 것부터 내부 반복자에게 안된다. 또한 한눈에 보기에도 내부 반복자가 훨씬 간결하다.

내부 반복자

코틀린의 내부 반복자는 java stream과 거의 유사하다. 코틀린 collection api를 참조해서 사용하면 된다.

java 처럼 stream으로 변환해서 사용하는게 아니라 코틀린 collection에 추가된 확장함수들이라는 점에 차이가 있다.

지연 연산을 위한 시퀀스

자바는 collection에서 내부 반복자를 왜 직접 사용하지 못하게 했을까? 답은 성능상의 이유에 있다.

반면 코틀린은 편의를 위해 collection에서도 내부반복자 사용이 가능하도록 선택권을 줬다. 하지만 성능상의 문제는 피해갈 수 없다.

결론적으로 코틀린은 크기가 작은 collection에서는 내부 반복자를 사용하고 사이즈가 큰 collection에서는 시퀀스를 이용해서 내부 반복자를 사용해야 한다.

개발자가 상황에 따라 선택해야 한다.

시퀀스로 성능 향상하기

먼저 시퀀스를 사용하지않고 컬렉션의 내부반복자를 사용했을 때를 살펴보자.

숫자 list에서 첫번째 짝수를 발견하면 2배로 만들어 저장하는 예제이다. 거기에 filter와 map 연산을 함수로 분리하고 println을 추가했다.

val numbers = listOf(10, 12, 14, 17, 18, 19)

fun isEven(num: Int): Boolean {
    println("enter isEven $num")
    return num % 2 == 0
}
fun makeDouble(num: Int): Int {
    println("enter makeDouble $num")
    return num * 2
}
val doubled = numbers
    .filter(::isEven)
    .map(::makeDouble)
    .first()

println(doubled)

위 코드를 실행하면 출력 결과는 아래와 같다.

enter isEven 10
enter isEven 12
enter isEven 14
enter isEven 17
enter isEven 18
enter isEven 19
enter makeDouble 10
enter makeDouble 12
enter makeDouble 14
enter makeDouble 18
20

필요한 첫 번째 짝수 값을 제일 처음에 발견했음에도 쓸모 없는 연산이 계속 수행됐다. 수천개의 요소를 가진 콜렉션을 사용했다면 수많은 연산이 낭비됐을 것이다.

이제 시퀀스를 사용한 연산을 살펴보자.

val numbers = listOf(10, 12, 14, 17, 18, 19)

fun isEven(num: Int): Boolean {
    println("enter isEven $num")
    return num % 2 == 0
}
fun makeDouble(num: Int): Int {
    println("enter makeDouble $num")
    return num * 2
}
val doubled = numbers
    .asSequence()
    .filter(::isEven)
    .map(::makeDouble)
    .first()

println(doubled)

아까와 같은 코드에 딱 .asSequence()만 추가했다. 출력을 보자.

enter isEven 10
enter makeDouble 10
20

수천개의 요소가 있다고 가정했을 때 성능상의 큰 이득을 볼 수 있음을 알 수 있다.

first() 메소드가 호출 될때 까지 filter()나 map()에 전달된 람다는 실행하지 않고 다른 시퀀스만을 리턴하다가 first() 메소드가 호출되면 그때 필요한 최소한의 연산만을 수행한다. 다른 시퀀스를 리턴하기에 중간 컬렉션을 만드는 오버헤드도 제거할 수 있다.

모든 내부 반복자를 시퀀스로 사용하면 좋을 것 같지만 collection이 작다면 퍼포먼스 차이가 아주 적기때문에 이럴땐 지연 연산을 하지 않는게 디버그하기 편하다.

무한 시퀀스

시퀀스의 지연 연산은 성능뿐 아니라 무한 시퀀스를 만들어 놓고 필요한 만큼만 연산하게 할 수 있다.

코틀린에서 무한 시퀀스를 만드는 방법으로 제공하는 generateSequence() 함수로 소수의 무한 시퀀스를 만들어보자.

fun isPrime(n: Long) = n > 1 && (2 until n).none { i -> n % i == 0L }
// tailrac은 ch14 꼬리호출 최적화에서 다룸
tailrec fun nextPrime(n: Long): Long = if (isPrime(n + 1)) n + 1 else nextPrime(n + 1)

val primes = generateSequence(5, ::nextPrime)
print(primes.take(6).toList())    // [5, 7, 11, 13, 17, 19]

무한 시퀀스를 사용했기에 toList() 함수가 실행될 때 take() 함수에 넘긴 숫자만큼의 값만 지연 연산을 실행해서 제공한다.

무한 시퀀스로 범위를 미리 알 수 없는 시퀀스를 만들 수 있는 것이다.

sequence() 함수를 generateSequence() 함수 대신 사용할수도 있다.

sequence() 함수는 컨티뉴에이션 람다를 받는데 이는 책의 뒷부분에서 다룰 예정이다.

sequence() 함수를 사용한 예제는 아래와 같다.

fun isPrime(n: Long) = n > 1 && (2 until n).none { i -> n % i == 0L }

val primes = sequence {
    var i: Long = 0
    while (true) {
        i++
        if (isPrime(i)) {
            yield(i)
        }
    }
}
println(primes.drop(2).take(6).toList())

sequence()함수 람다에 소수를 발생시키는 무한루프를 가지게 한 것이다.

위에서 봤던 nextPrime같은 분리된 함수가 있다면 generateSequence()를 사용하고 하나의 바디에서 무한한 시퀀스를 만들고 싶다면 sequence() 함수를 사용하자.