자바를 주 언어 또는 잘 사용하는 사람들은 모든 코드를 클래스 또는 메소드로 작성해야 한다는 사실을 잘 알고 있다. 그리고 보통 그런 구조는 아주 잘 작동한다.
하지만 코드를 짜다 보면 이런 생각이 들 때가 있다. 그것은 바로 특정 메소드들을 어느 한 클래스에 포함시키기 어려운 코드가 많이 생긴다는 점이다. 일부 연산에는 비슷하게 중요한 역할을 하는 클래스가 2개 이상일 수도 있다. 또는 중요한 객체는 하나뿐인데, 그 연산을 객체의 인스턴스 api에 추가해서 api를 너무 크게 만들고 싶지는 않은 경우도 있다. 물론 필자가 그런 예시를 모두 겪어본 것은 아니지만, 생각해보면 모든 클래스에 메소드를 담다보면, 그럴수도 있지 않을까? 라는 막연한 생각이 들기 시작했다.
여튼 이러한 상황을 자바에서는 조금이라도 줄이고자 다양한 정적 메소드를 모아두는 역할만 담당하면서 특별한 상태 또는 인스턴스 메소드는 없는 클래스가 생겨났다 그게 바로 jdk의 Collections 클래스이다. 이런 예시들은 java.util 클래스를 보면 되겠다.
여튼 코틀린에서는 위에서 언급했던 어떻게 보면 하나의 메소드를 사용하기 위해서 사용되지 않는 메소드나 클래스를 호출하여 메모리적인 낭비를 굳이 할 필요가 없다. 대신에 함수를 직접 소스 파일의 최상위 수준, 모든 클래스의 밖으로 위치시키면 된다.
Kotlin식 문법: 정적인 유틸리티 클래스 없애기: 최상위 함수와 프로퍼티 - 1
에서 작성했던 joinToString()
를 최상위 함수로 선언하기 위해 아래와 같은 코드로 Strings 패키지에 직접 넣어보자. 물론 파일의 이름은 joinToString.kt
이다.
package strings
fun <T> joinToString(
collection: Collection<T>,
separator: String =", ",
prefix: String = "",
postfix: String = ""
) : String {
val result = StringBuilder(prefix)
for ((idx, ele) in collection.withIndex()) {
if (idx > 0) result.append(separator)
result.append(ele)
}
result.append(postfix)
return result.toString()
}
그러면 이 함수는 어떻게 실행될까? 중요한 것은 JVM의 경우 class 안에 들어가 있는 코드만을 실행할 수 있기 때문에 컴파일러는 이 파일을 컴파일할 때 새로운 클래스를 정의해준다. 단순하게 코틀린만 사용하는 경우에는 그냥 그런 클래스가 생긴다는 사실만 기억하면 된다. 그래도 이 함수를 자바 등의 다른 JVM 언어에서 호출하고 싶다면 코드가 어떻게 컴파일되는지 알아야 joinToString과 같은 최상위 함수를 사용할 수 있다. 위 코드가 java 코드로 변환되면 어떻게 되는지 알아보자.
package strings;
public class JoinKT {
public static String joinToString(...) {...}
}
이렇게 되면 코틀린 컴파일러가 생성하는 class의 이름은 최상위 함수가 들어있던 코틀린 소스 파일의 이름과 대응된다. 코틀린 파일의 모든 최상위 함수는 이 클래스의 정적인 메소가 된다. 따라서 자바에서 joinToString()을 호출하기는 쉽다.
import strins.JoinKt
...
JoinKt.joinToString(list, ", ", "", "");
추가적으로 파일에 대응하는 클래스의 이름을 변경할 수 있는 방법도 있다. 이건
@JvmName
annotation을 사용하면 된다. 이 부분은 나중에 상세히 기록해보겠다.
프로퍼티의 경우에도 함수와 마찬가지로 파일의 최상위 수준에 놓을 수 있다. 어떤 데이터를 클래스 밖에 위치시켜야 하는 경우는 흔하지는 않다. (물론 알고리즘 문제를 풀 때 전역 변수를 사용해야 편리한 경우가 있다. 필자의 경우 main 함수 안에 다른 함수를 정의 하는 것을 그리 좋아하지 않는다. 아직도 이 부분에 대해 깊은 고민을 하고 있는 중이다...)
기존의 코드와 코틀린 코드를 자연스럽게 통합하는 것은 코틀린의 목표인 상호 운용성의 핵심이다. 완전히 코틀린으로 이루어진 프로젝트조차 JDK나 안드로이드 프레임워크 또는 다른 서드파티 프레임워크 들의 자바 라이브러리를 기반으로 하여 만들어진다. 따라서 코틀린을 기존 자바 프로젝트에 포함하는 경우에는 코틀린으로 변환하지 않았거나, 변환할 수 없었던 기존의 자바 코드들을 함께 처리될 수 있도록 해야 한다. 결국에는 기존 자바 api를 재작성하지 않고도 코틀린이 제공하는 여러 편리한 기능을 사용할 수 있다면, 정말 좋은 일 아닌가?
이런 기능을 확장 함수
가 해준다.
확장 함수
어떤 클래스의 멤버 함수인 것 처럼 호출할 수 있으나, 그 클래스 밖에 선언된 함수
예를 들어 다음의 코드를 봐보자.
fun String.lastChar(): Char = this[this.length-1]
fun main() = println("Koltin".lastChar())
여기서 String은 수신 객체 타입
이며, this는 수신객체
이다.
이러한 방식은 String 클래스에 새로운 메소드를 추가하는 것과 같다. 물론 String 클래스를 직접 작성한 것도 아니고 String class의 소스 코드를 소유한 것도 아니지만, 여전히 원하는 메소드를 String class에 추가할 수 있다. 심지어 String이 자바나 코틀린 들의 언어 중 어떤 것으로 작성됐는가느 중요하지 않다. 자바 클래스로 컴파일한 클래스 파일이 있는 한 그 클래스에 원하는 대로 확장을 추가할 수 있다. (이 부분은 kotlin compiler와 java compiler는 complie하는 방식이 조금 다른데, 해당 부분도 역시 나중에 포스팅하겠습니다.
fun main() {
val list = listOf(1, 2, 3)
println(list.joinToString(separator = "; ", prefix = "(", postfix = ")"))
}
fun <T> Collection<T>.joinToString(
separator:String = ", ",
prefix:String = "",
postfix: String = ""
) : String {
val result = StringBuilder(prefix)
for ((idx, ele) in this.withIndex()) {
if (idx > 0) result.append(separator)
result.append(ele)
}
result.append(postfix)
return result.toString()
}
물론 확장 함수는 단지 정적 메소드 호출에 대한 문법적인 편의일 뿐이다. 그래서 클래스가 아닌 더 구체적인 타입을 수신 객체 타입으로 지정할 수도 있다. 그래서 문자열의 컬렉션에 대해서만 호출할 수 있는 join 함수를 정의하고 싶다면 다음과 같이 할 수 있다.
fun main() {
val list = listOf("1", "2", "3")
println(list.join(" "))
}
fun <T> Collection<T>.joinToString(
separator:String = ", ",
prefix:String = "",
postfix: String = ""
) : String {
val result = StringBuilder(prefix)
for ((idx, ele) in this.withIndex()) {
if (idx > 0) result.append(separator)
result.append(ele)
}
result.append(postfix)
return result.toString()
}
fun Collection<String>.join(
separator: String = ", ",
prefix: String = "",
postfix: String = ""
) = joinToString(separator, prefix, postfix)
코틀린의 메소드 오버라이드도 일반적인 객체지향의 메소드 오버라이드와 마찬가지다. 하지만 확장 함수는 오버라이드할 수 없다. View와 그 하위 클래스인 Button이 있는데, Button이 상위 클래스의 click 함수를 오버라이드하는 경우를 생각해보면 다음과 같다.
open class View {
open fun click() = println("View Clicked")
}
class Button: View() {
override fun click() = println("Button Clicked")
}
fun main() {
val view: View = Button()
view.click() // Button Clicked
}
Button이 View의 하위 타입이기 때문에 View 타입 변수를 선언해도 Button 타입 변수를 그 변수에 대입할 수 있다. View 타입 변수에 대해 click과 같은 일반 메소드를 호출했는데, click을 Button 클래스가 오버라이드했다면, 실제로는 Button이 오버라이드한 click이 호출된다.
하지만, 확장은 이런식으로 작동하지 않는다. 중요한 것은 확장 함수는 클래스의 일부가 아니다.
무슨 이야기냐면 확장 함수는 클래스 밖에서 선언된다. 즉 이름과 파라미터가 완전히 같은 확장 함수를 기반이 되는 클래스와 하위 클래스에 대해 정의해도 실제로는 확장 함수를 호출할 때 수신 객체로 지정한 변수의 정적 타입에 의해 어떤 확장 함수가 호출될지 결정되며 그 변수에 저장된 객체의 동적인 타입에 의해 확장 함수가 결정되지 않는다. 다음의 예제를 보면서 이해 해보자.
open class View {
open fun click() = println("View Clicked")
}
class Button: View() {
override fun click() = println("Button Clicked")
}
fun View.showOff() = println("I'm a View")
fun Button.showOff() = println("I'm a Button")
fun main() {
val view: View = Button()
view.showOff() // I'm a View
}
위 코드에서는 View가 가리키는 객체의 실제 타입이 Buttond이지만, 이 경우 View의 타입이 View이기 때문에 무조건 View의 확장 함수가 호출된다. 앞ㅇ서 이야기한 것과 동일한 프로세스를 보여주는 것이다.
그렇다면 왜 이런 프로세스가 진행되는 것일까?
그것은 확장 함수를 첫 번째 인자가 수신 객체인 정적 자바 메소드로 컴파일 한다는 사실을 기억한다면 동작을 쉽게 이해할 수 있다. 자바도 호출할 static 함수를 같은 방식으로 정적으로 결정한다.
다시 쉽게 이야기 하자면 코틀린은 호출될 확장 함수를 정적으로 결정한다.
따라서 확장 함수를 override할 수 없다.
주의
어떤 클래스를 확장한 함수와 그 클래스의 멤버 함수의 이름과 시그니처가 같다면 확장 함수가 아니라 멤버 함수가 호출된다. (즉 멤버 함수의 우선순위가 더 높다) 클래스의 api를 변경할 경우 항상 이를 염두해 두어야 한다. 따라서 코드 소유권을 갖는 클래스에 대한 확장 함수를 정의해서 사용하는 외부 클라이언트 프로젝트가 있다고 하면, 그 확장 함수와 (이름과 시그니처)가 같은 멤버 함수를 클래스 내부에 추가하면 클라이언트 프로젝트에서 재컴파일한 순간부터 그 클라이언트는 확장 함수가 아닌 새로 추가된 멤버 함수를 사용하게 된다.
확장 프로퍼티를 사용하면 기존 클래스 객체에 대한 프로퍼티 형식의 구문으로 사용할 수 있는 api를 추가할 수 있다. 프로퍼티라는 이름으로 불리기는 하지만 상태를 저장할 방법이 없기 때문에 (기존 클래스의 인스턴스 객체에 추가할 방법이 없다) 실제로 확장 프로퍼티는 아무 상태도 가질 수 없다. 하지만, 프로퍼티 문법으로 더 짧게 코드를 작성할 수 있기 때문에 편한 경우가 있다.
아까 작성했던 lastChar이란 함수를 통해 다음 예제를 작성해보자.
val String.lastChar: Char
get() = get(length-1)
fun main() {
println("Kotlin".lastChar) // n
}
위의 코드를 살펴보면, 확장 프로퍼티도 일반적인 프로퍼티와 같은데, 단지 수신 객체 클래스가 추가되었을 뿐이다. 이때 기본 객체 구현을 제공하지 않기 때문에 getter의 경우 꼭 정의해야 한다. 또한! 초기화 코드에서 계산한 값을 담을 장소가 없기 때문에 초기화 코드도 쓸 수 없다.
StringBuilder에 같은 프로퍼티를 정의한다면 StringBuilder의 맨 마지막 문자는 변경 가능
하므로 프로퍼티를 var로 만들 수 있다.
val String.lastChar: Char
get() = get(length-1)
var StringBuilder.lastChar: Char
get() = get(length-1)
set(value: Char) {
this.setCharAt(length-1, value)
}
fun main() {
println("Kotlin".lastChar) // n
val sb = StringBuilder("kotlin?")
sb.lastChar = '!'
println(sb) // kotlin!
}
또한 자바에서 확장 프로퍼티를 사용하려면 항상 StringUtilKt.getLastChar("Java")
처럼 getter나 setter를 명시적으로 호출해야 한다.