반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

[이펙티브 코틀린] Item49. 하나 이상의 처리 단계를 가진 경우에는 시퀀스를 사용하라 본문

Kotlin/이펙티브 코틀린

[이펙티브 코틀린] Item49. 하나 이상의 처리 단계를 가진 경우에는 시퀀스를 사용하라

어리둥절범고래 2024. 2. 3. 19:23
반응형

Iterable과 Sequence는 완전히 다른 형태로 동작합니다.

public inline fun <T> Iterable<T>.filter(
    predicate: (T) -> Boolean
): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

public fun <T> Sequence<T>.filter(
    predicate: (T) -> Boolean
): Sequence<T> {
    return FilteringSequence(this, true, predicate)
}

 

  • Sequence 처리 함수들을 사용하면, 데코레이터 패턴으로 꾸며진 새로운 시퀀스가 리턴된다.
  • Sequence는 지연(lazy) 처리되며, 최종적인 계산은 toList또는 count 등의 최종 연산이 이루어질 때 수행된다.
  • Iterable은 처리 함수를 사용할 때마다 연산이 이루어진다.
val seq = sequenceOf(1,2,3)
val filtered = seq.filter { println("f$it"); it % 2 == 1 }
println(filtered) //FilteringSequence@214c265e
val asList = filtered.toList() // f1f2f3
println(asList) // [1, 3]

val list = listOf(1,2,3)
val listFiltered = list
    .filter { println("f$it"); it % 2 == 1 } // f1f2f3
println(listFiltered) // [1, 3]

시퀀스의 지연 처리는 다음과 같은 장점을 갖습니다.

 

  • 자연스러운 처리 순서를 유지한다.
  • 최소한만 연산한다.
  • 무한 시퀀스 형태로 사용할 수 있다.
  • 각각의 단계에서 컬렉션을 만들어 내지 않는다.

 

 

순서의 중요성

 

이터러블 처리와 시퀀스 처리는 연산의 순서가 달라지면, 다른 결과가 나옵니다.

 

  • 시퀀스 처리는 요소 하나하나에 지정한 연산을 한꺼번에 적용한다. (element-by-element order / lazy order)
  • 이터러블 처리는 요소 전체를 대상으로 연산을 차근차근 적용한다. (step-by-step order / eager order)
sequenceOf(1, 2, 3)
    .filter { print("F$it, "); it % 2 == 1 }
    .map { print("M$it, "); it * 2 }
    .forEach { print("E$it, ") }
    // F1, M1, E2, F2, F3, M3, E6,

listOf(1, 2, 3)
    .filter { print("F$it, "); it % 2 == 1 }
    .map { print("M$it, "); it * 2 }
    .forEach { print("E$it, ") }
    // F1, F2, F3, M1, M3, E2, E6,

 

 

최소 연산

 

시퀀스는 중간 연산이라는 개념을 갖고 있으므로, 원하는 요소에 원하는 처리를 적용할 수 있습니다.

반면, 이터러블은 그렇지 않으므로 모든 처리를 컬렉션 전체에 적용해야 합니다.

(1..10).asSequence()
     .filter { print("F$it, "); it % 2 == 1 }
     .map { print("M$it, "); it * 2 }
     .find { it > 5 }
     // F1, M1, F2, F3, M3,

(1..10)
     .filter { print("F$it, "); it % 2 == 1 }
     .map { print("M$it, "); it * 2 }
     .find { it > 5 }
     // F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, M1, M3, M5, M7, M9,

이러한 이유로 중간 처리 단계를 모든 요소에 적용할 필요가 없는 경우에는 시퀀스를 사용하는 것이 좋습니다.

 

 

무한 시퀀스

 

시퀀스는 최종 연산이 일어나기 전까지는 어떠한 처리도 하지 않습니다.

따라서 무한 시퀀스를 만들고, 필요한 부분까지만 값을 추출하는 것도 가능합니다.

무한 시퀀스를 사용할 때는 값을 몇 개 활용할 지 지정해야 하며, 그렇지 않으면 무한하게 반복합니다.

무한 시퀀스를 만드는 일반적인 방법은 generateSequence 또는 sequence를 사용하는 것입니다.

 

  • generateSequence는 '첫 번째 요소'와 '그 다음 요소를 계산하는 방법'을 지정해야 한다.
  • sequence는 중단 함수(suspending function, 시퀀셜 코루틴)로 요소들을 지정한다.
generateSequence(1) { it + 1 }
    .map { it * 2 }
    .take(10)
    .forEach { print("$it, ") } // 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 

val fibonacci = sequence {
    yield(1)
    var current = 1
    var prev = 1
    while (true) {
        yield(current)
        val temp = prev
        prev = current
        current += temp
    }
}
print(fibonacci.take(10).toList()) // [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
print(fibonacci.toList()) // 종료되지 않는다.

 

 

각각의 단계에서 컬렉션을 만들어 내지 않음

 

표준 컬렉션 처리 함수는 각각의 단계에서 새로운 컬렉션을 만들어 냅니다.

단점으로, 각 단계에서 결과가 만들어지면서 공간을 차지하는 비용이 들어갑니다.

numbers
    .filter { it % 10 == 0 } // 여기에서 컬렉션 하나
    .map { it * 2 } // 여기에서 컬렉션 하나
    .sum()
    // 전체적으로 2개의 컬렉션이 만들어진다.

numbers
    .asSequence()
    .filter { it % 10 == 0 }
    .map { it * 2 }
    .sum()
    // 컬렉션이 만들어지지 않는다.

크거나 무거운 컬렉션을 처리할 때는 큰 비용이 들어가게 됩니다.

예를 들어, 데이터 세트의 크기가 1.5GB인 파일을 통계를 위해 3번의 중간 연산을 처리한다고 가정해보겠습니다.

이 경우 4.5GB 이상의 메모리를 소비할 것이며, 이터러블로 처리했다면 모든 요소에 연산을 적용하게 되어 처리 속도도 오래 걸릴 것입니다.

그래서 일반적으로 파일을 처리할 때는 시퀀스를 활용하는 것이 메모리 효율과 성능면에서 좋습니다.

 

 

시퀀스가 빠르지 않은 경우

 

컬렉션 전체를 기반으로 처리해야 하는 연산은 시퀀스를 사용해도 빨라지지 않습니다.

유일한 예로 sorted가 있으며, sorted는 시퀀스를 List로 변환한 뒤에 sort를 사용해 처리합니다.

무한 시퀀스에 sorted를 적용하면, 무한 반복에 빠지게 되므로, 조심해야 합니다.

generateSequence(10) {it + 1}.take(10).sorted().toList()
generateSequence(10) {it + 1}.sorted().take(10).toList() // 무한반복

 

 

자바 스트림의 경우

 

자바 8부터는 컬렉션 처리를 위해 스트림 기능이 추가되었으며, 코틀린의 시퀀스와 비슷한 형태로 동작합니다.

스트림도 시퀀스와 마찬가지로 lazy하게 동작하며, 마지막 처리 단계에서 연산이 일어납니다.

productsList.asSequence()
    .filter { it.bought }
    .map { it.price }
    .average()
    
productsList.stream()
    .filter { it.bought }
    .mapToDouble { it.price }
    .average()
    .orElse(0.0)

자바의 스트림과 코틀린의 시퀀스는 다음과 같은 세 가지 차이점이 있습니다.

 

  • 코틀린의 시퀀스가 더 많은 처리함수를 가지고 있으며, 사용하기 더 쉽다.
  • 자바 스트림은 병렬 함수를 이용해 병렬 모드에서 실행가능 하다.
  • 코틀린 시퀀스는 일반적인 모듈(코틀린/JVM, 코틀린/JS 등)에서 모두 사용 가능하지만, 자바 스트림은 코틀린/JVM(버전 8이상)에서만 동작한다.

병렬 모드로 성능적 이득을 얻을 수 있다면 자바 스트림을 사용하고, 이외의 상황에서는 코틀린 시퀀스를 사용하는게 더 깔끔합니다.

 

 

코틀린 시퀀스 디버깅

 

시퀀스와 스트림 모두 단계적으로 요소의 흐름을 추적할 수 있는 디버깅 기능이 지원됩니다.

시퀀스는 'Kotlin Sequence Debugger'라는 이름의 플러그인, 자바 스트림은 'Java Stream Debugger'라는 이름의 플러그인을 활용하면 된다.

반응형