[코틀린] inline 함수와 reified

berry·2023년 4월 8일
0

Inline 함수 사용 이유

인라인 키워드는 코틀린 전용 키워드로, 고차 함수를 사용해서 발생할 수 있는 패널티를 방지해준다.(출처: 코틀린 inline 공식문서)

여기서 패널티란 추가적인 메모리 할당 및 함수호출로 Runtime overhead 가 발생한다는 것으로, 람다를 사용하면 각 함수는 객체로 변환되어 메모리 할당과 가상 호출 단계를 거치는데 여기서 런타임 오버헤드가 발생한다.

inline functions는 내부적으로 함수 내용을 호출되는 위치에 복사하며, Runtime overhead를 줄여준다.

람다식은 어떻게 디컴파일 될까?

람다의 경우 컴파일 단계에서 파라미터 개수에 따라 FunctionN 형태의 인터페이스로 변환이 된다. (파라미터 개수가 2개라면 Function2 인터페이스로 변환)

아래 코드는 함수를 파라미터로 받고 해당 함수를 호출하는 코드이다.

코틀린

fun doSomething(a: ()->Unit){
    a()
}

아래 코드는 디컴파일 코드이다.

자바 (디컴파일)

public static final void doSomething(@NotNull Function0 a) {
      Intrinsics.checkNotNullParameter(a, "a");
      a.invoke();
   }

컴파일된 코드를 보면 함수 타입은 일반 인터페이스로 바뀐다.
즉 함수 타입의 변수는 FunctionN 인터페이스를 구현하는 객체를 저장한다.
각 인터페이스에는 invoke 메소드 정의가 하나 들어 있다. invoke를 호출하면 함수를 실행할 수 있다.
함수 타입인 변수는 인자 개수에 따라 적당한 FunctionN 인터페이스를 구현하는 클래스의 인스턴스를 저장하며, 그 클래스의 invoke 메소드 본문에는 람다의 본문이 들어간다.

inline function: 람다의 부가 비용 없애기

코틀린은 람다를 보통 무명 클래스로 컴파일한다. 그렇다고 람다 식을 사용할 때마다 새로운 클래스를 만드는 것은 아니다.
보통 하나의 무명 클래스 객체를 만들어서 재사용하지만, 람다가 변수를 capture 하면 람다가 생성되는 시점마다 새로운 무명 클래스 객체가 생긴다. 이런 경우 실행 시점에 무명 클래스 생성에 따른 부가 비용이 든다. 따라서 람다를 사용하는 구현은 똑같은 작업을 수행하는 일반 함수를 사용한 구현보다 덜 효율적이다.

그러나 inline 변경자를 사용하면 효율적으로 람다를 사용할 수 있다.
inline 변경자를 함수에 붙이면, 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트코드로 바꿔치기해준다.

실제 작동 모습

  • 일반 함수
  • 람다를 매개변수로 받는 함수
  • 람다를 매개변수로 받는 inline 함수
    이 함수들을 디컴파일하면 어떻게 되는지 비교해보자!

코틀린

  fun nonInlineGeneralFun(a: Int, b: Int) {
    println("람다 없는 일반 함수")
}

fun nonInlineFun(operation: (Int, Int) -> Int) {
    operation(2, 3)
    println("람다 있는 non-inline 함수")
}

inline fun inlineFun(operation: (Int, Int) -> Int) {
    operation(2, 3)
    println("람다 있는 inline 함수")
}

fun main() {
    // 람다가 캡쳐할 변수
    var captured = 0

    nonInlineGeneralFun(1, 1)

    nonInlineFun { i, i2 ->
        println("[non-inline 함수] 람다 내 캡쳐된 변수: $captured")
        i + i2
    }

    inlineFun { i, i2 ->
        println("[inline 함수] 람다 내 캡쳐된 변수: $captured")
        i + i2
    }
}

자바

public static final void main() {
      final IntRef captured = new IntRef();
      captured.element = 0;
      //  일반 함수
      nonInlineGeneralFun(1, 1);

      // non-inline 함수에서 캡쳐된 변수를 사용했을 때, 아래처럼 무명함수 객체 생긴 것 확인
      nonInlineFun((Function2)(new Function2() {
         public Object invoke(Object var1, Object var2) {
            return this.invoke(((Number)var1).intValue(), ((Number)var2).intValue());
         }

         public final int invoke(int i, int i2) {
            String var3 = "[non-inline 함수] 람다 내 캡쳐된 변수: " + captured.element;
            boolean var4 = false;
            System.out.println(var3);
            return i + i2;
         }
      }));

      // inline 함수라서 함수 내용이 그대로 복사됨.
      int $i$f$inlineFun = false;
      int i2 = 3;
      int i = 2;
      int var4 = false;
      String var5 = "[inline 함수] 람다 내 캡쳐된 변수: " + captured.element;
      boolean var6 = false;
      System.out.println(var5);
      int var10000 = i + i2;
      String var7 = "람다 있는 inline 함수";
      boolean var8 = false;
      System.out.println(var7);
   }

inline 이 작동하는 방식

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

inline 함수가 람다를 받는다면 함수 본문 뿐만 아니라 그 함수에 전달된 람다의 본문도 함께 인라이닝 된다.

한 인라인 함수를 두 곳에서 각각 다른 람다를 사용해 호출한다면 그 두 호출은 각각 따로 인라이닝 된다. 인라인 함수의 본문 코드가 호출 지점에 복사되고 각 람다의 본문이 인라인 함수의 본문 코드에서 람다를 사용하는 위치에 복사된다.

Reified 란?

JVM의 제네릭은 보통 타입 소거(type eraser)를 사용해서 구현된다.

타입 소거

  • 원소 타입을 컴파일 타임에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것입니다.
  • 즉, 컴파일 타임에만 타입에 대한 제약 조건을 적용하고, 런타임에는 타입에 대한 정보를 제거한다는 뜻입니다.
    inline과 함께 refied 키워드를 사용하면 Generics를 사용하는 메소드 까지 처리할 수 있다.

이 말은 런타임에서 제네릭 클래스의 인스턴스에 타입 파라미터 정보가 들어있지 않다는 뜻이다.
타입 소거로 인해 생기는 한계로는, 런타임 시 타입 파라미터 정보가 없기 때문에 타입 파라미터를 검사할 수 없다. 예를 들어 List<String> 객체를 만들고 그 안에 문자열을 여러 개 넣더라도 런타임에서는 단지 List로만 볼 수 있게 된다.
이 List 객체가 어떤 타입의 원소를 저장하는지 런타임에서는 알 수가 없다.

먼저 결론부터 말하자면, 함수를 inline으로 만들고 타입 파라미터를 reified로 지정하면 타입 파라미터가 소거되지 않게 할 수 있다. 즉, value의 타입이 T의 인스턴스인지를 런타임 시점에 알 수 있게 된다.

fun <T> genericTest(value:Any?) = value is T // 에러 발생 (호출 시 쓰인 타입 파라미터를 알 수가 없다.)
inline fun <T> genericTest(value:Any) = value is T // 에러 발생
inline fun <reified T> genericTest(value:Any) = value is T // 실행 성공!

genericTest 함수를 인라인(inline) 함수로 만들고 타입 파라미터를 reified로 지정하면 value의 타입이 T의 인스턴스인지를 런타임 시점에 알 수 있게 된다.


출처

profile
공부 내용 기록

0개의 댓글