- 데이터를 어떤 조건 함수에 따라 걸러내거나, 데이터를 그룹화하여 나누거나, 컬렉션 아이템을 다른 것으로 변환하는 등의 일반적인 컬렉션 접근 패턴을 표준 라이브러리 함수와 람다를 조합해 표현할 수 있다.
- 함수형 스타일로 컬레션 다루기와 컬렉션 연산을 지연시켜 수행하기 위한 시퀀스를 알아보자
- 코틀린에서 컬렉션 연산을 즉시 실행하는 방법과 지연 실행하는 방법을 비교
6.1 컬렉션에 대한 함수형 API
6.1.1 원소 제거와 변환 : filter(추출)와 map(변환)
data class Person(val name:STring, val age:Int)
- filter함수는 컬렉션을 순회하면서 주어진 람다가 true를 반환하는 원소들만 모은다
fun main(){
val people = listOf(Person("Alice", 29), Person("Bob", 31))
println(people.filter{ it.age >= 30 }) // 출력 컬렉션의 원소들은 여전히 Person 객체
// [Person(name=Bob, age=31)]
}
- filter 함수는 주어진 술어와 일치하는 원소들로 이루어진 새 컬렉션을 만들 수 있지만 그 과정에서 원소를 변환하지는 않는다.
- map은 입력 컬렉션의 원소를 변환할 수 있게 해준다.
- map은 주어진 함수를 컬렉션의 각 원소에 적용하고 그 결과값들을 새 컬렉션에 모아준다.
fun main(){
val people = listOf(Person("Alice", 29), Person("Bob", 31))
println(people.map{ it.name })
//[Alice, Bob]
}
people.map(Person::name)
println(people.filter{ it.age > 30 }.map(Person::name))
people.filter{
val oldestPerson = people.maxByOrNull(Person::age)
it.age == oldestPerson?.age
}
- 꼭 필요하지 않은 계산의 반복을 일으키지 않도록 한다.
val maxAge = people.maxByOrNull(Person::age)?.age
people.filter{ it.age == maxAge }
- 추출하거나 변환하는 연산이 원소의 값 뿐 아니라 인덱스에 따라서도 달라진다면 filterIndexed와 mapIndexed를 사용하면된다.
fun main() {
val numbers = listOf(1, 2, 3, 4, 5, 6, 7)
val filtered = numbers.filterIndexed { index, element ->
index % 2 == 0 && element > 3
}
println(filtered)
//[5, 7]
val mapped = numbers.mapIndexed { index, element ->
index + element
}
println(mapped)
//[1,3,5,7,9,11,13]
}
- 맵 자료구조에도 추출 함수와 변환 함수를 적용할 수 있다.
fun main() {
val numbers = mapOf(0 to "zero", 1 to "one")
println(numbers.mapValues{ it.value.uppercase() })
}
- filterValues는 값을 추출, mapValues는 값을 변환
- filterKeys는 키를 추출, mapKeys는 키를 변환
6.1.2 컬렉션 값 누적: reduce와 fold
- reduce와 fold 함수는 컬렉션의 정보를 종합하는 데 사용한다.
- 원소로 이뤄진 컬렉션을 받아서 한 값을 반환하고 이 값은 누적기를 통해 점진적으로 만들어진다.
- reduce를 사용하면 컬렉션의 첫 번째 값을 누적기에 넣고 람다가 호출되면서 누적 값과 2번째 컬렉션의 두 번째 원소가 인자로 전달된다.
fun main() {
val list = listOf(1,2,3,4)
println(list.reduce{
acc, element -> acc + element
})
// 10
println(list.reduce{
acc, element -> acc * element
})
// 24
- fold 함수는개념적으로 reduce와 비슷하지만 reduce와 달리 임의의 값을 누적 시작값으로 할 수 있다.
fun main() {
val people = listOf(Person("Alex", 29), Person("Natalia", 28))
val folded = people.fold("") {
acc, person -> person.name
}
println(folded)
// AlexNatalia
}
- reduce나 fold에서 중간 단계의 모든 누적 값을 뽑아내고 싶다면 runningReduce와 runningFold를 사용하면 된다.
- reduce와 fold는 결과값 하나만 반환하지만 runningReduce와 runningFold는 중간 단계의 누적값들로 이루어진 리스트를 반환한다.
fun main() {
val list =listOf(1, 2, 3, 4)
val summed = list.runningReduce{
acc, element -> acc + element
}
println(summed)
// [1, 3, 6, 10]
val multiplied = list.runningReduce{
acc, element -> acc * element
}
println(multiplied)
// [1, 2, 6, 24]
val people = listOf(Person("Alex", 29), Person("Natalia", 28))
println(people.runningFold(""){
acc, person -> acc + person.name
})
//[, Alex, AlexNatalia]
}
6.1.3 컬렉션에 술어 적용: all, any, none, count, find
- 컬렉션의 모든 원소가 어떤 조건을 만족하는지 판단하는 연산: all, any, none
- count 함수는 조건을 만족하는 원소의 개수를 반환
- find 함수는 조건을 만족하는 첫 번째 원소를 반환
val canBeInClub = { p:Person -> p.age <= 27 }
fun main() {
val people = listOf(Person("Alice", 27), Person("Bob", 31))
println(people.all(canBeInClub))
// false
println(people.any(canBeInClub))
// true
println(people.none(canBeInClub))
// false
}
fun main() {
println(emptyList<Int>().any{ it > 42 })
// false
println(emptyList<Int>().none{ it > 42 })
// true
println(emptyList<Int>().all{ it > 42 })
// true
}
- 술어를 만족하는 원소의 개수를 알고 싶다면 count를 사용
fun main() {
val people = listOf(Person("Alice", 27), Person("Bob", 31))
println(people.count(canBeInClub))
// 1
}
- count 함수를 사용하지 않고 filter 함수의 결과에서 size 필드값을 가져오는 경우가 있는데 이렇게 처리하면 조건을 만족하는 모든 원소가 들어가는 중간 컬렉션이 생기기 때문에 비효율적이다.
- 술어를 만족하는 원소를 하나 찾고 싶으면 find 함수를 사용한다.
- 조건을 만족하는 첫 번째 원소를 반환하며, 조건을 만족하는 원소가 전혀 없는 경우 null을 반환하다.
- find는 firstOrNull과 같다.
fun main() {
val people = listOf(Person("Alice", 27), Person("Bob", 31))
println(people.find(canBeInClub))
// Person(name=Alice, age=27)
}
6.1.4 리스트를 분할해 리스트의 쌍으로 만들기: partition
val people = listOf(
Person("Alice", 26),
Person("Bob", 29)
Person("Carol", 31)
)
val (comeIn, stayOut) = people.partition(canBeInClub27)
println(comeIn) // [Person(name=Alice, age=26)]
println(stayOut) // [Person(name=Bobm age=29), Person(name=Carol, age=31)]
6.1.5 리스트를 여러 그룹으로 이뤄진 맵으로 바꾸기: groupBy
val people = listOf(
Person("Alice", 31),
Person("Bob", 29)
Person("Carol", 31)
)
println(people.groupBy{ it.age })
// {29=[Person(name=Bob, age=29)],
31=[Person(name=Alice, age=31), Person(name=Carol, age=31)]}
- groupBy 메서드의 결과 타입은 Map< K, List< V >>
- groupBy 메서드의 결과를 mapKeys나 mapValues 등을 사용해 변경할 수 있다.
6.1.6 컬렉션을 맵으로 변환: associate, associateWith, associateBy
- 원소를 그룹화하지 않으면서 컬렉셕으로부터 새로운 맵을 만들어내고 싶다면 associate 함수를 사용
- associate 함수에게 입력 컬렉션의 원소로부터 키/값 쌍을 만들어내는 람다를 제공해야 한다.
fun main() {
val people = listOf(Person("Joe", 22), Person("Mary", 31))
val nameToAge = people.associate{ it.name to it.age}
println(nameToAge)
// {Joe=22, Mary=31}
println(nameToAge["Joe"])
// 22
}
- associateWith 함수는 컬렉션의 원래 원소를 키로 사용하고 제공되는 람다가 그 원소에 대응하는 값을 만든다
- associateBy 함수는 컬렉션의 원래 원소를 맵의 값으로 하고, 제공되는 람다가 만들어내는 값을 맵의 키로 사용한다.
- 맵에서 키는 유일해야 하는데 associate, associateWith, associateBy 함수도 예외가 아니다.
- 변환 함수가 키가 같은 값을 여러 번 추가하게 되면 마지막 결과가 그 이전에 들어간 결과를 덮어쓴다.
fun main() {
val people = listOf(
Person("Joe", 22),
Person("Mary", 31),
Person("Jamie", 22)
)
val personToAge = people.associateWith{ it.age }
println(personToAge)
// {Person(name=Joe, age=22)=22, Person(name=Mary, age=31)=31,
// Person(name=Jamie, age=22)=22}
val ageToPerson = people.associateBy{ it.age }
println(ageToPerson)
// {22=Person(name=Jamie, age=22), 31=Person(name=Mary, age=31)}
}
6.1.7 가변 컬렉션의 원소 변경: replaceAll, fill
- 일반적으로 함수형 프로그래밍 스타일은 불변 컬렉션을 사용하라고 권장하지만 가변 컬렉션으로 작업하면 더 편리한 경우가 있다.
- 이런 경우에 대비해 코틀린 표준 라이브러리는 컬렉션 내용을 변경하는 데 도움이 되는 메서드를 제공한다.
- replaceAll 함수를 MutableList에 적용하면 지정한 람다로 얻은 결과로 컬렉션의 모든 원소를 변경할 수 있다.
- fill 함수를 사용하면, 가변 리스트의 모든 원소를 똑같은 값으로 바꿀 수 있다.
fun main() {
val names = mutableListOf("Martin", "Samuel")
println(names)
// [Martin, Samuel]
names.replaceAll{ it.uppercase() }
println(names)
// [MARTIN, SAMUEL]
names.fill("(redacted)")
println(names)
// [(redacted), (redacted)]
}
6.1.8 컬렉션의 특별한 경우 처리: ifEmpty
- ifEmpty 함수를 이용하면 컬렉션에 아무 원소도 없을 때 기본값을 생성하는 람다를 제공할 수 있다.
fun main() {
val empty = emptyList<String>()
val full = listOf("apple", "orange", "banana")
println(empty.ifEmpty{ listOf("no", "values", "here") })
// [no, values, here]
println(full.ifEmpty{ listOf("no", "values", "here") })
// [apple, orange, banana]
}
6.1.9 컬렉션 나누기: chunked와 windowed
fun main() {
val temperatures = listOf(27.7, 29.8, 22.0, 35.5, 19.1)
println(temperatures.windowed(3))
// [[27.7, 29.8, 22.0], [29.8, 22.0, 35.5],[22.0, 35.5, 19.1]]
println(temperatures.windowed(3){ it.sum() / it.size})
// [26.5, 29.0999999999998, 25.53333333333]
}
fun main(){
val temperatures = listOf(27.7, 29.8, 22.0, 35.5, 19.1)
println(temperatures.chunked(2))
// [[27.7, 29.8], [22.0, 35.5], [19.1]]
println(temperatures.chunked(2){ it.sum() })
// [57.5, 57.5, 19.1]
}
6.1.10 컬렉션 합치기: zip
- zip 함수를 사용해 두 컬렉션에서 같은 인덱스에 있는 원소들의 쌍으로 이뤄진 리스트를 만들 수 있다.
- 이 함수에 람다를 전달하면 출력을 변환할 수 있다.
fun main() {
val names = listOf("Joe", "Mary", "Jamie")
val ages = listOf(22, 31, 44, 0)
println(names.zip(ages))
// [(Joe, 22), (Mary, 31), (Jamie, 44)]
println(names.zip(ages){ name, age -> Person(name, age) })
// [Person(name=Joe, age=22), Person(name=Mary, age=31),
// Person(name=Jamie, age=44) ]
}
- Pair 객체를 생성하는 to 함수와 마찬가지로 zip 함수도 중위 표기법으로 호출이 가능하다
- 하지만 중위 표기법으로 호출을 하는 경우엔 람다 전달이 불가하다.
println(names zip ages)
// [(Joe, 22), (Mary, 31), (Jamie, 44)]
val countries = ["DE", "NL", "US"]
println(names zip ages zip countries)
// [((Joe, 22), DE), ((Mary, 31), NL), ((Jamie, 44), US)]
6.1.11 내포된 컬렉션의 원소 처리: flatMap과 flatten
class Book(val title: String, val authors: List<String>)
val library = listOf(
Book("A", listOf("Kim", "Lee", "Park")),
Book("B", listOf("Goo", "Kim", "Choo")),
Book("C", listOf("Song"))
)
- map을 사용해 기존 리스트의 원소를 변환하여 새로운 리스트를 만들면 내포된 컬렉션, 즉 컬레션 안의 컬렉션이 만들어질 수 있다.
fun main {
val authors = library.map{ it.authors }
println(authors)
// [[Kim, Lee, Park], [Goo, Kim, Choo], [Song]]
}
- flatMap 함수를 사용하면 내포된 컬렉션을 평평하게 만들 수 있다.
- flatMap이 하는 일은 두 가지: 우선 컬렉션의 각 원소를 파라미터로 주어진 함수를 사용해 변환, 그 후 변환한 결과를 하나의 리스트로 합치기
fun main() {
val authors = library.flatMap{ it.authors }
println(authors)
// [Kim, Lee, Park, Goo, Kim, Choo, Song]
println(authors.toSet())
// [Kim, Lee, Park, Goo, Choo, Song]
}
- 변환할 것이 없고 단지 이미 내포성이 있는 컬렉션 안의 컬렉션을 평평한 컬렉션으로 만들어 주기 위해선 flatten 함수를 사용
listOfLists.flatten()
6.2 지연 계산 컬렉션 연산: 시퀀스
- 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다.
people.map(Person::name).filter{ it.startsWith("A") }
// 연쇄 호출이 리스트를 2개 만든다
// 한 리스트는 map의 결과를 담고 다른 하나는 filter의 결과를 담는다
- 원소의 개수가 많은 경우 매 단계마다 새로운 컬렉션을 만들어 기존 컬레션의 원소를 하나씩 새로운 컬렉션에 새로 담아주는 것은 비효율적이다.
- 이를 더 효율적인 방식으로 만들려면 각 연산이 컬렉션을 직접 사용하는 것이 아니라 시퀀스를 사용하게 만들어야 한다.
- 지연 계산: 중간 결과를 저장하는 컬렉션이 생성되지 않는다.
people
.asSequence()
.map(Person::name)
.filter{ it.startsWith("A") }
.toList()
/*
시퀀스는 중간 결과를 저장하는 컬렉션이 생기지 않기 때문에,
즉 시퀀스는 게으르게 계산(지연 계산)하기 때문에 원소가 많은 경우
성능이 눈에 띄게 좋아진다.
*/
- 코틀린 지연 계산 시퀀스는 Sequence 인터페이스에서 시작한다.
- Sequence 인터페이스는 단지 한 번에 하나씩 열거될 수 있는 원소의 시퀀스를 표현할 뿐
- Sequence 안에는 iterator라는 단 하나의 메서드가 들어있고 그 메서드를 통해 시퀀스에서 원소 값들을 얻어 낼 수 있다.
- 시퀀스의 원소는 필요할 때 게으르게 계산되기 때문에 중간 처리 결과를 저장할 컬렉션을 만들지 않고도 연산을 연쇄적으로 적용해서 연산을 효율적으로 수행할 수 있다.
- asSequence 확장 함수를 호출하면 어떤 컬렉션이든 시퀀스로 바꿀 수 있으며 시퀀스를 리스트로 만들 때는 toList를 사용한다.
- 시퀀스의 원소를 차례대로 이터레이션해야 한다면 시퀀스를 직접 써도 되지만 시퀀스의 원소를 인덱스를 사용해 접근하는 등 다른 API 메서드를 호출해야 한다면 시퀀스를 리스트로 변환해야 한다.
- 시퀀스에 대한 연산은 지연 계산되기 때문에 정말 계산을 실행하기 위해선 결과 시퀀스의 원소를 하나씩 이터레이션 하거나 결과 시퀀스를 리스트로 변환해야 한다.
6.2.1 시퀀스 연산 실행: 중간 연산과 최종 연산
- map, filter와 같은 중간 연산은 다른 시퀀스를 반환한다. 이 시퀀스는 최초 시퀀스의 원소를 변환하는 방법을 안다.
- reduce, toList와 같은 최종 연산은 결과를 반환한다.
- 결과는 최초 컬렉션에 대해 변환을 적용한 시퀀스에서 일련의 계산을 수행해 얻을 수 있는 컬렉션이나, 원소, 수, 또는 다른 객체다.
- 중간 연산은 항상 지연 계산된다.
- 다음과 같은 코드는 아무 내용도 출력되지 않고 시퀀스의 원소들이 출력되는 대신 Sequence 객체 자체에 대한 출력을 한다.
fun main() {
println(
listOf(1, 2, 3, 4)
.asSequence()
.map{
print("map($it) ")
it * it
}.filter{
print("filter($it)")
it % 2 == 0
}
) // kotllin.sequences.FilteringSequence@506e1b77
}
- 최종 연산을 호출하면 연기됐던 모든 계산이 수행
fun main() {
listOf(1, 2, 3, 4)
.asSequence()
.map{
print("map($it) ")
it * it
}.filter{
print("filter($it)")
it % 2 == 0
}.toList()
}
- 연산 수행 순서가 중요하다.
- 단순하게 생각하면 map 함수를 각 원소에 대해 먼저 수행해서 새 시퀀스를 얻고 그 시퀀스에 대해 다시 filter를 수행한다고 생각할 것
- 컬렉션에 대한 map과 filter는 그런 방식으로 작동하지만 시퀀스에 대한 map과 filter는 그렇지 않다.
- 시퀀스의 경우 모든 연산은 각 원소에 대해 순차적으로 적용된다.
- 첫 번째 원소가 처리된 후에 두 번째 원소가 처리되며, 이런 처리가 모든 원소에 대해 적용
- 이런 방식의 의미는 원소에 연산을 차례대로 적용하다가 결과가 얻어지면 그 이후의 원소에 대해서는 계산이 이뤄지지 않을 수도 있다는 것이다. => 효율적
fun main() {
println(
listOf(1, 2, 3, 4)
.asSequence()
.map { it * it }
.find { it > 3}
)
}
- 컬렉션에 대해 수행하는 연산의 순서도 성능에 영향을 미친다.
- 연산 결과는 같아도 수행해야 하는 변환의 전체 횟수는 다를 수 있다.
- map을 먼저 하면 모든 원소를 변환하지만 filter를 먼저 하면 부적절한 원소를 먼저 제외하여 변환 횟수가 줄어든다.
fun main(){
val people = listOf(
Person("Alice", 29),
Person("Bob", 31),
Person("Charles", 31),
Person("Dan", 21),
)
println(
people
.asSequence()
.map(Person::name)
.filter{ it.length < 4 }
.toList()
)
println(
people
.asSequence()
.filter{ it.length < 4 }
.map(Person::name)
.toList()
)
}
6.2.2 시퀀스 만들기
- generateSequence 함수를 사용하여 시퀀스를 만들 수도 있다.
자연수의 시퀀스를 생성하고 사용
fun main() {
// 시퀀스, 정확히 말해 무한 시퀀스 생성
val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo100 = naturalNumbers.takeWhile{ it <= 100 } // 중간 연산
println(numebersTo100.sum()) // 지연 계산 수행
}
- 시퀀스를 사용하는 일반적인 용례 중 하나는 객체의 조상들로 이뤄진 시퀀스를 만들어내는 것이다.
- 어떤 객체의 조상이 자신과 같은 타입이고 모든 조상의 시퀀스에서 어떤 특성을 알고 싶을 때가 있다.
- 예를 들면 파일 계층 구조
fun File.isInsideHiddenDirectory() =
generateSequence(this){ this.parentFile }.any{ it.isHidden }
fun main() {
val file = File("/Users/svtk/.HiddenDir/a.txt")
println(file.isInsideHiddenDirectory())
// true
}
- 이런 식으로 시퀀스를 사용하면 조건을 만족하는 디렉터리를 찾은 뒤에는 더 이상 상위 디렉터리를 뒤져보지 않는다.