Study/kotlin

[코틀린 프로그래밍] Chapter.10 람다를 사용한 함수형 프로그래밍

에디개발자 2021. 5. 18. 07:00
반응형

나를 닮았다고 한다...

 

함수형 프로그래밍은 본질적으로 복잡성이 낮습니다. 개발자는 적은 코드로 기능을 구현하고 코드가 더 읽기 쉽고, 이해하기 쉽고, 유지보수하기 쉽게 만들 수 있습니다.

 

Java 같은 언어는 명령형 스타일과 객체지향 스타일의 조합을 제안했습니다. 객체지향 프로그래밍은 추상화와 캡슐화 개념에서 유용했습니다. 함수형 프로그래밍의 목적은 객체지향형 프로그래밍을 대체하는 것이 아니라 복잡성을 낮추는 것입니다. 코틀린은 태생부터 명령형, 함수형, 객체지향형 패러다임을 지원했습니다. 

 

이번 글에서는 함수형 프로그래밍의 장점에 대해서 정리해보겠습니다. 또한 람다 표현식을 만들고 사용하는 법과 람다를 사용하는 경우에 대해서 알아보겠습니다.

 

함수형 스타일

 함수형 스타일은 선언적 스타일에서 태어났습니다. 선언적 스타일의 핵심은 캡슐화입니다.

가장 대표적인 예로 contains() 메서드입니다. 우리는 루프를 돌면서 리스트에 해당 객체가 있는 지 일일이 찾지 않아도 되게끔 도와주는 메서드입니다. 내부 로직은 어떻게 구현되어있는지는 신경쓰지 않아도 됩니다. 이유는 캡슐화가 되어있기 때문에 개발자는 해당 메서드를 호출하여 어떤 결과가 나올지 예상하여 사용할 수 있습니다.

 

명령형 스타일은 많은 개발자에게 익숙하지만 스타일이 복잡하고 읽기 어렵습니다. 이유는 변화하는 부분들이 너무 많기 때문입니다. 간단한 예시를 들어보겠습니다.

var doubleOfEven = mutableListOf<Int>()
for (i in 1..10) {
    if (i % 2 == 0) {
        doubleOfEven.add(i * 2)
    }
}

 

1부터 10까지 루프를 돌면서 짝수인 값에 2배한 값을 리스트 변수에 담는 로직입니다. 이 코드는 변수에 어떤타입인지 확인하고 루프를 확인하고 조건문은 무엇이 있고 리스트에 어떻게 값을 넣는지 확인해야합니다. 위에서 작성한 것과 같이 스타일이 매우 복잡합니다. 이 코드를 선언적 스타일로 수정해보겠습니다.

val doubleOfEven = (1..10)
    .filter { e -> e % 2 == 0 }
    .map { e -> e * 2 }

 

한 눈에 보기 쉬운 코드로 변경된 것을 확인할 수 있습니다. 따로 설명하지 않아도 충분이 알 수 있는 코드입니다.

장점을 몇가지 짚고 넘어가자면 뮤터블로 변수를 선언하지 않았고, 코틀린에서 지향하는 val 변수로 바로 받을 수 있는 장점도 있습니다.

 

함수형 스타일은 왜, 언제 사용해야 하는가?

함수형 스타일은 덜 복잡합니다. 하지만 매번 함수형을 쓰는 것이 명령형을 사용하는 것보다 좋은 것이 아닙니다. 함수형 스타일은 코드가 연산에 집중하고 있을 때 써야합니다. 그리하면 뮤터빌리티를 피할 수 있습니다. 

함수형을 사용하지 않아야할 때는 많은 입출력이 존재해 뮤테이션이나 부작용을 피할 수 없거나 코드가 많은 수의 예외를 처리해야 한다면 명령형 스타일이 더 좋은 선택입니다.

 

람다 표현식

// 람다의 문법
{ parameter list -> body }

람다는 블록({})으로 감싸져 있고 바디는 화살표(->)를 이용해서 파라키터와 분리됩니다. 그리고 바디는 일반적으로 단일 명령문 혹은 단일식입니다. 필요하다면 여러 줄이 될 수 있습니다. 하지만 권장하지 않습니다. 이유로는 코드가 어려워지고 읽기가 불편해집니다. 

 

문제가 주어지면 변화하는 단계들을 생각하고, 그 단계들 속에서 우리를 도와줄 함수를 찾는 능력을 키웁니다.

 

간단한 예시로 접근해보자! 하나의 숫자가 주어지고 이 숫자가 소수인지 아닌지를 알려주는 함수를 구현해보겠습니다.

  • 1보다 커야한다.
  • 2와 자기 자신사이에 나누어 떨어지는 숫자가 없는 경우
fun isPrime(n: Int) = n > 1 && (2 until n).none({ i: Int -> n % i == 0})

 

작성한 함수에 대해서 간단하게 설명해보겠습니다.

  • 값은 1보다 커야한다.
  • 2 부터 값까지 IntRange 인스턴스를 리턴한다.
  • none() 메소드는 하나라도 값이 있다면 false를 리턴한다.
  • 즉 2 부터 값까지의 값 중 자기 자신과 사이의 값으로 나누기 하여 나머지가 있지 않는 경우를 판별한다.

작성한 함수를 간략하게 하는 과정을 살펴보겠습니다. 

 

코틀린은 정적언어이므로 타입 추론이 가능하여 타입을 삭제합니다.

fun isPrime(n: Int) = n > 1 && (2 until n).none({ i -> n % i == 0})

 

none 메서드에서 하나의 파라미터만 사용하므로 괄호를 생략합니다.

fun isPrime(n: Int) = n > 1 && (2 until n).none { i -> n % i == 0}

 

람다 내부에서는 암시적 파라미터(it)를 사용할 수 있습니다.

fun isPrime(n: Int) = n > 1 && (2 until n).none { n % it == 0}

 

람다 받기

지금까지 람다를 고차함수로 전달하는 방법을 작성하였습니다. 지금부터는 람다를 파라미터로 받는 람다를 만들어보겠습니다.

1부터 전달받은 숫자까지 반복하면서 전달받은 람다를 범위 안에서 실행하는 함수를 만들어보겠습니다.

fun walk1To(action: (Int) -> Unit, n: Int) =
    (1..n).forEach { action(it) }
    
walk1To({ i -> println(i) }, 5)  // 12345

 

람다를 마지막 파라미터로 사용하기

코틀린에서는 람다를 마지막 파라미터로 사용할 경우 중괄호 영역에 람다를 작성할 수 있습니다. 위의 예제에서 람다 표현식을 마지막 파라미터로 변경하여 예시를 들어보겠습니다.

fun walk1To( n: Int, action: (Int) -> Unit ) =
    (1..n).forEach { action(it) }
    
walk1To(5, { i -> println(i) })  // 12345

 

위 코드는 아래와 같이 사용할 수 있습니다.

walt2To(5) { i -> println(i) }

 

암묵적 파라미터를 적용합니다.

walt2To(5) { println(it) }

 

함수 참조 사용

코틀린은 가독성을 높이기 위해 어떻게 규칙을 풀어줬는지 알아보았습니다. 하지만 람다를 페스스루로 사용하면 보다 더욱 가독성이 높아집니다. 페스스루 람다를 파라미터가 전달될 함수의 이름으로 대체할 수 있습니다.

({x -> someMethod(x) })

 

위 코드는 아래와 같이 변경할 수 있습니다.

(::someMethod)

 

그렇다면 위에서 작성한 예시를 적용해보겠습니다.

// 적용전
fun walk1To( n: Int, action: (Int) -> Unit ) =
    (1..n).forEach { action(it) }
    

// 적용후
fun walk1To( n: Int, action: (Int) -> Unit ) =
    (1..n).forEach(action)

 

위 처럼 파라미터를 action으로 보내기만 하는 중개인을 제거했습니다. 전반적으로 이해하기도 쉬워지고 작업도 줄어들며 작동 시간도 짧아집니다. 다른 예도 살펴보겠습니다.

// 적용 전
walt2To(5) { i -> println(i) }


// 적용 후
walt2To(5, ::println)

 

참조는 암시적 리시버인 this가 될 수 있습니다. 아래의 코드에서 암시적 리시버인 this를 사용해보겠습니다.

fun send(n: Int) = println(n)

// 적용전
walk1To(5) { i -> send(i) }

// 적용후
walk1To(5, this::send)

 

같은 구조가 싱글톤에서 함수를 사용할 때 사용됩니다.

object Terminal {
    fun write(value: Int) = println(value)
}

// 적용 전
walk1To(5) { i -> Terminal.write(i) }

// 적용 후
walk1To(5, Terminal::write)

 

함수를 리턴하는 함수

함수를 리턴하는 함수가 필요한 경우에 대해서 작성해보겠습니다. 

// 람다를 중복사용..
val names = listOf("Pam", "Pat", "Namas", "Soms")
println(names.find { name -> name.length == 4 })
println(names.find { name -> name.length == 5 })

 

이 코드는 중복 코드입니다. 아래와 같이 수정할 수 있습니다.

// 람다를 리턴하는 함수
fun predicationOfLength(length: Int): (String) -> Boolean {
    return { input: String -> input.length == length }
}

println(names.find(predicationOfLength(4)))
println(names.find(predicationOfLength(5)))

 

위 코드는 더 간결하게 리팩토링할 수 있습니다.

// refectoring
fun predicationOfLength1(length: Int) =
    { input: String -> input.length == length }

 

람다와 익명 함수

람다는 함수의 아규먼트로 전달될 수도 있습니다. 그런데 람다가 중복호출이 된다면 2가지 방법으로 중복을 제거할 수 있습니다.

  • 재사용을 위해서 람다를 변수에 담는 것
  • 람다 대신 익명 함수를 사용하는 것

재사용을 위해 람다를 변수에 담기

val names = listOf("Kim", "Park", "Hello")
val checkLength5 = { name: String -> name.length == 5 }

println(names.find(checkLength5))  // Hello

위 예제는 변수에 String Type 리스트를 선언합니다. 그리고 람다는 String 변수가 넘어오면 길이가 5인지 체크합니다. 마지막으로 프린트에서 String Type 리스트에서 람다를 통해서 검색을 시도하는 예제입니다. 

위 코드에서는 람다에서 타입추론이 불가능하기 때문에 타입을 선언하는 코드를 확인할 수 있습니다. 

 

이 코드를 코틀린이 타입추론이 가능하도록 수정해보겠습니다.

val checkLength5: (String) -> Boolean = { name -> name.length == 5 }

변수가 리턴하는 타입에 변수를 지정하여 코틀린에서 타입 추론이 가능하도록 변경하였습니다.

 

Tip! 아래와 같이 변수와 람다 모두 타입을 지정하지 말자! 한 군데만 선언하도록 하자!

// 타입을 두 군데서나 선언하네..? 이런짓은 하지 말자
val checkLength5: (String) -> Boolean = { name: String -> name.length == 5 }

 

람다 대신 익명 함수를 사용

람다가 할당될 변수의 타입을 정의한다면 반드시 리턴타입을 지정해야합니다. 람다의 파라미터 타입을 지정한다면 리턴타입은 타입 추론이 됩니다. 다른 옵션으로 변수의 타입은 타입 추론을 사용하고, 리턴타입만 지정하는 방법이 있는데 이것을 익명 함수라고 합니다. 

 

val checkLength5 = fun(name: String): Boolean {
    return name.length == 5
}

위 코드 처럼 익명함수는 일반 함수처럼 작성되지만 이름이 없는 함수입니다.

 

names.find(fun(name: String): Boolean {return name.length == 5})

익명 함수를 변수에 저장하지 않고 함수를 호출할 때 직접 아규먼트에 작성하여 사용할 수도 있습니다. 

 

 

클로저와 렉시컬 스코핑

정의를 작성해도 잘 이해가 가지 않는다. 바로 예제로 알아보자! 

val doubleIt = { e: Int -> e * 2 }

위 람다 소스를 클로저로 변경해보겠습니다.

 

/*
 외부 상태에 의존하는 람다 생성.
 이것을 클로저라고 부른다.
 */
val factor = 2
val doubleIts = { e: Int -> e * factor }

람다의 바디 안에 factor는 존재하지 않습니다 즉, factor는 로컬 변수가 아니고 클로저의 범위(스코프)입니다. 위 람다는 외부 상태에 의존하는 람다입니다.

 

/*
 렉시컬 스코핑이란 무엇일까?
 위 소스에서 factor를 찾아야한다. 하지만 못찾는다면 스코프를 확장하고 못찾으면 또 확장하고.. 반복하는 행위를 말한다.
 아래의 코드에서 length 파라미터의 값은 존재하지 않는다. 스코프를 확장하여 찾아야한다. 이걸 말한다.
 */
fun predicationOfLength(length: Int): (String) -> Boolean {
    return { input: String -> input.length == length }
}

컴파일러는 factor 변수에 대한 클로저의 범위(클로저의 바디)가 정의된 곳을 찾게 됩니다. 클로저가 정의된 곳에서 factor 변수를 찾지 못하면 확장하고 또 못찾으면 확장하고... 이 것을 렉시컬 스코핑이라고 합니다. 

 

정리하자면...

  • 클로저 : 람다가 외부 상태값에 의존한다면 외부 상태값이 클로저이다. ( factor )
  • 렉시컬 스코핑 : 람다에서 사용중인 클로저의 바디를 찾는 행위

 

람다의 클로저를 뮤터블로 사용하는 것은 지양해야합니다. 아래 예제를 살펴보겠습니다. 

var factor = 2
val doubled = listOf(1, 2).map { it * factor }
val doubledAlso = sequenceOf(1, 2).map { it * factor }
factor = 0
doubled.forEach { println(it) }
doubledAlso.forEach { println(it) }

결과를 유추해보자. 2,4,2,4..? 0,0,0,0..? 2,4,0,0..? 유추하기 어려워집니다. 

 

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

람다는 리턴값이 있더라도 return 키워드를 가질 수 없습니다. 하지만 익명 함수는 리턴할 값이 있는 경우 return을 반드시 사용해야합니다. 이번 단락에서는 람다는 return이 없는 것이 기본인 것을 확인하고 라벨 리턴을 사용해보겠습니다.

 

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

람다는 return을 허용하지 않지만 특별한 상황에서는 사용할 수 있습니다. 예제를 통해서 알아보겠습니다. 

람다를 파라미터로 갖는 간단한 함수를 생성하고 사용해보겠습니다.

// 람다를 사용하는 간단한 메서드
fun invokeWith(n: Int, action: (Int) -> Unit) {
    println("enter invokeWith $n")
    action(n)
    println("exit invokeWith $n")
}
// lambda에서는 return이 없다.
fun caller() {
    (1..3).forEach { i ->
        invokeWith(i) {  // use
            println("enter for $it")

            if (it == 2) {
                return    // ERROR 발생!!
            }

            println("exit for $it")
        }
    }
}

위 코드에서 확인할 수 있듯이 람다 내부에서는 return을 사용할 수 없습니다. 이런 결과가 나온 이유는 코틀린은 return이 어떤 행위를 의미하는지 알 수 없기 때문입니다.

  • 람다에서 return..?
  • for 루프에서 return..?
  • caller 메서드에서 return..?

이런 혼란을 피하기 위해 라벨 리턴과 논로컬 리턴을 사용합니다.

라벨 리턴

현재 람다에서 return하고 싶을 경우 라벨 리턴을 사용합니다. 

/*
라벨 리턴
here@, @here을 사용하자! 리턴하면 여기로 오세요 ~
for문에서 continue와 동일하다.
 */

fun caller() {
    (1..3).forEach { i ->
        invokeWith(i) here@{  // 여기로 리턴!
            println("enter for $it")

            if (it == 2) {
                return@here  // 라벨리턴
            }

            println("exit for $it")
        }
    }
}

 

위 코드와 동일한 기능을 하는 암시적 라벨을 사용해보겠습니다.

fun caller1() {
    (1..3).forEach { i ->
        invokeWith(i) {
            println("enter for $it")

            if (it == 2) {
                return@invokeWith  // 암시적 라벨 리턴
            }

            println("exit for $it")
        }
    }
}

// use
caller1()
println("after return from caller")

이 코드를 보면 명확하게 어디로 리턴하는지 알 수 있습니다. 본인은 이 방법을 더 선호합니다.

/*
위 코드의 결과값...
enter invokeWith 1
enter for 1
exit for 1
exit invokeWith 1
enter invokeWith 2
enter for 2
exit invokeWith 2
enter invokeWith 3
enter for 3
exit for 3
exit invokeWith 3
after return from caller
 */

 

논로컬 리턴

람다와 함께 구현된 현재 함수에서 나갈 때 유용한 기능입니다. 위에서 작성한 메서드에 논로컬 리턴을 적용해보겠습니다.

/*
 논로컬 리턴
 논로컬 리턴을 사용하면 현재 동작중인 람다를 선언한 곳 바깥으로 나간다.
 */

fun caller2() {
    (1..3).forEach { i ->
        println("in forEach for $i")
        if (i == 2) {
            return  // 논로컬 리턴 사용
        }
        invokeWith(i) {
            println("enter for $it")

            if (it == 2) {
                return@invokeWith
            }

            println("exit for $it")
        }
    }
}

여기서 의문을 가져야합니다. forEach도 람다방식으로 사용하는 데 내부에서 return 사용이 됩니다.

 

왜?? 

invokeWith와 forEach는 차이점이 있기 때문입니다. 

fun invokeWith(n: Int, action: (Int) -> Unit) {
    println("enter invokeWith $n")
    action(n)
    println("exit invokeWith $n")
}

@kotlin.internal.HidesMembers
public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
    for (element in this) action(element)
}

forEach 메서드는 inline 함수이기 때문입니다. 논로컬 리턴은 inline으로 선언된 경우에만 사용할 수 있습니다.

 

람다를 이용한 인라인 함수

람다를 사용할 때 주의할 사항은 퍼포먼스 입니다. 코틀린은 람다를 사용할 때 호출 오버헤드를 제거하고 성능 향상을 위해 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") }
}

이 코드는 inline없이 시작하기에 좋은 예제입니다. 이 함수를 다른 함수에서 호출해보겠습니다.

 

fun callInvokeTwo() {
    invokeTwo(1, { i -> report(i) }, { i -> report(i) })
}

이 코드에서는 invokeTwo 메서드를 호출할 때 report 메서드를 호출하고 있습니다. report 메서드도 작성해보겠습니다.

 

fun report(n: Int) {
    println("")
    print("called with $n, ")
    val stackTrace = RuntimeException().stackTrace
    println("Stack depth: ${stackTrace.size}")
    println("Partial listing of the stack:")
    stackTrace.take(3).forEach(::println)
}

report 메서드는 함수의 콜스택 레벨을 보여주고 있습니다. 그럼 위 코드를 실행하여 결과값을 확인해보겠습니다.

 

enter invokeTwo 1

called with 1, Stack depth: 43
Partial listing of the stack:
Noinline.report(noinline.kts:19)
Noinline$callInvokeTwo$1.invoke(noinline.kts:26)
Noinline$callInvokeTwo$1.invoke(noinline.kts:1)

called with 1, Stack depth: 43
Partial listing of the stack:
Noinline.report(noinline.kts:19)
Noinline$callInvokeTwo$2.invoke(noinline.kts:26)
Noinline$callInvokeTwo$2.invoke(noinline.kts:1)
exit invokeTwo 1

callInvokeTwo 메서드를 실행시키면 내부에서 invokeTwo 메서드를 호출합니다. 이 때 파라미터로 report 메서드를 2번 호출하게 되고 함수의 콜스택을 프린트하게 됩니다. 결과값으로 알 수 있는 것은 invokeTwo 메서드를 호출하는 위치와 각각의 report() 호출 사이에 3개의 스택 레벨이 있습니다. 그게 43개의 뎁스 중 상위 3개입니다. 

 

인라인 최적화

위에서 작성한 코드를 inline 키워드를 이용하여 함수의 성능을 향상시키겠습니다. 함수가 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") }
}

invokeTwo 메서드를 inline 키워드를 작성한 후 위와 동일한 코드를 실행하여 결과값을 살펴보겠습니다.

 

enter invokeTwo 1

called with 1, Stack depth: 40
Partial listing of the stack:
Noinline.report(noinline.kts:19)
Noinline.callInvokeTwo(noinline.kts:26)
Noinline.<init>(noinline.kts:29)

called with 1, Stack depth: 40
Partial listing of the stack:
Noinline.report(noinline.kts:19)
Noinline.callInvokeTwo(noinline.kts:26)
Noinline.<init>(noinline.kts:29)
exit invokeTwo 1

 

콜스택의 상위 3개가 사라진것을 확인할 수 있습니다. (43 -> 40)

 

무작정 사용하는 것은 바람직하지 않습니다. inline이 될 함수가 매우 크거나 여러 곳에서 사용한다면 inline을 사용하지 않을 때보다 바이트코드가 훨씬 커지게 될 것입니다. 

 

선택적 노인라인 파라미터

선택적으로 인라인 최적화를 제거하고 싶다면 noinline 키워드를 사용합니다. 위 예제에서 invokeTwo() 함수를 inline으로 만들면 action1()도 인라인이 됩니다. 하지만 action2()에 noinline을 사용하면 최적화에서 제거할 수 있습니다.

// 선택적인 noinline
inline fun invokeTwo (
    n: Int,
    action1: (Int) -> Unit,
    noinline action2: (Int) -> Unit  // noinline
) : (Int) -> Unit {
    return { _: Int -> println("return...") }
}

noinline이 잘 적용되었는지 결과값으로 확인해보겠습니다.

 

enter invokeTwo 1

called with 1, Stack depth: 40
Partial listing of the stack:
Noinline.report(noinline.kts:19)
Noinline.callInvokeTwo(noinline.kts:26)
Noinline.<init>(noinline.kts:29)

called with 1, Stack depth: 42
Partial listing of the stack:
Noinline.report(noinline.kts:19)
Noinline$callInvokeTwo$2.invoke(noinline.kts:26)
Noinline$callInvokeTwo$2.invoke(noinline.kts:1)
exit invokeTwo  

 

action1은 인라인 최적화가 되어있고 action2는 최적화에서 제거되었습니다. 결과값에서 확인하면 action1은 40, action2는 42로 확인할 수 있습니다.

 

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

이전 예제에서 작성한 코드에서 논로컬 리턴을 사용한 예제를 작성해보겠습니다.

// 인라인 람다에서는 논로컬 리턴이 가능하다.
fun callInvokeTwo() {
    invokeTwo(1,
        { i -> if (i == 1) { return } },
        { i -> if (i == 1) { return } }  // noinline이기 때문에 return 불가
    )
}

 

첫 번째 파라미터는 inline 키워드를 사용했기 때문에 논로컬 리턴이 가능하지만 두 번째 파라미터는 noinline으로 최적화에서 제거하였기 때문에 논로컬리턴이 불가능한 것을 확인할 수 있습니다. 이유는 더 많은 스택 레벨이 있기 때문입니다.

 

크로스인라인 파라미터

함수에 inline 키워드를 사용했다면 noinline으로 최적화를 제거하지 않은 람다 파라미터는 inline으로 작성됩니다. ( 함수에서 람다가 실행되는 위치에 람다의 블록 바디가 삽입된다. ) 여기서 주어진 람다를 호출하지 않고 람다를 다른 함수로 전달하거나 콜러에게 다시 돌려준다면 미묘한 경우가 발생합니다. 람다가 호출되는게 아니고 전달되는 경우라면 람다 파라미터에게 아무런 어노테이션도 안한다는 건 불가능합니다. 이럴 경우 해결방법은 람다에 noinline 키워드를 작성하는 것인데 람다가 호출될지 아닐지 모를 때 인라인으로 만들고 싶다면 어떨까?? 이 때 크로스인라인을 사용하여 문제를 해결할 수 있습니다.

 

inline fun invokeTwo3 (
    n: Int,
    action1: (Int) -> Unit,
    action2: (Int) -> Unit
): (Int) -> Unit {
    println("enter invokeTwo $n")
    action1(n)
    println("exit invokeTwo $n")
    return { input: Int -> action2(input)}  // Error 발생
}

inline 키워드를 작성한 메서드의 파라미터인 람다는 inline으로 작성하게 됩니다. 이 예제에서 action2 파라미터는 inline으로 작성되었지만 내부에서 사용하지 않고 외부로 리턴하는 데 사용하기 때문에 inline으로 사용할 수 없습니다. 그런데 noinline으로 선안하지 않았기 때문에 충돌이 발생하고 컴파일러가 에러를 발생시킵니다. 이와 같은 경우 해결방법은 두 가지가 있습니다. 

- 두 번째 파라미터를 noinline으로 마크한다. 노인라인을 하면 성능상의 이득이 없고 논로컬 리턴을 사용할 권한도 없다.
- 두 번째 파라미터를 crossinline으로 만든다. action2 함수는 invokeTwo() 함수가 아니고 호출되는 부분에서 인라인이 된다. ( 선택 )

 

이 코드를 크로스인라인을 사용하여 해결해보겠습니다.

inline fun invokeTwo3(
    n: Int,
    action1: (Int) -> Unit,
    crossinline action2: (Int) -> Unit  // 크로스인라인 사용
): (Int) -> Unit {
    println("enter invokeTwo $n")
    action1(n)
    println("exit invokeTwo $n")
    return { input: Int -> action2(input) }
}

inline은 함수를 인라인으로 만들어서 함수 호출의 오버헤드를 제거해서 함수 성능을 최적화 시켰습니다. 그리고 두 번째 파라미터인 action2에 crossinline을 작성하여 최적화를 해줍니다.

But! 람다가 전달된 곳이 아니라 실제로 람다가 사용된 곳에서 인라인 최적화가 진행되고 파라미터로 전달된 람다가 noinline이나 crossinline이 아닌 경우만 논로컬 리턴으로 쓸 수 있습니다.

 

return과 inline과 연관된 좋은 예시

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

반응형