Kotlin

[Kotlin] SAM 을 익명 객체 VS 람다로 구현 & fun interface (feat. 우테코)

sh1mj1 2024. 3. 22. 15:59

SAM smith 아님

블랙잭 미션을 진행하면서, 크루원 중 한명이 SAM 인터페이스와 코틀린 fun interface 를 잘 모르겠다고 했다.

나도 코드에 이것을 잘 사용하는 것이 항상 어려웠다.

그래서 다시 공부해서 스터디 시간에 발표하려고 한다.

 

먼저 익명 객체와 람다부터 알아보자.

익명 객체(Anonymous Object)

코틀린에서 익명 객체(anonymous objects)는 객체 지향 프로그래밍에서 유용한 개념 중 하나이다.

익명 객체는 이름이 없는 클래스 인스턴스를 만들어내는 방법으로, 주로 인터페이스나 추상 클래스의 인스턴스를 만들 때 사용된다.

코틀린에서는 익명 객체를 선언하고 초기화하는 방법이 간단하고 직관적이다.

코틀린 vs 자바 에서 익명 객체 구현

코틀린에서 익명 객체 구현

val thread = Thread(object : Runnable {
    override fun run() {
        println("Thread is running")
    }
})

 `Thread` 인스턴스를 만드는  먼저 위 코드에서 `Thread` 와 `Runnable` 은 아래와 같다.

 `Thread` 는 자바의 생성자를 호출한 것이다.  그리고 `Runnable` 은 `run` 이라는 메서드를 가지고 있는 인터페이스이다.

아래 자바 코드에서 확인해볼 수 있다.

public Thread(Runnable task) {
    this(null, null, 0, task, 0, null);
}

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

 위에서 여기서 `Thread` 생성자의 파라미터로 들어가야 할 `Runnable` 인터페이스의 구현체를 따로 정의하지 않았다.

`object` 라는 키워드를 통해 `Runnable` 의 구현체를 바로 만들었다.

 

이렇게 익명 객체는 이름이 없는 클래스 인스턴스를 만들어내는 방법이다.

주로 인터페이스나 추상 클래스의 인스턴스를 만들 때 사용된다.

 

위 코드를 자바 바이트 코드로 디컴파일하면 아래와 같다.

private final Thread thread = new Thread((Runnable)(new Runnable() {
  public void run() {
     System.out.println("Thread is running");
  }
}));

 `Thread` 라는 객체를 만들고 `new Runnable` 을 통해 객체를 만드는 것을 볼 수 있다.

이렇게 자바 코드로도 익명 객체를 만들 수 있다.

코틀린 vs 자바 에서 상위 계층으로부터 상속받는 익명 객체

코틀린 익명 객체에서는 가능하지만 자바로는 불가능한 것이 있다.

아래 테코톡 할 때, 🦕서기🦖 가 만들었던 코드를 예로 들어보자.

interface Leader { fun lead() }

interface Writer { fun write() }

open class Teacher {
    open fun teach() { println("teach very well!") }
}

open class Developer {
    open fun coding() { println("code very well!") }
}

val threeJobPerson = object : Leader, Writer, Teacher() /*Developer() 당연히 여러 클래스를 상속받는 것은 불가능하다*/ {
    override fun lead() = println("lead pretty well")

    override fun write() = println("write pretty well")

    override fun teach() = println("teach pretty well!")
}

 threeJobPerson 에 익명 객체를 만들어서 저장했다.

이렇게 코틀린에서는 익명 객체가 여러 인터페이스와 하나의 클래스를 구현할 수 있다. (당연히 여러 클래스는 상속할 수 없다)

 

하지만 자바에서는 익명 객체가 한 번에 여러 인터페이스와 하나의 클래스를 구현할 수 없다.

자바에서는 `object` 라는 키워드가 없기 때문에 여러 인터페이스를 구현하는 익명 객체를 만들 수 없다.

그래서 자바에서는 아래와 같이 계층 구조를 하나 더 만들어야 한다.

LeaderAndWriterAndTeacher threeJob = new LeaderAndWriterAndTeacher() {
    @Override
    public void lead() { /* ... */ }

    @Override
    public void write() { /* ... */ }

    @Override
    public void teach() { /* ... */ }
};

코틀린 익명 객체는 싱글턴이 아니다

코틀린의 익명 객체는 `object` 라는 키워드 때문에 착각할 수 있지만, 싱글턴이 아니다.

즉, 객체 식이 쓰일 때마다 새로운 인스턴스가 생성된다.

val instance1 = object : Counter() {
    init {
        count++
    }
}

val instance2 = object : Counter() {
    init {
        count++
    }
}
assertThat(instance1.count).isEqualTo(1)
assertThat(instance2.count).isEqualTo(1) // 싱글턴이었다면 2가 되어야 함

 이 코드에서 만약 익명 객체가 싱글턴이라면 `instance2` 가 생성될 때 `count` 가 2가 되어야 한다.

하지만 그렇지 않다. 어찌보면 당연한 결과이다.

익명 객체와 클로저(Closure)

먼저 클로저가 뭘까?

ChatGpt, Wikipedia, MDN 문서에서는 이렇게 말한다.

  • 클로저는 함수 내부에서 정의된 익명 함수가 외부 범위의 변수를 캡처하고 저장할 수 있는 특성을 의미합니다. 따라서 자바와 코틀린에서 익명 객체(객체 식) 안의 코드가 포함된 함수의 변수에 접근할 수 있는 것은 클로저의 특성에 해당합니다.
  • A closure is a record storing a function together with an environment.
    (클로저란 함수를 그 환경과 함께 저장한 레코드를 말한다.)
  • A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment).
    (클로저는 함수와 그 주변 상태에 대한 참조의 조합이다.)

이제 익명 객체 안에서 그 식이 포함된 함수의 변수에 접근할 수 있다는 것을 간단히 표현하기 위해 클로저라고 표현하겠다.

(사실 자바에서는 공식적으로 클로저 개념을 갖고 있지 않으며  클로저 기능이 다른 언어에 비해 한계점이 있다고 한다.)

 

자바와 코틀린 모두에서 익명 객체(객체 식) 안의 코드그 식이 포함된 함수의 변수에 접근할 수 있다.

 

 코틀린 클로저

fun countClicks(window: Window) {
    val immutableVariable = 0
    window.addMouseListener(object : MouseAdapter() {
        override fun mouseClicked(e: MouseEvent) {
            println(immutableVariable) // 불변인 immutableVariable 읽기 가능
            super.mouseClicked(e)
        }
    })
}

 자바 클로저

public void countClicks(Window window) {
    final int immutableVariable = 0;
    window.addMouseListener(new MouseAdapter() {
        @Override
        public void mouseClicked(MouseEvent e) {
            System.out.println(immutableVariable); // 불변인 immutableVariable 읽기 가능
            super.mouseClicked(e);
        }
    });
}

 하지만 자바에서는 변수를 변경(변수에 쓰기)할 수는 없다.

아래처럼 컴파일 에러가 발생한다.

public void countClicks(Window window) {
    int clickCount = 0;
    window.addMouseListener(new MouseAdapter() {
        @Override
        public void mouseClicked(MouseEvent e) {
            System.out.println(++clickCount); // [COMPILE ERROR] Variable 'clickCount' is accessed from within inner class, needs to be final or effectively final
            super.mouseClicked(e);
        }
    });
}

함수형(Functional), SAM(Single Abstract Method: 단일 추상 메서드) 인터페이스 

이제는 다른 예시로 살펴보자.

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

그리고 `OnClickListener` 인터페이스는 `onClick` 이라는 메서드만 선언된 인터페이스이다.

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

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: 단일 추상 메서드) 인터페이스 라고 한다.

 그렇다면 람다로 SAM 을 구현하는 것과 익명 객체를 사용하여 구현하는 것의 차이는 오직 깔끔하게 보이는 것 뿐일까?

사실은 그렇지 않다. 굉장히 큰 차이가 있다.

 

이번에는 아래 예시를 보자.

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

void postponeComputation(int delay, Runnable computation);
참고로 `@FunctionalInterface`는 자바에서 함수형 인터페이스를 정의할 때 사용되는 어노테이션이다.
`@FunctionalInterface` 어노테이션은 함수형 인터페이스를 명시적으로 선언하고자 할 때 사용된다.
이 어노테이션이 붙은 인터페이스가 함수형 인터페이스의 규약을 지킨다는 것을 나타내며, 컴파일러가 해당 인터페이스가 함수형 인터페이스의 요구사항을 만족하는지 검사할 수 있도록 한다.

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

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

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

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

 

즉, `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 인스턴스 생성
}

 `postponeComputation` 의 람다인 파라미터 `println(id)` 는 `handleComputation` 함수의 파라미터를 캡처(포획)하고 있다.

 

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

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

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

이에 대해서는 인라이닝에 대해 깊게 이해해야 한다. 

fun interface ???

먼저 코틀린에서 추상 메서드가 한 개만 있는 인터페이스부터 살펴보자.

interface Printer {
    fun print()
}

interface OldPrinter : Printer

 

 OldPrinter 를 구현하는 익명 객체를 만들어 보자. 아래와 같다.

val oldPrinter = object : OldPrinter {
    override fun print() {
        println("Old Printer!!")
    }
}
oldPrinter.print()
// 출력: Old Printer!!

 익명 객체를 쉽게 구현했다.

그런데 이전에 `OnClickListener` 라는 SAM 인터페이스에 대해 람다 식을 익명 객체 대신에 넣을 수 있었다.

똑같이 해보자.

val oldPrinter2 = OldPrinter { println("Old Printer!!") } // [COMPILE ERROR] Interface OldPrinter does not have constructors

 아쉽게도 이것은 가능하지 않다고 나온다.

그런데 이상하다.

왜 자바로 구현된 `OnClickListener` 는 람다 식을 전달하여 객체를 만드는 것이 가능한데 코틀린 코드로는 안 될까? 

제대로 확인하기 위해 자바 코드로 만들어보자.

interface JavaPrinter {
    void print();
}
val javaPrinter = JavaPrinter { println("Java Printer!!") }
javaPrinter.print()

 이것은 가능하다! 이것으로 확실해졌다.

자바 SAM 인터페이스는 람다식을 전달하여 인스턴스를 만들 수 있지만, 코틀린으로는 (일단은) 불가능하다.

 

그렇다면 자바 SAM 인터페이스를 사용할 대처럼 람다 식을 넘겨서 바로 인스턴스를 만들고 싶다면, 어떻게 해야 할까?

가장 쉬운 방법은 가짜 생성자를 만드는 것이다.

 

가짜 생성자 OldPrinter

fun OldPrinter(block: () -> Unit): OldPrinter = object : OldPrinter {
    override fun print() = block()
}

val oldPrinter2 = OldPrinter { println("Old Printer!!") } // 그냥은 불가능 함수를 만들거나 해야 함
oldPrinter2.print()
// 출력: Old Printer!!

 이렇게 되면 자바 인터페이스와 비슷하게 사용할 수 있다.

하지만 직접 가짜 생성자를 만드는 것은 너무 번거롭다.

fun interface

이제 드디어 `fun interface` 가 해결법으로 등장한다.

  

코틀린 공식 문서 설명에는 이렇게 나온다.

An interface with only one abstract method is called a functional interface, or a Single Abstract Method (SAM) interface. The functional interface can have several non-abstract members but only one abstract member.
To declare a functional interface in Kotlin, use the fun modifier.

추상 메소드가 하나만 있는 인터페이스를 함수형 인터페이스 또는 SAM(Single Abstract Method) 인터페이스라고 한다
함수형 인터페이스에는 여러 개의 비추상 멤버가 있을 수 있지만 추상 멤버는 하나만 있어야 한다.

Kotlin에서 기능적 인터페이스를 선언하려면 `fun` 키워드를  `interface` 앞에 사용하라!

 

`fun interface NewPrinter`

interface Printer {
    fun print()
}

fun interface NewPrinter : Printer

val newPrinter1 = NewPrinter { println("Printing message...") }
newPrinter1.print()
// 출력 New Printer!!

 이렇게 interface 앞에 fun 키워드를 붙이면 람다 식을 파라미터로 넘겨서 인스턴스를 바로 생성할 수 있다.

익명 객체를 명시하지 않고서, 그리고 가짜 생성자를 생성하지 않고서도 가능하다!

 

다른 예시도 살펴보자.

만약 Office 라는 클래스가 Printer 를 지정하는 함수를 가진다고 하자.

class Office {
    fun setPrinter(printer: Printer) {
        println("The printer of this Office is")
        printer.print()
    }
}
val office = Office()

// 일반 SAM interface 사용 -> 익명 객체 명시 필수 ----------------------------
office.setPrinter(object : OldPrinter {
    override fun print() {
        println("Old Printer!!")
    }
})

/* 출력
The printer of this Office is
Old Printer!!
*/


// SAM fun interface 사용 -> 람다 식 사용 가능 ----------------------------
office.setPrinter(NewPrinter { println("New Printer!!") })

/* 출력
The printer of this Office is
New Printer!!
*/

 위처럼 사용할 수 있다.

마치 위에서 본 Button 에 OnClickListener 를 구현하는 인스턴스를 지정해주는 것과 유사한 모습이다.

왜??

자바의 SAM 인터페이스를 구현할 때는 람다 식을 파라미터로 전달하여 쉽게 인스턴스를 만들 수 있다.

그런데 코틀린에서 람다 식을 전달하여 인스턴스를 만들기 위해서는 왜 꼭 `fun` 이라는 키워드를 붙여야 할까?

 

이것이 궁금해서 먼저 코틀린 일반 SAM 인터페이스와 fun 키워드가 붙은 SAM 인터페이스를 자바로 디컴파일하여 확인해 보았다.

하지만, 두 사이의 코드 차이는 전혀 없었다.

 

그리고 이 이유에 대해서 찾아 보았지만 공식 문서에도, 구글에도 잘 나오지 않았다.

ChatGPT 에게 물어보았을 때는 이러한 답변을 받을 수 있었다.

가독성과 유지보수성: 람다 식을 직접적으로 전달하는 것이 아니라 익명 객체를 사용하는 것은 코드의 가독성과 유지보수성을 높일 수 있습니다. 익명 객체를 사용하면 명시적으로 인터페이스의 메서드를 구현하고 해당 메서드의 동작을 명확하게 정의할 수 있습니다.

안정성: 익명 객체를 사용하는 것은 컴파일러가 더 많은 유형의 오류를 감지하고 잠재적인 버그를 방지할 수 있습니다. 예를 들어, SAM 인터페이스의 메서드를 재정의하지 않았거나 잘못된 시그니처로 람다를 전달하는 경우 컴파일러가 오류를 발생시킵니다.

 

혹시 이 글을 읽는 사람이 알려주면 감사하겠습니다~~~ 😁😁😁

참조

SAM 인터페이스

https://kotlinlang.org/docs/fun-interfaces.html

클로저 관련

https://jaeyeong951.medium.com/java-%ED%81%B4%EB%A1%9C%EC%A0%80-vs-kotlin-%ED%81%B4%EB%A1%9C%EC%A0%80-c6c12da97f94

https://en.wikipedia.org/wiki/Closure_(computer_programming)

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures