Kotlin

[Kotlin] 확장 함수 & 확장 프로퍼티

sh1mj1 2024. 1. 1. 22:35

 

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

 

이미지 출처      https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

 

확장함수 - 메서드를 다른 클래스에 추가하기

저번 글에서 코틀린 컬렉션은 자바 컬렉션과 똑같은 클래스이지만, 코틀린에서는 아래 코드에서처럼 자바보다 더 많은 기능을 쓸 수 있다고 했다.

우리는 extension function(확장 함수)를 사용하여 기존 JVM 언어 API 를 재작성하지 않고도 코틀린이 제공하는 여러 편리한 기능을 사용할 수 있다.

 

확장함수어떤 클래스의 멤버 메서드인 것처럼 호출할 수 있지만 그 클래스의 밖에 선언된 함수이다. 

`StringUtil` 이라는 파일을 만들어서 어떤 문자열의 마지막 문자를 돌려주는 메서드를 추가해보자.

fun String.lastChar(): Char = this.get(this.length - 1)

확장 함수를 만드려면 `확장할 클래스이름.추가하려는 함수이름` 으로 만들면 된다.

fun String.lastChar(): Char = get(length - 1) // 일반 메서드처럼 this 를 생략할 수도 있다.

확장함수의 수신 객체 타입과 수신 객체

클래스 이름을 receiver type(수신 객체 타입)이라 하고, 확장 함수가 호출되는 대상(객체)를 receiver object(수신 객체) 라고 한다.

위에서 `this` 는 `String` 의 인스턴스 객체인 것이다.

println("Kotlin".lastChar())
// n 출력됨

위 실행 코드에서는 수신 객체 타입은 `String` 이고, `"Kotlin"` 이 수신 객체이다.

 

위처럼 `lastChar()` 이라는 함수를 선언하는 것은 `String` 클래스에 새로운 메서드를 추가하는 것이라고 봐도 된다.

String 클래스가 우리가 직접 작성한 코드가 아니며, String 클래스의 소스코드를 우리가 가지고 있지 않음에도 불구하고 원하는 메서드를 String 클래스에 추가할 수 있다.

심지어 String 이 어떤 언어로 작성되었는지도 중요하지 않다.

Groovy(그루비)와 같은 JVM 언어로 작성된 클래스도 코틀린의 확장 함수 기능으로 확장할 수 있다.

자바 클래스로 컴파일한 클래스 파일이 있기만 하면 그 클래스에 원하는 대로 확장을 추가할 수 있다.

 

확장 함수 내부에서는 일반 인스턴스 메서드의 내부처럼 수신 객체의 메서드나 프로퍼티를 바로 사용할 수 있다.

물론, `private` 이나 `protected` 멤버는 사용할 수 없다.

즉, 확장 함수가 캡슐화를 깨지는 않는다.

 

함수를 호출하는 쪽에서는 확장 함수와 멤버 메서드를 구분할 수 없다. 

import 와 확장 함수

확장 함수를 사용하기 위해서는 그 함수를 다른 클래스나 함수와 마찬가지로 `import` 해야 한다.

  • `import inaction.chap3.strings.lastChar`

* 를 사용한 import 도 당연히 작동한다. 

 

`as` 키워드를 사용하면 import 한 클래스나 함수를 다른 이름으로 부를 수 있다.

import inaction.chap3.strings.lastChar as last

val c = "Kotlin".last()

한 소스 파일 안에서 다른 여러 패키지에 속해있는 이름이 같은 함수를 가져와서 사용해야 한다면, `as` 를 사용해 import 시 이름을 바꾸면 이름 충돌을 막을 수 있다. 

물론 Fully Qualified Name(경로까지 포함한 이름) 을 써도 되지만, 코틀린 문법 상 확장 함수는 짧은 이름을 써야 하므로 이름 충돌의 경우 이름을 바꾸어서 사용하자.

자바에서 확장 함수 호출

내부적으로 확장 함수는 수신 객체를 첫번째 인자로 받는 정적 메서드이다.

그래서 확장 함수 호출 시에 다른 adapter 객체나 런타임에 부가 비용이 들지 않는다.

 

이런 설계 때문에 자바에서 확장 함수를 사용하기도 편하다.

정적 메서드를 호출하면서 첫번째 인자로 수신 객체를 넘기면 된다.

char c = StringUtilKt.lastChar("Java");

확장 함수로 유틸리티 함수 정의하기

그렇다면 이제 이전 글에서 계속해왔던 `joinToString` 함수의 최종 버전을 만들자.

이제 이 함수의 모습은 코틀린 라이브러리가 제공하는 함수와 거의 비슷하다.

fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = "",
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    return result.toString()
}
val list = listOf(1, 2, 3)
println(list.joinToString(separator = "; ", prefix = "(", postfix = ")"))

 

출력 결과
(1; 2; 3) 

확장 함수는 단지 정적 메서드 호출에 대한 문법적인 편의이다. 

확장 함수는 오버라이드 불가능하다

코틀린의 확장 함수는 오버라이드 할 수 없다. 

코틀린에서 오버라이드하는 경우 코드를 잠깐 살펴보자.

`View` 라는 `open class` 와 그 하위 클래스 `Button` 을 만들어보자.

open class View {
    open fun click() = println("View clicked")
}

class Button : View() {
    override fun click() = println("Button clicked")
}

 

val view: View = Button()
view.click()
// Button clicked 출력됨

`View` 타입(정적) 변수 `view` 의 실제 타입(동적)은 `Button` 이다. 이에 대해 `click` 메시지를 호출했는데 `click` 을 `Button` 클래스가 오버라이드했다면 실제로는 `Button` 이 오버라이드한 `click` 메서드가 호출된다.

 

하지만 확장 함수는 이런식으로 작동하는 것이 아니다. 

확장 함수는 클래스의 일부가 아니며, 클래스 바깥에 선언된다.

 

이름과 파라미터가 완전히 같은 확장 클래스를 부모 클래스와 자식 클래스에 대해 정의하더라도 확장 함수를 호출할 때 수신 객체의 변수의 정적 타입에 의해서 어떤 함수가 호출될지가 결정된다.

그 변수에 저장된 동적 타입에 의해 확장 함수가 결정되지 않는다.

fun View.showOff() = println("I'm a view!")
fun Button.showOff() = println("I'm a button!")


val view: View = Button()
view.showOff()
// I'm a view! 출력됨

아래 그림을 보고 이를 기억해두자.

확장 함수는 클래스 밖에 선언됨.

확장 함수는 첫번째 인자가 수신 객체인 정적 자바 메서드로 컴파일한다고 했다.

자바도 정적 함수를 같은 방식으로 정적으로 결정한다.

View view = new Button();
ExtensionFunctionKt.showOff(view);

// I'm a view! 출력됨

결론적으로 코틀린은 호출될 확장 함수를 정적으로 결정하기 때문에(컴파일될때 정적 메서드임) 확장 함수를 오버라이드할 수 없다!

어떤 클래스를 확장한 함수와 그 클래스의 멤버 함수의 이름과 시그니처가 같다면 확장함수가 아니라 멤버 함수가 호출된다. (멤버 함수의 우선순위가 더 높다)

확장 프로퍼티

확장 프로퍼티를 사용하면 기존 클래스 객체에 대한 프로퍼티 형식의 구문으로 사용할 수 있는 API 를 추가할 수 있다.

확장 '프로퍼티' 라는 이름과는 다르게 상태를 저장할 수 있는 방법이 없어 실제로는 아무런 상태도 가지고 있지 않다.

하지만 프로퍼티 문법으로 더 짧게 코드를 작성할 수 있어 편한 경우가 있다.

 

앞에서 `String.lastChar` 이라는 확장 함수를 만들었다. 같은 동작을 하는 확장 프로퍼티를 만들어보자

val String.lastChar: Char
    get() = this[this.length - 1]

확장 프로퍼티도 일반적은 프로퍼티와 같지만, 수신 객체 클래스가 추가되었다.

 

backing field(뒷받침하는 필드)가 없어서 기본 getter 구현을 제공할 수 없으므로 getter 는 무조건 정의를 해주어야 한다.

당연히 초기화 코드도 쓸 수 없다.

`StringBuilder` 에 같은 프로퍼티를 정의한다면 `StringBuilder` 의 맨 마지막 문자는 변경할 수 있다.

`StringBuilder` 로는 프로퍼티를 `var` 로 만들 수 있다.

var StringBuilder.lastChar: Char
    get() = this[this.length - 1]
    set(value) = this.setCharAt(length - 1, value)
    
    
 var sb = StringBuilder("kotlin?")
 sb.lastChar = '!'
 println(sb.lastChar)
 // ! 출력됨

 

자바에서 확장 프로퍼티를 사용하고 싶다면 gettter 나 setter 를 명시적으로 호출해야 한다.

StringBuilder sb = new StringBuilder("Java?");

StringUtilKt.getLastChar(sb);
StringUtilKt.setLastChar(sb, '!');

 

여기서부터 아래 내용은 Effective kotlin 의 내용을 참조한 내용입니다.

API 의 필수적이지 않은 부분을 확장 함수로 추출하자

클래스의 메서드를 정의할 때 메서드를 멤버 함수로 정의할 것인지, 확장 함수로 정의할 것인지 결정해야 한다.

 

멤버 함수와 멤버 프로퍼티로 정의한 `WorkShopUsingMember`

class WorkShopUsingMember(/* 주 프로퍼티 */) {

    private val name = "someName"
    // ...

    fun makeEvent(date: LocalDateTime) {
        // ..
        println("event is made on $date!")
    }

    val permalink
        get() = "/workshop/$name"

}

 

확장 함수와 확장 프로퍼티로 정의한 `WorkShopUsingExtension`

class WorkShopUsingExtension(/* 주 프로퍼티 */) {

    // ...
}

const val name = "someName"

fun WorkShopUsingExtension.makeEvent(date: LocalDateTime) {
    // ..
    println("event is made on $date!")
}

val WorkShopUsingExtension.permalink
    get() = "/workshop/$name"

 

두 가지 방법은 거의 비슷하다. 호출하는 방법, 리플렉션으로 레퍼런싱하는 방법도 비슷하다.

fun useWorkshop(workShop: WorkShopUsingMember) {
    val event = workShop.makeEvent(date = LocalDateTime.now())
    val permalink = workShop.permalink

    val makeEventRef = WorkShopUsingMember::makeEvent
    val permalinkRef = WorkShopUsingMember::permalink
}

fun useWorkshop(workShop: WorkShopUsingExtension) {
    val event = workShop.makeEvent(date = LocalDateTime.now())
    val permalink = workShop.permalink

    val makeEventRef = WorkShopUsingExtension::makeEvent
    val permalinkRef = WorkShopUsingExtension::permalink
}

확장 함수는 언제 사용할까?

확장 함수를 사용할 때는 따로 가져와서 사용해야 한다.

보통 확장 함수는 클래스와 다른 패키지에 위치하고, 우리가 직접 멤버를 추가할 수 없을 때, 데이터와 행위로 분리하도록 설계된 프로젝트에서 사용한다.

backing field 를 가진 프로퍼티는 클래스에 있어야 하는 것과 꽤나 다르다.

 

즉, `import` 해서 사용해야 하기 때문에 같은 타입, 같은 이름으로 여러 개의 확장 함수를 만들 수 있다.

그래서 여러 라이브러리에서 같은 시그니처를 가진 여러 메서드를 받을 수 있다는 장점이 있다. 

하지만 같은 이름으로 다른 동작을 하는 확장이 존재한다는 것은 위험할 수 도 있다.

만약 위험 가능성이 있다면 그냥 일반 함수를 사용하는 것이 좋다.

확장은 virtual(가상)이 아니다.

그래서 위에서 말했듯 런타임이 아닌, 컴파일 타임에 정적으로 선택되어서 override 되지 않는다.

결론적으로 상속을 목적으로 설계된 요소는 확장 함수로 만들면 안된다.

@Test
fun `멤버 함수는 자식 클래스에서 오버라이드할 수 있다`() {
    val d = D()
    assert(d.foo() == "d")

    val c: C = d // 정적 타입: C, 동적 타입: D
    assert(c.foo() == "d")

    assert(D().foo() == "d")
    assert((D() as C).foo() == "d") // 정적 타입: C, 동적 타입: D
}

@Test
fun `확장 함수는 자식 클래스에서 오버라이드할 수 없다`() {
    val dExtFunc = DExtFunc()
    assert(dExtFunc.foo() == "d")

    val cExtFunc: CExtFunc = dExtFunc // 정적 타입: CExtFunc, 동적 타입: DExtFunc
    assert(cExtFunc.foo() == "c")

    assert(DExtFunc().foo() == "d")
    assert((DExtFunc() as CExtFunc).foo() == "c") // 정적 타입: CExtFunc, 동적 타입: DExtFunc
}

확장 함수는 아래처럼 '첫번째 아규먼트 타입으로 리시버가 들어가는 일반, 정적 함수'로 컴파일되기 때문에 이러한 결과가 나온다.

즉, `확장 함수는 자식 클래스에서 오버라이드할 수 없다` 테스트 함수는 아래와 같다.

fun foo(`this$receiver`: C) = "c"
fun foo(`this$receiver`: D) = "d"

 

다시 한번 아래 그림으로 정리하자.

확장함수는 클래스가 아닌 타입에 정의한다.

확장 함수는 클래스가 아닌 타입에 정의하는 것이기 때문에 nullable 이나 구체적인 Generic 타입에도 확장 함수를 정의할 수 있다.

확장함수는 애노테이션 프로세서가 처리하지 않는다.

확장 함수는 클래스 레퍼런스에서 멤버로 표시되지 않아서 애노테이션 프로세서가 따로 처리하지 않는다. 

따라서 필수적이지 않은 요소를 확장함수로 추정하면 애노테이션 프로세서로부터 숨겨진다.

 

결론적으로 API 의 필수적인 부분은 멤버로 두는 것이 좋지만, 필수적이지 않은 부분은 확장 함수로 만드는 것이 좋다.

멤버 확장 함수의 사용을 피하자

여기서 멤버 확장 함수는 멤버로 추가된 확장 함수를 의미힌다.

클래스에 대한 확장 함수를 정의할 때 이를 멤버로 추가하는 것보다 최상위 함수로 사용하는 것이 좋다.

클래스의 멤버와 인터페이스 내부의 확장함수

반복해서 말하지만 확장함수는 '첫번째 아규먼트로 리시버를 받는 단순한 일반, 정적 함수' 로 컴파일된다고 했다.

그러므로 확장 함수를 클래스의 멤버로 정의할 수도 있고, 인터페이스 내부에 정의하는 것도 가능하다. 

interface PhoneBook {
    fun String.isPhoneNumber(): Boolean
}

class Fizz : PhoneBook {
    override fun String.isPhoneNumber(): Boolean = length == 7 && all { it.isDigit() }
}

 

하지만 DSL 을 만들 때를 제외하면, 이를 사용하지 않는 것이 좋다.

만약 멤버 확장 함수를 사용한다고 해보자.

// Bad code
class PhoneBookIncorrect {
    // ...
    fun String.isPhoneNumber() = length == 7 && all { it.isDigit() }
}

 

그리고 위 멤버 확장 함수를 사용할 때는 `PhoneBookIncorrect` 클래스의 멤버이므로 이렇게 사용해야 한다.

@Test
fun `멤버 확장 함수를 사용한다`() {
    PhoneBookIncorrect().apply {
        println("1234567890".isPhoneNumber())
    }
}

단지 확장 함수를 사용하는 형태를 어렵게 만들 뿐이다. 

 

간혹 확장 함수의 가시성 제한을 위해 (예를 들어 해당 클래스 내에서만 사용하기 위해) 멤버로 추가하는 경우가 있는데 그것이 의도라면 더욱 잘못된 방법이다.

클래스 내부에 확장 함수를 배치한다고 해서 외부에서 해당 함수를 사용하지 못하게 제한되는 것이 아니다.

 

만약 가시성 제한이 목적이라면 아래처럼 사용하면 된다.

class PhoneBookIncorrect {
    // ...
}

private fun String.isPhoneNumber() = length == 7 && all { it.isDigit() }

 

즉, 확장 함수의 가시성을 제한하고 싶다면, 멤버로 만들지 말고, 가시성 한정자를 붙여주면 된다.

멤버 확장을 피해야 하는 이유

  • 멤버 확장함수는 멤버 참조를 지원하지 않는다.
// 레퍼런스를 지원하지 않는다.
fun supportReference() {
    val ref = String::isPhoneNumber
    val str = "1234567890"
    val boundedRef = str::isPhoneNumber

    // val refX = PhoneBookIncorrect::isPhoneNumber // 오류
    val book = PhoneBookIncorrect()
    // val boundedRefX = book::isPhoneNumber // 오류
}
  • 암묵적 접근을 할 때, 두 수신 객체 중에서 어떤 수신 객체가 선택될지 혼동된다.
class A {
    var a = 10
}

class B {
    var a = 20
    var b = 30
    fun A.test() = a + b // a 에 암묵적 접근을 한다. a 가 A 의 것인지 B 의 것인지 혼동됨
    // 여기서 a 는 클래스 A 의 a 이므로 10 이다.
}

@Test
fun `명확하지 않은 테스트`() {
    val aInstance = A()
    val bInstance = B()
    bInstance.apply {
        assert(aInstance.test() == 40)
    }
}
  • 확장 함수가 외부에 있는 다른 클래스를 수신 객체로 받을 때, 해당 함수가 어떤 동작을 하는지 명확하지 않다.
class A {
    var a = 10
}

class B {
    var a = 20
    var b = 30
    
    fun A.update() {
        a = 100 // A 의 a 를 업데이트할지 B 의 a 를 업데이트할지 혼동됨.
        // 여기서 a 는 클래스 A 의 a 이다.
    }
}

@Test
fun `명확하지 않은 업데이트`() {
    val aInstance = A()
    val bInstance = B()
    bInstance.apply {
        aInstance.update()
    }
    assert(aInstance.a == 100)
}

 

멤버 확장 함수를 사용하는 것이 의미가 있는 경우에는 사용해도 좋지만, 일반적으로는 명확한 단점으로 인해 사용하지 않는다.

 

다음 글에서는 컬렉션을 처리할 때 유용한 라이브러리 함수를 몇개 살펴본다