Today I Learned

람다를 사용한 함수형 프로그래밍(2) 본문

Kotlin/다재다능 코틀린 프로그래밍

람다를 사용한 함수형 프로그래밍(2)

하이라이터 2023. 9. 22. 15:11
728x90

비지역성(non-local)과 라벨(labeled) 리턴

리턴은 허용되지 않는 게 기본이다

  • 람다에서 return은 허용되지 않지만 특별한 상황에서는 사용할 수 있다.
fun invokeWith(n: Int, action: (Int) -> Unit) {
  println("enter invokeWith $n")
  action(n)
  println("exit invokeWith $n")
}
fun caller() { 
  (1..3).forEach { i -> 
    invokeWith(i) {
      println("enter for $it")
      
      if (it == 2) { return } //ERROR, return is not allowed here <label id="code.errorreturn" />
      
      println("exit for $it")
    }
  }

  println("end of caller")
} 

caller()
println("after return from caller")
  • caller() 함수의 if( it ==2 ) { return } 조건문은 여러 가지 의미로 해석 될 수 있다. 
    1. 즉시 람다에서 빠져나오고 invokeWith() 함수의 action(n) 이후의 나머지를 실행하라.
    2. for 루프를 빠져나와라.
    3. caller() 함수를 빠져나와라. 
  • 이런 혼란을 피하기 위해 코틀린은 return 키워드를 허용하지 않는다. 대신 예외 규칙을 만들어놨다.

 

라벨 리턴

  • 현재 람다에서 즉시 나가고 싶다면 라벨 리턴을 사용하면 된다.
  • return@label 형태로 사용하고, label 자리에는 label@ 문법을 이용해서 만든 라벨을 넣을 수 있다.
fun invokeWith(n: Int, action: (Int) -> Unit) {
  println("enter invokeWith $n")
  action(n)
  println("exit invokeWith $n")
}

fun caller() {
  (1..3).forEach { i ->
    invokeWith(i) here@ { 
      println("enter for $it")
      
      if (it == 2) {
        return@here 
      }
      
      println("exit for $it")
    }
  }

  println("end of caller")
} 

caller()
println("after return from caller")
  • 라벨 리턴은 람다의 흐름을 제어해서 라벨 블록으로 점프를 하기 위해 만들어졌다.
  • 라벨 리턴의 행동은 명령형 프로그래밍에서 반복문의 continue와 동일하다.
    • @here같이 명시된 라벨을 사용하는 대신 람다가 전달된 함수의 이름같은 암시적인 라벨을 사용할 수도 있다.
      (return@here -> return@invokeWith)

 

논로컬 리턴

  • 논로컬 리턴은 람다와 함께 구현된 현재 함수에서 나갈 때 유용하다.
fun caller() { 
  (1..3).forEach { i ->
    println("in forEach for $it")
    if (it == 2) { return }
    invokeWith(i) {
      println("enter for $it")
      if (it == 2) { return@invokeWith }
      println("exit for $it")
    }
  }

  println("end of caller")
} 

caller()
println("after return from caller")
  • 코틀린은 7번째 줄처럼 라벨 리턴이 아니라면 람다 내에서 리턴을 허용하지 않지만, 4번째 줄에서는 아무런 문제도 없다.
  • 4번째 줄의 리턴은 람다를 빠져나가는 라벨 리턴이 아니라 현재 실행중인 caller()를 빠져나간다.
  • 왜 invokeWith() 람다에 라벨이 없는 경우 return을 허용하지 않지만 foreEach()에 전달한 람다에는 return을 사용해도 상관없을까?
fun invokeWith(n: Int, action: (Int) -> Unit) {
inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
  • 답은 inline 키워드이다. 함수가 inline으로 선언되어있다면 논로컬 리턴을 사용해서 현재 동작중인 람다를 선언한 곳 바깥으로 나갈 수 있다.

 

람다를 이용한 인라인 함수

인라인 최적화는 없는 게 기본

fun invokeTwo(
  n: Int, 
  action1: (Int) -> Unit, 
  action2: (Int) -> Unit
  ): (Int) -> Unit {
  println("enter invokeTwo $n")
  action1(n)
  action2(n)
  println("exit invokeTwo $n")
  return { _: Int -> println("lambda returned from invokeTwo") }
}
fun callInvokeTwo() { 
  invokeTwo(1, { i -> report(i) }, { i -> report(i) }) 
}

callInvokeTwo()
fun report(n: Int) {
  println("")
  print("called with $n, ")                                 
  
  val stackTrace = RuntimeException().getStackTrace()
  
  println("Stack depth: ${stackTrace.size}")
  println("Partial listing of the stack:")
  stackTrace.take(3).forEach(::println)
}
  • callInvokeTwo()를 호출하면 invokeTwo()가 호출된다.invokeTwo()의 호출이 첫 번째 람다 파라미터인 action1()을 호출한다.
    람다는 report() 함수를 호출한다. 
  • 이와 유사하게 invokeTwo()가 두 번째 람다인 action2()를 호출하면 action2()가 report()를 호출한다.
  • report 함수는 현재 실행되고 있는 report() 함수의 콜스택 레벨을 알려준다.
  • 확인해보면 invokeTwo()를 호출하는 위치와 각각의 report() 호출 사이에 3 개의 스택 레벨이 있다. 

 

인라인 최적화

  • inlinte 키워드를 이용해 람다를 받는 함수의 성능을 향상시킬 수 있다.
  • 함수가 inline으로 선언되어잇으면 함수를 호출하는 대신 함수의 바이트코드가 함수를 호출하는 위치에 들어가게 된다.
  • 이렇게하면 함수 호출의 오버헤드를 제거하지만 함수가 호출되는 모든 부분에서 바이트 코드가 위치하기 때문에 바이트 코드가 커진다. 따라서 일반적으로 함수가 길거나 여러 곳에서 호출할 경우, 인라인으로 사용하는건 좋지 않다.
inline fun invokeTwo(
  n: Int, 
  action1: (Int) -> Unit, 
  action2: (Int) -> Unit
  ): (Int) -> Unit { ... }
  • 함수 바디는 변경 없이 inline 어노테이션만 함수 정의 앞에 붙었다. 컴파일러에게 호출을 최적화하라고 말한 것이다.
  • 다시 콜스택의 뎁스를 확인해보면 inline 어노테이션을 추가하기 전의 콜스택 3개가 사라져있다.

 

선택적 노인라인 파라미터

  • 어떤 이유로 람다 호출을 최적화하지 않는다면, 람다의 파라미터를 noinline으로 표시하여 최적화를 제거할 수 있다.
  • noinline 키워드는 함수가 inline인 경우에만 파라미터로 사용할 수 있다.
inline fun invokeTwo(
  n: Int, 
  action1: (Int) -> Unit, 
  noinline action2: (Int) -> Unit
  ): (Int) -> Unit { ... }

 

인라인 람다에서는 논로컬 리턴이 가능하다.

  • 이전 예제에서 invokeTwo() 함수에 inline 어노테이션을 사용했다. 그 결과 action1()은 인라인이 되었지만, action2()는 noinline을 사용했다.
  • 그래서 action1에 전달된 람다에는 논로컬 리턴과 라벨 리턴을 허용하지만, action2에는 라벨 리턴만 허용된다.

 

크로스인라인 파라미터

  • 함수를 인라인으로 마크하면 함수에서 실행되는 람다 파라미터는 인라인으로 간주된다.
  • 그런데 만약 주어진 람다를 호출하는게 아니라 람다를 다른 함수로 전달하거나 콜러에게 다시 돌려준다면 어떻게 될까?
    호출하지 않는 람다를 인라인으로 만들수 없다.
  • 하지만 람다가 호출될지 아닐 지 모를 때 인라인으로 만들고 싶다면 호출한 쪽으로 인라인을 전달하도록 함수에게 요청할 수 있다. 이게 바로 크로스인라인이다.
inline fun invokeTwo(
  n: Int,
  action1: (Int) -> Unit,
  action2: (Int) -> Unit //ERROR
  ): (Int) -> Unit {
  println("enter invokeTwo $n")
  action1(n)
  println("exit invokeTwo $n")
  return { input: Int -> action2(input) }
}
  • invokeTwo가 인라인일때 내부의 호출인 action1(n) 역시 인라인이 될 수 있다. 하지만 action2는 직접 호출하지 않기 때문에 마지막 줄의 람다에 포함된 action2(input)은 인라인이 될 수 없어서 에러가 발생한다.
inline fun invokeTwo(
  n: Int,
  action1: (Int) -> Unit,
  crossline action2: (Int) -> Unit
  ): (Int) -> Unit {
  • action2를 crossline으로 만들면 invokeTwo() 함수가 아니라 호출되는 부분에서 인라인이 된다.

 

인라인과 리턴을 위한 연습

  • 라벨이 없는 리턴은 항상 함수에서 발생하며 람다에서는 발행하지 않는다.
  • 라벨이 없는 리턴은 인라인이 아닌 람다에서 허용되지 않는다.
  • 함수명은 라벨의 기본 값이 되지만 이를 맹신해서는 안된다. 라벨 리턴을 사용할거라면 항상 라벨명을 지어야한다.
  • 일반적으로 코드 최적화를 하기 전에(특히 람다를 사용하는 코드) 성능 측정을 먼저 하라.
  • inline은 눈에 띄는 성능 향상이 있을 때만 사용하라.
728x90
Comments