프로그래밍 노트/Effective 시리즈
[Effective Kotlin] 8장 효율적인 컬렉션 처리
깡냉쓰
2023. 12. 18. 20:51
728x90
반응형
[Effective Kotlin. 49] 하나 이상의 처리 단계를 가진 경우에는 시퀀스를 사용하라
Iterable vs Sequence
- Sequence
- 지연(lazy) 연산
- 따라서 시퀀스 처리함수들은 데코레이터 패턴으로 꾸며진 새로운 시퀀스가 리턴 됨
- 최종 계산은 toList 또는 count 등의 최종 연산이 이루어질 때 수행됨
- Iterable
- 즉시(eagerly) 연산
- 처리 함수를 사용할 때마다 연산이 이루어져 List가 만들어짐
public inline fun <T> Sequence<T>.filter(
predicate: (T) -> Boolean
): Sequence<T> {
return FilteringSequence(ths, true, predicate)
}
public inline fun <T> Iterable<T>.filter(
predicate: (T) -> Boolean
): List<T> {
return filterTo(ArrayList<T>(), predicate)
}
시퀀스 장점
- 자연스러운 처리 순서를 유지
- 최소한만 연산
- 무한 시퀀스 형태로 사용할 수 있음
- 각각의 단계에서 컬렉션을 만들어 내지 않음
순서의 중요성
- 연산의 순서가 달라지면, 다른 결과가 나옴
- 시퀀스 : element-by-element order / lazy order
- 이터러블 : step-by-step order / eager order
listOf(1, 2, 3, 4).asSequence()
.map { print("map($it) "); it * it }
.filter{ print("filter($it) "); it % 2 ==0}
.toList()
println()
listOf(1, 2, 3, 4)
.map { print("map($it) "); it * it }
.filter{ print("filter($it) "); it % 2 ==0}
.toList()
// 시퀀스 호출 순서
map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)
// collection stream 호출 순서
map(1) map(2) map(3) map(4) filter(1) filter(4) filter(9) filter(16)
최소 연산
- 컬렉션에 어떤 처리를 적용하고, 앞의 요소 10개만 필요한 상황이라면 sequence(lazy 연산)이 유리하다.
- Iterable 처리는 중간 연산이 없으므로, 원하는 처리를 컬렉션 전체에 적용한 뒤 앞의 10개 요소를 써야한다.
- Sequence 처리는 중간 연산이라는 개념을 갖고 있으므로, 앞의 요소 10개에만 원하는 처리를 적용할 수 있다.
(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
- 중간 처리 단계를 모든 요소에 적용할 필요가 없는 경우 sequence를 사용하자.
무한 시퀀스
- 시퀀스는 실제로 최종 연산이 일어나기 전까지 어떠한 처리도 하지 않음
- 따라서 무한 시퀀스(infinite sequence)를 만들고, 필요한 부분까지만 값을 추출하는 것이 가능
- generateSequence로 무한 시퀀스를 만들 수 있다.
첫 번째 요소
와그 다음 요소를 계산하는 방법
을 지정하면 됨
generateSequence(1) { it + 1}
.map { it * 2}
.take(10) // 활용할 값의 수를 지정
.forEach { print("$it ,") }
// 2, 4, 6, 8, 10, 12, 14, 16, 18, 20
- first, find, any, all, none, indexOf와 같은 일부 요소만 선택하는 종결 연산 활용
각각의 단계에서 컬렉션을 만들어 내지 않음
- 표준 컬렉션 함수는 각각의 단계에서 새로운 컬렉션을 만들어냄
- 각각의 단계에서 만들어진 결괄르 활용하거나 저장할 수 있다는 것은 장점
- 각각의 단계에서 만들어지면서 공간을 차지하는 비용이 든다는 것은 단점
numbers
.filter { it % 10 == 0 } // 여기에 컬렉션 하나
.map { it * 2 } // 여기에 컬렉션 하나
.sum() // 전체적으로 2개의 컬렉션
numbers
.asSequence()
.filter { it % 10 == 0 }
.map { it * 2 }
.sum() // 컬렉션이 만들어지지 않음
- 크거나 무거운 컬렉션을 처리할 때 굉장히 큰 비용이 들어감
- 메모리 절약 뿐만 아니라 성능도 향상시킬 수 있음
- 큰 컬렉션으로 여러 처리 단계를 거쳐야 한다면, 컬렉션 처리보다는 시퀀스 처리를 사용하는 것이 좋음
자바 스트림?
- 자바 스트림은 kotlin sequence와 같이 lazy하게 작동함
- 즉, 마지막 처리 단계에서 연산이 일어남
- 코틀린의 시퀀스가 더 많은 처리 함수를 가지고 있음(확장 함수)
- 또한 더 쉬우며, 간단하게 사용 가능(collect(Collectors.toList()) -> toList())
- 자바 스트림은 병렬 모드로 실행이 가능. 멀티 코어 환경에서 큰 성능 향상을 가져다 줌
- 병렬 모드로 성능적 이득을 얻을 수 있는 곳에서만 자바 스트림을 사용하고, 이외의 일반적인 경우에는 시퀀스를 사용하자
[Effective Kotlin. 50] 컬렉션 처리 단계 수를 제한하라
- 표준 컬렉션 : 내부적으로 요소들을 활용해 반복을 돌며, 추가적엔 컬렉션을 만들어 사용
- 시퀀스 : 시퀀스 전체를 랩하는 객체가 만들어지며, 조작을 위해 또 다른 추가적인 객체를 만들어냄
적절한 메서드를 활용해, 컬렉션 처리 단계 수를 적절하게 제한하자.
class Student(val name: String?)
// 작동은 함
fun List<Student>.getNames(): List<String> = this
.map { it.name }
.filter { it != null }
.map { it!! }
// 더 좋음
fun List<Student>.getNames(): List<String> = this
.map { it.name }
.filterNotNull()
// 가장 좋음
fun List<Student>.getNames(): List<String> = this
.mapNotNull { it.name }
이 코드보다는 | 이 코드가 좋음 |
---|---|
.filter { it != null } .map { it!! } | .filterNotNull() |
.map { <Transformation> } .filterNotNull() | .mapNotNull { <Transformation> } |
.map { <Transformation> } .joinToString() | .joinToString { <Transformation> } |
.filter { <Predicate 1> } .filter { <Predicate 2> } | .filter { <Predicate 1> && <Predidcate 2> } |
.filter { it is Type } .map { it as Type } | .filterIsInstance<Type>() |
.sortedBy { <Key 2> } .sortedBy { <Key 1> } | .sortedWith(compareBy({ <Key 1> }, { <Key 2> })) |
listOf(...) .filterNotNull() | listOfNotNull(...) |
.withIndex() .filter { (index, elem) -> <Predicate using index> } .map { it.value } | .filterIndexed { index, elem -> <Predicate using index> } (map, forEach, reduce, fold 비슷) |
- intellij에서 어느정도 알려준다.
[Effective Kotlin. 51] 성능이 중요한 부분에는 기본 자료형 배열을 사용하라
- 최적화를 위해서 내부적으로 기본 자료형(primitive)를 사용할 수 있음
- 가본 자료형의 특징
- 가벼움. 일반 객체와는 다르게 추가적으로 포함되는 것들이 없음
- 빠름. 값에 접근할 때 추가 비용이 들지 않음
코틀린에서 사용하는 List와 Set 등의 컬렉션은 제네릭 타입. 제네릭 타입은 기본 자료형을 사용할 수 없으므로 랩핑된 타입을 사용해야 함
일반적인 경우 이렇게 하는 것이 훨씬 처리가 쉽지만 성능이 중요한 경우 IntArray, LongArray 등 기본 자료형을 활용하는 배열
을 사용해야함
배열을 사용하는 경우 약 25% 빠름
[Effective Kotlin. 52] mutable 컬렉션 사용을 고려하라
- immutable과 비교하여 성능적인 측면에서 빠르다는 장점이 있음
- immutable 컬렉션에 요소를 추가하기 위해서는 새로운 컬렉션을 만들고 요소를 추가하게 되어 있음
operator fun <T> Iterable<T>.plus(element: T): List<T> {
if (this is Collection) return this.pluse(element)
val result = ArrayList<T>()
result.addAll(this)
result.add(element)
return result
}
- 즉, 복제하는 처리 비용이 많이 듬
- 그래서 mutable 컬렉션이 성능적 관점에서 좋음
- 하지만 immutable 컬렉션은 안전하다는 측면에서 좋음 but 일반적인 지역 변수는 문제가 될 수 있는 경우(동기화와 캡슐화)에 해당되지 않음
- 그래서! 지역변수로 사용할 때는 mutable 컬렉션이 더 합리적
728x90
반응형