Iterable과 Sequence의 코드는 동일하지만 둘은 완전 다른 목적으로 설계되었다.
interface Iterable<out T> {
operator fun iterator(): Iterator<T>
}
interface Sequence<out T> {
operator fun iterator(): Iterator<T>
}
Sequence는 지연(lazy)로 처리된다. 따라서 시퀀스 처리 함수들을 사용하면, 데코레이터 패턴으로 꾸며진 새로운 시퀀스가 리턴된다. 최종적인 계산은 toList 또는 count 등의 최종 연산이 이루어질때 수행된다.
Iterable은 처리 함수를 사용할 때 마다 연산이 이루어져 List가 만들어진다.
이와 같은 시퀀스의 지연 처리는 다음과 같은 장점을 갖는다.
- 자연스러운 처리 순서를 유지한다.
- 최소한만 연산한다.
- 무한 시퀀스 형태로 사용할 수 있다.
- 각각의 단계에서 컬렉션을 만들어내지 않는다.
순서의 중요성
-
element-by-element order(lazy 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,
-
step-by-step order(eager order)
반면 이터러블은 요소 전체를 대상으로 연산을 차근차근 적용해 나간다.
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,
반복문과 조건을 사용해 처리하면 시퀀스 처리인 element-by-element order와 같은 동작을 한다.
for (e in listOf(1,2,3)) {
print("F$e, ")
if (e % 2 == 1) {
print("M$e, ")
val mapped = e * 2
print("E$mapped, ")
}
}
따라서 시퀀스 처리에 사용되는 element-by-element order가 훨씬 자연스러운 처리라고 할 수 있다.
또한 기본 반복문, 조건문을 사용한 코드와 같으므로 조만간 낮은 레벨 컴파일러 최적화를 통해 더 빨라질 수도 있다.
최소 연산
컬렉션에 어떤 처리를 적용하고 나서 앞의 요소 10개만 필요한 상황은 굉장히 자주 접할 수 있는 상황이다.
-
Iterable
- 중간 연산이라는 개념이 없어 원하는 처리를 컬렉션 전체에 적용한 뒤 앞의 요소 10개만 사용해야 한다.
-
Sequence
- 중간 연산 개념이 있어 앞의 요소 10개에만 원하는 처리를 적용할 수 있다.
이러한 이유로 중간 처리 단계를 모든 요소에 적용할 필요가 없는 경우 시퀀스를 사용하는게 좋다.
무한 시퀀스
시퀀스는 최종 연산이 일어나기 전까지는 어떠한 처리도 하지 않는다. 따라서 무한 시퀀스(infinite sequence)를 만들고 필요한 부분까지만 값을 추출하는 것도 가능하다.
무한 시퀀스를 만들기 위해 일반적으로 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
중단 함수(suspending function, sequential 코루틴)으로 요소들을 지정한다. 시퀀스 빌더는 중단 함수 내부에서 yield로 값을 하나씩 만들어 낸다.
val fibonacci = sequence { yield(1) val 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]
무한 시퀀스 사용 시 값을 몇개 활용할지 지정해야 한다.
take를 사용해 활용할 값의 수를 지정하거나 first, find, any, all, none, indexOf와 같은 일부 요소만 선택하는 종결 연산을 활용해야 한다.
종결 연산을 잘못 활용하면 아래처럼 무한 반복에 빠지는 경우가 많다.
- any에서 true를 리턴하지 못하는 경우
- all, none에서 false를 리턴하지 못하는 경우
무한 시퀀스는 종결 연산으로 take, first 정도만 사용하는 것이 좋다.
각각의 단계에서 컬렉션을 만들어 내지 않음
표준 컬렉션 처리 함수는 각각의 단계에서 새로운 컬렉션(대부분 List)을 만들어낸다. 각각의 단계에서 결과가 만들어지면서 공간을 차지하는 비용이 든다는 것은 큰 단점이다.
numbers
.filter { it % 10 == 0 } // 여기서 컬렉션 하나
.map { it * 2 } // 여기에서 컬렉션 하나
.sum()
// 총 2개 컬렉션 생성
numbers
.asSequence()
.filter { it % 10 == 0 }
.map { it * 2 }
.sum()
// 시퀀스를 사용하면 컬렉션이 만들어지지 않음
무거운 컬렉션을 처리할때는 위에서 말한 비용은 굉장히 커진다. 극단적인 예이지만 기가바이트 단위의 파일을 읽고 컬렉션 처리를 하면 엄청난 메모리 낭비가 발생한다.
따라서 일반적으로 파일을 처리할 때는 시퀀스를 활용한다.
아래처럼 1.53GB 정도의 데이터를 처리하는 경우
File("ChicagoCrimes.csv").readLines()
.drop(1)
.mapNotNull { it.split(",").getOrNull(6) }
.filter { "CANNABIS" in it }
.count()
.let(::println)
컬렉션을 추가로 만드는 중간 연산이 3번이나 일어나며 대충 어림잡아도 4.59(1..53 x 3)GB의 메모리를 소비한다. 이를 시퀀스로 만드는 경우 이러한 낭비를 줄일 수 있다.
File("ChicagoCrimes.csv").useLines { lines ->
lines.
.drop(1)
.mapNotNull { it.split(",").getOrNull(6) }
.filter { "CANNABIS" in it }
.count()
.let { println(it) }
}
useLines를 사용하면 Suquence
형태로 파일을 사용할 수 있다.
큰 컬렉션으로 여러 처리 단계를 거쳐야 한다면 컬렉션 처리보다는 시퀀스 처리를 사용하는 것이 좋다.
책에서는 2-3개의 처리 단계를 가지는 함수를 비교하는데 아래와 같은 결과가 나온다.
twoStepListProcessing 81.095 ns
twoStepSequenceProcessing 55.68 ns
twoStepListProcessingAndAcumulate 83.307 ns
twoStepSequenceProcessingAndAcumulate 6.928 ns
책에서는 하나 이상의 처리 단계를 포함하는 경우 시퀀스 사용시 컬렉션 처리 대비 20~40%정도의 성능 향상을 보았다고 한다.
시퀀스가 빠르지 않은 경우
컬렉션 전체를 기반으로 처리해야 하는 연산은 시퀀스를 사용해도 빨라지지 않는다.
코틀린 stdlib의 sorted가 그런 예 중 하나이다. sorted는 Sequence를 List로 변환한 다음에 stdlib의 sort를 이용해 처리하며 이런 변환으로 인해 시퀀스가 컬렉션 처리보다 느려진다. (큰 차이는 아니다.)
무한 시퀀스처럼 시퀀스의 다음 요소를 lazy하게 구하는 시퀀스에 sorted를 적용하면 무한 반복에 빠진다.
generateSequence(0) { it + 1 }.take(10).sorted().toList()
// 정상 동작
generateSequence(0) { it + 1 }.sorted().take(10).toList()
// 무한 반복
sorted는 시퀀스보다 컬렉션이 더 빠른 희귀한 예 중 하나이다. 다른 처리는 모두 시퀀스가 빠르기 때문에 여러 처리가 결합된 경우라면 sorted가 포함되어 있더라도 시퀀스를 사용하는것이 더 빠르다.
// 벤치마크 150.48s ns
fun productsSortAndProcessingList(): Double {
return productsList
.sortedBy { it.price }
.filter { it.bought }
.map { it.price }
.average()
}
// 벤치마크 96.811s ns
fun productsSortAndProcessingSequence(): Double {
return productsList.asSequence()
.sortedBy { it.price }
.filter { it.bought }
.map { it.price }
.average()
}
자바 스트림의 경우
자바 8부터는 컬렉션 처리를 위해 스트림 기능이 추가되었다. 코틀린의 시퀀스와 유사하게 lazy로 동작한다.
다만 자바의 스트림과 코틀린의 시퀀스는 아래와 같은 세가지 큰 차이점을 가진다.
-
코틀린의 시퀀스가 더 많은 처리 함수를 갖고 있다. 그리고 더 사용하기 쉽다.
productsList.stream() .filter { it.bought } .collect(Collectors.toList()) productsList.asSequence() .filter { it.bought } .toList() // toList 하나로 간단하게 활용 가능
- 자바 스트림은 병렬 함수를 사용해서 병렬 모드로 실행할 수 있다. 이는 멀티 코어 환경에서 큰 성능 향상을 가져온다. (몇가지 결함이 있으니 주의해서 사용해야 한다. 병렬 함수 내부에서 사용하는 common join-fork 스레드풀 관련 이슈가 존재)
- 코틀린의 시퀀스는 코틀린/JVM, 코틀린/JS, 코틀린/네이티브 등의 일반적인 모듈에서 모두 사용할 수 있다. 자바 스트림은 코틀린/JVM 그리고 JVM 8 이상일때만 동작한다.
병렬 모드로 성능적인 이득을 얻을 수 있는 곳이 아니라면 코틀린 시퀀스를 사용하는 것이 좋다.
코틀린 시퀀스 디버깅
코틀린 시퀀스나 자바 스트림은 단계 요소 흐름을 추적할 수 있는 디버깅 기능을 지원한다.
아래 플러그인을 이용해 활용할 수 있다.
- Java Stream Debugger
- Kotlin Sequence Debugger
정리
시퀀스는 lazy하게 처리되므로 아래와 같은 장점이 있다.
- 자연스러운 처리 순서를 유지한다.
- 최소한만 연산한다.
- 무한 시퀀스 형태로 사용할 수 있다.
- 각각의 단계에서 컬렉션을 만들어내지 않는다.
Reference
- 이펙티브 코틀린 - 프로그래밍 인사이트, 마르친 모스칼라 지음, 윤인성 옮김
개인적인 기록을 위해 작성된 글이라 잘못된 내용이 있을 수 있습니다.
오류가 있다면 댓글을 남겨주세요.