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

woga·2023년 12월 30일
0

코틀린 공부

목록 보기
50/54
post-thumbnail

Iterable과 Sequence 차이를 잘 모를 수 있다. 왜냐면 둘은 정의가 같다.

interface Iterable<out T> {
	operator fun iterator(): Iterator<T>
}

interface Sequence<out T> {
	operator fun iterator(): Iterator<T>
}

하지만 이 둘은 완전히 다른 목적으로 설계되어서 완전히 다른 형태로 동작한다.
시퀀스는 lazy(지연) 처리된다. 따라서 시퀀스 처리 함수들을 사용하면 데코레이터 패턴으로 꾸며진 새로운 시퀀스가 리턴된다.
최종적인 계산은 toList 또는 count 등의 최종 연산이 이루어질 때 수행되는데 반면, Iterator은 처리 함수를 사용할 때마다 연산이 이루어져 List가 만들어진다.

즉, 컬렉션 처리 연산은 호출할 때 연산이 이루어진다. 반면, 시퀀스 처리 함수는 최종 연산이 이루어지기 전까지 각 단계에서 연산이 일어나지 않는다.

sequence.map{ ... }.filter{ ... }.toList()

여기서 map...filter..는 중간 연산이고 toList()가 최종 연산이다. 이 최종 연산 때 실질적인 필터링 처리가 이루어진다.

시퀀스는 다음과 같은 장점이 있다.

  • 자연스러운 처리 순서를 유지

  • 최소한만 연산

  • 무한 시퀀스 형태로 사용 가능

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

순서의 중요성

시퀀스 처리는 요소 하나하나에 지정한 연산을 한꺼번에 적용한다. 이를 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, ") }
// print : 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, ") }
// print : F1, F2, F3, M1, M3, E2, E6,

반복문을 이용해 다음과 같이 구현한다면 시퀀스와 같다

for ( e in listOf(1,2,3)) {
	print("F$e, ")
    if (e % 2 == 1) {
    	print("M$e, ")
        val mapped = e * 2
        print("E$mapped, ")
    }
}
// print : F1, M1, E2, F2, F3, M3, E6,

따라서 시퀀스 처리에서 사용되는 element by element order가 훨씬 자연스러운 처리라고 할 수 있다.

최소 연산

컬렉션 중 앞의 요소 10개만 필요한 상황은 굉장히 자주 접할 수 있다. 이터러블 처리는 기본적으로 중간 연산이라는 개념이 없어 원하는 처리를 컬렉션 전체에 적용한 뒤 앞의 요소 10개를 사용한다.
하지만 시퀀스는 중간 연산이라는 개념을 갖고 있어 앞의 요소 10개에만 원하는 처리를 적용할 수 있다.

무한 시퀀스

시퀀스는 실제로 최종 연산이 일어나기 전까지는 컬렉션에 어떠한 처리도 하지 않는다. 따라서 무한 시퀀스를 만들고 필요한 부분까지만 값을 추출하는 것도 가능하다.
무한 시퀀스를 만드는 일반적인 방법은 generateSequence 또는 sequence를 사용하는 것이다. 먼저 generateSequence는 '첫 번째 요소'와 '그 다음 요소를 계산하는 방법'을 지정해야 한다.

generateSequence(1) { it + 1 }
	.map { it*2 } 
    .take(10)
    .forEach { print("$it, ") }
// 2, 4, 6, 8, 10, 12, 14, 16, 18, 20,

sequence는 중단 함수로 요소들을 지정한다. 시퀀스 빌더는 중단 함수 내부에서 yield로 값을 하나씩 만들어 낸다.

  • 피보나치 숫자
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]

무한 시퀀스를 실제로 사용할 때는 값을 몇 개 활용할지 지정해야 한다. 그렇지 않으면 무한 반복이다

ex) print(fibonacci.toList()) // not exit

일부 요소만 선택하는 종결 연산을 활용하다. 모든 요소를 처리하지 않아 시퀀스가 이터러블보다 더 효율적으로 동작한다.
하지만 실제로 사용하면 무한 반복에 빠지는 경우가 많다. any는 true를 리턴하지 못하면 무한 반복이고 all과 none은 false를 리턴하지 못하면 무한 반복이다.

결과적으로 무한 시퀀스는 종결 연산으로 take와 first 정도로만 사용하는 것이 좋다.

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

표준 컬렉션 처리 함수는 각각의 단계에서 새로운 컬렉션을 만들어낸다. ex) List
그렇게되면 공간을 차지하는 비용이들어 단점이다.

기가바이트 단위의 파일을 읽어 들이고 컬렉션 처리를 한다면 엄청난 메모리 낭비를 불러 일으킨다. 그래서 일반적으로 파일을 처리할 때는 시퀀스를 활용한다.

보통의 경험으로 하나 이상의 처리 단계를 포함하는 컬렉션 처리는 시퀀스를 이용함으로써 20~40% 정도의 성능이 향상된다.

시퀀스가 빠르지 않은 경우

컬렉션 전체를 기반으로 처리해야 하는 연산은 시퀀스를 사용해도 빠르지 않다. 유일한 예로 코틀린 stdlib의 sorted가 있다.
이는 시퀀스를 리스토로 변환해서 자바 stdlib의 sort를 사용해 처리한다. 이런 변환 처리로 인해 컬렉션 처리보다 시퀀스가 느려진다.

그리고 lazy하게 구하는 시퀀스에 sorted를 적용하면 무한 반복에 빠진다.

ex)

generateSequence(0) { it + 1 }.take(10).sorted().toList()
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

generateSequence(0) { it + 1}.sorted().take(10).toList()
// not return

그래서 sorted는 List가 더 빠른 희귀한 예다. 그 외는 시퀀스가 빠르므로 여러 처리가 결한됐다면 컬렉션보다 시퀀스를 사용하자.

자바 스트림의 경우

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

ex)

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

하지만 이 둘의 큰 차이점 3가지가 있다

  • 코틀린 시퀀스가 더 많은 처리함수를 갖고 있고 사용하기 더 쉽다.

  • 자바 스트림은 병렬 함수를 사용해서 병렬 모드로 실행할 수 있다.

  • 코틀린의 시퀀스는 코틀린/JVM, 코틀린/JS, 코틀린/네이비트 등의 일반적인 모듈에서 모두 사용할 수 있다. (자바 스트림은 코틀린/JVM, JVM 8 버전 이상일때만)

코틀린 시퀀스 디버깅

그리고 둘 다 모두 단계적으로 요소의 흐름을 추적할 수 있는 디버깅 기능이 지원된다.
자바 스트림은 Java Stream Debugger 플러그인, 코틀린은 Kotlin Sequence Debugger라는 플러그인이 있다.
그리고 코틀린 시퀀스 디버거는 코틀린 플러그인에 통합되어 있다.

정리

컬렉션과 시퀀스는 같은 처리 메서드를 지원하며, 사용하는 형태가 거의 비슷하다.

일반적으로 데이터를 컬렉션에 저장하므로 시퀀스 처리를 하려면 시퀀스로 변환하는 과정이 필요하다.
또한 최종적으로 컬렉션 결과를 원하는 경우가 많으르모 시퀀스를 다시 컬렉션으로 변환하는 과정도 필요하다. 그래서 이것이 시퀀스 처리의 단점이라고 할 수 있지만 시퀀스는 lazy하게 처리된다!

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

이런 장점들이 있으니 규모가 큰 컬렉션을 여러 단계에 걸쳐서 처리할 때는 시퀀스를 사용하자.

profile
와니와니와니와니 당근당근

0개의 댓글