009. 확장 함수와 확장 프로퍼티

확장 함수와 확장 프로퍼티

최초 코틀린의 개요에서 언급했듯이, 코틀린은 기존 코드와의 원활한 통합이 주요 테마중 하나이다.

심지어 순수하게 코틀린으로만 작성된 프로젝트도 Java와 동일한 라이브러리 위에 구축되어있을 정도다.

이때 Java에 있는 API를 코틀린으로 가져와 작업하는 경우 코틀린에서만 사용할 수 있는 특장점을 활용할 방법은 없을까?

이제부터 확장 함수에 대해 알아보도록 하자.

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

위의 코드는 String 클래스에 lastChar() 라는 확장 함수를 추가한 것이다.

이처럼 확장 함수는 확장 대상인 클래스 혹은 인터페이스의 이름만 가지고도 손쉽게 할 수 있다.

추가한 뒤엔 마치 원래 제공되던 API인 것처럼 호출하면 된다.

1
2
>>> println("Kotlin".lastChar())
n

개념상 확장 함수는 특정 클래스에 함수 하나를 추가한 것처럼 생각될 것이다.

실제로도 그렇게 동작하기 때문에 lastChar() 함수에서 this는 String 클래스를 가리키게 된다.

따라서 this포인터를 생략할 수 있다.

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

import / extension

확장 함수를 정의한 후, 사용하고 싶은 곳에서 명시적으로 import 시켜야만 사용할 수 있다.

바로 전체 프로젝트에서 자동적으로 참조하지 않는 이유는 확장 함수의 네이밍과 기존 정의된 메소드명이 충돌할 여지가 있기때문이다.

1
2
import strings.lastChar
val c = "Kotlin".lastChar()

기존 Java처럼 *로 import해도 잘 동작한다.

1
2
import strings.*
val c = "Kotlin".lastChar()

아래와 같은 방법으로 확장 함수에 접근할 이름을 별도로 또 선언해줄 수도 있다.

1
2
import strings.lastChar as last
val c = "Kotlin".last()

Java에서의 확장 함수 호출

만약 Java에서 확장 함수를 선언하게 되면 코틀린에서처럼 접근할 수 없는 문제가 있다.

이를 쉽게 해결하려면 정적 메소드를 하나 선언한 뒤, 해당 메소드를 최상위 함수에 배치하면 일종의 브릿지 형태로 쓸 수 있다.

1
2
/* Java */
char c = StringUtilKt.lastChar("Java");

유틸리티 함수의 확장

joinToString 함수의 최종 버전을 작성해보도록 하자.

아래 코드는 실제 코틀린의 표준 라이브러이와도 유사하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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)

Collections에 확장 함수를 추가하고 기본 파라미터값을 지정하여 아래와 같이 간편하게 호출할 수도 있다.

1
2
3
>>> val list = arrayListOf(1, 2, 3)
>>> println(list.joinToString(" "))
1 2 3

만약 문자열로만 구성된 Collections인 경우에만 호출할 수 있는 join이란 기능을 구현한다고 하면 아래와 같이 구현할 수 있다.

1
2
3
4
5
6
7
8
9
fun Collection<String>.join(
separator: String = ", ",
prefix: String = "",
postfix: String = ""
) = joinToString(separator, prefix, postfix)

/* 출력 결과 */
>>> println(listOf("one", "two", "eight").join(" "))
one two eight

타입이 맞지않으면 아래와 같은 오류를 출력하게 된다.

1
2
>>> listOf(1, 2, 8).join()
Error: Type mismatch: inferred type is List<Int> but Collection<String> was expected.

확장 함수의 오버라이드

뭐든지 다 될 것만 같은 코틀린에서도 확장 함수에 대한 오버라이딩은 불가능하다.

만약 아래의 두 개의 클래스를 가지고 있고, click() 이라는 메소드를 오버라이드하고 싶다고 가정하자.

1
2
3
4
5
6
open class View {
open fun click() = println("View clicked")
}
class Button: View() {
override fun click() = println("Button clicked")
}

View 타입의 변수를 선언하게 되면, ButtonView의 서브클래스이기 때문에 해당 변수를 Button으로 초기화할 수 있을 것이다.

이 경우 click() 메소드를 호출하면 Button#click()이 호출될 것이다.

1
2
3
>>> val view: View = Button()
>>> view.click()
Button clicked

하지만 확장 함수는 클래스의 일부가 아닌 외부로 선언되기 때문에 슈퍼클래스에서 서브클래스로 상속되지 않는다.

최종적으로, 해당 확장 함수가 선언된 클래스 혹은 인터페이스에 정의된 형태로 동작하게 된다.

1
2
3
4
5
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!

Java에서 호출하더라도 이 현상은 동일하여 아래와 같은 결과를 돌려주게된다.

이를 통해 확장 함수 작성시 리시버 클래스에 대해 면밀하게 접근해야 함을 알 수 있다.

1
2
3
4
/* Java */
>>> View view = new Button();
>>> ExtensionsKt.showOff(view);
I am a view!

확장 프로퍼티

프로퍼티 또한 확장이 가능하다.

단 확장 함수처럼 명시적으로 작성되진 않고, 확장하려는 프로퍼티에 대한 get() 형태로 추가된다.

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

특정 클래스에서 동일한 프로퍼티를 확장하는 경우, 해당 클래스의 내용을 수정할 가능성이 있으므로 아래와 같이 작성해주어야 한다.

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

이렇게 확장된 프로퍼티는 기존에 작성된 프로퍼티들과 동일하게 접근할 수 있다.

1
2
3
4
5
6
>>> println("Kotlin".lastChar)
n
>>> val sb = StringBuilder("Kotlin?")
>>> sb.lastChar = '!'
>>> println(sb)
Kotlin!