코틀린 입문 스터디 (15) Sequences

mook2_y2
14 min readMar 10, 2019

--

스터디파이 코틀린 입문 스터디 (https://studypie.co/ko/course/kotlin_beginner) 관련 자료입니다.

코틀린 입문반은 Kotlin을 직접 개발한 개발자가 진행하는 Coursera 강좌인 “Kotlin for Java Developers” (https://www.coursera.org/learn/kotlin-for-java-developers) 를 기반으로 진행되며 아래는 본 강좌 요약 및 관련 추가 자료 정리입니다.

목차

(1) Introduction

(2) From Java to Kotlin

(3) Basics

(4) Control Structures

(5) Extensions

(6) 실습 : Mastermind game

(7) Nullability

(8) Functional Programming

(9) 실습 : Mastermind in a functional style, Nice String, Taxi Park

(10) Properties

(11) Object-oriented Programming

(12) Conventions

(13) 실습 : Rationals, Board

(14) Inline functions

(15) Sequences

(16) Lambda with Receiver

(17) Types

(18) 실습 : Game 2048 & Game of Fifteen

1. Collections vs Sequences

  • Collections에 대해 정의된 lambda를 인자로 받는 확장함수들 (ex: filter(), map(), find(), groupBy() )은 앞서 배운 것처럼 inline 함수로 정의되어 익명 클래스 객체 생성 측면에 대해서는 퍼포먼스 오버헤드 걱정없이 자유롭게 사용할 수 있습니다.
  • 하지만, Collections 확장함수의 경우 퍼포먼스 측면에서 한가지 문제가 있는데 각 확장함수를 호출할 때마다 새로운 Collection이 생성되어 반환된다는 점입니다.
val list = listOf(1, 2, -3)   // [1, 2, -3] 생성
val maxOddSquare = list
.map { it * it } // [1, 4, 9] 생성
.filter { it % 2 == 1 } // [1, 9] 생성
.max()
  • 위 코드의 경우 맨 처음 val list 를 초기화할 때, map() 연산이 수행될 때, filter() 연산이 수행될 때 총 3개의 intermediate collection이 생성됩니다. 원하는 최종 결과물을 위해 불필요한 중간 결과물이 생성된다는 비효율성이 존재합니다. 1개의 연산만을 진행할 경우 바로 최종 결과물에 도달하므로 문제가 없지만 chain calls 연산을 진행하는 경우 유의미한 퍼포먼스 오버헤드가 생길 수 있습니다.
  • 한편 Collection 을 다룰 때는 1개의 함수로 처리하기 보다는 다양한 확장함수를 이용한 chain calls 패턴이 대부분이기 때문에 이 이슈가 중요할 수 있으며 이 문제를 피하는 한가지 방법이 Sequence입니다.
  • Kotlin의 sequence는 Java8의 stream에 대응되는 개념으로 lazy evaluation 으로 처리된다는 공통점이 있습니다. Collection은 연산에 대해 eager evaluation으로 처리하지만, sequence는 연산에 대해 lazy evaluation으로 처리합니다.
val list = listOf(1, 2, -3)   // [1, 2, -3] 생성
val maxOddSquare = list
.asSequence()
.map { it * it }
.filter { it % 2 == 1 }
.max()
  • Collection을 Sequence로 변환하기 위해서는 chain calls 앞에 asSequence() 확장함수를 붙여주면 됩니다. 한편 이렇게 진행하는 경우 각 중간 과정에서 새로운 intermediate collection이 반환되지 않으며, first collection에 대한 reference와 어떠한 연산이 수행되는지를 저장해둔 sequence object가 반환됩니다. 그리고 결과가 필요한 시점에만 연산을 수행 (lazy evaluation)하여 최종 결과만을 반환합니다. 이 방식을 통해 chain calls에 대한 퍼포먼스 오버헤드를 막을 수 있습니다.

2. More about sequences

  • n개의 원소를 가진 collection/sequence에 대해map()-> find() 순서로 chain calls가 발생한다고 할 때, collection의 경우 n개의 원소 전부에 대해 map() transform을 한뒤에 find() 로 조건에 맞는 첫번째 원소를 찾습니다. (horizontal evaluation)
  • 한편, seqeunce의 경우 맨 앞 인덱스 부터 하나씩 map() transform을 하고 find() 로 조건이 맞는지 확인하며 조건이 맞는 원소가 발견되면 연산을 종료합니다. (vertical evaluation)
fun m(i: Int): Int {
print("m$i ")
return i
}
fun f(i: Int): Boolean {
print("f$i ")
return i % 2 == 0
}
val list = listOf(1, 2, 3, 4)// 1. Collection (m1 m2 m3 m4 f1 f2 f3 f4)
list.map(::m).filter(::f)
// 2. Sequence (m1 f1 m2 f2 m3 f3 m4 f4)
list.asSequence().map(::m).filter(::f).toList()
// 3. Sequence - terminal operation이 없는 경우 (nothing is printed)
list.asSequence().map(::m).filter(::f)
  • 실제로 위 코드를 동작시키면, Collection과 Sequence의 연산 순서가 다르게 동작함을 확인할 수 있습니다. 또한 Seqeunce의 경우 Seqeunce가 아닌 다른 타입 (ex: collection, value as an element, boolean 등)을 반환하는 terminal operation이 수행되기 전까지는 아예 연산이 실행되지 않음을 확인할 수 있습니다.

3. Creating sequences

  • Eager -> lazy 변경을 쉽게 하기 위해 Collection/Seqeunce에 대한 확장함수들은 대부분 매칭됩니다. 기존 Collection에 대한 chain calls 앞에 asSequence() 만 붙여주면 됩니다.
  • 한편, Sequence에 대한 확장함수들은 intermediate operation (반환 타입이 sequence인 연산)과 terminal operation (반환 타입이 sequence가 아닌 연산)으로 나뉩니다.
// Sequence의 first() : terminal operation (inline함수로 정의됨)
public inline fun <T> Sequence<T>.first(predicate:(T)->Boolean): T {
for (element in this) if (predicate(element)) return element
throw NoSuchElementException("Sequence contains no element matching the predicate.")
}
// Sequence의 filter() : intermediate operation
public fun <T> Sequence<T>.filter(predicate: (T) -> Boolean): Sequence<T> {
return FilteringSequence(this, true, predicate)
}
  • Terminal operation (ex: first())은 즉각적으로 lambda를 호출하여 연산을 수행하므로 Collection에 대응되는 연산들과 동일하게 inline 함수로 정의되어 있습니다. 한편, intermediate operation (ex: filter())의 경우 즉각적으로 lambda를 호출하여 연산을 수행하는 것이 아니라, 추후에 연산을 수행하기 위해 lambda를 저장해두므로 inline 함수로 정의되지 않습니다.
  • collection으로 부터 asSequence() 를 통해 sequence를 생성하는 방법 외에 sequence를 직접 생성하는 방법으로 1) generateSequence(nextFunction: () -> T?), 2) generateSequence(seed: T?, nextFunction: () -> T?), 3) yield 등이 있습니다.
val input = generateSequence { readLine().takeIf { it != "exit" } }
println(input.toList())
  • generateSequence(nextFunction: () -> T?) 를 이용하는 방법의 경우 lambda로 정의된 nextFunction에 따라 무한히 반복하여 원소를 생성하며, nextFunction이 null을 반환할 때 종료됩니다. 이를 사용하는 예로 위 코드처럼 특정한 String에 도달할 때 까지 input을 줄단위로 읽으며 sequence를 생성하는 경우가 있습니다.
val numbers = generateSequence(0) {it + 1}
println(numbers.take(5).toList())
val numbers2 = generateSequence(BigInteger.ZERO){it+BigInteger.ONE}
  • generateSequence(seed: T?, nextFunction: () -> T?) 를 이용하는 방법의 경우 seed에 인자로 받아진 값을 첫 원소로 생성하고, nextFunction을 통해 seed를 변환하며 무한히 반복하여 원소를 생성합니다. 숫자가 1씩 증가하는 등 단순한 규칙에 따라 원소가 존재하는 경우에 사용하며, take() 함수를 통해 원하는 지점까지의 값을 취해 sequence를 만들 수 있습니다.
  • Sequence는 terminal operation을 진행하기 전까지 연산이 일어나지 않기 때문에 무한히 원소를 생성하는 방식으로 sequence를 만들어도 최종 결과물이 유한한 형태로 제한된다면 overflow 문제는 없습니다. 하지만 operation에 따라 원소가 Integer overflow가 발생할 때 까지 생길 가능성도 있으므로 BigInteger 타입으로 sequence를 만들 수 도 있습니다. (관련 링크 : 큰 정수를 표현하기 위한 JavaAPI : BigInteger 클래스)
// 강좌에서는 buildSequence가 사용되었으나 Kotlin 1.3부터 sequence로 대체됨var numbers = sequence {
var x = 0
while (true){
yield (x++)
}
}
var mySequence = sequence {
yield(1)
yieldAll(3..5)
yieldAll(listOf(7, 9))
}
  • yield 를 이용하는 방법의 경우 가장 자유도가 높게 sequence를 생성할 수 있습니다. while과 같이 사용하여 특정한 규칙에 따라 sequence를 만들 수 도 있고, yield(value) , yieldAll(list) , yieldAll(sequence) 를 사용하여 원하는 원소를 자유롭게 추가할 수 있습니다.

4. Library functions

  • Chain calls 상황에서 퍼포먼스를 최적화 하는 방법은 Sequence를 사용하는 것 외에도 1) chain calls에 대응되는 1개의 함수 사용, 2) 연산 순서 변경 이 있습니다.
class Person(val age:Int, val isPublicProfile:Boolean, val name:String)
lateinit var people:ArrayList<Person>
// 아래 2줄은 동일한 연산을 수행한다.
people.filter { it.age < 21 }.size
people.count { it.age < 21}
// 아래 2줄은 동일한 연산을 수행한다.
people.sortedBy { it.age }.reversed()
people.sortedByDescending { it.age }
// 아래 2줄은 동일한 연산을 수행한다.
people.map { person ->
person.takeIf { it.isPublicProfile }?.name
}.filterNotNull()
people.mapNotNull { person ->
person.takeIf {it.isPublicProfile}?.name
}
var map = mutableMapOf<Int, MutableList<Person>>()
// 아래 3줄은 동일한 연산을 수행한다.
for (person in people){
if(person.age !in map){
map[person.age] = mutableListOf()
}
map.getValue(person.age) += person
}
for (person in people){
map.getOrPut(person.age) { mutableListOf() } += person
}
val map2 = people.groupBy { it.age }
  • 첫번 째 방법은 위 코드 예시 처럼 chain calls에 대응되는 1개의 함수가 있는 경우 대체하여 사용 하는 방식입니다. chain calls의 문제는 각 collection operation 함수마다 intermediate collection이 생성된다는 점이기 때문에 1개의 함수로 축약하여 사용하면 굳이 sequence를 생성할 필요가 없습니다.
fun m(i: Int): Int {
print("m$i ")
return i
}
fun f(i: Int): Boolean {
print("f$i ")
return i % 2 == 0
}
val list = listOf(1, 2, 3, 4)// collection chain calls의 순서를 바꾼 경우
list.map(::m).filter(::f) // m1 m2 m3 m4 f1 f2 f3 f4 (8회)
list.filter(::f).map(::m) // f1 f2 f3 f4 m2 m4 (6회)
// sequence chain calls의 순서를 바꾼 경우
list.asSequence().map(::m).filter(::f).toList()
// m1 f1 m2 f2 m3 f3 m4 f4 (8회)
list.asSequence().filter(::f).map(::m).toList()
// f1 f2 m2 f3 f4 m4 (6회)
  • 또 다른 방법은 연산의 순서를 변경하는 방식입니다. 연산의 순서를 항상 바꿀 수 있는 것은 아니지만 바꾸는 것이 가능한 경우 위 코드 사례와 같이 원소를 줄여주는 연산을 먼저 진행하면 전체 연산의 수를 줄일 수 있습니다. 이는 collection/sequence 둘다에 해당됩니다. 하지만 이 경우에도 collection 연산은 여전히 intermediate collection을 생성한다는 문제가 있습니다.

5. Fibonacci sequences 실습 코드 작성시 고려할 점

fun fibonacci(): Sequence<Int> = sequence {
var elements = Pair(0,1)
while(true){
yield(elements.first)
elements = Pair(elements.second, elements.first + elements.second)
}
}
println(fibonacci().take(4).toList().toString())
// "[0, 1, 1, 2]"
println(fibonacci().take(10).toList().toString())
// "[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]"
  • 위 Fibonacci sequence 생성 함수 자체는 while(true)를 이용하여 무한히 원소를 생성하나, take() 함수와 같이 사용하므로써 필요한 유한한 길이 만큼의 sequence로 잘라서 사용할 수 있습니다.

--

--