컬렉션(Collections) 라이브러리는 람다를 기반으로 다양한 기능의 함수를 제공한다.
컬렉션 함수를 잘 알아두면 컬렉션을 다룰 때 편하다. 대부분의 컬렉션 작업에 활용할 수 있으며 그로 인해 코드를 아주 간결하게 만들 수 있다.
컬렉션 필수 함수와 그 외의 몇몇 주요 함수를 알아보자.
filter와 map은 컬렉션을 다룰 때 기반이 되는 함수다. 대부분의 컬렉션 작업을 두 함수를 통해 표현할 수 있다.
두 함수를 예시를 통해 살펴보자.
filter 함수는 주어진 람다에 컬렉션의 각 원소를 넘겨서 true를 반환하는 원소만 모아 '새로운' 컬렉션으로 반환한다.
val people = listOf(Person("홍길동", 30), Person("이몽룡", 21))
println(people.filter { it.age >= 30 })
// 출력: [Person(name="홍길동", age=30)]
위 예시는 people에서 30살 이상인 사람만 필터링한 것이다. 원본 컬렉션은 변하지 않는다.
filter는 주어진 컬렉션에서 원치 않는 원소를 걸러내지만 원소를 변환하진 못한다. 원소를 조작하고 싶다면 map 함수를 사용하자.
map 함수는 주어진 람다에 각 원소를 넘겨 리턴된 값을 모아 새로운 컬렉션으로 반환한다.
val names = people.map { it.name } // people.map(Person::name)과 같다
println(names) // 출력: [홍길동, 이몽룡]
위 예시는 Person 리스트에서 name 프로퍼티만 모은 문자열 리스트를 반환한다. 원본은 역시 변하지 않는다.
각 함수가 컬렉션을 반환하기 때문에 filter와 map을 함수형 프로그래밍 스타일로 연쇄 호출할 수도 있다.
people.filter { it.age >= 30 }.map(Person::name)
30살 이상인 사람들 중에서 이름을 모아서 문자열 리스트를 반환한다. 간결하고 어떤 동작을 하는지 명확하다.
val numbers = mapOf(0 to "zero", 1 to "one")
println(numbers.mapValues { (key, value) ->
value.toUpperCase()
})
// 출력: [0=ZERO, 1=ONE]
mapValues는 Map 컬렉션의 각 원소를 Map.Entry<key, value> 형태로 람다에 전달하여 반환된 결과를 모은다. 그리고 결과를 동일한 key에 새로운 value로 만든 Map을 반환한다.
비슷하게 mapkeys, filterValues, filterKeys 함수도 있다.
(참고로 람다 변수 주변의 괄호는 구조 분해 선언을 사용한 것이다.)
코틀린 표준 함수를 보면 predicate라는 매개 변수를 자주 볼 수 있다. true/false를 반환하는 식을 술어(predicate)라고 한다.
filter 외에도 predicate 람다를 입력 받는, 자주 사용되는 함수가 있다. all, any, count, find가 그렇다.
val people = listOf(Person("Alice", 17), Person("Bob", 21))
val isAdult = { p: Person -> p.age >= 20 }
이 리스트와 predicate 람다 식을 이용하여 각 함수를 알아보자.
all 함수는 컬렉션의 모든 원소가 주어진 술어를 만족하는지 판단한다.
println(people.all(isAdult)) // people.all { it.age >= 20 }
// 출력: false
모든 Person이 20살 이상인지 확인한다.
any는 주어진 술어를 만족하는 원소가 하나라도 있는지 확인한다.
println(people.any(isAdult))
// 출력: true
한 명이라도 성인이 있는지 확인한다.
count는 주어진 술어를 만족하는 원소의 개수를 알려준다.
println(people.count(isAdult))
// 출력: 1
성인이 몇 명인지 세고 있다.
count와 filter{}.size는 같은 결과를 반환하지만 성능은 count가 좋다. 왜냐면 filter.size는 중간 컬렉션을 생성하지만, count는 개수만 추적하지 만족하는 원소를 따로 저장하지 않기 때문이다.
주어진 술어를 만족하는 원소를 찾고 싶으면 find를 사용한다.
println(people.find(isAdult)) // 출력: Person(name="Bob", age=21)
만족하는 원소가 여러 개라면 앞에서부터 가장 먼저 찾은 것을 반환하며, 없으면 null을 반환한다.
컬렉션의 원소를 어떤 기준을 가지고 분류하고 싶을 수 있다. 예를 들어, 사람을 나이에 따라 분류하는 것이다. groupBy 함수가 이러한 작업을 수행한다.
val people = listOf(Person("Alice", 31), Person("Bob", 29), Person("Carol", 31))
println(people.groupBy { it.age })
위 연산의 결과는 원소를 구분하는 특성이 키이고 특성에 포함되는 원소의 리스트가 값인 Map이다. 즉, Map<특성, 리스트<원소>>이다.
{ 29=[Person("Bob", 29)], 31=[Person("Alice", 31), Person("Carol", 31)] }
따라서, 여기서 결과 타입은 Map<Int, List<사람>>이다.
리스트 안에 리스트가 중첩되어 있는 상황을 생각해보자. 2차원 리스트를 1차원 리스트로 펼치고 싶을 수 있다. 이 때 flatten 함수를 사용한다.
val numbersInNumbers = listOf(listOf(1,2,3,4), listOf(3,4,5,6))
print(numbersInNumbers.flatten())
// 출력: [1, 2, 3, 4, 3, 4, 5, 6]
flatMap은 map + flatten의 조합이다.
어떤 리스트에 map을 수행하는데 람다의 결과가 리스트인 상황을 가정하자. map의 결과는 2차원 리스트이다. 그 결과에 flatten 함수를 호출하면 1차원 리스트가 된다.
그리고 flatMap은 정확히 이 동작을 수행한다. 예시를 보자.
val numbers = listOf("12345", "26437")
println(numbers.flatMap { it.toList() })
// 출력: [1,2,3,4,5,2,6,4,3,7]
문자열 리스트의 각 문자열을 람다에 대입하여 리스트로 변환한 후 flatten() 작업을 통해 일차원 리스트로 펼친다.
그리고 이것은 아래의 코드와 같다.
numbers.map { it.toList() }.flatten()
둘의 성능은 비슷하지만 flatMap이 더 간결하다. 따라서, 변환 후 펼쳐야 한다면 flatMap을 활용하자.
flatMap과 map + flatten의 성능을 비교해본 결과 비슷했다.
테스트 코드는 아래와 같다.
val numbers1 = mutableListOf<String>()
val numbers2 = mutableListOf<String>()
repeat(2_000_000) {
val sb = StringBuilder()
repeat(10) { i ->
sb.append(it + i)
}
sb.toString().also {
numbers1.add(it)
numbers2.add(it)
}
}
val time1 = measureTimeMillis {
numbers1.flatMap { it.toList() }
}
val time2 = measureTimeMillis {
numbers2.map { it.toList() }.flatten()
}
println("flatMap: $time1") // flatMap: 7026
println("map and flatten: $time2") // map and flatten: 7224
컬렉션 함수를 잘 사용하면 직접 구현하는 것보다 더 빠르고 간결하게 개발할 수 있다. 그러니 적극적으로 활용하자.