[Kotlin] 7. 메소드를 다른 클래스에 추가: 확장 함수와 확장 프로퍼티

2023. 3. 26. 16:36Programming Languages/Kotlin

확장 함수

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

package strings

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

확장 함수를 만들려면 추가하려는 함수 앞에 그 함수가 확장할 클래스의 이름을 덧붙이면 된다. 이때, 클래스 이름을 수신 객체 타입(receiver type), 확장 함수가 호출되는 대상이 수신 객체(receiver object)라고 부른다. 위의 경우 String이 수신 객체 타입, this가 수신 객체가 된다.

위 함수를 사용하는 코드를 보자.

println("Kotlin".lastChar()) // n

이때 수신 객체 타입은 String이고 수신 객체는 Kotlin이라는 문자열이 된다. 또한 확장 함수를 작성할 때 메소드의 본문에서 this를 생략하는 것이 가능하다.

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

확장 함수 내부에서는 일반적인 인스턴스 메소드와 마찬가지로 수신 객체의 메소드나 프로퍼티를 사용할 수 있다. 하지만 확장 함수의 캡슐화는 깨지지 않는다. 왜냐하면 확장 함수 안에서는 private 멤버나 protected 멤버를 사용할 수 없기 때문이다.

1. 임포트와 확장 함수

확장 함수도 사용하려면 그 함수를 다른 클래스나 함수처럼 임포트해야 한다.

import strings.lastChar

val c = "Kotlin".lastChar()
// *를 사용한 임포트도 가능하다.
import strings.*

val c = "Kotlin".lastChar()
// alias도 지정 가능하다.
import string.lastChar as last

val c = "Kotlin".last()
💡한 파일 안에서 다른 여러 개의 패키지에 속해 있는 이름이 같은 함수를 가져와 써야하는 경우 이름을 alias를 통해 바꿔서 임포트하면 이름 충돌을 막을 수 있다. 이것이 확장 함수의 이름 충돌을 해결할 수 있는 유일한 방법이다.

2. Java에서 확장 함수 호출

내부적으로 확장 함수는 수신 객체를 첫 번째 인자로 받는 정적 메소드다. 그래서 확장 함수를 호출해도 다른 어댑터 객체나 실행 시점에 부가 비용이 들지 않는다.

다음은 StringUtil.kt에 확장 함수를 정의한 것을 자바 코드로 변환한 것이다. 인자로 수신 객체만 넘기면 된다.

char c = StringUtilKt.lastChar("Java")

3. 확장 함수로 유틸리티 함수 정의

fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    suffix: String = ""
): String {
    val result = StringBuilder(prefix)

    for ((index, elem) in this.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(elem)
    }

    result.append(suffix)
    return result.toString()
}

val list = listOf(1, 2, 3)
println(list.joinToString(
    separator = "; ", prefix = "(", suffix = ")")
) // (1; 2; 3)
💡확장 함수는 정적 메소드 호출에 대한 문법적 설탕(syntactic sugar)일 뿐이다. 따라서 클래스가 아닌 구체적인 타입을 수신 객체 타입으로 지정할 수도 있다. 위 예제의 제네릭 타입을 String으로 변경하면 String 타입의 Collection만 사용할 수 있다.

4. 확장 함수는 오버라이드할 수 없다.

확장 함수는 정적 바인딩이기 때문에 오버라이드할 수 없다.

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

Kotlin은 Java와 마찬가지로 오버라이드된 메소드 호출에 대해 동적 바인딩이 적용된다. 즉 런타임에 실행될 메소드가 결정된다. 하지만 확장 함수는 이렇게 동작하지 않는다. 확장 함수는 정적 바인딩이 적용되기 때문이다.

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

val view: View = Button()
view.showOff() // I'm view
💡기존 클래스의 메소드와 확장 함수의 시그니처가 같다면 항상 멤버 메소드가 실행된다.

5. 확장 프로퍼티

확장 프로퍼티를 사용하면 기존 클래스 객체에 대한 프로퍼티 형식의 구문으로 사용할 수 있는 API를 추가할 수 있다. 프로퍼티라고 불리기는 하지만 상태를 저장할 방법이 없기 때문에 확장 프로퍼티는 실제로 아무런 값도 가질 수 없다. 대신 코드의 간결성을 향상시킬 수 있다.

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

단순히 일반 프로퍼티에 수신 객체 클래스가 추가된 것이다. 뒷받침하는 필드가 없어서 기본 게터는 항상 정의해줘야 한다. 그리고 초기화 코드에서 계산한 값을 담을 수 없으므로 초기화 코드도 쓸 수 없다.
StringBuilder에 같은 프로퍼티를 정의하면 StringBuilder의 맨 마지막 문자는 변경 가능하므로 프로퍼티를 var로 만들 수 있다.

var StringBuilder.lastChar: Char
    get() = get(length - 1)
    set(value: Char) {
        this.setCharAt(length - 1, value)
    }

사용 방법은 멤버 프로퍼티를 사용하는 방법과 같다.

println("Kotlin".lastChar) // n

val sb = StringBuilder("Kotlin?")
sb.lastChar = '!'
println(sb)

Java에서 확장 프로퍼티를 사용하려면 항상 StringUtilKt.getLastChar("Java") 와 같이 getter를 호출해야 한다.