프로그래밍 노트/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
반응형