본문 바로가기
Language/Kotlin

[Kotlin IN ACTION] - Kotlin 함수 정의와 호출(확장함수)

by Jman 2022. 7. 18.

코틀린 컬렉션 (List, Set, Map)

val list = arrayListOf(1, 7, 53)
val set = hashSetOf(1, 7, 20)
val map = hashMapOf(1 to "one", 2 to "seven", 3 to "fifty-three")

위와 같이 Java 라는 언어와 비슷한 방법으로 List, Set, Map을 만들 수 있다.

Map 자료구조만 생김새가 좀 다르게 생겼다.

여기서  to 가 언어가 제공하는 특별한 키워드가 아니라 일반 함수라는 점에 유의해야 한다.

 

호출부분

println(list.javaClass)
println(set.javaClass)
println(map.javaClass)

출력부분

class java.util.ArrayList
class java.util.HashSet
class java.util.HashMap

위 코드를 보면, 자바에서 getClass() 와 비슷한 역할을 하는 java.Class() 를 호출하면 확인하면, 만든 객체가 어떤 클래스에 속하는 지 확인할 수 있다.

 

여기서 알 수 있는 점이 있을 것이다.

코틀린이 자신만의 컬렉션 기능을 제공하지 않는다는 뜻이다.

그 이유는, 표준 자바 컬렉션을 활용하면 자바 코드와 상호작용하기가 훨씬 더 쉽고, 자바 함수 호출 할 때 자바와 코틀린 컬렉션은 서로 변환할 필요가 없다는 장점이 있다. 

 

val strings = listOf("fifty-three", "second", "fourteenth")
println(strings.last())
val numbers = setOf(1, 14, 2, 53)
println(numbers.maxOrNull())

또 코틀린은 자바가 가진 내장함수 이외에 더 추가적으로 컬렉션에 다양한 내장함수를 갖고 있다.

위 코드에서 .last() 또는 .maxOrNull() 와 같은 다양한 내장함수가 더 있으니, 내장함수를 이용하여 더 쉽게 무언갈 구현할 수 있을 것이다.

 

 

이름 붙인 인자

코틀린으로 작성한 함수를 호출할 때는 함수에 전달하는 인자 중 일부(또는 전부)의 이름을 명시할 수 있다.

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

// 일반적인 방법
println(joinToString(list, "; ", "(", ")"))

// 코틀린의 장점
println(joinToString(list, separator = "; ", prefix = "(", postfix = ")"))

 

 

디폴트 파라미터 값

아래는 java.lang.Thread 에 있는 8가지 생성자이다.

이런식으로 오버로딩 메소드들은 하위 호환성을 유지하거나, API 사용자에게 편의를 더하는 등 여러가지 이유로 만들었다.

 

하지만,

어느 경우든 중복이라는 결과가는 같다.

파라미터 이름과 타입이 계속 반복되며, 대부분 설명을 반복해 달아야한다.

그리고 인자 중 일부가 생략된 오버로드함수를 호출할 때, 어떤 함수가 불릴지 모호한 경우가 생긴다...

 

그래서, 함수 선언에서 파라미터의 디폴트 값을 지정을 해두면 위 오버로드 중 상당수를 피할 수 있다.

 

fun <T> joinToString(
    collection: Collection<T>,
    separator : String = ", ",
    prefix : String = "",
    postfix : String = ""
) : String {

    val result = StringBuffer(prefix)

    for ( (idx, e) in collection.withIndex()) {
        println(idx)
        if (idx > 0) {
            result.append(separator)
        }
        result.append(e)
    }
    result.append(postfix)
    return result.toString()
}

위 joinToString 함수 처럼 3~4 Line 을 보면 미리 디폴트 값을 지정한 걸 확인할 수 있다.

위와 같이 할 경우, 호출할 때 달라진다.

// 기존에 썼던 방법
joinToString(list, ", ", "", "")

joinToString(list) // separator, prefix, postfix 생략

joinToString(list, "; ") // prefix, postfix 생략

 

이렇게 디폴트 값을 지정하면 생성자의 상당 수를 줄일 수 있는 건 알 수 있다.

좀 더 생각해서 호출 부분에서 우리가 호출하는 메서드의 인자를 사용하는 경우에는 인자 목록의 중간에 있는 인자를 생략하고 지정하고 싶은 인자를 이름을 붙여 순서와 관계없이 지정할 수 있다.

// 중간 separator 인자 값을 건너띄우고 인자명을 지정하여 값을 넣어 호출
joinToString(list, postfix = "; ", prefix= "# ");

 

함수의 디폴트 파라미터 값은 함수를 호출하는 쪽이 아니라, 함수 선언 쪽에서 지정된다.

따라서, 어떤 클래스 안에 정의된 함수의 디폴트 값을 바꾸고, 그 클래스가 포함된 파일을 재컴파일 하면 그 함수를 호출하는 코드 중에 값을 지저하지 않은 모두 인자는 자동으로 바뀐 디폴트 값을 적용 받는다.

 

정적인 유틸리티 클래스 없애기 : 최상위 함수와 프로퍼티

코틀린에서는 무의미한 클래스가 필요 없다.

대신 함수를 직접 소스 파일의 최상위 수준, 모든 다른 클래스의 밖에 위치시키면 된다.

 

//[Kotolin]
package strings

fun joinToString(...) : String {...}

 

//[Java]
package strings;

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

 

 

자바와 코틀린이 다르다는 걸 위 두 코드를 보면 알 수 있다.

코틀린은 함수가 최상위가 될 수 있단은 걸 보여주려고 비교 코드를 작성했다.

 

코틀린 함수가 어떻게 실행될 수 있을까?

JVM 이 클래스 안에 들어있는 코드만을 실행할 수 있기 때문에 컴파일러는 코틀린 함수가 적힌 파일을 컴파일 할 때, 새로운 클래스를 정의해준다. 즉, 코틀린만 사용하는 경우엔은 그냥 그런 클래스가 생긴다는 사실만 기억하면 왼다.

 

 

최상위 프로퍼티

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

어떤 데이터를 클래스 밖에 위치시켜야하는 경우

 

val opCount = 0 // 최상위 프로퍼티를 선언

// 최상위 프로퍼티의 값을 변경
fun performOperation() {
	opCount++
    //....
}

fun reportOperationCount() {
	println("Operation performed $opCount times") // 최상위 프로퍼티의 값을 읽기.
}

 

이렇게 프로퍼티의 값은 정적 필드에 저장된다.

 

그리고 아래 코드를 살펴보면 상수로 지정한 언어별 코드이다.

//[Kotlin]
const val UNIX_LINE_SEPARATOR = "\n"
//[Java]
public static final String UNIX_LINE_SEPARATOR = "\n";

 

 

메소드를 다른 클래스에 추가 : 확장 함수와 확장 프로퍼티

  • 수신 객체 타입 (receiver type) : 클래스 이름
  • 수신 객체 (receiver object) : 호출 되는 대상이 되는 값(객체)
fun String.lastChar() : Char = this[this.length - 1]
또는
fun String.lastChar2() : Char = get(length - 1)
fun main () {
    println("Kotlin".lastChar())
}

수신 객체 타입 : String

수신 객체 : Kotlin

 

 

임포트와 확장함수

확장 함수를 사용하기 위해서는 그 함수를 다른 클래스나 함수와 마찬가지로 임포트해야만 한다.

//1.
import strings.lastChar

val c = "Kotlin".lastChar()

//2.
import strings.*

val c = "Kotlin".lastChar()

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

세번 째 모드만 약간 달라서 의아할 것이다.

 

한 파일 안에 다른 여러 패키지에 속해있는 이름이 같은 함수를 가져와 사용해야하는 경우?

이름을 위 코드처럼 바꿔서 임포트하면 이름 충돌을 막을 수 있다.

 

자바에서 확장 함수 호출

내부적으로 확장 함수는 수신 객체를 첫 번째 인자로 받는 정적 메소드다.

그래서 확장 함수를 호출해도 다른 어댑터 객체나 실행 시점 부가 비용이 들지 않는다.

 

//StringUil.kt 파일
char c = StringUtil.kt.lastChar("Java");

 

 

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

BEFORE

fun <T> joinToString(
    collection: Collection<T>,
    separator : String = ", ",
    prefix : String = "",
    postfix : String = ""
) : String {

    val result = StringBuffer(prefix)

    for ( (idx, e) in collection.withIndex()) {
        println(idx)
        if (idx > 0) {
            result.append(separator)
        }
        result.append(e)
    }
    result.append(postfix)
    return result.toString()
}

 

AFTER

fun <T> Collection<T>.joinToString( // Collection<T> 에 대한 확장 함수를 선언
    separator : String = ", ",
    prefix : String = "",
    postfix : String = ""
) : String {

    val result = StringBuffer(prefix)

    for ( (idx, e) in this.withIndex()) { // "this" 는 수신 객체를 가리킴. 여기서는 T 타입의 원소로 이루어진 컬렉션
        println(idx)
        if (idx > 0) {
            result.append(separator)
        }
        result.append(e)
    }
    result.append(postfix)
    return result.toString()
}

호출 부분

println(listOf("one", "two", "three").joinToString(separator = ", "))

 

 

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

확장함수는 클래스의 일부가 아니다.

확장 함수는 클래스 밖에 선언된다.

이름과 파라미터가 완전히 같은 확장함수를 기반 클래스와 하위 클래스에 대해 정의해도

실제로 확장 함수를 호출할 때, 수신 객체로 지저앟ㄴ 변수의 정적 타입에 의해 어떤 확장함수가 호출될지 결정되지,

그 변수에 저장된 객체의 동적인 타입에 의해 확장 함수가 결정되지 않는다.

 

차이 예시

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

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

fun main () {
   val view = Button()
   view.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 button!")

fun main () {
    val view : View = Button()
    //val view = View()
    view.showOff()

    val button = Button()
    button.showOff()
}

위 그림처럼 연관이 없는 상태로 된다.

 

어떤 클래스를 확장한 함수와 그 클래스의 멤버 함수의 이름과 시그니처가 같다면 확장 함수가 아니라 멤버 함수가 호출된다.

why? 멤버 함수의 우선순위가 더 높다.

따라서, 클래스의 API를 변경할 경우 항상 이를 염두에 둬야 한다.

 

확장 프로퍼티

확장 프로퍼티 선언하기

확장 프로퍼티를 사용하면 기존 클래스 객체에 대한 프로퍼티 형식의 구문으로 사용할 수 있는 API를 추가할 수 있다.

fun main () {
    println("Kotlin".lastChar)
}

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

위 코드를 보고 생각하자.

확장 함수의 경우와 마찬가지로 확장 프로퍼티도 일반적인 프로퍼티와 같은데,

단지 수신 객체 클래스가 추가됐을 뿐이다.

 

하지만,

뒷받침하는 필드가 없어서 기본 게터구현을 제공할 수 없으므로 최소한 게터는 꼭 정의를 해야한다.

또한, 초기화 코드에서 계산한 값을 담을 장소가 전혀 없으므로 초기화 코드도 쓸수 없다.

 

변경 가능한 확정 프로퍼티 선언하기

fun main () {
    var sb = StringBuilder("Kotlin!")
    sb.lastChar = '?'
    println(sb)
}

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

StringBuilder 에 같은 프로퍼티를 정의 한다면 맨 마지막 문자는 변경 가능하므로 프로퍼티를 var 로 만들 수 있다.