KotlinInAction/고차 함수: 파라미터와 반환 값으로 람다 사용

인라인 함수: 람다의 부가 비용 없애기

webmaster 2022. 8. 11. 17:25
728x90

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

인라이닝이 작동하는 방식

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

fun main(args: Array<String>) {
    val lock = ReentrantLock()
    synchronized(lock){
        // logic
    }
}
inline fun <T> synchronized(lock: Lock, action: () -> T): T {
    lock.lock()
    try {
        return action()
    }finally {
        lock.unlock()
    }
}
  • 자바의 synchronized 문과 똑같아 보이지만, 자바는 임의의 객체에 synchronized를 사용할 수 있지만, 이 함수는 Lock클래스의 인스턴스를 요구하는 차이가 있다.
  • 코틀린 표준 라이브러리는 아무 타입 객체나 인자로 받을 수 있는 synchronized 함수를 제공한다.
  • 동기화에 명시적인 락을 사용하면 더 신뢰할 수 있고, 관리하기 쉬운 코드를 만들 수 있다.
fun foo(lock: Lock){
    println("Before sync")
    synchronized(lock){
        println("Action")
    }
    println("After sync")
}

디컴파일(인라인 함수)

fun __foo__(lock: Lock){
    println("Before sync") //Synchronized 함수를 호출하는 foo 함수의 코드
    
    lock.lock() //Synchronized 함수가 인라이닝된 코드(* 로 표기)
    try { //*
        println("Action") //람다 코드의 본문이 인라이닝된 코드
    }finally {//*
        lock.unlock()//*
    }//*
    println("After sync")
}
  • synchronized 함수의 본문뿐만 아니라 Synchronized에 전달된 람다의 본문도 함께 인라이닝이 된다.
  • 람다의 본문에 의해 만들어지는 바이트코드는 그 람다를 호출하는 코드 정의(Synchronized)의 일부분으로 간주되기 때문에 코틀린 컴파일러는 람다를 함수 인터페이스를 구현하는 무명 클래스로 감싸지 않는다.
class LockOwner(val lock: Lock) {
    fun runUnderLock(body: () -> Unit) {
        synchronized(lock, body) //람다 대신에 함수 타입인 변수를 인자로 넘긴다
    }
}
  • 인라인 함수를 호출하면서 람다를 넘기는 대신 함수 타입의 변수를 넘길 수도 있다.
  • 인라인 함수를 호출하는 코드 위치에서는 변수에 저장된 람다의 코드를 알 수 없기 때문에 람다 본문은 인라이닝 되지 않고, synchronized 함수의 본문만 인라이닝 된다.

디 컴파일

class LockOwner(val lock: Lock) {
    fun __runUnderLock__(body: () -> Unit) { //이 함수는 runUnderLock을 실제로 컴파일한 바이트코드와 비슷하다.
    	lock.lock()
        try{
        	body() //Synchronized를 호출하는 부분에서 람다를 알 수 없으므로 본문(body())는 인라이닝되지 않는다.
        }finally{
        	lock.unlock()
        }
    }
}
  • 한 인라인 함수를 두 곳에서 각각 다른 람다를 사용해 호출한다면 그 두 호출은 각각 따로 인라이닝 된다.
  • 인라인 함수의 본문 코드가 호출 지점에 복사되고, 각 람다의 본문이 인라인 함수의 본문 코드에서 람다를 사용하는 위치에 복사된다.

인라인 함수의 한계

인라이닝을 하는 방식으로 인해 람다를 사용하는 모든 함수를 인라이닝 할 수는 없다. 함수가 인라이닝 될 때 그 함수에 인자로 전달된 람다식의 본문은 결과 코드에 직접 들어갈 수 있지만, 이렇게 람다가 본문에 직접 펼쳐지기 때문에 함수가 파라미터로 전달받은 람다를 본문에 사용하는 방식이 한정될 수밖에 없다. 함수 본문에서 파라미터로 받은 람다를 호출한다면, 그 호출을 쉽게 람다 본문으로 바꿀 수 있지만, 파라미터로 받은 람다를 다른 변수에 저장하고, 나중에 그 변수를 사용한다면 람다를 표현하는 객체가 어딘가는 존재해야 하기 때문에 람다를 인라이닝 할 수 없다.

일반적으로 인라인 함수의 본문에서 람다식을 바로 호출하거나, 람다식을 인자로 전달받아 바로 호출하는 경우에는 그 람다를 인라이닝 할 수 있다. 그 외의 경우 컴파일러는 "Illegal usage of inline-parameter" 메시지와 함께 인라이닝을 금지한다.

fun <T, R> Sequence<T>.map(transform: (T) -> R): Sequence<R>{
    return TransformingSequence(this, transform)
}
  • 함수는 transform 파라미터로 전달받은 함숫값을 호출하지 않는 대신, TransformingSequence 클래스의 생성자에게 그 함숫값을 넘긴다.
    • TransformingSequence 생성자는 전달받은 람다를 프로퍼티로 저장
  • 이런 기능을 지원하려면 map에 전달되는 transform 인자를 일반적인(인 라이닝 x) 함수 표현으로 만들 수밖에 없다.
    • 여기서는, transform 함수 인터페이스를 무명 클래스 인스턴스로 만들어야 된다.
inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit){
    //...
}
  • 둘 이상의 람다를 인자로 받는 함수에서 일부만 인라이닝을 할 수 있다.
    • 인라이닝하면 안 되는 람다를 파라미터로 받는다면 noinline 변경자를 파라미터 이름 앞에 붙여서 인라이닝을 금지할 수 있다

코틀린에서는 어떤 모듈, 서드파티 라이브러리 안에서 인라인 함수를 정의하고, 그 모듈이나, 라이브러리 밖에서 해당 인라인 함수를 사용할 수 있다. 또 자바에서 정의한 인라인 함수를 호출할 수도 있다. 이런 경우 컴파일러는 인라인 함수를 인라이닝하지 않고 일반 함수로 컴파일한다.

컬렉션 연산 인라이닝

람다를 사용해 컬렉션 거르기

data class Person(val name:String, val age: Int)
fun main(args: Array<String>) {
    val people = listOf(Person("Alice", 29), Person("Bob", 31))
    println(people.filter { it.age < 30 })
    //람다를 사용하지 않은 컬렉션 거르기
    val result = mutableListOf<Person>()
    for(person in people){
        if(person.age < 30)
            result.add(person)
    }
    println(result)
}
  • 코틀린의 filter 함수는 인라인 함수이다. -> filter 함수의 바이트 코드는 그 함수에 전달된 람다 본문의 바이트코드와 함께 filter를 호출한 위치에 들어간다.
fun main(args: Array<String>) {
    val people = listOf(Person("Alice", 29), Person("Bob", 31))
    println(people.filter { it.age < 30 }.map( Person::name))
}
  • filter와 map 같이 사용(filter와 map은 인라인 함수이다)
    • 두 함수의 본문은 인라이닝 되며, 추가 객체나 클래스 생성은 없다.
    • 하지만 이 코드는 리스트를 걸러낸 중간 리스트를 만들고, 그 중간 리스트를 읽어서 사용한다.
  • asSequence를 통해 중간 시퀀스는 람다를 필드에 저장하는 객체로 표현되며, 최종 연산은 중간 시퀀스에 있는 여러 람다를 연쇄 호출한다.
    • 시퀀스는 람다를 인라인 하지 않는다.
    • 람다가 인라이닝이 되지 않기 때문에 크기가 작은 컬렉션은 오히려 일반 컬렉션 연산보다 성능이 더 좋지 않다.

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

inline 키워드를 배운다고 이를 남용하면 안 된다. inline 키워드를 사용해도 람다를 인자로 받는 함수만 성능이 좋아질 가능성이 높기 때문이다. 다른 경우에는 주의 깊게 성능을 측정하고 조사해 봐야 한다.

  • 일반 함수의 호출의 경우 JVM이 이미 강력하게 인라이닝을 지원한다.(JVM이 코드 실행을 분석해 가장 이익이 되는 방향으로 호출을 인라이닝한다). 
    • 이런 과정은 JIT 과정에서 일어난다
    • JVM의 최적화를 활용한다면, 바이트코드에서 각 함수 구현이 정확히 한 번만 있으면 되고, 그 함수를 호출하는 부분에서 따로 함수 코드를 중복할 필요가 없다.
    • 반면 코틀린 인라인 함수는 바이트 코드에서 각 함수 호출 지점을 본문으로 대치하기 때문에 코드 중복이 생긴다.
  • 반면 람다를 인자로 받는 함수를 인라이닝하면 이점이 많다.
    • 인라이닝을 통해 없앨 수 있는 부가비용이 크다.(함수 호출 비용 감소, 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체를 만들 필요 없다)
    • 현재의 JVM은 함수 호출과 람다를 인라이닝 해줄 정도록 똑똑하지 않다
    • 인라이닝을 사용하면 일반 람다에서는 사용할 수 없는 몇 가지 기능을 사용이 가능하다.
  • inline 변경자를 함수에 붙일 때는 코드 크기에 주의해야 한다
    • 인라이닝하는 함수가 큰 경우 함수의 본문에 해당하는 바이트코드를 모든 호출 지점에 복사해 넣고 나면 바이트 코드가 전체적으로 커질 수 있다.(그런 경우 람다 인자와 무관한 코드를 별도의 비인라인 함수로 빼낼 수도 있다)
    • 코틀린 표준 라이브러리가 제공하는 inline 함수를 보면 모두 크기가 아주 작다.

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

작업을 마친 후 자원을 해제하는 자원관리에서 람다를 사용해 중복을 제거할 수 있다. 여기서 자원은 파일, 락, 트랜잭션 등을 지칭한다.=

@kotlin.internal.InlineOnly
public inline fun <T> Lock.withLock(action: () -> T): T { //락 획들 후 작업하는 과정을 별도의 함수로 분리
    contract { callsInPlace(action, InvocationKind.EXACTLY_ONCE) }
    lock()
    try {
        return action()
    } finally {
        unlock()
    }
}
  • 코틀린에 정의되어 있는 withLock 함수 정의이다.
  • withLock은 Lock 인터페이스의 확장 함수이다.

자바에서의 try- with- resource문을 활용한 파일 읽기

static String readFirstLineFromFile(String path) throws IOException{
  try(BufferedReader br = new BufferedReader(new FileReader(path))){
    return br.readLine();
  }
}

코틀린

fun readFirstLineFromFile(path: String): String{
    BufferedReader(FileReader(path)).use{br -> //BufferedReader 객체를 만들고 use 함수를 호출하면서 파일에 대한 연산을 실행할 람다를 넘긴다
        return br.readLine() //자원에서 맨 처음 가져온 한줄을 람다가 아닌 readFirstLineFromFile에서 반환한다
    }
}
  • 코틀린에서는 함수 타입의 값을 파라미터로 받는 함수를 통해 쉽게 처리 가능
  • try - with - resource와 같은 기능을 제공하는 use라는 함수가 코틀린 표준 라이브러리에 들어있다
  • use는 닫을 수 있는 자원에 대한 확장 함수며 람다를 인자로 받는다.
  • use는 람다를 호출한 다음 자원을 닫아준다(예외가 발생한 경우에도 확실히 닫아준다)
    • use도 인라인 함수이다.
728x90