람다 함수형 프로그래밍은 컬렉션(Collection)을 다룰 때 상당히 편리하고 막강합니다.
대부분의 작업에 라이브러리 함수를 활용할 수 있고, 그로 인해 코드를 아주 간결하게 만들 수 있습니다.
그러므로 이번에는, 컬렉션을 다루는 코틀린 표준 라이브러리들을 살펴보도록 하겠습니다.
참고로, 코틀린에서 제공하는 컬렉션 API는 새롭게 추가된 것이 아닌, 기존의 java, C#, 그루비, 스칼라 등 람다를 지원하는 대부분의 언어에서 사용하는 것들과 동일합니다.
필수적인 함수 : filter, map
filter와 map은 컬렉션을 활용할 때 기반이 되는 함수로, 대부분의 컬렉션 연산을 이 두 함수를 통해 표현할 수 있습니다.
filter
filter 함수는 컬렉션을 iteration 하면서 주어진 람다에 각 원소를 넘겨서 람다가 true를 반환하는(조건에 맞는) 원소만 필터링하는 기능을 합니다.
filter의 결과는, 입력 컬렉션의 원소 중에서 주어진 조건문에 만족하는 원소만으로 이루어진 새로운 컬렉션 입니다.
/* 리스트에서 짝수만 뽑아내는 예제 */
>>> val list = listOf(1, 2, 3, 4)
>>> println(list.filter { it % 2 == 0 }) //짝수만 필터링
[2, 4]
그림으로 표현하자면 아래와 같습니다.
다른 예제로, 나이가 30살 이상인 사람만 추려내기 위해서 filter를 사용해보았습니다.
/* 나이가 30살 이상인 사람만 뽑아내는 예제 */
>>> val people = listOf(Person("안드로이드", 29), Person("코틀린", 30))
>>> println(people.filter { it.age >= 30 })
[Person(name=코틀린, age=30)]
이렇듯, filter 함수는 컬렉션에서 원치 않는 원소들을 제거해줄 수 있습니다.
하지만, filter는 원소를 변환할 수는 없습니다. 원소를 변환하기 위해서는 map 함수를 사용해야 합니다.
map
map 함수는 각 원소를 원하는 형태로 변환하는 기능을 하며, 변환한 결과를 모아서 새 컬렉션을 만듭니다.
결과는 원본 리스트와 원소 개수는 같지만, 각 원소는 주어진 람다(함수)에 따라 변환된 새로운 컬렉션입니다.
/* 각 원소의 제곱으로 모인 리스트를 만드는 map 예제 */
>>> val list = listOf(1, 2, 3, 4)
>>> println(list.map { it * it }) //제곱 만들기 (1x1, 2x2, 3x3, 4x4)
[1, 4, 9, 16]
예로, 사람 리스트가 아닌 이름 리스트를 출력하고 싶다면 map으로 사람 리스트를 이름 리스트로 변환하면 됩니다.
/* 사람 리스트 -> 이름 리스트 변환 예제 */
>>> val people = listOf(Person("안드로이드", 29), Person("코틀린", 30))
>>> println(people.map { it.name })
[안드로이드, 코틀린]
이 예제를 람다의 멤버 참조를 사용해서 더 멋지게 작성할 수도 있습니다.
/* 멤버 참조 문법 */
people.map(Person::name)
이제 우리는 이런 함수들을 연쇄시켜서, 원하는 결과를 쉽게 얻어낼 수 있습니다!
예를 들어, 30살 이상인 사람의 이름을 출력하려는 경우
>>> people.filter { it.age >= 30 }.map(Person::name)
[코틀린]
이와 같이, 컬렉션 함수를 연쇄시켜 아주 쉽게 원하는 결과를 얻어내었습니다!
그렇다면, 이제 리스트에서 가장 나이 많은 사람의 이름을 알고 싶다고 가정하겠습니다.
음~먼저 사람 리스트의 나이 최댓값을 구하고, 그 최댓값과 나이가 같은 사람을 반환하면 되겠죠?
람다를 사용하면 이런 코드를 쉽게 작성할 수 있습니다.
/* filter '내부'에서 maxBy 이용 [비효율적 코드] */
people.filter { it.age == people.maxBy(Person::age)!!.age }
/* filter '외부'에서 maxBy 이용 [효율적 코드] */
val maxAge = people.maxBy(Person::age)!!.age
people.filter { it.age == maxAge }
첫 번째 코드는 filter 안에서 최댓값 구하는 작업을 계속 반복하기 때문에, 만약 100명의 리스트가 있다면 filter를 돌면서 100번 최댓값 연산을 수행하기 때문에 상당히 비효율적입니다.
그에 비해, 두 번째 코드는 최댓값을 한 번만 계산하게 만든 코드이기 때문에 훨씬 더 개선되고 효율적인 코드라고 할 수 있습니다.
따라서, 무작정 코드량을 줄이려고 사용하기보다는, 불필요한 반복이 없는지 등... 내부적인 동작에 대해서 고려한 후에 코드를 작성하시는 게 더 좋은 코드가 될 수 있을 것 같습니다. ^^
filter와 map 함수를 Map(Key, Value) 에도 적용할 수 있습니다.
Map(Key, Value)의 경우 키와 값을 처리하는 함수가 따로 존재합니다.
- Key 추출 : filterKeys, mapKeys
- Value 추출 : filterValues, mapValues
>>> val numbers = mapOf(0 to "zero", 1 to "one")
>>> println(numbers.mapValues { it.value.toUpperCase() }) //값을 대문자로 변환
{0=ZERO, 1=ONE}
all, any, count, find : 컬렉션 조건 함수
컬렉션에 대해 자주 수행하는 조건 함수들에 대해서 살펴보도록 하겠습니다.
api | description | return type |
all | 컬렉션의 모든 원소가 조건을 만족하는지 판단 | Boolean |
any | 컬렉션의 원소 중에, 조건을 만족하는 원소가 하나라도 있는지 판단 | Boolean |
count | 조건을 만족하는 원소의 개수를 반환 | Int |
find | 조건을 만족하는 첫번째 원소를 반환 | <T> |
이런 함수들의 동작을 살펴보기 위해, 예제로 나이가 30살 미만인지 판단하는 코드를 구현해보겠습니다.
all, any
val under30 = { p:Person -> p.age < 30 }
//모든 원소가 만족하는지 판단하려면 all 함수를 사용합니다.
>>> val people = listOf(Person("안드로이드", 25), Person("코틀린", 33))
>>> println(people.all(under30))
false
//하나라도 만족하는 원소가 있는지 판단하려면 any 함수를 사용합니다.
>>> println(people.any(under30))
true
팁으로, !all과 !any를 사용할 순 있지만,
다만 ( !all = any ), ( !any = all ) 와 같은 경우에는 앞에 ! 연산자를 눈치 채지 못하는 경우가 있기 때문에 가독성을 높이기 위해 가급적이면 any와 all 앞에 ! 를 붙이지 않는 편이 낫습니다!
count
조건을 만족하는 원소의 개수를 구할 때는 count를 사용합니다.
val under30 = { p:Person -> p.age < 30 }
>>> val people = listOf(Person("안드로이드", 25), Person("코틀린", 33))
>>> println(people.count(under30))
1
함수를 효과적으로 사용하기 : count와 size
count 함수가 있다는 사실을 잊어버리고, 컬렉션을 필터링한 결과의 크기를 size로 가져오는 경우가 종종 있습니다.
>>> println(people.filter(under30).size)
1
하지만, 이렇게 처리하면 조건을 만족하는 모든 원소가 들어가는 중간 컬렉션이 생겨버리게 됩니다.
반면에, count는 조건을 만족하는 원소의 개수만을 추적하지 원소를 따로 저장하지는 않기 때문에,
따라서 count가 훨씬 더 효율적이라고 할 수 있습니다.
find
find는 조건을 만족하는 첫 번째 원소를 반환하고, 만족하는 원소가 없다면 null을 반환합니다.
val under30 = { p:Person -> p.age < 30 }
>>> val people = listOf(Person("안드로이드", 25), Person("코틀린", 33))
>>> println(people.find(under30))
Person(name=안드로이드, age=25)
find는 조건에 만족하는 첫번째 원소를 반환해주기 때문에 firstOrNull과 기능이 동일합니다.
그렇기 때문에, 조건을 만족하는 원소가 없으면 null이 나온다는 사실을 좀 더 명확하게 표현하고 싶다면 firstOrNull을 사용해도 무방합니다. ^^
groupBy : 리스트를 여러 그룹으로 이루어진 맵으로 변경
컬렉션의 모든 원소를 어떤 특성에 따라 여러 그룹으로 나누고 싶은 경우가 있습니다.
이런 분류 특성을 파라미터로 전달하면 컬렉션을 자동으로 구분해주는 함수가 있으면 참~ 편리할 것입니다.
groupBy 함수가 그런 역할을 합니다.
예를 들어, 사람을 나이에 따라 분류해보겠습니다.
/* groupBy 예제 */
>>> val people = listOf(
Person("안드로이드", 25),
Person("코틀린", 30),
Person("자바", 30))
>>> println(people.groupBy { it.age })
{ 25=[Person(name=안드로이드, age=25)],
30=[Person(name=코틀린, age=30), Person(name=자바, age=30)] }
이 연산의 결과는 아래와 같은 맵(Map)입니다.
- Key : 원소를 구분하는 특성 (예제에서는 age)
- Value : Key 값에 따른 각 그룹 리스트 (예제에서는 Person 객체 리스트)
그림으로 표현하면 아래와 같습니다.
눈치채셨을 수도 있겠지만, 각 그룹은 리스트입니다.
따라서, 위의 groupBy 결과 타입은 Map<Int, List<Person>>이 됩니다.
flatMap과 flatten : 중첩된 컬렉션 안의 원소 처리
flatMap은 주어진 람다를 컬렉션의 모든 객체에 적용하고, 적용한 결과로 얻어지는 여러 리스트를 한 리스트로 flat 하게 만드는 함수입니다.
즉, map을 처리하고 난 다음의 결과가 list인 경우, 이 list의 원소를 다시 펼쳐서 하나의 list로 만듭니다.
말이 조금 어렵고 정신없을 수 있지만, "리스트의 리스트를 처리할 때 쓰는 함수" 로 기억해주시면 쉬울 것 같습니다!
예제를 하나 살펴보도록 하겠습니다.
>>> val strings = listOf("abc", "def")
>>> println(strings.flatMap { it.toList() })
위 코드는 다음과 같은 두 단계를 수행합니다.
- it.toList()를 이용하여 해당 원소로 이루어진 리스트가 만들어진다. => list("abc"), list("def")
- flatMap이 list의 원소를 flat 하게 모든 원소로 이루어진 단일 리스트를 반환한다. => list("a", "b", "c", "d", "e", "f")
그리하여 출력의 결과는 다음과 같습니다.
[a, b, c, d, e, f]
리스트 원소를 사용해서 특별히 변환해야 할 내용이 없다면, 단순히 리스트의 리스트를 평평하게 펼치기만 하는 flatten 함수를 사용할 수 있습니다.
마무리
지금까지 코틀린 표준 라이브러리가 제공하는 몇 가지 컬렉션 연산 함수를 살펴보았습니다.
물론 이 외에도 더 많은 함수가 있기 때문에, 대부분의 경우 원하는 함수를 잘 찾으셔서 활용하시면 직접 코드로 로직을 구현하는 것보다 더 빠르고 쉽게 문제를 해결할 수 있을 것 같습니다. ^^
'코틀린' 카테고리의 다른 글
코틀린(Kotlin) - 함수형 인터페이스 활용 (0) | 2021.02.24 |
---|---|
코틀린(Kotlin) - 지연 계산(lazy) 컬렉션 연산 : Sequence (0) | 2020.04.24 |
코틀린(Kotlin) - 초간단 파일 저장 방법! (1) | 2020.03.25 |
코틀린(Kotlin) - 람다 식과 멤버 참조 (0) | 2020.03.17 |
코틀린(Kotlin) - object 키워드 : 싱글톤, static 멤버, 객체 식 선언 (1) | 2020.03.05 |