본문 바로가기

Tech

[Kotlin] 코틀린 Sequence 생성 방법, Iterable과 차이점

코틀린 표준 라이브러리에는 Sequence(시퀀스)라는 타입이 있다. 시퀀스는 컬렉션과 다르게 요소를 포함하고 있지 않고 반복하는 동안 요소를 생성한다. 그래서 자바의 Iterable과 같은 역할을 한다고 볼 수 있지만 멀티 스텝 처리를 하는 경우에는 Iterable과 다른 방식으로 동작한다.

 

Iterable이 멀티 스텝을 처리하는 경우는 각 단계(Intermediate collection - 중간 결과 컬렉션)을 바로 계산(eagerly)한다. 반면에 시퀀스는 가능하면 계산을 나중으로 미루기(lazily) 때문에 그 결과값을 호출할 때 전체 프로세스 연산을 시작한다. 시퀀스 연산의 결과가 다시 시퀀스인 경우는 계산이 지연되고, Intermediate(중간 결과)라고 부른다. 결과가 시퀀스가 아닌 경우는 terminal(최종 결과)라고 한다. 시퀀스의 요소는 terminal operation(최종 연산)를 호출하는 경우에 얻을 수 있고 최종 연산의 예로는 toList(), sum() 등이 있다. 그리고 연산 실행 순서에서도 차이가 난다. Sequence는 각 요소에 대해서 전체 프로세스를 실행하고, Iterable은 각 프로세스를 전체 컬렉션에 대해 실행하고 그 다음 단계를 차례로 수행한다.

 

중간 결과 컬렉션을 생성하지 않음으로써 컬렉션 처리의 성능을 향상할 수 있지만, 지연 연산에 오버헤드가 따르기 때문에 컬렉션 크기가 작거나 간단한 연산에 대해서는 큰 효과를 보기 어려울 수 있다. 그래서 SequenceIterable중 무엇이 내 상황에서 더 좋을지 고려해봐야 한다.

Sequence 생성 방법

요소들에서 생성하는 방법

리스트를 생성하는 것과 동일하게 요소들을 sequenceOf()의 인자로 넣어주면 생성할 수 있다.

val korSequence = sequenceOf("가", "나", "다", "라")

리스트에서 생성하는 방법

이미 Iterable(List나 Set 등)이 있다면 asSequence()를 호출해서 생성할 수 있다.

val kors = listOf("가", "나", "다", "라")
val korSequence = kors.asSequence()

함수(람다)를 통해 생성하는 방법

요소를 생성하는 함수(람다)를 이용해서 시퀀스를 생성하는 방법도 있다. 이 때는 generateSequence()함수의 인자로 초기값, 생성함수를 넣어준다. 시퀀스는 생성 함수가 null을 리턴하는 경우 요소 생성이 멈춘다. 그래서 아래 예제처럼 무한으로 요소가 생성되는 시퀀스를 만드는 것도 가능하다.

val oddNumbers = generateSequence(1) { it + 2 } // 1은 초기값, it은 이전 요소를 나타냄
println(oddNumbers.take(5).toList()) // [1, 3, 5, 7, 9]
// println(oddNumbers.count()) // 무한 시퀀스라서 에러 발생

청크에서 생성하는 방법

마지막으로 yield()yieldAll() 함수 호출을 포함하는 람다식을 인자로 받아서 요소를 하나씩 만들거나 또는 임의의 개수만큼 만드는 sequence() 함수를 사용하는 방법이 있다. 이 함수는 시퀀스 소비자가 다음 요소를 호출할 때까지 sequence()함수의 호출을 미룬다. yield()는 인자로 요소 하나를 받고, yieldAll()Iterable이나 Sequence를 인자로 받을 수 있다. yieldAll()의 인자가 Sequence인 경우 그 뒤의 값들은 호출이 안될 것이기 때문에 람다식 가장 마지막에 와야 한다.

val oddNumbers = sequence {
    yield(1)
    yieldAll(listOf(3, 5))
    yieldAll(generateSequence(7) { it + 2 })
}
println(oddNumbers.take(5).toList())

Sequence와 Iterable 예제

단어 리스트가 주어졌을 때 단어가 3자 초과인 것들만 필터링하고 첫 네개 요소들의 글자수만 프린트 하는 예제로 동작 차이를 살펴 본다.

Iterable

예제 코드를 실행해보면 filter(), map가 코드상 순서와 동일하게 호출됨(eagerly)을 확인할 수 있다.

val words = "The quick brown fox jumps over the lazy dog".split(" ")
val lengthsList = words.filter { println("filter: $it"); it.length > 3 }
    .map { println("length: ${it.length}"); it.length }
    .take(4)

println("Lengths of first 4 words longer than 3 chars:")
println(lengthsList)

Sequence

시퀀스 코드의 결과는 최종 연산(toList())가 호출되기 전까지 실행이 지연되기 때문에 "Lengths of.."라는 문장이 호출되고 난 뒤에 filter()map() 연산 결과가 출력된다. 시퀀스는 필터링 후 맵 연산을 수행하고 시퀀스 결과 개수가 4개가 됐을 때 처리가 중단된다.

val words = "The quick brown fox jumps over the lazy dog".split(" ")
//convert the List to a Sequence
val wordsSequence = words.asSequence()

val lengthsSequence = wordsSequence.filter { println("filter: $it"); it.length > 3 }
    .map { println("length: ${it.length}"); it.length }
    .take(4)

println("Lengths of first 4 words longer than 3 chars")
// terminal operation: obtaining the result as a List
println(lengthsSequence.toList())

처리 순서를 도식화하면 다음과 같다. 같은 결과에 대해서 리스트는 총 23번 스텝을 동작하고 시퀀스는 총 18번 스텝을 동작한다.

좌: 리스트 처리 동작 순서, 우: 시퀀스 처리 동작 순서

 

참조

https://kotlinlang.org/docs/sequences.html