5장에서 코틀린이 보통 람다를 무명 클래스로 컴파일하지만 그렇다고 람다 식을 사용할 때마다 새로운 클래스가 만들어지지는 않는다는 사실을 설명했고, 람다가 변수를 포획하면 람다가 생성되는 시점마다 새로운 무명 클래스 객체가 생긴다는 사실도 설명했다. 이런 경우 실행 시점에 무명 클래스 생성에 따른 부가 비용이 든다. 따라서 람다를 사용하는 구현은 똑같은 작업을 수행하는 일반 함수를 사용한 구현보다 덜 효율적이다.

반복되는 코드를 별도의 라이브러리 함수로 빼내되 컴파일러가 자바의 일반 명령문처럼 효율적인 코드를 생성하게 만들 수는 없을까? inline 변경자를 어떤 함수에 붙이면 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트코드로 바꿔치기 해준다.

인라이닝이 작동하는 방식

어떤 함수를 inline으로 선언하면 그 함수의 본문이 인라인된다. 다른 말로 하면 함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신에 함수 본문을 번역한 바이트코드로 컴파일한다는 뜻이다.

아래 예시의 함수는 다중 스레드 환경에서 어떤 공유 자원에 대한 동시 접근을 막기 위한 것이다. 이 함수는 Lock 객체를 잠그고 주어진 코드 블록을 실행한 다음에 Lock 객체에 대한 잠금을 해제한다.

inline fun <T> synchronized(lock: Lock, action: () -> T): T {
    lock.lock()
    try {
        return action()
    }
    finally {
        lock.unlock()
    }
}

fun main() {
    val l = Lock()
    synchronized(l) {
        TODO()
    }
}

이 함수를 호출하는 코드는 자바의 synchronized문과 똑같아 보인다. 차이는 자바에서는 임의의 객체에 대해 synchronized를 사용할 수 있지만 이 함수는 Lock 클래스의 인스턴스를 요구한다는 점뿐이다.

여기서 보여준 코드는 단지 예일 뿐이다. 코틀린 표준 라이브러리는 아무 타입의 객체나 인자로 받을 수 있는 synchronized 함수를 제공한다.

하지만 동기화에 명시적인 락을 사용하면 더 신뢰할 수 있고 관리하기 쉬운 코드를 만들 수 있다. 코틀린에서 락을 건 상태에서 코드를 실행해야 한다면 먼저 withLock을 써도될지 고려해봐야 한다.

synchronized 함수를 inline으로 선언했으므로, synchronized를 호출하는 코드는 모두 자바의 synchronized문과 같아진다.

fun foo(l: Lock) {
    println("Before sync")
    synchronized(l) {
        println("Action")
    }
    println("After sync")
}

아래 그림은 이 코틀린 코드와 동등한 코드를 보여준다. 이 코드는 앞의 코드와 같은 바이트코드를 만들어낸다.

20240414_043419.jpg

synchronized 함수의 본문뿐 아니라 synchronized에 전달된 람다의 본문도 함께 인라이닝된다는 점에 유의하자. 람다의 본문에 의해 만들어지는 바이트코드는 그 람다를 호출하는 코드(synchronized) 정의의 일부분으로 간주되기 때문에 코틀린 컴파일러는 그 람다를 함수 인터페이스를 구현하는 무명 클래스로 감싸지 않는다.

인라인 함수를 호출하면서 람다를 넘기는 대신에 함수 타입의 변수를 넘길 수도 있다.

class LockOwner(val lock: Lock) {
    fun runUnderLock(body: () -> Unit) {
        synchronized(lock, body) // 람다 대신에 함수 타입인 변수를 인자로 넘긴다.
    }
}

이런 경우 인라인 함수를 호출하는 코드 위치에서는 변수에 저장된 람다의 코드를 알 수 없다. 따라서 람다 본문은 인라이닝되지 않고 synchronized 함수의 본문만 인라이닝된다. 따라서 람다는 다른 일반적인 경우와 마찬가지로 호출된다. runUnderLock을 컴파일한 바이트코드는 다음 함수와 비슷하다.

class LockOwner(val lock: Lock) {
    fun __runUnderLock__(body: () -> Unit) {
        lock.lock()
        try {
            body() // synchronized를 호출하는 부분에서 람다를 알 수 없으므로 본문(body)은 인라이닝되지 않는다.
        }
        finally {
            lock.unlock()
        }
    }
}

한 인라인 함수를 두 곳에서 각각 다른 람다를 사용해 호출한다면 그 두 호출은 각각 따로 인라이닝된다. 인라인 함수의 본문 코드가 호출 지점에 복사되고 각 람다의 본문이 인라인 함수의 본문 코드에서 람다를 사용하는 위치에 복사된다.

인라인 함수의 한계