Kotlin

[Kotlin] 자바 함수형(SAM) 인터페이스 활용

sh1mj1 2024. 1. 16. 16:34

kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

 

 

코틀린으로 개발을 할 때 대다수의 API 는 코틀린이 아닌 자바로 작성된 API 일 것이다.

그런데 코틀린 람다를 자바 API 에 사용해도 아무 문제가 없다.

 

코틀린에서는 자바 메서드에 람다를 넘길 수 있다.

안드로이드 개발시 많이 사용하는 `Button` 클래스는 `setOnClickListener` 메서드를 사용하여 버튼의 리스너를 설정한다.

public class Button {
    public void setOnClickListener(OnClickListener l) { ... }
}

 여기서 `OnClickListener` 인터페이스는 `onClick` 이라는 메서드만 선언된 인터페이스이다.

public interface OnClickListener {
    void onClick(View v);
}

 자바 8 이전 자바에서는 `setOnClickListener` 메서드로 인자를 넘기기 위해서는 익명 객체(익명 클래스 인스턴스)를 만들어야 했다.

button.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        ...
    }
}

반면, 코틀린에서는 익명 객체 대신 람다를 넘길 수 있다.

button.setOnClickListener { view -> ... }

 이런 코드가 작동하는 이유는 `OnClickListener` 에 추상 메서드가 단 하나만 있기 때문이다.

그런 인터페이스를 functional interface(함수형 인터페이스), 또는 SAM(Single Abstract Method, 단일 추상 메서드) 인터페이스라고 한다.

 

위 코틀린 코드에서는 람다의 파라미터는 메서드의 파라미터와 대응한다.

람다의 파라미터는 메서드의 파라미터와 대응한다

이렇게 코틀린 코드는 익명 객체를 따로 선언할 필요없이 람다를 사용하면 되어 깔끔하다.

코틀린에서는 함수 타입이 존재한다.
따라서 코틀린에서는 함수를 인자로 받는 함수는 함수형 인터페이스가 아닌, 함수 타입을 인자 타입으로 사용하는 게 좋다.
함수 선언에서 함수 타입을 사용하는 방법은 나중에 다루니, 그런게 있다고 기억해두고 넘어가자.

자바 메서드에 람다를 인자로 전달

함수형 인터페이스를 인자로 하는 자바 메서드에 코틀린 람다를 넣을 수 있다.

 

`Runnable` 타입의 인자를 받는 함수 예

void postponeComputation(int delay, Runnable computation);

 `Runnable` 은 SAM 인터페이스이므로 람다를 넘길 수 있다.

@FunctionalInterface
public interface Runnable {
    void run();
}

 `postponeComputation` 함수를 코틀린으로 호출해보자.

postponeComputation(1000) { println(42) }

 컴파일러는 자동으로 람다를 `Runnable` 인터페이스로 변환해준다.

즉, `Runnable` 을 구현한 익명 클래스의 인스턴스를 만들어주는 것이다.

람다 바디는 `run` 메서드의 바디이다.

람다로 SAM 구현 VS. 익명 객체 사용

위 `postponeComputation` 호출 시, 물론 `Runnable` 을 구현하는 익명 객체를 명시적으로 만들어서 사용할 수도 있다.

postponeComputation(1000, object: Runnable {
    override fun run() { 
        println(42)
    }
})

 하지만 람다로 SAM 을 구현하는 것과 익명 객체를 사용하는 것에는 큰 차이가 있다.

 

익명 객체를 명시적으로 선언하는 경우, 메서드를 호출할 때마다 새 객체가 생성된다.

반면에, 람다가 정의된 함수에서 변수를 캡처(포획)하지 않으면, 람다가 만들어낸 익명 객체를 호출 시마다 반복 사용한다.

즉, `postponeComputation(1000) { println(42) }` 에서 `println(42)` 를 바디로 가지는 `Ruannable` 의 인스턴스는 프로그램 전체에서 단 하나만 만들어진다.

 

만약 익명 객체를 명시할 때, 람다와 동일한 코드는 아래와 같다.

val runnable = Runnable { println(42) } // 전역 변수로 컴파일되므로 프로그램 안에 단 하나의 인스턴스만 존재
fun handleComputation() {
    Postpone.postponeComputation(1000, runnable) // 모든 handleComputation 호출에 같은 객체를 사용
}

 하지만 만약 람다가 주변 스코프의 변수를 캡처(포획)한다면 매 호출마다 같은 인스턴스를 사용할 수 없다.

그런 경우 컴파일러는 매번 주변 스코프의 변수를 캡처한 새 인스턴스를 만든다.

 

id 를 필드로 저장하는 새 Runnable 인스턴스를 매번 새로 만드는 예시

fun handleComputation(id: String) { // 람다 안에서 id 변수를 캡처함
    Postpone.postponeComputation(1000) { println(id) } // handleComputation 을 호출할 때마다 새 Runnable 인스턴스 생성
}

 

실제로 함수형 인터페이스를 받는 자바 메서드를 코틀린에서 호출할 때, 람다는 익명 객체를 내부적으로 생성해서 메서드에 넘긴다.

하지만, 코틀린 inline 으로 표시된 코틀린 함수에게 람다를 넘기는 경우, 익명 객체를 생성해내지 않는다.

컬렉션을 확장한 메서드, 그리고 대부분의 코틀린 확장 함수들은 inline 표시가 붙어있다.

이에 대해서는 인라이닝에 대해 깊게 이해해야 하므로 나중에 따로 다루겠다.

SAM 생성자: 람다를 함수형 인터페이스로 명시적으로 변경

위에서 람다와 자바 함수형 인터페이스 사이의 변환은 자동으로 이뤄지는 것을 배웠다.

하지만 수동으로 변환해야 하는 경우가 있다.

 

SAM 생성자람다를 함수형 인터페이스의 인스턴스로 변환할 수 있도록 컴파일러가 자동으로 생성한 함수이다.

컴파일러가 자동으로 람다를 함수형 인터페이스 익명 클래스로 바꾸지 못할 때 SAM 생성자를 사용할 수 있다.

 

함수형 인터페이스의 인스턴스를 리턴하는 메서드는 람다를 직접 리턴 불가 -> 리턴하고픈 람다를 SAM 생성자로 감싸야 함.

fun createAllDoneRunnable(): Runnable {
    return Runnable { println("All don!") }
}

createAllDoneRunnable().run()

 위처럼 SAM 생성자의 이름의 함수형 인터페이스의 이름과 같다.

 

또 람다로 생성한 함수형 인터페이스 인스턴스를 변수에 저장할 때도 SAM 생성자를 사용하면 된다.

SAM 생성자를 사용해 `listener` 인스턴스 재사용

val listener = OnClickListener { view ->
    val text = when (view.id) {
        R.id.button1 -> "First button"
        R.id.button2 -> "Second button"
        else -> "Unknown button"
    }
    showToast(text)
}

button1.setOnClickListener(listener)
button2.setOnClickListener(listener)

 `listener` 는 어떤 버튼이 클릭되었는지에 따라 적절한 동작을 수행하도록 되어 있다.

 

함수형 인터페이스를 요구하는 메서드를 호출할 때 대부분의 SAM 변환을 컴파일러가 자동으로 수행할 수 있지만, 

가끔 오버로드한 메서드가 많아서 어떤 타입의 메서드를 선택하여 람다를 변환해 넘겨줘야 할지 모호할 때가 있다.

이런 경우에도 명시적으로 SAM 생성자를 적용하면 컴파일 오류를 피할 수 있다.

람다와 리스너 등록/해제

람다에서는 인스턴스 자기 자신을 가리키는 `this` 가 없다.

람다에서 `this` 를 호출하면, 그 람다를 둘러싼 클래스의 인스턴스를 가리킨다.

즉, 람다를 변환한 익명 클래스의 인스턴스를 참조할 수 없다.

 

반면에 익명 객체 안에서는 `this` 가 그 익명 객체 인스턴스 자신을 가리킨다.

그러므로 이벤트 리스너가 이벤트를 처리하다가 자기 자신의 리스너 등록을 해제해야 한다면, 람다가 아닌, 익명 객체를 사용해야 한다.