함수를 호출하기 쉽게 만들기

이름 붙인 인자

fun <T> joinToString(
    collection: Collection<T>,
    separator: CharSequence,
    prefix: CharSequence,
    postfix: CharSequence
) : String{
    /* ... */
}

위 함수를 사용할 때는 아래와 같이 사용할 것이다.

joinToString(list, "; ", "(", ")")

함수를 사용할 때 인자로 전달한 문자열이 어느 역할을 하는지 구분할 수가 없다. 각 인자의 역할이 궁금해질 때마다 함수의 시그니처를 살펴봐야 할 것이다.

코틀린에서는 함수를 호출할 때 함수 인자의 이름을 명시할 수 있다.

joinToString(list, separator = " ", prefix = " ", postfix = " ")

다만 어느 한 인자에 이름을 명시하고 나면 혼동을 막기 위해 그 뒤에 오는 모든 인자는 이름을 명시해야 한다.

디폴트 파라미터 값

자바는 일부 클래스에서 오버로딩한 메소드가 너무 많아진다는 문제가 있다. 오버로딩이 많아지면 혼동이 생길 수 있다.

<figcaption>java.lang.Thread의 오버로딩 메소드 예시, 8개의 오버로딩 메소드가 있다.</figcaption></figure>

코틀린에서는 함수 선언에서 파라미터의 디폴트 값을 지정할 수 있으므로 이런 오버로드의 문제점 대부분을 피할 수 있다. 위의 joinToString 함수에 디폴트 값을 추가해보자.

fun <T> joinToString(
    collection: Collection<T>,
    separator: CharSequence = ", ",
    prefix: CharSequence = "",
    postfix: CharSequence = ""
) : String{
    /* ... */
}

이제 함수를 호출할 때 모든 인자를 쓸 수도 있고, 일부를 생략할 수 있다.

joinToString(list)
joinToString(list, " | ")
joinToString(list, prefix = "{", postfix = "}")

함수 인자에 이름을 지정하지 않으면 함수를 선언할 때와 같은 순서로 인자를 지정해야 한다. 함수 인자에 이름을 지정하면 이 순서를 무시하고 원하는 대로 인자를 지정할 수 있다(joinToString(list, prefix = "{", postfix = "}")).

함수의 디폴트 파라미터 값은 함수를 선언하는 쪽에서 결정한다. 따라서 함수를 선언하는 쪽에서 디폴트 값을 바꾸면 함수를 호출하는 쪽에서 값을 지정하지 않은 모든 인자는 바뀐 디폴트 값을 적용받는다.

디폴트 값과 자바
자바는 디폴트 파라미터 값이라는 개념이 없어서 코틀린 함수를 자바에서 호출하는 경우에는 디폴트 파라미터 값이 있더라도 모든 인자를 명시해야 한다. 코틀린에서 @JvmOverloads Annotation을 함수에 추가하여 자바에서도 인자를 생략할 수 있다. @JvmOverloads를 함수에 추가하면 코틀린 컴파일러가 맨 마지막 파라미터부터 파라미터를 하나씩 생략한 오버로딩한 자바 메소드를 알아서 추가해준다.

최상위 함수와 프로퍼티

자바는 모든 코드는 클래스의 메소드로 작성해야 한다. 대부분은 문제가 없지만 가끔씩 어느 클래스에 포함시키기 애매한 코드가 생기기도 한다. 코틀린에서는 함수를 소스 파일의 최상위 수준(클래스 밖)에 위치시킬 수 있다.

//join.kt
package strings
fun joinToString(...): String { ... }

위 코드는 joinToString 함수를 strings 패키지에 직접 넣은 것이다.

컴파일러는 컴파일할 때 이 함수를 새로운 클래스에 넣어준다(JVM은 클래스 안의 코드만 실행시킬 수 있기 때문에). 코틀린에서 사용할 때는 그냥 그렇구나 생각할 수 있지만, 자바에서 이 함수를 실행시키려면 이 ‘새로운 클래스’를 알아야 할 것이다. 코틀린 컴파일러가 생성하는 클래스의 이름은 소스 파일의 이름에 대응한다. 따라서 위 함수는 JoinKt 클래스에 포함된 함수로 컴파일된다.

package strings;

public class JoinKt {
    public static String joinToString(...) { ... }
}

자동 생성되는 클래스의 이름을 변경하려면 package 선언 위에 @JvmName Annotation을 사용하면 된다.

@file:JvmName("StringFunctions")
package strings
fun joinToString(...): String { ... }

최상위 프로퍼티

함수와 마찬가지로 프로퍼티도 최상위에 놓을 수 있다.

package aaa

var opCount = 0
fun performOperation() {
    opCount++
}

최상위 프로퍼티는 정적 필드에 저장된다.

최상위 프로퍼티는 보통 코드에 상수를 추가할 때 쓰인다.

val UNIX_LINE_SEPARATOR = "\n"

최상위 프로퍼티도 다른 프로퍼티와 같이 Getter와 Setter가 생긴다. 자바에서 사용할 때는 상수처럼 보이지 않는다는 것이다. 자바에서도 상수처럼 보이도록 하려면 상수를 public static final 필드로 컴파일해야 한다. 코틀린에서 const 변경자를 추가하면 프로퍼티를 public static final 필드로 컴파일할 수 있다.

const val UNIX_LINE_SEPARATOR = "\n"

확장 함수와 확장 프로퍼티

확장 함수라는 개념은 어떤 클래스의 메소드인 것처럼 호출할 수 있지만 그 클래스의 밖에 선언된 함수를 뜻한다. 이걸 어디에 활용할 수 있을까? 기존의 자바 API를 수정하지 않고 API에 여러 기능을 덧붙일 수 있다. 이는 기존 코드와 코틀린 코드를 자연스럽게 통합하는 코틀린의 핵심 목표 중 하나이다. 확장 함수는 다음과 같이 만들 수 있다.

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

확장 함수를 만들려면 추가하려는 함수 이름 앞에 확장할 클래스 이름을 덧붙이면 된다. 여기서 클래스 이름을 수신 객체 타입(Receiver type, 위 코드의 String)이라 부르며, 확장 함수가 호출되는 대상이 되는 값(객체)을 수신 객체(Receiver object, 위 코드의 this)라고 부른다.

위 코드를 호출하는 구문은 다른 일반 클래스 멤버를 호출하는 구문과 똑같다.

println("Kotlin".lastChar())
n

여기서는 String이 수신 객체 타입이고 “Kotlin”이 수신 객체이다.

함수 본문에서 this를 사용할 수 있고 원한다면 생략할 수도 있다.

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

확장 함수가 캡슐화를 깨지는 않는다. 확장 함수는 클래스 내부에서 사용할 수 있는 private, protected 멤버를 사용할 수 없다.

임포트와 확장 함수

확장 함수는 어디서든 선언할 수 있다는 특징으로 인해 그 확장 함수를 사용하기 위해서는 임포트가 필요하다. 코틀린에서는 클래스와 동일한 구문을 이용해 개별 함수를 임포트할 수 있다.

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

*을 사용한 임포트도 가능하다.

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

as 키워드를 사용하여 임포트한 클래스나 함수를 다른 이름으로 부를 수 있다.

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

이름이 같은 함수를 as 키워드로 이름을 바꿔서 임포트하면 이름 충돌을 막을 수 있다. FQN(Fully Qualified Name, 전체 이름)을 사용할 수도 있지만 코틀린 문법상 확장 함수는 반드시 짧은 이름을 써야 한다.

자바에서 확장 함수 호출

확장 함수는 내부적으로 수신 객체를 첫 번째 인자로 받는 정적 메소드다. 자바에서는 단순히 정적 메소드를 호출하면서 첫 번째 인자로 수신 객체를 넘기면 된다. 확장 함수를 StringUtil.kt 파일에 정의했다면 다음과 같이 호출할 수 있다.

char c = StringUtilKt.lastChar("Java");

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

위 joinToString 함수를 확장 함수로 정의해보자.

fun <T> Collection<T>.joinToString(
    separator: CharSequence = ", ",
    prefix: CharSequence = "",
    postfix: CharSequence = ""
): String {
    val result = StringBuilder(prefix)
    
    //this는 수신 객체이며 여기서는 T 타입의 원소로 이뤄진 컬렉션이다.
    for((index, element) in this.withIndex()) {
        if(index > 0) result.append(separator)
        result.append(element)
    }
    
    result.append(postfix)
    return result.toString()
}

이제 이 함수는 마치 클래스의 멤버인 것처럼 호출할 수 있다.

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

1 2 3

확장 함수는 단지 정적 메소드 호출에 대한 문법 설탕(Syntactic sugar)일 뿐이다. 따라서 클래스보다 더 구체적인 타입을 수신 객체 타입으로 지정할 수 있다.

fun Collection<String>.join(...) {...}

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

코틀린의 메소드 오버라이드는 일반적인 메소드 오버라이드와 같다.

open class View {
    open fun click() = println("View clicked")
}

class Button: View() {
    override fun click() = println("Button clicked")
}

View 타입 변수를 선언해도 그 안에 Button 타입 변수를 대입할 수 있다. 이 경우 click 메소드를 호출하면 Button이 오버라이드한 click이 호출된다.

위와 같이 실행 시점에 객체 타입에 따라 동적으로 호출될 대상 메소드를 결정하는 방식을 동적 디스패치(Dynamic dispatch)라고 한다. 반대로 컴파일 시점에 알려진 변수 타입에 따라 정해진 메소드를 호출하는 방식은 정적 디스패치(Static dispatch)라고 한다.

하지만 확장 함수는 이런 식으로 작동하지 않는다. 확장 함수는 클래스 밖에 선언된다. 이름과 파라미터가 완전히 같은 확장 함수를 기반 클래스와 하위 클래스에 정의하고 확장 함수를 호출할 때 수신 객체로 지정한 변수의 정적 타입에 의해 어떤 확장 함수를 호출할 지 결정한다.

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! //확장 함수는 정적으로 결정된다.

view가 가리키는 실제 타입과 관계 없이 view의 타입인 View의 확장 함수가 호출된다.

이는 내부적으로 확장 함수가 첫 번째 인자가 수긴 객체인 정적 자바 메소드로 컴파일된다는 점을 통해 쉽게 이해할 수 있다. 자바도 호출할 정적 함수를 같은 방식으로 정적으로 결정한다.

>>> View view = new Button();
>>> ExtensionsKt.showOff(view);
I'm a view!
멤버 함수와 확장 함수의 우선순위
어떤 클래스의 확장 함수와 멤버 함수의 이름과 시그니처가 같으면 멤버 함수가 호출된다(멤버 함수의 우선순위가 더 높다). 자신이 소유권을 가지지 않은 API를 확장할 때에는 이를 염두해 두어야 한다. 이 API가 정의한 확장 함수와 같은 이름과 시그니처의 멤버 함수를 추가하면 확장 함수 대신 멤버 함수가 실행될 것이다.

확장 프로퍼티

확장 프로퍼티를 사용하면 기존 클래스 객체에 대한 프로퍼티 구문으로 사용할 수 있는 API를 추가할 수 있다. 이름이 프로퍼티이긴 하지만 상태를 저장할 적절한 벙법은 없어 확장 프로퍼티는 아무 상태도 가질 수 없다.

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

확장 프로퍼티는 다음과 같은 특징을 갖는다. 마찬가지로

  • 확장 프로퍼티도 일반적인 프로퍼티와 같으나 수신 객체 클래스가 추가된 형태이다.
  • 뒷받침하는 필드(프로퍼티 값을 저장하기 위한 필드)가 없어 기본 게터 구현을 제공할 수 없다. 따라서 게터를 꼭 정의해야 한다.
  • 초기화 코드에서 계산한 값을 담을 곳이 없어서 초기화 코드도 쓸 수 없다.

var 프로퍼티는 변경 가능하므로 getter와 setter를 모두 정의할 수 있다.

val 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)
Kotlin!

자바에서 확장 프로퍼티를 사용하려면 getter나 setter를 명시적으로 호출해야 한다.

StringUtilKt.getLastChar("Java");

© 2021. All rights reserved.

Powered by Hydejack v9.1.6