집계 연산
집계 연산 (Aggregate operations)
컬렉션을 다루다 보면 "전체 요소의 합은?", "가장 큰 값은?", "평균은?"처럼 컬렉션 전체를 한 값으로 뭉뚱그려 얻고 싶을 때가 많아요. Kotlin은 이런 집계(aggregate) 연산을 위한 함수들을 기본으로 제공해요. 대부분 다른 언어에서도 흔히 쓰는 것들이라 감각이 금방 잡혀요.
출처: Kotlin 공식 문서
본문
Kotlin 컬렉션에는 자주 쓰는 집계 연산 – 컬렉션 내용을 바탕으로 단일 값을 반환하는 연산 – 함수들이 포함돼 있어요. 대부분 잘 알려진 것들이고 다른 언어에서와 같은 방식으로 동작해요.
minOrNull()과maxOrNull()은 각각 가장 작은 요소와 가장 큰 요소를 반환해요. 빈 컬렉션에서는null을 반환해요.average()는 숫자 컬렉션 요소들의 평균값을 반환해요.sum()은 숫자 컬렉션 요소들의 합을 반환해요.count()는 컬렉션의 요소 개수를 반환해요.
fun main() {
val numbers = listOf(6, 42, 10, 4)
println("Count: ${numbers.count()}")
println("Max: ${numbers.maxOrNull()}")
println("Min: ${numbers.minOrNull()}")
println("Average: ${numbers.average()}")
println("Sum: ${numbers.sum()}")
}
특정 선택 함수(selector)나 커스텀 Comparator로 가장 작은·큰 요소를 구하는 함수도 있어요.
maxByOrNull()과minByOrNull()은 선택 함수를 받아 그 함수가 가장 큰·작은 값을 반환하는 요소를 돌려줘요.maxWithOrNull()과minWithOrNull()은Comparator객체를 받아 그Comparator기준으로 가장 큰·작은 요소를 반환해요.maxOfOrNull()과minOfOrNull()은 선택 함수를 받아 선택 함수 자체의 반환값 중 가장 큰·작은 값을 반환해요.maxOfWithOrNull()과minOfWithOrNull()은Comparator객체를 받아 그Comparator기준으로 선택 함수 반환값 중 가장 큰·작은 값을 돌려줘요.
이 함수들은 빈 컬렉션에서 null을 반환해요. 반대의 대안들(maxOf, minOf, maxOfWith, minOfWith)도 있는데, 같은 일을 하되 빈 컬렉션에서는 NoSuchElementException을 던져요.
fun main() {
val numbers = listOf(5, 42, 10, 4)
val min3Remainder = numbers.minByOrNull { it % 3 }
println(min3Remainder)
val strings = listOf("one", "two", "three", "four")
val longestString = strings.maxWithOrNull(compareBy { it.length })
println(longestString)
}
일반 sum() 외에 더 고급 합산 함수인 sumOf()가 있어요. 선택 함수를 받아 이를 모든 컬렉션 요소에 적용한 값들의 합을 반환하죠. 선택 함수는 Int, Long, Double, UInt, ULong(JVM에서는 BigInteger와 BigDecimal도) 같은 여러 숫자 타입을 반환할 수 있어요.
fun main() {
val numbers = listOf(5, 42, 10, 4)
println(numbers.sumOf { it * 2 })
println(numbers.sumOf { it.toDouble() / 2 })
}
Fold와 reduce
더 특수한 경우를 위해 reduce()와 fold() 함수가 있어요. 이 함수들은 제공된 연산을 컬렉션 요소에 순차적으로 적용하며 누적된 결과를 반환해요. 연산에는 인자가 두 개 들어가요. 이전까지 누적된 값과 컬렉션 요소죠.
두 함수의 차이는 이러해요. fold()는 초기값을 받아 첫 단계에서 그 값을 누적값으로 사용해요. 반면 reduce()의 첫 단계는 첫 번째와 두 번째 요소를 연산 인자로 사용해요.
fun main() {
val numbers = listOf(5, 2, 10, 4)
val simpleSum = numbers.reduce { sum, element -> sum + element }
println(simpleSum)
val sumDoubled = numbers.fold(0) { sum, element -> sum + element * 2 }
println(sumDoubled)
//incorrect: the first element isn't doubled in the result
//val sumDoubledReduce = numbers.reduce { sum, element -> sum + element * 2 }
//println(sumDoubledReduce)
}
위 예시가 그 차이를 잘 보여줘요. fold()는 두 배한 요소들의 합을 계산하는 데 쓰여요. 같은 함수를 reduce()에 넘기면 첫 단계에서 리스트의 첫 번째와 두 번째 요소를 인자로 쓰기 때문에 첫 번째 요소가 두 배가 되지 않아서, 다른 결과가 나와요.
요소를 역순으로 함수에 적용하려면 reduceRight()와 foldRight()를 써요. 이 함수들은 fold()와 reduce()와 비슷하게 동작하되 마지막 요소에서 시작해 이전 요소로 진행해요. 오른쪽으로 접거나 줄일 때는 연산 인자의 순서가 바뀐다는 점을 기억하세요. 먼저 요소가 오고 그다음에 누적된 값이 와요.
fun main() {
val numbers = listOf(5, 2, 10, 4)
val sumDoubledRight = numbers.foldRight(0) { element, sum -> sum + element * 2 }
println(sumDoubledRight)
}
요소의 인덱스를 파라미터로 받는 연산을 적용할 수도 있어요. 이때는 reduceIndexed()와 foldIndexed()를 쓰고, 요소 인덱스를 연산의 첫 번째 인자로 넘겨요.
마지막으로, 이런 연산을 컬렉션 요소에 오른쪽에서 왼쪽으로 적용하는 함수인 reduceRightIndexed()와 foldRightIndexed()도 있어요.
fun main() {
val numbers = listOf(5, 2, 10, 4)
val sumEven = numbers.foldIndexed(0) { idx, sum, element -> if (idx % 2 == 0) sum + element else sum }
println(sumEven)
val sumEvenRight = numbers.foldRightIndexed(0) { idx, element, sum -> if (idx % 2 == 0) sum + element else sum }
println(sumEvenRight)
}
모든 reduce 연산은 빈 컬렉션에서 예외를 던져요. 대신 null을 받으려면 *OrNull() 버전을 쓰면 돼요.
중간 누적값들을 저장하고 싶은 경우에는 runningFold()(또는 그 동의어인 scan())과 runningReduce()가 있어요.
fun main() {
val numbers = listOf(0, 1, 2, 3, 4, 5)
val runningReduceSum = numbers.runningReduce { sum, item -> sum + item }
val runningFoldSum = numbers.runningFold(10) { sum, item -> sum + item }
val transform = { index: Int, element: Int -> "N = ${index + 1}: $element" }
println(runningReduceSum.mapIndexed(transform).joinToString("\n", "Sum of first N elements with runningReduce:\n"))
println(runningFoldSum.mapIndexed(transform).joinToString("\n", "Sum of first N elements with runningFold:\n"))
}
연산 파라미터에 인덱스가 필요하다면 runningFoldIndexed()이나 runningReduceIndexed()을 사용하세요.
더 알아보기 (Learn more)
집계 연산은 컬렉션 전체를 요약할 때 정말 자주 쓰여요. fold와 reduce의 차이, 그리고 OrNull 버전이 필요한 이유를 직접 코드로 확인해 보면 훨씬 잘 기억돼요. 컬렉션의 다른 연산들도 공식 문서의 컬렉션 관련 장에서 이어서 볼 수 있어요.