Programming/Kotlin

코틀린(Kotlin) - 인라인 함수

JunsuKim 2021. 11. 12.
728x90

람다를 활용한 코드에선 무명 클래스 생성에 따른 부가 비용이 든다.

이 때문에 같은 작업을 수행하는 일반 함수를 사용한 구현보다 효율적이지 못하다.

inline 변경자를 어떠한 함수에 붙이면 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트코드로 바꿔 일반 명령문과 같은 효율적인 코드를 생성하게 해 준다.

 

인라이닝이 작동하는 방식

위에서도 말했듯이 어떤 함수를 inline 선언하면 그 함수의 본문이 인라인 된다.

다른 말로 "함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신 함수 본문을 번역한 바이트 코드로 컴파일하는 것이다."

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

fun main() {
    val l = Lock()
    synchronized(1) {
        //. . .
    }
}

이 코드에선 synchronized 함수를 inline으로 선언하였다.

따라서 이를 호출하는 코드는 모두 synchroized와 같아진다.

 

synchronized 함수의 본문뿐 아니라 synchronized에 전달된 람다의 본문도 같이 인라이닝 된다.

람다의 본문에 의해 만들어지는 바이트코드는 그 람다를 호출하는 코드 정의의 일부분으로 간주되기 때문에

코틀린 컴파일러는 그 람다를 함수 인터페이스를 구현하는 무명 클래스로 감싸지 않는다.

 

class LockOwner(val lock: Lock) {
    fun runUnderLock(body: () -> Unit) {
        synchronized(lock, body)
    }
}

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

이 경우 인라인 함수를 호출하는 코드 위치에서는 변수에 저장된 람다의 코드를 알 수 없다.

-> 람다 본문은 인라이닝되지 않고 함수의 본문만 인라이닝된다.

 

한 인라인 함수를 두 곳에서 각각 다른 람다를 사용해 호출하면 각각 따로 인라이닝 된다.

호출 지점에서 인라인 함수의 본문 코드가 복사되고, 각 람다의 본문이 인라인 함수의 본문 코드에서 람다를 사용하는 위치에 복사된다.

 

인라인 함수의 한계

일반적으로 인라인 함수의 본문에서 람다 식을 바로 호출하거나 람다 식을 인자로 전달받아 바로 호출하는 경우

그 람다를 인라이닝할 수 있다.

둘 이상의 람다를 인자로 받는 함수에서 일부 람다만 인라이닝하고 싶을 땐 인라이닝을 하면 안되는 람다에

noinline 변경자를 붙여 인라인을 금지할 수 있다.

inlone fun foo(inlined: () -> Unit, noinline notInlined: () -> unit) {
    //. . .
}

코틀린에서는 어떤 모듈이나 서드파티 라이브러리 안에 인라인 함수를 정의하고 그 모듈이나 라이브러리 밖에서 해당

인라인 함수를 사용할 수 있다.

 

컬렉션 연산 인라이닝

코틀린 표준 라이브러리의 컬렉션 함수는 대부분 람다를 인자로 받는다.

표준 라이브러리 함수를 사용하지 않고 직접 이런 연산을 구현하면 더 효율적 일지 두 가지 방법을 비교해보도록 하겠다.

// 람다를 사용해 컬렉션 거르기
data class Person(val name: String, val age: Int)
val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> println(people.filter{ it.age < 30 })
[Person(name=Alice, age=29)]
// 컬렉션을 직접 거르기
>>> val result = mutableListOf<Person>()
>>> for(person in people) {
        if(person.age < 30) result.add(person)
    }
>>> println(result)
[Person(name=Alice, age=29)]

filter 함수는 인라인 함수이다.

따라서 filter 함수의 바이트코드는 그 함수에 전달된 람다 본문의 바이트코드와 함께 filter를 호출한 위치에 들어간다.

결과적으로 filter를 써서 생긴 바이트코드와 뒤 예제에서 생긴 바이트코드는 거의 일치한다.

따라서 코틀린이 제공하는 함수 인라이닝을 믿고 성능에 신경 쓰지 않아도 된다.

 

함수를 인라인으로 선언해야 하는 경우

일반 함수 호출의 경우 JVM은 이미 강력하게 인라이닝을 지원한다.

(JVM은 코드 실행을 분석하여 가장 이익이 되는 방향으로 호출을 인라이닝한다.)

이 과정은 바이트코드를 실제 기계어 코드로 번역하는 과정에서 일어나는데 이런 JVM의 최적화를 활용하면 바이트코드에서 각 함수 구현이 정확히 한 번만 있으면 되고, 함수를 호출할 때 따로 함수 코드를 중복할 필요가 없다.

반면 코틀린 인라인 함수는 바이트코드에서 각 함수 호출 지점을 함수 본문으로 대치하기 때문에 코드 중복이 생긴다.

 

람다를 인자로 받는 함수를 인라이닝하면 이익이 더 많다.

  • 인라이닝을 통해 없앨 수 있는 부가 비용이 상당하다.
    → 함수 호출 비용을 줄일 뿐 아니라 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체를 만들 필요 없다.
  • 현재의 JVM은 함수 호출과 람다를 인라이닝 해줄 정도로 똑똑하지 못하다.
  • 인라이닝을 사용하면 일반 람다에서 사용할 수 없는 기능을 사용할 수 있다.

하지만 inline 변경자를 함수에 붙일 때 코드 크기에 주의를 기울여야 한다.

인라이닝하는 함수가 큰 경우 함수의 본문에 해당하는 바이트코드를 모든 호출 지점에 복사해 넣고 나면 바이트코드가

전체적으로 아주 커질 수 있기 때문이다.

 

자원 관리를 위해 인라인된 람다 사용

람다로 중복을 없앨 수 있는 일반적인 패턴 중 하나는 어떤 작업을 하기 전 자원을 획득하고 작업을 마친 후 자원을

해제하는 자원 관리다.

※ 자원은 파일, 락, 데이터베이스 트랜잭션 등 여러 다른 대상을 가리킨다.

자원 관리 패턴을 만들 때 사용하는 일반적인 방법은 try / finally문을 사용하되 try 블록을 시작하기 직전에 자원을

획득하고 finally 블록에서 자원을 해제하는 것이다.

 

728x90

댓글