코틀린에서 람다를 사용하는 구현은 똑같은 코드를 직접 실행하는 것보다 덜 효율적이다.
코틀린 컴파일러는 람다는 익명 클래스로 컴파일되어 사용된다. 그러므로 람다식마다 새로운 클래스가 생기고 람다가 변수를 캡처하면 람다 정의가 포함된 코드를 호출하는 시점마다 새로운 객체가 생긴다..
결론적으로 람다를 사용하는 구현은 똑같은 코드를 직접 실행하는 함수보다 덜 효율적이라고 할 수 있다.
인라인 함수를 통해 람다의 부가 비용 없애기
책에서는 이러한 문제를 인라인 함수를 통해 부가 비용을 없앨 수 있다고 소개한다. 인라인 함수를 사용하면 반복되는 코드를 뺴내되, 직접 실행될 만큼 효울적인 코드를 컴파일러가 생성하게 할 수 있다고 설명하고 있다.
그런데 인라인 함수를 사용해야 하면, 람다의 장점은 간결함인데, 인라인 함수를 사용하면 결국 함수를 따로 정의해야 할텐데, 간결함을 챙길 수 없지 않을까? 라는 의문이 들었다. 우선 이런 의문을 가지고 이에 대해 계속 탐구해봤다.
인라이닝이 작동하는 방식
이 부분은 내가 C++을 배울 때 알고있던 인라인 함수의 작동 방식과 동일했다. 인라인 함수를 사용하게 되면, 함수를 호출하는 부분에서 함수를 호출하는 것이 아니라, 실제 함수 본문을 끼워넣어버리기 때문에 함수 호출 비용을 아낄 수 있다는 것이다.
import java.util.concurrent.locks.Lock
import java.util.concurrent.locks.ReentrantLock
inline fun <T> synchronized(lock: Lock, action:() -> T): T {
lock.lock()
try {
return action()
}
finally {
lock.unlock()
}
}
fun main() {
val l = ReentrantLock()
synchronized(l) {
//...
}
}
위와 같이 람다 함수를 호출하는 인라인 함수를 사용했을 때, 함수의 본문 뿐 아니라 전달된 람다의 본문도 함께 인라이닝 되기 때문에 원래라면, 익명클래스로 람다를 감싸야했지만, 그 작업이 그냥 단순히 코드 삽입으로 끝난다.
위 예제 코드를 보고 그제서야 내가 애초에 책의 의도를 잘못 이해했음을 깨달았다. 바로 람다 대신 인라인 함수를 쓰는 것이 아니라, 함수 타입 파라미터를 받는 함수를 인라인 함수로 선언하면, 람다 함수가 가지는 성능적인 이슈가 해결된다는 것이었다..
다만 인라인 함수가 호출되는 시점에서 변수에 저장된 람다의 코드를 알 수 없는 경우 즉 일반적인 람다 호출처럼 사용된다.
class LockOwner(val lock: Lock) {
fun runUnderLock(body: () -> Unit) {
synchronized(lock, body) // 이 위치에서는 body 변수가 어떤 함수인지 알 수 없기에 인라인되지 않는다.
}
}
의문이 해결되는 한편 다음 의문이 생겼다, 그러면 함수 타입을 파라미터로 받는 함수는 디폴트 값이 왜 인라인이 아닐까?
다행히 그 다음 부분에서 이에 대한 설명이 나왔다.
인라인 함수의 제약
호출 시점에서 변수에 저장된 람다의 구현을 알 수 없는 경우
이전에 언급했듯 인라인 함수가 호출되는 시점에서 변수에 저장된 람다의 코드를 알 수 없는 경우 람다를 인라인 호출할 수 없다.
함수 타입의 파라미터를 값으로 다루는 경우
다음 코드처럼 함수 타입의 변수로 넘기지 않았더라도, 인라인 함수의 함수형 파라미터는 호출 지점에서 코드로 치환되기 때문에 값으로 사용될 수 없다.
class FunctionStroage {
var myStoredFunction: ((Int) -> Unit)? = null
inline fun storeFunction(f: Int) -> Unit) {
myStoredFunction = f
}
}
람다를 내부적으로 저장해야하는 경우
다음 코드같은 경우에도 람다가 인라인 될 수 없다. TransformingSequence 클래스의 생성자로 람다를 전달하고 있는데, 이 경우에도 방금과 같은 경우에도 코드로 치환해서는 지원할 수 없기 때문에 익명 클래스 인스턴스로 만들 수 밖에 없다.
fun <T, R> Sequence<T>.map(transform: (T) -> R): Sequence<R> {
return TransformingSequence(this, transform)
}
인라이닝 불가능한 람다를 noinline으로 선언하여야 한다.
이러한 경우는 직접 noinline 변경자를 파라미터 앞에 붙여 인라이닝이 되지 않도록 해주어야 한다.
inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) {
// ...
}
코틀린의 filter와 map은 람다 호출 비용이 거의 없다
나 또한 처음 람다 호출의 비용에 대해 알고 나서는 그렇다면 코틀린의 표준 라이브러리에서의 람다 호출도 그만한 비용을 지불하던 것이구나, 라고 생각했지만, 책에서 우선 filter, map은 인라인 함수라고 설명하고 있다.
중간 리스트의 문제
data class Person(val name: String, val age: Int)
val people = listOf(Person("Alice", 29), Person("Bob", 31))
fun main() {
println(
people.filter { it.age > 30 } // 여기서 중간 리스트가 생성된다
.map(Person::name)
)
// [Bob]
}
위 코드에서의 filter, map은 인라인되기 때문에 추가 객체나 클래스 생성 없이 즉시 수행된다. 하지만 이는 내부적으로 filter함수의 결과를 저장할 중간 리스트를 만들고 이를 다시 map으로 전달하기 때문에 중간 리스트로 인한 비용 문제가 발생한다(메모리, 리스트를 여러번 순회하게 됨)
asSequence, 리스트대신 시퀀스를 사용하여 중간 리스트 사용 멈추기
책에서는 이러한 상황에서 시퀀스를 사용하면 이러한 비용을 줄일 수 있다고 설명한다. 시퀀스를 통해 이를 구현하게 되면, 중간 시퀀스가 람다를 필드에 저장하는 객체로 표현되며, 최종 연산은 중간 시퀀스에 있는 람다를 연쇄 호출하기 때문이다.
하지만 시퀀스 연산에서는 람다가 인라이닝 되지 않는다.
람다를 필드에 저장한다는 설명에서 알 수 있듯이 이는 람다를 인라이닝 할 수 없다는 것이기 때문에 오히려, 중간 리스트를 통한 연산으로 리스트를 여러 번 순회하는 비용을 줄이기 위해 다 시퀀스로 만들어버리면, 람다를 익명 클래스 인스턴스로 만들어야 하는 비용이 계속 발생하여 오히려 비용이 더 발생할 수도 있다.
인라인 함수를 언제 사용해야 할까?
사실 이쯤되면 인라인 함수를 사용 불가한 경우가 많긴 했지만, 이외의 경우에는 인라인 함수를 사용할 만 하지 않을까? 라는 생각을 했다.
일반 함수 호출은 JVM이 인라이닝을 지원한다.
일반 함수 즉 람다를 인자로 받지 않는 함수는 JVM이 이미 강력하게 인라이닝을 지원한다고 한다. 책에서는 다음과 같이 나와있다.
JVM은 코드 실행을 분석해서 가장 이익이 되는 방향으로 호출을 인라이닝한다.
단순히 가장 이익이 되는 방향이라고만 나와있어서 약간 애매한 느낌이 있었다. 나중에 추가적으로 정리해야겠다.
람다를 인자로 받는 함수는 인라이닝 하면 이익이 많다!
이전에도 설명했듯이 람다를 인자로 하는 함수는 인라이닝 하면 해당 함수의 호출 비용뿐만 아니라 람다를 표현하는 클래스와 객체를 만들 필요가 없어지기 때문에 이익이 더 극대화 된다고 할 수 있겠다.
하지만 현재의 JVM은 람다를 인라이닝 해줄 정도의 고수준은 아니라고 한다. 사실 이는 잘 와닿지 않았다. 람다도 결국 JVM이 바라볼때는 익명 객체의 메서드중 하나일텐데, 그렇다면 다른 일반 함수들과 비슷하지 않나 싶었다.
이에 대해서는 아주 조만간 찾아봐야겠다.
함수 안의 람다에서 return 사용하기
사실 당연히 뭔가 람다 바깥의 함수가 리턴될까 싶다가도, 중첩된 반복문에서 break, continue를 수행하면 가장 가까운 루프에 적용되는 것을 생각해보니, 아닐 것도 같았다.
결론적으로는 람다를 인자로 받는 함수가 인라인 함수라면, return이 바깥쪽 함수를 반환시킬 수 있었다.
이는 나름대로의 이유가 있었는데, 만약 인라이닝 되지 않는 함수라면 이를 어떤 변수, 그러니까 함수보다 스코프가 긴 변수에 대입할 수도 있다. 그렇게 되면, 함수가 반환된 뒤에 나중에 실행될 수도 있기 때문에 이러한 위험성을 차단하기 위해 인라이닝 가능한 변수에만 적용한 것으로 보인다.
반대로 기본적으로 비로컬 리턴인 인라이닝 되는 함수내 리턴을 레이블을 사용해서 로컬 리턴으로 지정할 수도 있다.
data class Person(val name: String, val age: Int)
val people = listOf(Person("Alice", 29), Person("Bob", 31))
fun lookForAlice(people: List<Person>) {
people.forEach label@{
if (it.name != "Alice") return@label
print("Found Alice!")
}
}
fun main() {
lookForAlice(people)
// Found Alice!
}
혹은 레이블 대신 람다를 인자로 받는 인라인 함수의 이름을 return 뒤에 사용해도 된다! 위의 경우에는 forEach를 사용하면 될 것이다.