devseop08 님의 블로그

[API] 2. 컬렉션과 시퀀스 본문

Language/Kotlin

[API] 2. 컬렉션과 시퀀스

devseop08 2025. 7. 7. 00:48
  • 데이터를 어떤 조건 함수에 따라 걸러내거나, 데이터를 그룹화하여 나누거나, 컬렉션 아이템을 다른 것으로 변환하는 등의 일반적인 컬렉션 접근 패턴을 표준 라이브러리 함수와 람다를 조합해 표현할 수 있다.
  • 함수형 스타일로 컬레션 다루기와 컬렉션 연산을 지연시켜 수행하기 위한 시퀀스를 알아보자
  • 코틀린에서 컬렉션 연산을 즉시 실행하는 방법과 지연 실행하는 방법을 비교

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)
  • filter와 map을 연결할 수 있다.
println(people.filter{ it.age > 30 }.map(Person::name))
  • maxByOrNull
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
}
  • 이런 식으로 시퀀스를 사용하면 조건을 만족하는 디렉터리를 찾은 뒤에는 더 이상 상위 디렉터리를 뒤져보지 않는다.

'Language > Kotlin' 카테고리의 다른 글

[OOP] 4. 연산자 오버로딩  (1) 2025.07.14
[API] 3. 고차 함수  (2) 2025.07.14
[OOP] 3. 기본 타입, 컬렉션, 배열  (1) 2025.06.09
[API] 1. 람다를 사용한 프로그래밍  (3) 2025.06.04
[OOP] 2. 널이 될 수 있는 값  (0) 2025.06.04