Kotlin

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

sh1mj1 2024. 1. 12. 12:18

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

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

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

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

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

filter & map 함수

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

filter 함수

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

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

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

 

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

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`: 인덱스를 통해 처리하고 싶을 때
dinnerList.filterIndexed { index, s ->
    index == 3
}
  • `filterKeys`: Map의 키를 걸러낼 때
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 를 걸러낼 때
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` 함수는 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아서 새 컬렉션을 만든다.

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

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

 

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

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

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

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

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

 그 밖의 map 류 함수

  • `mapIndexed`: 인덱스를 통해 처리하고 싶을 때
assert(
    list.mapIndexed{idx, value -> idx * value } == listOf(0, 2, 6, 12)
)
  • `mapNotNull`, `mapIndexedNotNull`: null 인 원소들은 포함하지 않는다
  • `mapKeys`: Map 의 key 값들을 변환
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 값들을 변환
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` 리스트에서 가장 나이가 많은 모든 사람들을 리턴해보자

 

잘못된 예시 코드

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 번 최대값 연산을 수행한다.

 

이를 개선한 예시 코드

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

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

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

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

all & any & count &  find 함수

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

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

`all` 과 `any` 가 그런 연산이다.

all 함수

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

 

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

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` 를 사용하여 판단

assertTrue { people.any(canBeInClub27) }

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

count 함수

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

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

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

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

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

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

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

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

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

find 함수 & firstOrNull 함수

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

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 을 리턴한다.

 `find` 는 `firstOrNull` 과 같다.

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

people.firstOrNull { it.age <= 27 }

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

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

@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)` 처럼 멤버 참조를 사용해도 된다.

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

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")
    )
)

`first` 는 `String` 의 멤버가 아닌 확장 함수이다.

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

groupingBy 함수

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

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

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

 

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

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)
    }
}

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

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

 

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

  • `eachCount` 함수: 각 그룹의 원소를 세어준다.
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` 함수: 각 그룹에 대해 초기 값을 지정하고, 각 원소에 대해 누적 연산을 수행한다.
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 을 하고 `initialValue` 를 `0` 으로 했기 때문이다.

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

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

  • `reduce` 함수: `fold()`와 비슷하지만 초기 값 설정없이 각 그룹의 첫 번째 원소를 초기 값으로 사용한다.
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` 함수: 각 그룹에 대해 누적 연산을 수행하며, 중간 결과에 접근할 수 있습니다.
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` 함수: 각 그룹별로 누적 연산을 수행하고, 결과를 지정된 컬렉션에 추가한다.
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`

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

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

 

문자열 리스트에 대해 `map`

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

 

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

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

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

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

 

`List<Book>` 에 대해 `flatMap`

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`

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>>` 펼치기

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