[Kotlin] 람다를 인자로 받는 컬렉션 함수형 API
Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.
함수형 프로그래밍 스타일을 사용하면 컬렉션을 다룰 때 편하다.
컬렉션을 다루는 코틀린 표준 라이브러리를 몇가지 살펴보자.
`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://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/grouping-by.html