본문 바로가기

코틀린

코틀린(Kotlin) - inline 함수 : 람다의 부가 비용 없애기

람다의 경우 컴파일 단계에서 파라미터 개수에 따라 FunctionN 형태의 인터페이스로 변환이 됩니다.

예를 들어 아래와 같이 파라미터가 두 개인 람다 식은 Function2<P1, P2, R> 의 인터페이스로 변환이 되는 것을 알 수가 있습니다.

 

fun calculator(x: Int, y: Int, operation: (Int, Int) -> Int) {
   operation(x, y)
}

/* 컴파일 시, FunctionN 형태의 인터페이스 구현 객체로 변환 */
public final void calculator(int x, int y, @NotNull Function2 operation) {
   Intrinsics.checkNotNullParameter(operation, "operation");
   operation.invoke(x, y);
}

 

그렇기 때문에 람다 식을 상용하는 경우 일반 함수 구현에 비해 부가적인 비용이 들게 되기 때문에, 똑같은 작업을 하는 일반 함수보다 덜 효율적이게 됩니다.

 

하지만! 코틀린 컴파일러에서는 람다식을 일반 함수만큼 효율적인 코드로 만들어주는 기능을 제공합니다!

inline 키워드를 함수에 붙이면 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트코드로 싹 바꿔치기해주기 때문에 오버헤드를 줄일 수가 있습니다!

 

inline이 작동하는 방식

어떤 함수 앞에 inline을 선언하면 그 함수의 본문이 호출 지점에 inline 됩니다.

inline 함수로 정의된 함수는 컴파일 단계에서 함수를 호출하는 코드 대신에, 함수 본문 코드 자체가 삽입되는 방식으로 컴파일됩니다.

예로, 만약 inline 함수에 두줄 코드가 있다면 이 두줄 코드를 그대로 호출한 부분에 inline 시켜준다는 뜻입니다.

 

/* inline 예제 */

inline fun doSomething() {
   println("인라인")
   println("예제")
}

fun example() {
   doSomething()
}

/* 인라인 된 디컴파일 결과 */
fun example() {
   println("인라인")
   println("예제")
}

 

/* inline 예제 */

inline fun doSomething(body: () -> Unit) {
   body()
}

fun example() {
   doSomething {
      println("인라인 함수 예제")
   }
}

/* 인라인 된 디컴파일 결과 */
fun example() {
   println("인라인 함수 예제") //doSomething()이 인라인 됨
}

 

inline fun calculator(x: Int, y: Int) = x + y

fun example() {
   val total = calculator(10, 20)
}

/* 인라인 된 디컴파일 결과 */
fun example() {
   val x = 10
   val y = 20
   val total = x + y //인라인 되어 함수 본문이 그대로 삽입 됨
}

 

이렇게 inline 된 코드는 변환된 바이트코드 자체가 그 부분으로 쏙 들어가기 때문에 더 이상 람다를 인터페이스로 구현하는 FunctionN 객체는 생기지 않고, 오버헤드가 발생하지 않습니다!

 

noinline

모든 람다식에 inline을 쓰고 싶지 않을 수 있습니다.

이런 경우에는 아래와 같이 해당 파라미터 람다식에 noinline 키워드를 붙여서 인라이닝을 막을 수 있습니다.

 

inline fun doSomething(testInLine: () -> Unit, noinline testNoInLine: () -> Unit) {
    ...
}

 

컬렉션 연산 인라이닝

코틀린의 컬렉션 함수는 대부분 람다를 파라미터로 받습니다.

혹시, 코틀린 표준 라이브러리 함수를 사용하지 않고 그냥 전통적인 방법대로 직접 연산을 구현한다면 더 효율적이지 않을까요?

예를 들어 Person 리스트를 걸러내는 두 가지 방법을 비교해보도록 하겠습니다.

람다를 사용했을 때

/* 코틀린 람다를 사용해서 컬렉션 걸러내기 */
data class Person(val name: String, val age: Int)

val people = listOf(Person("김안드", 31), Person("김틀린", 29))

>>> println(people.filter { it.age < 30 })
[Person(name=김틀린, age=20)]

람다 없이 전통적인 방법으로 구현했을 때

/* 람다 없이 전통적인 연산 방식으로 구현 */
val resultList = mutableListOf<Person>()

for(person in people) {
   if(person.age < 30) {
      resultList.add(person)
   }
}

>>> println(resultList)
[Person(name=김틀린, age=20)]

 

코틀린의 filter 함수는 inline으로 만들어진 함수입니다.

따라서 filter 함수의 바이트코드는 그 함수에 전달된 람다 본문의 내용 그대로 filter를 호출한 위치에 쏙 들어가게 됩니다.

그렇기 때문에 우리는 코틀린다운 연산으로 컬렉션에 대해 안전하게 사용할 수 있고, 코틀린이 제공하는 함수 인라이닝을 믿고 성능에 전혀 신경 쓰지 않아도 됩니다! ^^

 

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

inline 키워드의 이점을 배우고 나면 코드를 더 빠르게 만들기 위해 코드 여기저기에서 inline을 사용하고 싶어질 수도 있습니다.

하지만 그리 좋은 생각은 아닙니다... 😥

이유는 inline 키워드를 사용한다 해도 람다를 인자로 받는 함수 성능이 좋아질 가능성이 높기 때문에, 그 외 다른 코드의 경우에는 주의 깊게 성능을 측정하고 분석해야 합니다.

일반 함수 호출의 경우

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

JVM은 코드 실행을 분석해서 가장 유리한 방향으로 호출을 인라이닝 해줍니다. 👍

이런 과정을 바이트코드를 실제 기계어 코드로 번역하는 과정(JIT)에서 일어납니다. 

이런 JVM의 최적화를 활용한다면 바이트코드에서는 각 함수 구현이 정확히 한 번만 있으면 되고, 그 함수를 호출하는 부분에서 따로 함수 코드를 중복할 필요가 없어집니다.

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

람다를 파라미터로 받는 함수의 경우

람다를 파라미터로 받는 함수의 경우는 인라이닝을 하는게 훨씬 유리합니다.

첫째로, 인라이닝을 통해 없앨 수 있는 부가 비용의 이점이 상당합니다!

함수 호출 비용을 줄일 수 있을 뿐 아니라 람다를 표현하는 클래스와 람다 객체를 만들 필요도 없어집니다.

둘째로, 현재의 JVM은 함수 호출과 람다를 인라이닝해 줄 정도로 똑똑하지는 못합니다...ㅜㅜ

마지막으로, 인라이닝을 사용하면 일반 람다에서는 사용할 수 없는 몇 가지 기능을 사용할 수 있습니다.

그런 기능 중에는 나중에 설명할 넌로컬(non-local) 반환이 있습니다.

 

단, inline 함수를 만들 때 코드 크기가 큰 함수의 경우는 모든 호출 지점에 바이트코드가 복사되기 때문에 오히려 더 성능을 악화시킬 수 있기 때문에 가급적이면 코드 크기가 작은 부분에만 inline 함수를 사용하면 좋을 것 같습니다!

실제로, 코틀린 라이브러리가 제공하는 inline 함수를 보면 모두 다 크기가 아주 작다는 사실을 알 수가 있습니다.

 

리소스 관리를 위해 inline 된 람다 사용

안드로이드 프로그래밍을 하다 보면 리소스를 획득하여 사용하고 필요 작업을 마친 후 해제해줘야 하는 리소스 관리가 필요할 때가 있습니다.

예를 들면 BufferedReader와 같은 I/O 클래스들을 사용하는 경우 사용 완료 후에는 close()와 같이 리소스를 해제해주곤 하는데, 이때 보통 사용하는 방법은 try/finally문을 사용하여 try 블록 안에서 리소스를 사용하고 finally 블록에서 리소스를 해제해주는 방법입니다.

 

코틀린에서는 이런 작업을 아주 매끄럽게 처리할 수 있는 "use" 라는 함수를 제공해줍니다. 👍

 

/* use 함수로 리소스 자동 관리하기 */
fun readFirstLineFromFile(path: String): String {
   BufferedReader(FileReader(path)).use { br ->
      return br.readLine()  //람다에서 반환하는 것이 아닌 readFirstLineFromFile에서 반환한다.
   }
}

 

use 를 쓰게 되면 finally에서 리소스 close를 강제로 해줘야 번거로움이 없어지며, 혹여 exception이 발생해서 비정상 종료가 되더라도 리소스 해제를 해주도록 구현이 되어있으니 안심하고 믿고 사용할 수 있는 유용한 함수입니다. 😃