2022. 10. 12. 23:32ㆍSpring/Kotlin
kotlin의 람다 사용 방식 중 Collection과의 조합과 사용을 Java 코드와 비교하며 학습하는 것이 해당 포스팅의 목표입니다.
안녕하세요. 이번 포스팅에서는 Kotlin의 람다의 사용방식 중 Collection과의 사용 방식을 학습합니다.
HOF, High Order Function
: 고차 함수. 함수형 프로그래밍에서는 람다나 다른 함수를 인자로 받거나 함수를 반환하는 함수
고차 함수는 기본 함수를 조합해서 새로운 연산을 정의하거나,
다른 고차 함수를 통해 조합 된 함수를 또 조합해서 더 복잡한 연산을 쉽게 정의할 수 있다는 장점이 있다.
고차 함수와 단순한 함수를 조합하는 방식을 컴비네이터 패턴(combinator pattern)이라 부르고,
고차함수로 구현한 컴비네이터 패턴을 컴비네이터(combinator) 라고 부른다.
Functional APIs for collections
컬렉션을 다룰 때 함수형 프로그래밍을 사용하면 굉장히 편리하다.
대부분의 로직에 라이브러리 함수를 활용하여 코드를 아주 간결하게 만들 수 있다.
filter()
: 주어진 조건에 따라 컬렉션을 필터링하여 필요없는 원소 제거
즉, 컬렉션을 이터레이션하면서 주어진 람다에 각 원소를 넘겨서 람다가 true를 반환하는 원소만 모은다.
예제로 짝수만 필터링하는 코드를 확인해보자.
val list = listOf (1, 2, 3, 4)
println(list.filter { it % 2 == 0 }) // [2, 4]
이 때, 람다로 입력한 참/거짓을 반환하는 조건 구문을 술어predicate라고 한다.
filter 함수는 주어진 술어를 만족하는 모든 원소를 선택한다.
val people = listOf(Person("Alice", 29), Person("Bob", 31))
println(people.filter { it.age > 30 }) // 30살 이상 filtering
// [Person(name=Bob, age=31)]
map()
: 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아서 새로운 컬렉션 생성
val list = listOf(1, 2, 3, 4)
println(list.map { it * it }) // [1, 4, 9, 16]
결과는 원본 리스트와 동일한 원소의 개수를 갖지만, 각 원소는 주어진 조건에 맞게 새롭게 변환된 컬렉션이다.
val people = listOf(Person("Alice", 29), Person("Bob", 31))
/* Person 객체의 이름만을 출력 */
people.map { it.name }
// or
people.map(Person::name)
// [Alice, Bob]
또 다른 예제로 30살 이상인 사람의 이름을 출력하면 아래와 같다.
people.filter { it.age > 30 }.map(Person::name) // [Bob]
위의 코드를 명확하게 작성하면 아래와 같다.
(people.filter({ it.age > 30 })).map(Person::name)
Applying a predicate
술어를 적용하여 컬렉션의 연산을 수행하는 연산을 자주 사용하곤 한다.
가령, 아래와 같은 조건을 필요로 하는 연산이 자주 있다.
- 모든 원소가 특정 조건을 만족하는지 : all()
- 어떤 조건을 만족하는 원소가 있는지 : any()
- 조건을 만족하는 원소의 개수를 반환: count()
- 조건을 만족하는 첫 번째 원소를 반환 : find()
하나씩 알아보자.
all()
이런 함수를 살펴보기 위해 어떤 사람의 나이가 27살 이하인지 판단하는 술어 함수 canBeInClub27
를 만들자.
val canBeInClub27 = { p: Person -> p.age <= 27 }
모든 원소가 이 술어를 만족하는지 궁금하다면 all 함수를 쓴다.
val people = listOf(Person("Alice", 27), Person("Bob", 31))
println(people.all(canBeInClub27)) // false
any()
술어를 만족하는 원소가 하나라도 있는지 궁금하면 any를 쓴다.
println(people.any(canBeInClub27)) // true
어떤 조건에 대해 !all
을 수행한 결과와 그 조건의 부정에 대해 any를 수행한 결과는 같다.
드 모르간의 법칙De Morgan’s Theorem
또 어떤 조건에 대해 !any
를 수행한 결과와 그 조건의 부정에 대해 all
을 수행한 결과도 같다.
가독성을 높이려면 any와 all 앞에 !
를 붙이지 않는 편이 낫다.
풀어보면 "모든 원소가 조건에 해당하지 않음 = 조건에 해당하지 않는 것이 단 하나도 없음"이기 때문이다.
val list = listOf (1, 2, 3)
println(!list.all { it == 3 }) //`!`를 눈치 채지 못하는 경우가 자주 있다. 따라서 이런 식보다는 any를 사용하는 식이 더 낫다.
// true
println(list.any { it != 3 }) // any를 사용하려면 술어를 부정해야 한다.
// true
모든 원소가 3이 아님 == 3인 원소가 단 하나도 없음
count()
술어를 만족하는 원소의 개수를 구하려면 count를 사용할 수 있다.
val people = listOf (Person ("Alice", 27), Person ("Bob", 31))
println(people.count(canBeInClub27)) // 1
⚠️ count vs size
count가 있다는 사실을 잊어버리고, 컬렉션을 필터링한 결과의 크기를 가져오는 경우가 있다.
println(people.filter(canBeInClub27).size) // 1
하지만 이렇게 처리하면 조건을 만족하는 모든 원소가 들어가는 중간 컬렉션이 생긴다.
반면 count는 조건을 만족하는 원소의 개수만을 추적하지 조건을 만족하는 원소를 따로 저장하지 않는다.
따라서 count가 훨씬 더 효율적이다.
find()
술어(조건절)를 만족하는 원소를 하나 찾고 싶으면 find 함수를 사용한다.
val people = listOf (Person ("Alice", 27), Person ("Bob", 31))
println(people.find(canBeInClub27)) // Person(name="Alice", age=27)
이 식은 조건을 만족하는 원소가 하나라도 있는 경우 가장 먼저 조건을 만족한다고 확인 된 원소를 반환하며,
만족하는 원소가 전혀 없는 경우 null을 반환한다.
find는 firstOrNull
과 같다.
조건을 만족하는 원소가 없으면 null이 나온다는 사실을 더 명확히 하고 싶다면 firstOrNull
을 쓸 수 있다.
groupBy()
: 리스트를 여러 그룹으로 이뤄진 맵으로 변경
컬렉션의 모든 원소를 어떤 특성에 따라 여러 그룹으로 나누고 싶다고 하자.
예를 들어 사람을 나이에 따라 분류해보자.
특성을 파라미터로 전달하면 컬렉션을 자동으로 구분 해주는 함수가 있으면 편리할 것이다.
groupBy 함수가 그런 역할을 한다.
val people = listOf (Person("Alice", 31), Person("Bob", 29), Person("Carol", 31))
println(people.groupBy { it.age })
이 연산의 결과는 컬렉션의 원소를 구분하는 특성(it.age)이 키key이고,
키 값에 따른 각 그룹(이 예제에서는 Person 객체의 모임)이 값value인 맵이다.
출력은 다음과 같다.
{29=[Person(name=Bob, age=29)],
31=[Person(name=Alice, age=31), Person(name=Carol, age=31)]}
각 그룹은 리스트다.
따라서 groupBy의 결과 타입은 Map<Int, List<Person>>
이다.
필요하면 이 맵을 mapKeys나 mapValues 등을 사용해 변경할 수 있다.
다른 예로 멤버 참조를 활용해 문자열을 첫 글자에 따라 분류하는 코드를 보자.
val list = listOf ("a", "ab", "b")
println(list.groupBy(String::first)) // {a=[a, ab], b=[b]}
first는 String의 멤버가 아니라 확장 함수지만 여전히 멤버 참조를 사용해 first에 접근할 수 있다.
Nested Collection
: 중첩된 컬렉션 안의 원소 처리
리스트의 리스트가 있을 때, 모든 원소를 연산하고 싶다면 flatMap과 flatten을 사용할 수 있다.
.flatMap()
: 인자로 받은 람다 함수를 컬렉션의 모든 원소에 적용mapping하고, 람다를 통해 얻은 여러 결과 리스트를 한 리스트로 모음
책에 대한 정보를 저장하는 도서관이 있다고 가정하자.
class Book(val title: String, val authors: List<String>)
책마다 저자가 한 명 또는 여러 명 있을 수 있기 때문에, 도서관 내 모든 책의 저자를 모은 집합을 다음과 같이 가져올 수 있다.
books.flatMap { it .authors }.toSet() // books 컬렉션에 있는 책을 쓴 모든 저자의 집합
val strings = listOf("abc", "def")
println(strings.flatMap { it.toList() }) // [a, b, c, d, e, f]
toList
함수를 문자열에 적용하면 그 문자열에 속한 모든 문자로 이뤄진 리스트가 만들어진다.
map과 toList를 함께 사용하면 문자로 이뤄진 리스트로 이뤄진 리스트가 생긴다.
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]
book.authors 프로퍼티는 작가를 모아둔 컬렉션이다.
flatMap 함수는 모든 책의 작가를 평평한(문자열만으로 이뤄진) 리스트 하나로 모은 다.
toSet은 flatmap의 결과 리스트에서 중복을 없애고 집합으로 만들기 때문에, 최종 출력에서는 Terry Pratchett를 한 번만 볼 수 있다.
.flatten()
: 2차 이상의 리스트를 하나의 리스트로 모음
리스트의 리스트가 있는데 모든 중첩된 리스트의 원소를 한 리스트로 모아야 한다면 flatMap을 떠올릴 수 있을 것이다.
하지만 특별히 변환해야 할 내용이 없다면 리스트의 리스트를 평평하게 펼치기만하면 된다.
그런경우 listOfLists.flatten()
처럼 flatten 함수를 사용할 수 있다.
val deepArray = arrayOf(
arrayOf(1),
arrayOf(2, 3),
arrayOf(4, 5, 6)
)
println(deepArray.flatten()) // [1, 2, 3, 4, 5, 6]
다음 포스팅은 Kotlin에서 stream을 사용한 성능 개선을 고려할 수 있는 내용을 가져오겠습니다.
오타나 잘못된 내용을 댓글로 남겨주세요!
감사합니다 ☺️
'Spring > Kotlin' 카테고리의 다른 글
Kotlin, 어렵지 않게 사용하기 (0) | 2022.10.19 |
---|---|
Kotlin, 어렵지 않게 사용하기 (8) - lambda 3 (1) | 2022.10.18 |
Kotlin, 어렵지 않게 사용하기 (6) - lambda 1 (0) | 2022.09.28 |
Kotlin, 어렵지 않게 사용하기 (5) - copy, by, companion (0) | 2022.09.27 |
Kotlin, 어렵지 않게 사용하기 (4) - Object 2 (0) | 2022.09.22 |
Backend Software Engineer
𝐒𝐮𝐧 · 𝙂𝙮𝙚𝙤𝙣𝙜𝙨𝙪𝙣 𝙋𝙖𝙧𝙠