코틀린의 리스트는 명령형 방식과 함수형 방식을 모두 제공한다.
함수형 방식으로 작성하는 경우 고차 함수를 체이닝 하면 명령형 프로그래밍에서 for와 if문을 사용해서 작성하는 로직을 간결하게 표현할 수 있다.
예를 들면 입력 리스트의 값을 제곱하여 10보다 작은 첫 번째 값을 리턴하는 함수를 명령형으로 작성해보자.
fun imperativeWay(): Int {
val bigIntList = (1..10000000).toList()
var firstSquare = 0
for (value in bigIntList) {
val doubleValue = value * value
if (doubleValue < 10) {
firstSquare = doubleValue
break
}
}
return firstSquare
}
그렇다면 이번엔 함수형으로 작성해보자.
fun functionalWay(): Int =
(1..10000000).toList().map { n -> n * n }.first { n -> n < 10 }
함수형이 더 간결하고 한눈에 보기 쉽다.
그리고 코드를 실행해보면 당연히 동일한 결괏값을 가진다.
fun result(){
println(imperativeWay())
println(functionalWay())
}
//imperativeWay - "1"출력
//functionalWay - "1"출력
그럼 성능에서는 어떤 차이점이 있을지 알아보자.
성능의 차이를 확실히 보이기 위해 리스트의 크기를 매우 크게 만들고 명령형 방식의 함수와 함수형 방식의 함수를 실행해보자.
fun test() {
var start = System.currentTimeMillis()
imperativeWay()
println("imperativeWay result: ${System.currentTimeMillis() - start} ms")
start = System.currentTimeMillis()
functionalWay()
println("functionalWay result: ${System.currentTimeMillis() - start} ms")
}
수행 결과를 보면 명령형으로 처리한 함수가 더 좋은 성능을 보여준다.
imperativeWay result: 202 ms
functionalWay result: 329 ms
왜 이런 차이가 발생할까?
두 함수에 값이 [1, 2, 3, 4, 5]인 리스트를 넣는다고 가정해보자.
imperativeWay 함수의 경우 처음부터 10보다 작은 값이 계산되어 나왔으므로 제곱 연산 1회, 비교 연산 1회를 총 2회의 연산을 하고 바로 1을 리턴한다.
functionalWay 함수의 경우 5개의 값에 대해서 모두 map을 실행한 후, filter를 수행한다.
그리고 마지막에 first 함수를 실행하므로 총 11회의 연산이 수행되어야 한다.
이와 같은 성능 문제는 코틀린의 모든 컬렉션에서 발생하게 된다.
따라서 다음과 같은 상황일 경우 컬렉션 사용을 지양해야 한다.
- 퍼포먼스에 민감한 프로그램을 작성할 때
- 컬렉션의 크기가 고정되어 있지 않을 때
- 고정된 컬렉션 크기가 매우 클 때
해결법
코틀린의 컬렉션은 기본적으로 값이 즉시 평가된다.
게으른 평가로 실행되지 않기 때문에 이렇게 성능이 저하된다.
이러한 부분을 보완하기 위해서 아래와 같이 시퀀스(sequence)를 사용하여 개선할 수 있다.
fun test(){
var start = System.currentTimeMillis()
realFunctionalWay()
println("${System.currentTimeMillis() - start} ms")
}
fun realFunctionalWay(): Int =
(1..10000000).toList().asSequence()
.map { n -> n * n }
.filter { n -> n < 10 }
.first()
또는 시퀀스와 같은 게으른 컬렉션을 만들어 사용하면 된다.
매개변수의 형태를 중점적으로 참고해서 보면 되겠다.
sealed class FunList<out T>{
object Nil: FunList<Nothing>
data class Cons<T>(val head: T, var tail: FunList<T>): FunList<T>()
}
FunList는 Cons가 생성되는 시점에 head와 tail을 평가해서 그 값을 가지게 된다. 즉 즉시 평가가 이루어진다.
하지만 이러한 형태를 게으른 평가로 변환할 수 있는 간단한 방법이 있다.
sealed class FunStream<out T>{
object Nil: FunStream<Noting>()
data class Cons<out T>(val head: () -> T, val tail: () -> FunStream<T>):
FunStream<T>()
}
위의 FunList와의 차이점을 보면 입력 매개변수를 람다로 받았다.
이렇게 하면 Cons가 생성되는 시점에 입력 매개변수는 즉시 평가되지 않는다.
실제로 값이 평가되는 시점은 그 값을 실질적으로 사용할 때이므로 Funstream의 head, tail을 얻기 위한 함수는
아래와 같아진다.
fun main(){
println(funStreamOf(1, 2, 3).getHead()) //...1
println(funStreamOf(1, 2, 3).getTail()) //...2
}
fun <T> FunStream<T>.getHead(): T = when(this){
FunStream.Nil -> throw NoSuchElementException()
is FunStream.Cons -> head()
}
fun <T> FunStream<T>.getTail():T = when(this){
FunStream.Nil -> throw NoSuchElementException()
is FunStream.Cons -> tail()
}
//1. "1" 출력
//2. "Cons(head=() -> T, tail = () -> ...)" 출력
getHead() 같은 경우는 사용가능 한 값이 실제로 있기 때문에 값이 평가된다.
하지만 getTail()의 경우는 여전히 FunStream을 반환하기 때문에 값이 평가되지 않고 이름이 출력된다.
FunStram은 게으른 컬렉션이 되었다.
'Kotlin' 카테고리의 다른 글
[Kotlin] inline 함수 (0) | 2021.09.06 |
---|---|
[Kotlin] Flow (0) | 2021.08.13 |
[Kotlin] 커링(Currying) 함수 (0) | 2021.08.03 |
[Kotlin] Tailrec (0) | 2021.08.02 |
[Kotlin] Sealed Class (0) | 2021.07.30 |