Kotlin

[Kotlin] 람다를 인자로 받는 컬렉션 함수형 API

sh1mj1 2024. 1. 12. 12:18

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

이미지 출처              https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

함수형 프로그래밍 스타일을 사용하면 컬렉션을 다룰 때 편하다.

컬렉션을 다루는 코틀린 표준 라이브러리를 몇가지 살펴보자.

filtermap 함수, 그 함수를 뒷받침하는 개념으로부터 시작된다.

filter & map 함수

대부분의 컬렉션 연산을 filtermap 함수를 통해 표현할 수 있다.

filter 함수

filter 함수는 컬렉션을 iteration 하면서 주어진 람다에 각 원소를 넘겨서 람다가 true 를 리턴하는 원소만 모아서 List 로 리턴한다.

kotlin
닫기
val list = listOf(1, 2, 3, 4)
assert(
    list.filter { it % 2 == 0 } == listOf(2, 4)
)

주어진 predicate(술어: 참/거짓을 리턴하는 함수)를 만족하는 원소만으로 이루어진 리스트를 리턴한다.

 

30살 이상인 사람만 리턴하도록 filter 사용

kotlin
닫기
val people = listOf(Person("Alice", 29), Person("Bob", 31))
assert(
    people.filter { it.age > 30 } == listOf(Person("Bob", 31))
)

filter 함수는 이렇게 컬렉션에서 원치 않는 원소를 제거한다.

그 밖에 filter 류 함수

그 밖에 filter 류의 함수들이다.

  • filterNot: predicate 가 false 를 리턴하는 원소만 리스트로 리턴
  • filterNotNull: null 이 아닌 원소만 리스트로 리턴 (인자가 따로 없음)
  • filterIsInstance<T>: 특정 타입인 원소만 리스트로 리턴 (filterIsInstance<String>() 처럼 사용)
  • filterIndexed: 인덱스를 통해 처리하고 싶을 때
kotlin
닫기
dinnerList.filterIndexed { index, s ->
    index == 3
}
  • filterKeys: Map의 키를 걸러낼 때
kotlin
닫기
val numbers = mapOf(0 to "zero", 1 to "one", 2 to "two", 3 to "three")
assert(numbers.filterKeys { it % 2 == 0 } == mapOf(0 to "zero", 2 to "two"))
  • filterValues: Map의 value 를 걸러낼 때
kotlin
닫기
val numbers = mapOf(0 to "zero", 1 to "one", 2 to "two", 3 to "three")
assert(numbers.filterValues { it.contains('t') } == mapOf(2 to "two", 3 to "three"))
  • ...... 등 등

map 함수

filter 는 원소를 변환할 수는 없다.

원소를 변환하려면 map 함수를 사용한다.

map 함수는 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아서 새 컬렉션을 만든다.

kotlin
닫기
val list = listOf(1, 2, 3, 4)
assert(
    list.map { it * it } == listOf(1, 4, 9, 16)
)

숫자로 이뤄진 리스트를 각 제곱이 모인 리스트로 바꿀 수 있다.

 

map 함수로 사람의 리스트를 이름의 리스트로 변환

kotlin
닫기
val people = listOf(Person("Alice", 29), Person("Bob", 31))
assert(
    people.map { it.name } == listOf("Alice", "Bob")
)

{ it.name } 이라는 람다식을 사용하여 이름 리스트로 바꾸고 있다.

(Person::name) 으로 멤버 참조를 사용할 수도 있다.

kotlin
닫기
assert(
    people.map(Person::age) == listOf("Alice", "Bob")
)

 그 밖의 map 류 함수

  • mapIndexed: 인덱스를 통해 처리하고 싶을 때
kotlin
닫기
assert(
    list.mapIndexed{idx, value -> idx * value } == listOf(0, 2, 6, 12)
)
  • mapNotNull, mapIndexedNotNull: null 인 원소들은 포함하지 않는다
  • mapKeys: Map 의 key 값들을 변환
kotlin
닫기
val numbers = mapOf(0 to "zero", 1 to "one", 2 to "two", 3 to "three")
assert(
    numbers.mapKeys { it.key.plus(1) } == mapOf(1 to "zero", 2 to "one", 3 to "two", 4 to "three") && 
            numbers.mapKeys { it.value.uppercase() } == mapOf("ZERO" to "zero", "ONE" to "one", "TWO" to "two", "THREE" to "three")
)
  • mapValues: Map 의 value 값들을 변환
kotlin
닫기
val numbers = mapOf(0 to "zero", 1 to "one", 2 to "two", 3 to "three")
assert(
    numbers.mapValues { it.value.uppercase() } == mapOf(0 to "ZERO", 1 to "ONE", 2 to "TWO", 3 to "THREE") &&
            numbers.mapValues { it.key.plus(1) } == mapOf(0 to 1, 1 to 2, 2 to 3, 3 to 4)
)
  • .... 등 등

필요하지 않은 경우 굳이 계산을 반복하지 말자

그렇다면 이제 people 리스트에서 가장 나이가 많은 모든 사람들을 리턴해보자

 

잘못된 예시 코드

kotlin
닫기
val people = listOf(Person("Alice", 29), Person("Bob", 31))
assert(
    people.filter { it.age == people.maxBy(Person::age).age } == listOf(Person("Bob", 31))
)

이 코드에서는 무언가 단점이 있다.

people 리스트에서 최댓값을 구하는 작업을 계속 반복한다는 단점이다.

만약 100 명이 있다면, 100 번 최대값 연산을 수행한다.

 

이를 개선한 예시 코드

kotlin
닫기
assertTrue {
    val maxAge = people.maxBy(Person::age).age
    people.filter { it.age == maxAge } == listOf(Person("Bob", 31))
}

 꼭 필요하지 않은 경우 굳이 계산을 반복하지 말자.

람다를 인자로 받는 함수에 람다를 넘기면 겉으로 볼 때는 단순해 보이는 식이 내부 로직의 복잡도로 인해 실제로는 불합리한 계산식이 될 때가 있다.

작성하는 코드를 명확히 이해하고 사용하자.

all & any & count &  find 함수

컬렉션의 모든 원소가 어떤 조건을 만족하는지 판단하는,

컬렉션의 어떤 조건을 만족하는 원소가 있는지 판단하는 연산이 자주 수행된다.

allany 가 그런 연산이다.

all 함수

모든 원소가 어떤 predicate 를 만족하는지 판단하려면 all 함수를 사용한다.

 

Person 리스트가 모두 27 살 이하인지 all 을 사용하여 판단

kotlin
닫기
val canBeInClub27 = { p: Person -> p.age <= 27 }
val people = listOf(Person("Alice", 27), Person("Bob", 31))
assertFalse { people.all(canBeInClub27) }

Bob 은 31 살이므로 false 가 된다.

any 함수

predicate 를 만족하는 원소가 하나라도 있는지 판단하려면 any 함수를 사용한다.

 

Person 리스트에서 27 살 이하가 있는지 any 를 사용하여 판단

kotlin
닫기
assertTrue { people.any(canBeInClub27) }

Alice 가 27 살이므로 true 가 된다.

count 함수

count 는 predicate 을 원소의 개수를 리턴한다.

kotlin
닫기
val people = listOf(Person("Alice", 27),Person("Bob", 31), Person("John", 20),Person("Harry", 25), )
assert(people.count { it.age <= 27 } == 3)

그런데 count 가 있다는 사실을 잊어버리고 아래처럼 컬렉션을 필터링한 결과의 size 를 가져오는 경우가 종종 있다.

kotlin
닫기
people.filter { it.age <= 27 }.size == 3

그런데 이렇게 하는 것은 비효율적이다.

이렇게 처리하면 predicate 를 만족하는 모든 원소가 들어가는 중간 컬렉션(intermidate collection)이 생긴다!!!

반면 count 를 사용하면, 조건을 만족하는 원소의 개수만을 추적한다. 

즉, 조건을 만족하는 원소를 따로 저장하지 않는다. 따라서 count 가 더 효율적이다.

이렇게 필요에 따라 가장 적합한 연산을 선택하는 것이 가장 좋다.

find 함수 & firstOrNull 함수

find 는 predicate 를 만족하는 원소 하나를 리턴한다.

kotlin
닫기
val people = listOf(Person("Alice", 27), Person("Bob", 31), Person("John", 20), Person("Harry", 25))
assert(people.find { it.age <= 27 } == Person("Alice", 27))

조건을 만족하는 원소가 하나라도 있는 경우 가장 먼저 조건을 만족한다고 확인된 원소를 리턴한다.

만약 원소가 전혀 없는 경우 null 을 리턴한다.

 findfirstOrNull 과 같다.

조건을 만족하는 원소가 없으면 null 을 리턴한다는 사실을 더 명확히 하고 싶다면 firstOrNull 을 사용하면 된다.

kotlin
닫기
people.firstOrNull { it.age <= 27 }

 groupBy 함수 - 리스트를 여러 그룹으로 이뤄진 맵으로

groupBy 함수는 컬렉션의 모든 원소를 어떤 특성에 따라 여러 그룹으로 나눌 수 있다.

kotlin
닫기
@Test fun testGroupBy() {
    val people = listOf(Person("Alice", 31), Person("Bob", 29), Person("Carol", 31))
    println(people.groupBy { it.age }) 
    // print 
    /* {31=[Person(name=Alice, age=31), Person(name=Carol, age=31)], 29=[Person(name=Bob, age=29)]} */
    assert( people.groupBy(Person::age) == mapOf(
            31 to listOf(Person("Alice", 31), Person("Carol", 31)),
            29 to listOf(Person("Bob", 29))
        )
    )
}

 이 연산의 결과는 컬렉션의 원소를 구분하는 특성(age)이 key 이고, 키 값에 따른 각 그룹(List<Person>)이 value 인 Map 이다.

즉, 결과 타입은 Map<Int, List<Person>> 이다.

{ it.age } 처럼 람다식을 사용해도 되고, (Person::age) 처럼 멤버 참조를 사용해도 된다.

문자열을 첫 글자에 따라 분류하는 코드

kotlin
닫기
val list = listOf("a", "ab", "b")
println(list.groupBy { it.first() }) // print /* {a=[a, ab], b=[b]} */
assert(
    list.groupBy(String::first) == mapOf(
        'a' to listOf("a", "ab"), 'b' to listOf("b")
    )
)

firstString 의 멤버가 아닌 확장 함수이다.

이 경우에도 멤버참조를 사용하여 first 에 접근할 수 있다.

groupingBy 함수

groupBy 와 이름이 비슷하지만 다른 groupingBy 함수가 있다. (코틀린 1.1 부터 사용 가능)

groupingBy 는 각 원소에 키를 추출하기 위한 keySelector 함수를 사용하여,

나중에 groupfold 작업과 함께 사용할 배열에서 Grouping 소스를 만든다.

 

무슨 말인지 와닿지 않으니 코드로 보자.

kotlin
닫기
public inline fun <T, K> Iterable<T>.groupingBy(crossinline keySelector: (T) -> K): Grouping<T, K> {
    return object : Grouping<T, K> {
        override fun sourceIterator(): Iterator<T> = this@groupingBy.iterator()
        override fun keyOf(element: T): K = keySelector(element)
    }
}

 groupingByIterable 뿐 아니라, Sequence, Array, CharSequence 에도 사용할 수 있다.(공식 문서 참조)

모두 Grouping 타입의 객체가 만들어진다.

 

Grouping 에는 다양한 함수를 호출할 수 있다.

  • eachCount 함수: 각 그룹의 원소를 세어준다.
kotlin
닫기
val words = listOf("a", "abc", "ab", "def", "abc")
val counts = words.groupingBy { it.first() }.eachCount()
println(counts) // print /* {a=4, d=1} */
assert(
    counts == mapOf('a' to 4, 'd' to 1)
)
  • fold 함수: 각 그룹에 대해 초기 값을 지정하고, 각 원소에 대해 누적 연산을 수행한다.
kotlin
닫기
val numbers = listOf("one", "two", "three", "four", "five")
val lengths = numbers.groupingBy { it.first() }.fold(initialValue = 0) { acc, s -> acc + s.length }
println(lengths) // print /* {o=3, t=8, f=8} */
assert(
    lengths == mapOf('o' to 1, 't' to 8, 'f' to 4)
)

 처음에는 { o=0, t=0, f=0 } 으로 시작한다.

문자열의 첫번째 문자(first()) 로 grouping 을 하고 initialValue0 으로 했기 때문이다.

그리고 각 문자열의 길이만큼 해당 그룹의 value 에 더하는 연산을 하고 있다.

그래서 o= (0+3), t= (0+3+5), f=(0+4+4) 가 된다.

  • reduce 함수: fold()와 비슷하지만 초기 값 설정없이 각 그룹의 첫 번째 원소를 초기 값으로 사용한다.
kotlin
닫기
val numbers = listOf("one", "two", "three", "four", "five")
val concatenated = numbers.groupingBy { it.first() }.reduce { _, accumulator, element -> accumulator + element }
println(concatenated) // print /* {o=one, t=twothree, f=fourfive} */
assert(concatenated == mapOf('o' to "one", 't' to "twothree", 'f' to "fourfive"))

각 그룹의 첫번째 원소를 초기값으로 하기 때문에 { o="one", t="two", f="four" } 로 시작한다. 

그리고 numbers 를 iteration 하면서 각 원소를 만나면 람다 식의 연산(더하는 연산)을 하고 있다.

reduce 연산에서는 만난 원소가 그룹의 첫번째 원소이면 람다 식의 연산을 하지 않는다.

  • aggregate 함수: 각 그룹에 대해 누적 연산을 수행하며, 중간 결과에 접근할 수 있습니다.
kotlin
닫기
val numbers = listOf("one", "two", "three", "four", "five")
val aggregated = numbers.groupingBy { it.first() }.aggregate { _, accumulator: String?, element, first ->
    if (first) element else accumulator + element
}
println(aggregated) // print /* {o=one, t=twothree, f=fourfive} */
assert(aggregated == mapOf('o' to "one", 't' to "twothree", 'f' to "fourfive"))

 함수를 잘 확인하면, 위에 reduce 함수와 동작 결과가 같다는 것을 볼 수 있다.

reduce 함수는 내부적으로 aggregate 함수를 사용한다. 

즉, 위 aggregate 예제 코드는 reduce 함수와 똑같이 동작하도록 작성한 것이다.

  • foldTo 함수: 각 그룹별로 누적 연산을 수행하고, 결과를 지정된 컬렉션에 추가한다.
kotlin
닫기
val words = listOf("a", "bc", "def", "ghij")
val lengths = mutableMapOf<Char, Int>()
words.groupingBy { it.first() }.foldTo(lengths, 0) { acc, s -> acc + s.length }

println(lengths) // print /* {a=1, b=2, d=3, g=4} */
assert(lengths == mapOf('a' to 1, 'b' to 2, 'd' to 3, 'g' to 4))

foldTo(),reduceTo(),aggregateTo() 등은 각각 fold(),reduce(),aggregate()와 비슷한 작업을 수행하지만 결과를 기존의 컬렉션에 추가하는 방식으로 동작한다.

그러므로 reduceTo(),aggregateTo() 함수는생략하겠다.

flatMap & flatten

flatMap

flatMap 함수는 먼저 인자로 주어진 람다를 컬렉션의 모든 객체에 매핑하고(map) 람다를 적용한 결과 얻어지는 여러 리스트를 한 리스트로 모은다(flatten).

 

문자열 리스트에 대해 flatMap

kotlin
닫기
val strings = listOf("abc", "def")
assert(
    strings.flatMap { it.toList() } == listOf('a', 'b', 'c', 'd', 'e', 'f')
)

flatMap { it.toList() } 가 아닌, map { it.toList() } 와 비교해보자.

 

문자열 리스트에 대해 map

kotlin
닫기
val strings = listOf("abc", "def")
assert(
    strings.map { it.toList() } == listOf(
        listOf('a', 'b', 'c'), listOf('d', 'e', 'f')
    )
)

 

Book 으로 표현된 책에 대한 정보를 저장하는 도서관이 있다고 하자.

kotlin
닫기
class Book(val title: String, val authors: List<String>)

 책마다 저자가 여러 명 있을 수 있다.

도서관에 있는 책의 저자를 모두 모은 집합을 아래처럼 가져올 수 있다.

 

List<Book> 에 대해 flatMap

kotlin
닫기
val books = listOf(
    Book("Thursday Next", listOf("Jasper Fforde")),
    Book("Mort", listOf("Terry Pratchett")),
    Book("Good Omens", listOf("Terry Pratchett", "Neil Gaiman")),
)

println(books.flatMap { it.authors }.toSet()) /* [Jasper Fforde, Terry Pratchett, Neil Gaiman] */
assert(
    books.flatMap { it.authors }.toSet() == setOf(
        "Jasper Fforde", "Terry Pratchett", "Neil Gaiman"
    )
)

 이번에도 flatMap 을 사용한 것이 아닌 map 과 비교해보자.

 

List<Book> 에 대해 map

kotlin
닫기
println(books.map { it.authors }) /* [[Jasper Fforde], [Terry Pratchett], [Terry Pratchett, Neil Gaiman]] */
assert(
    books.map { it.authors } == listOf(
        listOf("Jasper Fforde"), listOf("Terry Pratchett"), listOf("Terry Pratchett", "Neil Gaiman")
    )
)

이렇게 리스트의 리스트(List<List<T>>) 가 있을 때 모든 중첩된 리스트의 원소를 한 리스트로 모아야 한다면 flatMap 을 떠올릴 수 있다.

flatten

flatMap 과 달리 특별히 변환해야 할 내용이 없다면, 리스트의 리스트(List<List<T>>)를 평평하게 펼치기만 하면 된다.

그런 경우 flatten 함수를 사용할 수 있다.

 

flatten 으로 List<List<Int>> 펼치기

kotlin
닫기
val listOfStringList = listOf(listOf(1, 2, 3, 4), listOf(11, 12, 13, 14))
println(listOfStringList.flatten()) // print /* [1, 2, 3, 4, 11, 12, 13, 14] */
assert(listOfStringList.flatten() == listOf(1, 2, 3, 4, 11, 12, 13, 14))

이렇게 코틀린 표준 라이브러리가 제공하는 몇 가지 컬렉션 연산 함수를 살펴보았다.

이 밖에도 굉장히 많은, 유용한 컬렉션 연산 함수가 있다. 

컬렉션을 다루는 코드를 작성할 때는 원하는 바를 일반적인 패턴으로 표현할 수 있는지 생각하고,

그런 변환을 제공하는 라이브러리 함수가 있는지 살펴보자.

 

참조

https://blog.yena.io/studynote/2020/01/22/Kotlin-Collection-Filter.html

https://woojeenow.tistory.com/entry/Kotlin-Collection-%EA%B4%80%EB%A0%A8-%ED%95%A8%EC%88%98%EB%93%A4%EC%9D%84-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90-filter-map

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/grouping-by.html