이동식 저장소

[Kotlin] Collections 확장 함수 본문

Primary/Kotlin

[Kotlin] Collections 확장 함수

해스끼 2021. 1. 13. 13:16

코틀린에는 List, Set 등 다양한 collections 클래스가 존재하고, 또 다양한 확장 함수를 지원한다. 확장 함수를 적절히 사용하면 코드를 더 간결하고 읽기 쉽게 작성할 수 있다.

목차

  • Ordering (순서)
    • Sorting
    • Reverse Order
    • Random Order (shuffling)
  • Aggregate (요약)
    • Aggregations
    • Fold, Reduce
  • Transformations (변환)
    • Mapping
    • Zipping
    • Association
    • Flattening
    • String Representation
  • Filtering (검색)
    • Filtering by predicate
    • Partitioning
    • Testing predicates
  • 참고문헌

Ordering (순서)

Sorting

sorted(), sortedDescending()을 이용하면 컬렉션을 정렬한 결과를 반환받을 수 있다. 이때 원본 객체는 정렬되지 않는다.

val numbers = listOf("one", "two", "three", "four")

println("Sorted ascending: ${numbers.sorted()}")
println("Sorted descending: ${numbers.sortedDescending()}")

/*
Sorted ascending: [four, one, three, two]
Sorted descending: [two, three, one, four]
*/

문자열의 정렬은 사전 순을 따른다.

sortedBy()sortedByDescending()을 사용하여 정렬 조건을 지정할 수도 있다. 조건은 람다식으로 작성하며, 람다식의 반환 결과를 참고하여 오름차순 또는 내림차순으로 정렬한다.

val numbers = listOf("one", "two", "three", "four")

val sortedNumbers = numbers.sortedBy { it.length }
println("Sorted by length ascending: $sortedNumbers")
val sortedByLast = numbers.sortedByDescending { it.last() }
println("Sorted by the last letter descending: $sortedByLast")

/*
Sorted by length ascending: [one, two, four, three]
Sorted by the last letter descending: [four, two, one, three]
*/

정렬 조건을 람다식 말고 Comparator 객체로 지정할 수도 있다. compareBy() 함수를 이용하여 Comparator 객체를 만들 수 있다.

val numbers = listOf("one", "two", "three", "four")
println("Sorted by length ascending: ${numbers.sortedWith(compareBy { it.length })}")

/*
Sorted by length ascending: [one, two, four, three]
*/

Mutable한 객체의 경우, 객체 자체를 정렬할 수 있다. sorted* 대신 sort* 함수를 사용하면 된다.

val numbers = mutableListOf("one", "two", "three", "four")

numbers.sort()
println("Sort into ascending: $numbers")
numbers.sortDescending()
println("Sort into descending: $numbers")

numbers.sortBy { it.length }
println("Sort into ascending by length: $numbers")
numbers.sortByDescending { it.last() }
println("Sort into descending by the last letter: $numbers")

numbers.sortWith(compareBy<String> { it.length }.thenBy { it })
println("Sort by Comparator: $numbers")

/*
Sort into ascending: [four, one, three, two]
Sort into descending: [two, three, one, four]
Sort into ascending by length: [two, one, four, three]
Sort into descending by the last letter: [four, two, one, three]
Sort by Comparator: [one, two, four, three]
*/

Reverse Order

reversed() 함수를 사용하여 컬렉션을 뒤집을 수 있다. 원본 객체는 뒤집지 않으며, 뒤집어진 새로운 객체를 반환하므로 원본을 바꾸고 싶지 않을 때 사용하자.

val numbers = listOf("one", "two", "three", "four")
println(numbers.reversed())

/*
[four, three, two, one]
*/

asReversed()를 사용하면 새 객체를 만드는 게 아니라 객체를 순회하는 순서만 반대로 볼 수 있다. 객체를 새로 만들지 않으므로 더 가벼우며, 원본 객체가 바뀌지 않을 때 사용하면 좋다.

val numbers = listOf("one", "two", "three", "four")
val reversedNumbers = numbers.asReversed()
println(reversedNumbers)

/*
[four, three, two, one]
*/

Mutable한 객체의 경우 객체 자체를 뒤집을 수 있다. reversed()를 사용하면 객체가 뒤집어지며, asReversed()를 사용하면 원본의 변경 사항이 뒤집어진 복사본에도 반영된다. 물론 그 반대도 성립한다.

val numbers = mutableListOf("one", "two", "three", "four")
val reversedNumbers = numbers.asReversed()
println(reversedNumbers)
numbers.add("five")
println(reversedNumbers)

/*
[four, three, two, one]
[five, four, three, two, one]
*/

Random order (shuffling)

shuffled()을 사용하면 원소의 순서를 임의로 섞은 List를 반환받을 수 있다.

val numbers = listOf("one", "two", "three", "four")
println(numbers.shuffled())

/*
[three, one, two, four]
*/

Aggregate (요약)

Aggregations

컬렉션을 대표하는 값을 계산할 수 있다. 통계학에서 요약 통계량이라고 불리는 최댓값, 최솟값 등을 쉽게 계산할 수 있다.

  • minOrNull(), maxOrNull() 함수는 원소의 최솟값 또는 최댓값을 반환한다. 컬렉션이 비어 있다면 null을 반환한다.
  • average()는 원소의 평균을 계산한다. 정수형 컬렉션에만 적용할 수 있다.
  • sum()은 원소의 합을 계산한다. 마찬가지로 정수형 컬렉션에만 적용할 수 있다.
  • count()는 원소의 개수를 계산한다. size()와 동일하다.
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()}")

/*
Count: 4
Max: 42
Min: 4
Average: 15.5
Sum: 62
*/

요약할 때 특정 기준을 지정할 수도 있다.

  • maxByOrNull()minByOrNull()은 람다식의 결과 중 최댓값과 최솟값을 찾는다. 컬렉션이 비어 있다면 null을 반환한다.
  • maxWithOrNull()minWithOrNull()Comparator 객체를 받아서 최댓값 또는 최솟값을 찾는다. 컬렉션이 비어 있다면 null을 반환한다.
// 3으로 나눈 나머지가 가장 큰 원소는?
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)

/*
42
three
*/

sum()에도 함수를 적용할 수 있다. 함수를 인자로 받아서 각 원소의 함숫값의 합을 계산할 수 있다.

  • sumBy()Int를 반환하는 함수를 받아서 각 원소에 함수를 적용한 결과를 합한다.
  • sumByDouble()는 위와 같지만, 합을 Double로 반환한다.
val numbers = listOf(5, 42, 10, 4)
println(numbers.sumBy { it * 2 })
println(numbers.sumByDouble { it.toDouble() / 2 })

/*
122
30.5
*/

Fold, Reduce

fold() 또는 reduce()를 사용하여 특정 연산(람다식)을 순서대로 적용한 결과를 얻을 수 있다. fold()는 초기값을 받으며, reduce()는 초기값을 컬렉션의 첫 번째 원소로 설정하고 두 번째 원소부터 연산을 수행한다. 이러한 성질 때문에 똑같은 연산이라도 fold()reduce()의 결과가 다를 수 있다.

val numbers = listOf(5, 2, 10, 4)

val sum = numbers.reduce { total, element -> total + element }
println(sum)
val sumDoubled = numbers.fold(0) { total, element -> total + element * 2 }
println(sumDoubled)

// 첫 번째 원소는 2배로 더해지지 않음
val sumDoubledReduce = numbers.reduce { total, element -> total + element * 2 } 
println(sumDoubledReduce)

/*
21
42
37
*/

foldRight() 또는 reduceRight()을 사용하여 컬렉션의 맨 뒤부터 연산을 적용할 수 있다. 위에서처럼 단순한 합의 경우에는 차이가 없겠지만, pow 등의 경우에는 연산 결과가 달라질 수 있으니 주의하자.

foldIndexed() 또는 reducedIndexed()을 사용하면 람다식에서 인덱스와 값을 동시에 인자로 받을 수 있다. foldRightIndexed()reducedRightIndexed()는 컬렉션의 맨 뒤에서부터 인덱스와 함께 연산을 수행한다.

val numbers = listOf(5, 2, 10, 4)
val sumEven = numbers.foldIndexed(0) { idx, total, element -> 
                                      if (idx % 2 == 0) total + element else total }
println(sumEven)

val sumEvenRight = numbers.foldRightIndexed(0) { idx, total, element -> 
                                                if (idx % 2 == 0) total + element else total }
println(sumEvenRight)

/*
15
15
*/

컬렉션이 비어있을 때 모든 reduce() 함수는 Exception을 발생시킨다. 다음의 함수를 사용하면 컬렉션이 비어있을 때 null을 반환받을 수 있다.

  • reduceOrNull()
  • reduceRightOrNull()
  • reduceIndexedOrNull()
  • reduceRightIndexedOrNull()

Transformations (변환)

파이썬을 알고 있다면 친숙한 이름이 많이 등장한다.

Mapping

Mapping은 컬렉션 객체에 특정 연산을 적용하여 새로운 컬렉션을 만드는 것이다. 가장 단순한 함수로 map()이 있다. map()은 함수를 인자로 받아서 각 원소에 함수를 적용한 결과를 모아 새 컬렉션 객체를 만든다. mapIndexed()를 사용하면 함수에서 원소의 인덱스를 추가로 사용할 수 있다.

val numbers = setOf(1, 2, 3)
println(numbers.map { it * 3 })
println(numbers.mapIndexed { idx, value -> value * idx })

/*
[3, 6, 9]
[0, 2, 6]
*/

변환 과정에서 null이 발생할 여지가 있는 경우, mapNotNull() 또는 mapIndexedNotNull()을 사용하여 null을 거를 수 있다.

val numbers = setOf(1, 2, 3)
println(numbers.mapNotNull { if ( it == 2) null else it * 3 })
println(numbers.mapIndexedNotNull { idx, value -> if (idx == 0) null else value * idx })

/*
[3, 9]
[2, 6]
*/

컬렉션 Mapmapping할 때는 key 또는 value 하나에만 함수를 적용할 수 있다. 각각 mapKeys()mapValues()를 사용하면 된다.

val numbersMap = mapOf("key1" to 1, "key2" to 2, "key3" to 3, "key11" to 11)
println(numbersMap.mapKeys { it.key.toUpperCase() })
println(numbersMap.mapValues { it.value + it.key.length })

/*
{KEY1=1, KEY2=2, KEY3=3, KEY11=11}
{key1=5, key2=6, key3=7, key11=16}
*/

Zipping

Zipping이란 두 개의 컬렉션에서 원소를 동시에 읽는 것이다. 두 객체의 i번째 원소를 동시에 읽을 수 있다. zip()을 사용하면 i번째 원소를 Pair로 묶은 List 객체가 반환된다.

주의: a.zip(b) 대신 a zip b를 사용할 수도 있다.

val colors = listOf("red", "brown", "grey")
val animals = listOf("fox", "bear", "wolf")
println(colors zip animals)

/*
[(red, fox), (brown, bear), (grey, wolf)]
*/

컬렉션의 길이가 서로 다른 경우, 더 짧은 컬렉션의 길이까지만 탐색하여 반환한다.

val twoAnimals = listOf("fox", "bear")
println(colors.zip(twoAnimals))

/*
[(red, fox), (brown, bear)]
*/

Pair 대신 다른 객체를 반환하게 할 수도 있다. 두 번째 매개변수로 함수를 주면 된다.

val colors = listOf("red", "brown", "grey")
val animals = listOf("fox", "bear", "wolf")

println(colors.zip(animals) { color, animal -> "The ${animal.capitalize()} is $color"})

/*
[The Fox is red, The Bear is brown, The Wolf is grey]
*/

zip()을 되돌리고 싶다면 unzip()을 사용하면 된다. 단, Pairunzip() 가능함에 주의하자. unzip()은 각 Pair의 첫 번째와 두 번째 원소끼리 리스트로 묶은 결과를 반환한다.

val numberPairs = listOf("one" to 1, "two" to 2, "three" to 3, "four" to 4)
println(numberPairs.unzip())

/*
([one, two, three, four], [1, 2, 3, 4])
*/

Association

Association은 각 컬렉션의 원소를 key로 하여 value를 대응시키는 변환이다. Association의 결과는 keyvalueMap이다.

가장 기본적인 함수로 associateWith()가 있다. 함수를 매개변수로 받아서 각 원소를 key로, 원소에 함수를 적용한 결과를 value로 하는 Map을 만든다.

val numbers = listOf("one", "two", "three", "four")
println(numbers.associateWith { it.length })

/*
{one=3, two=3, three=5, four=4}
*/

함수를 value가 아닌 key에 대응시킬 수도 있다. associateBy()를 사용하면 각 원소를 value로, 원소에 함수를 적용한 결과를 key로 하는 Map을 만들 수 있다. WithBy를 잘 구별하자.

keyvalue를 특정 형식으로 지정할 수도 있다. 각각 keySelectorvalueTransform에 함수를 넘겨주면 된다.

val numbers = listOf("one", "two", "three", "four")

println(numbers.associateBy { it.first().toUpperCase() })
println(numbers.associateBy(keySelector = { it.first().toUpperCase() }, valueTransform = { it.length }))

/*
{O=one, T=three, F=four}
{O=3, T=5, F=4}
*/

Flattening

Flattening은 여러 개의 컬렉션을 하나의 컬렉션으로 모으는 연산이다. flatten()을 사용하면 각 컬렉션을 List로 변환한 후, 변환된 List를 하나로 이어붙인다.

val numberSets = listOf(setOf(1, 2, 3), setOf(4, 5, 6), setOf(1, 2))
println(numberSets.flatten())

/*
[1, 2, 3, 4, 5, 6, 1, 2]
*/

복잡한 컬렉션(Map 등)을 이어붙일 때는 flatMap()을 고려해 보자. flatMap()은 인자로 함수를 넘겨받아서 각 원소에 함수를 map()한 결과를 flatten()한다.

val containers = listOf(
    StringContainer(listOf("one", "two", "three")),
    StringContainer(listOf("four", "five", "six")),
    StringContainer(listOf("seven", "eight"))
)
println(containers)
println(containers.flatMap { it.values })

/*
[StringContainer(values=[one, two, three]), StringContainer(values=[four, five, six]), StringContainer(values=[seven, eight])]
[one, two, three, four, five, six, seven, eight]
*/

String Representation

컬렉션의 원소를 문자열로 보여줄 필요가 있을 때는 보통 toString()을 사용하지만, 특정 포맷을 지정하고 싶다면 joinToString() 또는 joinTo()를 사용해 보자.

두 함수는 공통적으로 각 원소를 문자열로 이어붙인다. 이때 각 원소를 구분할 구분자(delimiter)를 지정할 수 있다. 보통 쉼표(,)를 구분자로 많이 사용한다. joinToString()은 문자열을 반환하며, joinTo()는 문자열을 인자로 주어진 Appendable 객체 뒤에 덧붙인다.

val numbers = listOf("one", "two", "three", "four")

println(numbers)         
println(numbers.joinToString()) // 기본값: ", "
println(numbers.joinToString("|"))

val listString = StringBuffer("The list of numbers: ")
numbers.joinTo(listString)
println(listString)

/*
[one, two, three, four]
one, two, three, four
one|two|three|four
The list of numbers: one, two, three, four
*/

Filtering (검색)

Filtering은 컬렉션에서 조건을 만족하는 특정 원소만 걸러내는 작업이다. 컬렉션으로 하는 대부분의 작업은 아마 filtering일 것이다. 아래에서 소개할 함수는 공통적으로 predicate 함수를 받아서, 함수의 결과값이 true인 원소와 false인 원소를 나눈다. 모든 filtering 함수는 원본을 수정하지 않는다.

  • predicate: 원소가 특정 조건을 만족하면 true를, 만족하지 않으면 false를 반환하는 람다 함수

보통은 filtering한 결과에 map() 등 계속 다른 함수를 이어붙여(function chaining) 작업을 수행한다.

Filtering by predicate

가장 기본적인 filtering 함수는 filter()이다. filter()predicate가 지정하는 조건에 맞는 원소만 골라내 반환한다. ListSet의 결과는 List이고, Map의 결과는 Map이다.

val numbers = listOf("one", "two", "three", "four")  
val longerThan3 = numbers.filter { it.length > 3 }
println(longerThan3)

val numbersMap = mapOf("key1" to 1, "key2" to 2, "key3" to 3, "key11" to 11)
val filteredMap = numbersMap.filter { (key, value) -> key.endsWith("1") && value > 10}
println(filteredMap)

/*
[three, four]
{key11=11}
*/

predicate에서 인덱스를 사용하고 싶다면 filterIndexed()를 사용하자.

val numbers = listOf("one", "two", "three", "four")  
val longerThan3 = numbers.filterIndexed { index, value -> value.length > 3 }
println(longerThan3)

/*
[three, four]
*/

조건을 만족하지 않는 원소를 찾고 싶다면 filterNot()을 사용하자.

val numbers = listOf("one", "two", "three", "four")

val filteredNot = numbers.filterNot { it.length <= 3 }
println(filteredNot)

/*
[three, four]
*/

filterIsInstance()는 특정 타입의 원소만 걸러낼 수 있다. List<Any> 객체에서 filterIsInstance<T>()를 호출하면 List<T>를 반환하고, 반환된 리스트에 메소드를 이어붙일 수 있다.

val numbers = listOf(null, 1, "two", 3.0, "four")
println("All String elements in upper case:")
numbers.filterIsInstance<String>().forEach {
    println(it.toUpperCase())
}

/*
All String elements in upper case:
TWO
FOUR
*/

filterNotNull()null이 아닌 원소만 걸러낸다. List<T?> 객체에서 filterNotNull()을 호출하면 List<T: Any>를 반환하므로 여기에도 메소드를 계속 이어붙일 수 있다.

val numbers = listOf(null, "one", "two", null)
numbers.filterNotNull().forEach {
    println(it.length)   // nonnull 문자열의 길이만 출력
}

/*
3
3
*/

Partitioning

partition() 함수를 사용하면 조건을 만족하는 원소와 만족하지 않는 원소를 모두 돌려받을 수 있다. 보통은 partition()의 결과를 unzip하여 받는다.

방금 말한 unzip은 코틀린의 unzip()과는 다릅니다. 여기서 말하는 unzip은 Pair의 원소를 다음과 같이 받는 방법을 의미합니다.

val numbers = listOf("one", "two", "three", "four")
val (match, rest) = numbers.partition { it.length > 3 } // unzip

println(match)
println(rest)

/*
[three, four]
[one, two]
*/

Testing predicates

컬렉션에 조건을 만족하는 원소가 있는지 없는지도 검사할 수 있다.

  • any(): 조건을 만족하는 원소가 하나라도 있다면 true를 반환한다.
  • none(): 조건을 만족하는 원소가 하나도 없을 때 true를 반환한다.
  • all(): 모든 원소가 조건을 만족할 때 true를 반환한다. 컬렉션이 비어 있다면 all()은 항상 true를 반환한다. 이것을 vacuous truth라고 부른다.
val numbers = listOf("one", "two", "three", "four")

println(numbers.any { it.endsWith("e") })  // true
println(numbers.none { it.endsWith("a") }) // true
println(numbers.all { it.endsWith("e") })  // false

println(emptyList<Int>().all { it > 5 })   // true (vacuous truth)

참고문헌

Ordering - Kotlin Programming Language (kotlinlang.org)

Aggregate Operations - Kotlin Programming Language (kotlinlang.org)

Collection Transformation Operations - Kotlin Programming Language (kotlinlang.org)

Filtering Collections - Kotlin Programming Language (kotlinlang.org)

'Primary > Kotlin' 카테고리의 다른 글

[Kotlin] Coroutines - Basics  (0) 2021.01.19
[Kotlin] Sequence  (0) 2021.01.15
[Kotlin] Thread 생성 및 실행  (0) 2021.01.14
[Kotlin] 데이터 클래스  (0) 2021.01.08
[Kotlin] 위임  (0) 2021.01.06
Comments