본문 바로가기

Kotlin

공식 문서로 배우는 코틀린 - 19. Extensions

열아홉 번째, 확장입니다.

 

Kotlin에서는 상속을 받거나 데코레이터 패턴 같은 디자인 패턴을 사용하지 않고도 새로운 기능으로 클래스나 인터페이스를 확장할 수 있습니다. 이는 확장(extensions)이라고 불리는 특별한 선언을 통해서 가능합니다.

 

예를 들어, 수정할 수 없는 써드 파티 라이브러리의 클래스나 인터페이스에 새로운 함수를 작성할 수 있습니다. 이러한 함수는 마치 원래 클래스의 멤버였던 것처럼 일반적인 방법으로 호출할 수 있습니다. 이러한 메커니즘은 확장 함수라고 부릅니다. 또한, 기존의 클래스에 새로운 프로퍼티를 정의하는 확장 프로퍼티도 있습니다.

 

확장 함수

확장함수를 정의할 때는 이름 앞에 확장될 타입을 나타내는 수신자 타입(receiver type)을 붙입니다. 다음은 MutableList<Int>에 swap 함수를 추가하는 예입니다.

fun MutableList<Int>.swap(index1: Int, index2: Int) {
    val tmp = this[index1] // 'this'는 리스트에 해당합니다.
    this[index1] = this[index2]
    this[index2] = tmp
}

 

확장 함수 내에서 this는 수신자 객체(receiver object)에 해당합니다(즉, . 전에 넘겨진 객체를 얘기합니다. foo.swap(...)이라면 foo를 얘기합니다). 위와 같이 정의한 이후로는 모든 MutableList<Int>에 대해 다음과 같은 식으로 호출할 수 있습니다.

val list = mutableListOf(1, 2, 3)
list.swap(0, 2) // 'swap()'안의 'this'는 'list'의 값들을 가지고 있습니다.

 

확장 함수는 특정 클래스나 인터페이스에 추가(확장)되는 함수입니다. 여기서 대상이 되는 클래스나 인터페이스를 수신자 타입(또는 수신 타입, receiver type)이리고 하고, 사용 시점으로 보면 해당 타입이 인스턴스화 된 객체에서 확장 함수가 호출되므로, 확장 함수내에서의 this는 수신자 객체(또는 수신 객체, receiver object)라고 부릅니다. 앞서도 언급한 것처럼, 확장 함수는 원래 클래스 멤버처럼 동작하는 것이기 때문에 this는 당연히 확장 함수가 추가되는 대상을 가리킵니다. 수신자라는 표현은 확장을 받기때문이라고 생각하시면 기억이 쉽습니다.

 

확장 함수는 제네릭에도 대응할 수 있습니다.

fun <T> MutableList<T>.swap(index1: Int, index2: Int) {
    val tmp = this[index1] // 'this'는 리스트에 해당합니다.
    this[index1] = this[index2]
    this[index2] = tmp
}

 

수신자 타입 표현식(MutableList<T>)에서 사용할 수 있도록 함수 이름 앞에 제네릭 타입 매개변수를 선언해야 합니다(fun 다음의 <T>). 제네릭과 관련된 보다 자세한 내용은 제네릭 함수 부분을 보시기 바랍니다.

 

확장은 정적으로 결정(resolved)됩니다.

확장은 실제 대상이 되는 클래스를 변경하지 않습니다. 확장을 정의한다고 해서 클래스에 새로운 멤버를 추가하는 것은 아니고, 단지 해당 타입의 변수에 . 표기로 호출할 수 있는 새로운 함수를 만드는 것 뿐입니다.

 

확장 함수는 정적으로 결정됩니다(dispatch). 그래서, 어떤 확장 함수가 호출되는지는 수신자 타입에 기반하여 이미 컴파일 타임에 알려져 있습니다. 예를 들면 다음과 같습니다.

fun main() {
    open class Shape
    class Rectangle: Shape()

    fun Shape.getName() = "Shape"
    fun Rectangle.getName() = "Rectangle"

    fun printClassName(s: Shape) {
        // s의 타입인 Shape를 정적으로 결정됩니다. 
        // 이는 실행 시간에 Shape의 하위 타입이 인수로 오고 해당 타입에 getName() 확장 함수가 있더라도
        // 적용되지 않고 정적 바인딩된 Shape의 getName을 사용합니다. 
        println(s.getName())
    }

    printClassName(Rectangle())
}

/* 결과
Shape
*/

 

호출되는 확장함수가 매개변수 s의 타입(Shape)에 대한 것이기 때문에, 이 예는 Shape를 출력합니다. 확장함수가 실제 멤버로 추가되는 것이라면 이 경우 Rectangle가 출력됐을 것입니다. 

dynamic dispatch/binding 그리고 virtual
동적 바인딩, 동적 디스패치, 가상으로(virtual) ... 식의 표현은 모두 같은 의미입니다. virtual이라는 단어는 주로 C++ 쪽에서 많이 사용되는 용어입니다.
실행 시간의 호출 시점에 동적으로 선택된다/결정된다 식으로 이해하시면 좋을 거 같습니다.

 

어떤 클래스에 멤버 함수가 있는데, 이 타입에 추가하는 확장 함수가 이름이 같고 주어진 인수를 둘 다 수용할 수 있는 상황(즉, 매개 타입이 둘 다 호환되는 경우)일 때는 항상 멤버가 우선시 됩니다. 예를 들면 다음과 같습니다.

fun main() {
    class Example {
        fun printFunctionType() { println("Class method") }
    }

    fun Example.printFunctionType() { println("Extension function") }

    Example().printFunctionType()
}

 

이 코드는 "Class Method"를 출력합니다.

 

하지만, 같은 이름에 다른 시그니처를 가져 기존 멤버를 오버로딩을 하는 확장 함수는 완벽하게 잘 동작합니다.

fun main() {
    class Example {
        fun printFunctionType() { println("Class method") }
    }

    fun Example.printFunctionType(i: Int) { println("Extension function #$i") }

    Example().printFunctionType(1)
}
/* 결과
Extension function #1
*/

 

널 가능한 수신자

확장은 널 가능한 타입에 정의될 수 있습니다. 이러한 확장은 객체 변수가 널이어도 호출될 수 있습니다. 그런 경우에는 this는 null입니다. 그러므로, 널 가능한 타입에 확장을 정의할 때는 컴파일 에러를 피하기 위해 함수 몸체 내에서 this == null 검사를 권장합니다.

 

Kotlin에서는 널 검사 없이 toString()을 호출할 수 있는데, 그 이유는 확장 함수 내에서 미리 검사를 하기 때문입니다.

fun Any?.toString(): String {
    if (this == null) return "null"
    // 널 검사 후에, 'this'는 자동으로 널 가능하지 않은 타입으로 캐스트 됩니다.
    // 그래서, 아래의 toString()은 Any 클래스의 멤버 함수로 처리됩니다.
    return toString()
}

 

확장 프로퍼티

Kotlin은 확장 함수처럼 확장 프로퍼티를 지원합니다.

val <T> List<T>.lastIndex: Int
    get() = size - 1
확장은 실제로 해당 클래스의 멤버로 추가되는 것이 아니기 때문에 확장 프로퍼티가 뒷받침하는 필드를 갖는 실질적인 방법은 없습니다. 그러기 때문에, 확장 프로퍼티에는 초기화 부분(initializer)이 허용되지 않습니다. 확장 프로퍼티의 행위는 오로지 명시적인 게터/세터로 정의됩니다.

 

val House.number = 1 // error: 확장 프로퍼티에 초기화(initializer)는 허용되지 않습니다.

 

동반 객체 확장

클래스가 (정의된) 동반 객체를 갖는 경우, 해당 동반 객체에 확장 함수로 확장 속성을 정의할 수 있습니다. 동반 객체의 정규 멤버처럼 클래스 이름을 수식어로 하여 호출할 수 있습니다.

class MyClass {
    companion object { }  // 동반 객체. 아래처럼 Companion이라고 불림(MyClass.Companion)
}

fun MyClass.Companion.printCompanion() { println("companion") }

fun main() {
    MyClass.printCompanion()
}

/* 결과
companion
*/

 

확장의 유효 범위(scope)

대부분의 경우 확장은 최상위 수준으로 패키지 아래 직접적으로 정의할 수 있습니다.

package org.example.declarations

fun List<String>.getLongestString() { /*...*/}

 

해당 패키지 바깥에서 확장을 사용할 때는 사용하는 쪽에서 임포트합니다.

package org.example.usage

import org.example.declarations.getLongestString

fun main() {
    val list = listOf("red", "green", "blue")
    list.getLongestString()
}

 

보다 자세한 정보는 여기를 참고하세요.

 

멤버로 확장 선언

다른 클래스 안에서 특정 클래스의 확장을 선언할 수 있습니다. 이러한 확장 내에서는 묵시적인 복수의 수신자 - 클래스 타입으로 수식할 필요 없이 바로 접근 가능한 멤버를 가진 객체가 있습니다. 확장 함수 선언이 포함된 클래스의 인스턴스를 디스패치 수신자(dispatch receiver)라고 하고, 확장 메소드(함수)의 수신자 타입 인스턴스는 확장 수신자(extension receiver)라고 합니다.

class Host(val hostname: String) {
    fun printHostname() { print(hostname) }
}

class Connection(val host: Host, val port: Int) {
    fun printPort() { print(port) }

    fun Host.printConnectionString() {
        printHostname()   // Host.printHostname() 호출. Host의 인스턴스는 확장 수신자.
        print(":")
        printPort()   // Connection.printPort() 호출. Connection의 인스턴스는 디스패치 수신자.
    }

    fun connect() {
        /*...*/
        host.printConnectionString()   // 확장 함수 호출
    }
}

fun main() {
    Connection(Host("kotl.in"), 443).connect()
    //Host("kotl.in").printConnectionString()  // 오류, Connection 바깥에서는 사용 불가
}

/* 결과
kotl.in:443
*/

 

디스패치 수신자와 확장 수신자의 멤버 이름이 충돌하는 경우 확장 수신자가 우선 순위를 갖습니다. 디스패치 수신자의 멤버를 참조하기 위해서 this로 수식하는 구문을 사용할 수 있습니다.

class Connection {
    fun Host.getConnectionString() {
        toString()         // Host.toString() 호출
        this@Connection.toString()  // Connection.toString() 호출
    }
}

 

멤버로 선언된 확장은 open으로 지정되어 하위 클래스에서 오버라이딩 할 수 있습니다. 이 말은 이 함수의 선택이 디스패치 수신자 대해서는 가상적(virtual 또는 동적)이지만, 확장 수신자 타입에 대해서는 정적이라는 뜻입니다.

open class Base { }

class Derived : Base() { }

open class BaseCaller {
    open fun Base.printFunctionInfo() {
        println("Base extension function in BaseCaller")
    }

    open fun Derived.printFunctionInfo() {
        println("Derived extension function in BaseCaller")
    }

    fun call(b: Base) {
        // 항상 Base 클래스에 정의된 확장 함수를 호출합니다.
        // 위의 Derived 같이 Base 클래스로부터 파생된 클래스를 넘겨 받아도 마찬가지입니다.
        // 여기에서 확장 수신자는 정적으로 결정됩니다.
        b.printFunctionInfo()
    }
}

class DerivedCaller: BaseCaller() {
    override fun Base.printFunctionInfo() {
        println("Base extension function in DerivedCaller")
    }

    override fun Derived.printFunctionInfo() {
        println("Derived extension function in DerivedCaller")
    }
}

fun main() {
    BaseCaller().call(Base())   // "Base extension function in BaseCaller"
    DerivedCaller().call(Base())  // "Base extension function in DerivedCaller" - 디스패치 수신자가 동적으로 결정됨
    DerivedCaller().call(Derived())  // "Base extension function in DerivedCaller" - 확장 수신자가 정적으로 결정됨
}

/* 결과
Base extension function in BaseCaller
Base extension function in DerivedCaller
Base extension function in DerivedCaller
*/

 

디스패치 수신자는 동적으로 결정되기 때문에 DerivedCaller().call(..)과 BaseCaller().call(...) 각각 해당 클래스의 확장 함수 정의가 선택(dispatch)되지만, 확장 수신자는 정적으로 결정되기 때문에, DerivedCaller.call(Base())나 DerivedCaller().call(Derived()) 처럼 각각 다른 클래스인 기반 클래스와 파생 클래스를 넘겨도 BaseCaller::call 부분에서 기반 클래스인 Base로 정적 바인딩된 상태가 반영됩니다.

가시성에 대한 주의 사항

확장은 해당 범위(scope)에 선언되는 일반적인 함수와 같은 가시성을 같습니다. 예를 들면 다음과 같습니다.

  • 파일의 최상위 수준에 선언된 확장은 같은 파일의 다른 pritvate 최상위 수준 선언들에 접근 권한을 갖습니다.
  • 확장이 수신자 타입의 외부에 선언되는 경우 수신자의 private이나 protected 멤버는 접근할 수 없습니다.