Kotlin

[Kotlin] 멤버참조 문법과 예시, 람다의 컴파일에 대해 알아보자!

노소래 2023. 1. 23. 23:55

다른 글에서 람다를 알아보며 멤버참조에 대해서 짧게 알아본 적이 있다. (링크)

 

유용한 사용 예시는 람다함수를 인자로 넣는 것이었다.

람다를 인자로 받는 함수에 인자로 넘기려는 코드가 이미 함수로 선언된 경우 멤버 참조를 사용하여 짧은 코드로 작성할 수 있었다.

하지만 내용이 너무 빈약하여 추가로 그 원리가 무엇인지 정리하려고한다.

 

그래도 그 전에 다시 리마인드 해보자.

 

멤버 참조?

문법적으론 이중콜론 (::) 을 사용하여 멤버를 참조할 수 있다. 아래와 같은 형태이다.

클래스이름::멤버이름

멤버참조는 그 멤버를 호출하는 람다와 같은 타입이다.

멤버 참조는 프로퍼티나 메서드를 단 하나만 호출하는 함수 값을 만들어준다.

 

추가적인 규칙은 다음과같다. 

  • 다른 클래스의 멤버가 아니고 최상위에 선언된 함수나 프로퍼티를 참조할 수도 있다.
    클래스 밖에 함수를 선언해도 클래스 이름 없이 ::함수or프로퍼티이름 과 같은 형태로 말이다.
  • 생성자 참조도 가능하다. ::클래스이름 과 같은 형태로 말이다.
    class Car(price: Int) 라는 클래스 있으면 val createCar = ::Car 로 생성자 참조를 만들었다가 나중에 createCar(price = 1000) 이런식으로 만들 수 있다는 말이다.
  • 확장 함수, 확장 프로퍼티도 참조할 수 있다. 멤버와 똑같은 방식으로말이다!

 

이렇게만 말하고 끝내면 추상적이니 예시를 들어보려고 한다.

예시

예를 들어 Car 라는 클래스가 price 라는 프로퍼티를 가진다 했을 때 Car::price 는 다음과 같은 함수 값이다.

{ car: Car -> car.price }

그럼 어떤 일을 할 수 있을까?

앞서 말한 람다를 인자로 받는 고차함수에 인자로 넘기려는 코드가 이미 함수로 선언된 경우 에 대해, 멤버 참조로 인자를 전달할 수 있다.

클래스 내에서도 호출해서 쓰고, 바깥에서도 호출해서 쓰고, 람다 인자로도 전달할 수 있다는 말이다!

아래 예시를 살펴보자.

data class Car(val price: Int) {
    fun isExpensive(): Boolean {
        // 비싼지 안비싼지 복잡한 연산이 필요하다고 가정하자...
        return price > 10000
    }
}

fun Car.asEntity(): CarEntity {
    // 맵핑하는데 다양한 프로퍼티 있다고 가정하고 많은 연산 필요하다고 가정하자...
    return CarEntity(price = price)
}

data class CarEntity(val price: Int)

val Car.doublePrice: Int get() = price * 2

fun externalPrint() = println("아무말~")

fun main() {
    val carCreator = ::Car // 생성자 참조
    val cars = listOf(carCreator(2000), carCreator(13000), carCreator(15000))

    println(cars.minByOrNull(Car::price)) // 멤버 프로퍼티 참조
    println(cars.minByOrNull(Car::doublePrice)) // 확장 멤버도 참조 가능

    cars.filter {
        it.isExpensive()
    }.map {
        it.asEntity()
    }.forEach {
        println(it)
    }

    cars.filter(Car::isExpensive) // 멤버 함수 참조
        .map(Car::asEntity)
        .forEach {
            println(it)
        }
    
    run(::externalPrint) // 최상위 선언 참조 
}

minByOrNull 멤버 프로퍼티 참조는 다른 글에서도 다뤘으니 filter, map 에 주목해보자.

그들의 파라미터는 각각 다음과 같다.

predicate: (T) -> Boolean
transform: (T) -> R

차이는 두 캡쳐를 비교하면 명확해진다.

 

람다안에서 함수를 다시 호출하는 것은 중복이다.
이처럼 함수를 직접 넘긴다.

 

멤버함수의 컴파일에 대한 이해

람다 뒤에 괄호를 붙이는 일반적인 람다 호출 방식은 invoke 관례를 적용한 것이다.

멤버참조는 그 멤버를 호출하는 람다와 같은 타입이라고 하였다.

그런데 인라인하는 람다를 제외한 모든 람다는 함수형 인터페이스를 구현하는 클래스로 컴파일된다.

따라서 :: 로 참조하는 함수의 값 타입은 함수형 인터페이스를 구현한 클래스임 추론할 수 있다. 

람다도 함수형 인터페이스 구현하는 클래스 타입이고 ::  참조하는 함수의 값 타입도 함수형 인터페이스를 구현한 클래스이면 같다는 말.

그래서 람다를 파라미터로 받는 함수 인자로 멤버 참조를 사용할 수 있었던 것으로 이해한다.

*함수형 인터페이스에 대하여..

각 함수형 인터페이스 안에는 그 인터페이스 이름이 가리키는 개수만큼 파라미터를 받는 invoke 메서드가  들어있다.

3개의 파라미터가 있는 함수형 인터페이스의 예를 들면 다음과 같다. 

interface KFunction3<in P1, in P2, in P3, out R> {
    operator fun invoke(p1: P1, p2: P2, p3: P3): R
}

좀 더 와닿게 전달하기 위해 코틀린 리플렉션 API 를 사용하며 접하게 되는 내용과 연관지어 생각해보면 아래와같다.

클래스이름::class 로 KClass 인스턴스를 얻을 수 있다.

KClass 는 클래스 안에 있는 모든 선언을 열거, 접근할 수 있고 상위 클래스를 얻는 등의 작업이 가능하다.

KClass 의 멤버 중에 KCallable 의 콜렉션이 있는데 이는 함수(KFunction)와 프로퍼티(KProperty)를 아우르는 공통 상위 인터페이스이다.

KCallable 안에는 call 메서드가 들어있는데 call 을 사용하면 함수나 프로퍼티의 게터를 호출할 수 있다!

KFunction, KProperty 로 call 로 호출할 수 있는 것이다.

위 예시에서 봤던 KFunctionN 은 컴파일러가 생성한 합성 타입이다. 그래서 kotlin.reflect 패키지에선 정의를 찾을 수 없다.

대신 원하는 수만큼 많은 파라미터를 갖는 함수에 대한 인터페이스를 사용할 수 있는 것이다!

예를 들면 아래와 같이 말이다.

import kotlin.reflect.KFunction1
import kotlin.reflect.KFunction2
import kotlin.reflect.KProperty1

...

fun sum(x: Int, y: Int) = x + y
val kFunction: KFunction2<Int, Int, Int> = ::sum
val carFunction: KFunction1<Car, Boolean> = Car::isExpensive
val carProperty: KProperty1<Car, Int> = Car::price

 

*invoke 관례에 대하여..

operator 변경자가 붙은 invoke 메서드 정의가 들어있는 클래스의 객체를 함수처럼 호출할 수 있다.

이 말이 무엇인지 알려면 배경지식으로 관례에 대한 지식도 필요할 수 있다. 이와 관련된 것은 다른 글에 있다.

다른 글에서 여러 관례에 대해 정리한 것중 다루지 않은 것이 있다. 바로 invoke 이다.

invoke 관례는 객체에 괄호 () 를 붙여서 함수처럼 invoke 함수를 호출할 수 있게해준다.

 

안드로이드 개발하면서 사용해본 당장 떠오르는 예시는 클린아키텍쳐 기반 프로젝트를 진행하며 UseCase 에 operator fun invoke(~): ~ 와 같은 형태로 작성한 것이다. 

그 후 ViewModel 에 주입받은 UseCase 객체에 괄호 () 를 붙여 invoke 함수를 호출할 수 있었다.

getMoneyUseCase 라는 유스케이스 객체가 있을 때 

getMoneyUseCase(~) 로 호출하면 getMoneyUseCase.invoke(~) 로 컴파일 되는 것이다.

~로 표현한 것은 필자 임의이고, invoke 메서드에 대해 시그니처 요구사항은 없으므로 원하는 대로 파라미터 개수나 타입을 지정할 수 있고 심지어 오버로딩도 가능하다.

 

 

 

 

 

출처:  대부분 Kotlin in Action + 약간의 구글링이고, 예시는 일부는 필자머리

언제나 틀린 부분은 댓글로 남겨주시면 감사합니다..ㅎㅎ