Kotlin 확장 함수 (Extension functions)

개요

코틀린은 클래스 상속하거나 Decorator와 같은 디자인 패턴을 사용하지 않고도 새로운 기능으로 클래스를 확장할 수 있는 기능을 제공한다. 이는 확장(extension) 선언을 하여 수행된다.

예를 들어, 수정할 수 없는 타사 라이브러리(Third-party library)의 클래스에 새로운 함수를 작성할 수 있다. 이러한 함수는 원래 클래스의 메소드인 것처럼 일반적인 방식으로 호출할 수 있다. 이 메커니즘을 확장 함수(Extension functions)이라고 한다. 기존 클래스에 대한 새로운 속성을 정의 할 수 있는 확정 속성(Properties)도 있다.

그리고, 기존 이미 있는 기본 클래스에도 함수를 추가 할 수 있다. 예를 들면 아래와 같이 Int, String와 같은 기본 타입(클래스)에도 함수를 추가할 있다는 것을 의미한다.

fun String.hello() = println("Hello $this")

fun main() {
    "devkuma".hello()
}

확장 함수

확장 함수을 선언하려면 확장되는 유형을 참조하는 수신 타입(receiver type, 확장 대상이 되는 클래스)을 이름 앞에 접두사로 붙인다. 정의를 하자면 아래와 같다.

fun 수신타입.함수명(매개변수): 반환타입 { 구현부 }

다음 예제는 MutableList<Int>에 스왑 함수를 추가하였다.

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

확장 함수 내부의 this 키워드는 수신 객체(함수명 점 앞에 전달되는 객체)에 해당한다. 이것으로 MutableList<Int>에 선언한 함수를 호출할 수 있게 된다.

val list = mutableListOf(1, 2, 3)
list.swap(0, 2) // 'swap()' 내부에 'this'는 'list' 값을 가지고 있다.

Generic class의 확장 함수

Generic class에 대해서 확장 함수를 추가하려면아래와 같이 정의할 수 있다.

fun 클래스이름.함수이름(인자타입): 리턴타입 { 구현부 }

위 함수에 Int뿐 아니라 다른 객체에서도 활용 될 수 있게, 제네릭을 사용하여 MutableList<T>으로 일반화할 수 있다.

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

확장은 정적으로 해결 (Extensions are resolved statically)

확장은 실제로 확장하는 클래스를 수정하지 않는다. 확장을 정의하게 되면 클래스에 새 멤버를 삽입하지 않고, 이 타입의 변수에 대해 점(.) 표기법으로 새로운 ​​함수를 호출할 수만 있게 된다.

확장 함수은 정적으로 전달되며 이는 수신 타입에 따라 가상이 아님을 의미한다. 호출되는 확장 함수는 런타임에 해당 표현식을 평가한 결과의 유형이 아니라 함수가 호출되는 표현식의 유형에 따라 결정된다.

예를 들어:

open class Shape
class Rectangle: Shape()

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

fun printClassName(s: Shape) {
    println(s.getName())
}

fun main() {
    printClassName(Rectangle())
}

Output:

Shape

이 예제에서는 호출된 확장 함수가 Shape 클래스인 매개변수 s의 선언된 타입에만 의존하기 때문에 Shape를 출력한다.

멤버 함수가 있는 클래스에 같은 수신 타입, 같은 이름, 같은 인자의 확장 함수가 정의되면 맴버 함수가 실행된다.

예를 들어,

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

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

fun main() {
    Example().printFunctionType()
}

Output:

Class method

이 예제는 Class 메소드가 출력된다.

여기서 확장 함수의 이름은 같지만, 인자가 다른 멤버 함수를 오버로드되는 경우는 다른 함수를 추가하는 것이기에 확장한 함수가 실행된다.

class Example2 {
    fun printFunctionType() { println("Class method") }
}

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

fun main() {
    Example2().printFunctionType(1)
}

Null 허용 수신 (Nullable receiver)

확장은 nullable 수신 타입으로 정의할 수도 있다. 이러한 확장은 값이 null인 경우에도 객체 변수를 호출할 수 있고, 내부에서는 this == null에 대해 확인하고 있다.

그래서 확장 함수 내에서 이미 확인이 수행되므로, null을 확인하지 않고도 toString()를 호출할 수 있다.

fun Any?.toString(): String {
    if (this == null) return "null"
    // null인지 확인 후에 'this'는 null이 아닌 타입으로 자동 변환되므로 아래의 'toString()'은
    // Any 클래스의 멤버 함수로 확인된다.
    return toString()
}

아래 예제는 실행해 보도록 하자.

fun main() {
    println(null.toString())
}

Output:

null

null에 대해 toString()을 실행하였는데, 에러(NPE)가 발생하고 않고 null이 출력되는 것을 볼 수 있다.

확장 속성 (Extension properties)

Kotlin은 기능을 지원하는 것처럼 확장 속성을 지원한다.

val <T> List<T>.lastIndex: Int
    get() = size - 1

확장은 실제로 멤버를 클래스에 삽입되지 않으므로, 확장 속성에 필드 지원(backing field)을 할 수 있는 효율적인 방법은 없다. 이런 이유로 초기화가 확장 속성에 허용되지 않는다. 그러기에 getter/setter를 명시적으로 제공해야만 정의할 수 있다.

val House.number = 1 // 에러: 초기화는 확장 속성에 허용되지 않는다.

Companion 객체 확장 (Companion object extensions)

클래스에 Companion 객체가 정의되어 있으면 Companion 객체에 대한 확장 기능 및 속성을 정의할 수도 있다. Companion 객체의 일반 멤버와 마찬가지로 클래스 이름만 한정자로 사용하여 호출 할 수 있다.

class MyClass {
    companion object { }  // 이런 코드를 "Companion"이라고 한다.
}

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

fun main() {
    MyClass.printCompanion()
}

Output:

companion

확장 범위 (Scope of extensions)

대부분의 경우 패키지 바로 아래의 최상위 수준(top level)에서 확장을 정의한다.

package org.example.declarations

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

선언하는 패키지 외부에서 확장을 사용하려면, 호출 곳에서 확장을 가져온다(import).

package org.example.usage

import org.example.declarations.getLongestString

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

자세한 내용은 가져오기(import)를 참조하라.

확장을 맴버으로 선언 (Declaring extensions as members)

다른 클래스 내에서 한 클래스에 대한 확장을 선언할 수 있다. 이러한 확장 내부 에는 한정자 없이 멤버에 액세스할 수 있는 객체인 여러 암시적인 수신자가 있다. 확장이 선언된 클래스의 인스턴스를 디스패치 수신자(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()'를 호출한다.
        print(":")
        printPort()   // 'Connection.printPort()'를 호출한다.
    }

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

fun main() {
    Connection(Host("kotl.in"), 443).connect()
    //Host("kotl.in").printConnectionString()  // 에러, 'Connection' 외부에서 확장 함수를 사용할 수 없다.
}

Output:

kotl.in:443

디스패치 수신자와 확장 수신자 사이에 이름에 충돌이 있는 경우, 확장 수신자가 우선한다. 디스패치 수신자의 멤버를 참조하려면 정규화된 this 구문을 사용할 수 있다.

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

멤버로 선언된 확장은 open 하위 클래스로 선언되고 재정의 될 수 있다. 이는 이러한 기능의 디스패치가 디스패치 수신자 유형에 대해서는 가상이지만 확장 수신자 타입에 대해서는 정적임을 의미한다.

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) {
        b.printFunctionInfo()   // call the extension function
    }
}

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" - dispatch receiver is resolved virtually
    DerivedCaller().call(Derived())  // "Base extension function in DerivedCaller" - extension receiver is resolved statically
}

Output:

Base extension function in BaseCaller
Base extension function in DerivedCaller
Base extension function in DerivedCaller

접근 제한에 대한 참고 사항 (Note on visibility)

확장은 동일한 범위에서 선언된 일반 함수와 동일한 접근 제한자를 활용한다. 예를 들어:

  • 파일의 최상위 수준에서 선언된 확장 private은 동일한 파일의 다른 최상위 수준 선언에 액세스할 수 있다.
  • 확장이 수신 타입 외부에서 선언되면 수신 private 또는 protected 멤버에 액세스할 수 없다.

참조