Kotlin, 어렵지 않게 사용하기 (2) - 함수

2022. 9. 11. 23:48Spring/Kotlin

kotlin의 함수 사용 방식과 용례를 확인하는 것이 해당 포스팅의 목표입니다.

🔗 Kotlin 시리즈 모아보기

 

 

안녕하세요. 이번 포스팅에서는 Kotlin의 함수와 관련된 기본 문법을 학습합니다.

Java와의 호환성을 크게 갖기 때문에, Java와 호환하여 정의 및 호출하는 방식을 익히도록 합니다.

 

 


 

Collection

함수에 관련한 내용을 다루기 전에, 기본적인 컬렉션을 생성하는 방법을 먼저 알아본다.

아래는 순서대로 자바의 HashSet, ArrayList, HashMap을 생성하는 예시다.

 

val set = hashSetOf(1, 7, 53)
val list = arrayListOf<Number>(1, 7, 53)
val map = hashMapOf<Number, String>(1 to "one", 7 to "seven", 53 to "fifty-three")

println(set.javaClass)	// class java.util.HashSet
println(list.javaClass) // class java.util.ArrayList
println(map.javaClass)	// class java.util.HashMap

 

⚠️ to는 키워드가 아니라 일반 함수이며, 코틀린의 중위 호출 방식으로 공백으로 구분하여 사용하는 방식

- Tuples.kt 의 to 함수 정의 : 

public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

 

✔️ .javaClass는 자바 getClass()에 해당하는 코틀린 코드

 

Kotlin은 Kotlin 자체의 Collection을 정의하지 않고 Java Collection을 사용한다.

arrayListOf 의 정의를 대표적으로 보면,

Java의 ArrayList를 생성하는 wrapper method임을 확인할 수 있다.

 

// kotlin.collections.kt
@SinceKotlin("1.1")
@kotlin.internal.InlineOnly
public inline fun <T> arrayListOf(): ArrayList<T> = ArrayList()

 

 

Function

함수를 정의하는 방식은 지난 포스팅에 다뤘다시피 기본적으로 Java와 비슷하다.

다만 fun 키워드를 사용하며, 파라미터 정의 방식에서 더 편리하도록 변경된 차이를 확인할 수 있다.

 

아래 정의한 joinToString 함수는 컬렉션의 원소를 String으로 변환하기 위해 StringBuilder를 사용하며,

이때 원소 사이에 구분자seperator를 추가하고, stringBuilder의 맨 앞과 맨 뒤 에는 접두사prefix접미사postfix를 추가한다.

 

fun <T> joinToString(
    collection: Collection<T>,
    sep: String,
    prefix: String,
    postfix: String
): String {
    val result = StringBuilder(prefix)

    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(sep)
        result.append(element)
    }

    result.append(postfix)
    return result.toString()
}

 

제네릭을 사용하였으며, 제네릭 함수의 문법은 Java와 비슷함

 

 

Function call

지금부터는 함수 호출과 사용하기 편리한 Kotlin의 호출 방식에 대해 알아본다.

 

기본적인 호출 방식은 Java와 동일하다.

joinToString(list, "; ", "(", ")")  // "(1; 2; 3)"

 

 

Named arguments

: 파라미터 명 표시 

 

기본적인 Java 함수 호출 형식과 같이 아래의 호출 방식은 가독성이 좋지 않다.

 

joinToString(collection, " ", " ", ".")

 

함수의 시그니처를 살펴보지 않고는 구별하기 어렵다.

함수 시그니처를 외우거나 IDE가 함수 시그니처를 표시할 수 있지만, 코드 자체는 여전히 모호하다.

 

📌 vs.Java 
일부 Java 코딩 스타일 가이드에서는 아래처럼 파라미터 이름을 주석으로 표시하기도 한다.

joinToString(collection, /* separator */ " ", /* prefix */ " ", /* postfix */ ".");

 

Kotlin에서는 시그니처를 명시적으로 표시할 수 있다.

 

joinToString(list, sep = " ", prefix = " ", postfix = ".")

 

Java와 JDK로 작성한 코드를 호출할 때는 이름 붙인 인자를 사용할 수 없다.

클래스 파일(.class 파일)에 함수 파라미터 정보를 넣는 것은 자바 8이후 추가된 선택적 특징인데,

코틀린은 JDK 6와 호환되기 때문이다.

 

Kotlin이 wrapper method로 정의하지 않은 java method에 시그니처를 표시하려고 하면 컴파일 오류가 발생한다.

Java의 ArrayList method 중 ensureCapacity method가 그 예시이다.

 

// java method: ensureCapacity
list.ensureCapacity(minCapacity= 2) // Named arguments are not allowed for non-Kotlin functions

 

 

Default parameter values

자바에서는 일부 클래스에서 오버로딩overloading한 메소드가 너무 많아진다는 문제가 있는데,
코틀린에서는 함수 선언에서 파라미터의 디폴트 값을 지정하여 이를 해결할 수 있다.

 

fun <T> joinToString(
	collection: Collection<T>,
    sep: String = " ",		// default argument를 받는 함수로 수정
    prefix: String = "",
    postfix: String = ""
    ): String {
    val result = StringBuilder(prefix)

    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(sep)
        result.append(element)
    }

    result.append(postfix)
    return result.toString()
}

 

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

 

// default parameter로 아래와 같이 시그니처를 생략해서 사용할 수 있다
joinToString(list)					// 1 2 3
joinToString(list, " ")				// 1 2 3
joinToString(list, ", ", "-> ")		// -> 1, 2, 3
joinToString(list, "; ", "(", ")")	// (1; 2; 3)

 

이름을 붙이면 순서를 바꿔도 문제 없다.

 

joinToString(list, "# 1, 2, 3;", postfix = "; ", prefix = "#") // # l, 2, 3;

 

 

@JvmOverloads

: Kotlin 메소드의 파라미터를 맨 마지막부터 하나씩 생략하여 오버로딩한 Java 메소드를 생성

 

자바에는 디폴트 파라미터 값이라는 개념이 없다.

코틀린 함수를 자바에서 호출하는 경우, 코틀린 함수가 디폴트 파라미터 값을 제공해도 모든 인자를 명시해야 한다.

 

코틀린 함수를 자주 호출해야 하거나 더 편하게 호출하고 싶다면 @JvmOverloads 를 사용할 수 있다.

@JvmOverloads를 함수에 추가하면 코틀린 컴파일러가 자동으로

맨 마지막부터 파라미터를 하나씩 생략한 자바 오버로딩 메소드를 생성해준다.

 

예를 들어 joinToString에 @JvmOverloads를 붙이면 아래와 같이 Java의 오버로딩 함수가 만들어진다.

 

/* from Kotlin method */
@JvmOverloads
fun <T> joinToString_usingDefault(
	collection: Collection<T>,
	sep: String = " ",
	prefix: String = "",
	postfix: String = ""
): String { /* ... */ }
    
/* to Java method: Kotlin 컴파일러가 생성한 Java 호출 함수 */
String joinToString(Collection<T> collection);
String joinToString(Collection<T> collection, String separator);
String joinToString(Collection<T> collection, String separator, String prefix);
String joinToString(Collection<T> collection, String separator, String prefix, String postfix) ;

 

 

Top-level functions

: 정적인 유틸리티 클래스 없애기

 

코틀린에서는 함수를 직접 소스 파일의 최상위 수준, 모든 다른 클래스의 밖에 위치시킬 수 있다.

 

자바에서는 모든 코드를 클래스의 메소드로 작성해야 하지만,

한 클래스에 포함시키기 어려운 코드가 많이 생긴다.

 

그래서 다양한 정적 메소드를 모아두기 위해 인스턴스 없이 형식만 갖춘 클래스가 생겨났는데,

코틀린에서는 이럴 필요없이 최상위에 정의할 수 있다.

 

// join.kt
package strings

fun <T> joinToString(
    collection: Collection<T>,
    sep: String,
    prefix: String,
    postfix: String
): String {
    val result = StringBuilder(prefix)

    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(sep)
        result.append(element)
    }

    result.append(postfix)
    return result.toString()
}

 

JVM이 클래스 안에 들어있는 코드만을 실행할 수 있기 때문에,

해당 파일을 컴파일할 때 컴파일러가 새로운 클래스를 정의한다.

 

만약 자바 등의 다른 JVM 언어에서 호출하고 싶다면, 코틀린 컴파일러가 클래스를 어떻게 정의하는지 확인해보자.

join.kt를 컴파일한 결과 클래스를 자바 코드로 써보면 다음과 같다.

 

/* Java */
package strings;

public class JoinKt { // join.kt 파일에 해당하는 클래스
    public static String joinToString(...) {
        // ...
    }
}

 

코틀린 컴파일러가 생성하는 클래스의 이름은 소스 파일의 이름과 대응하여 생성된다.

그 결과, join.kt에 대응하여 Joinkt class로 생성되었다.


Java에서 joinToString을 호출하는 것은 아래처럼 간단히 할 수 있다.

 

/* Java */
import strings.JoinKt;
JoinKt.joinToString(list, ",", "", "");

 

@JvmName

:  컴파일러가 생성하는 클래스 이름 변경

 

클래스의 이름을 바꾸고 싶다면 파일에 @JvmName 애노테이션을 추가할 수 있다.

@JvmName 애노테이션은 파일의 맨 앞, 패키지 선언 이전에 위치해야 한다.

 

@file:JvmName("StringFunctions")      // 클래스 이름을 지정하는 애노테이션
package strings                         // @file:JvmName 에노테이션 뒤에 패키지 문이 와야 한다.

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

 

이제 Java에서 다음과 같이 joinToString 함수를 호출할 수 있다.

 

/* 자바 */
import strings.StringFunctions; 
StringFunctions.joinToString(list, ", ", "", "");

 

 

Top-level Properties

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

 

var opCount = 0

fun reportOperationCount() {
    opCount++
    println("Operation performed $opCount times")
}

 

이런 프로퍼티의 값은 정적 필드에 저장되고, 이를 사용해서 상수를 추가할 수 있다.

 

val UNIX_LINE_SEPARATOR = "\n"

 

주의할 점은 최상위 프로퍼티도 기본적으로 접근자 메소드가 생긴다.

val의 경우 게터, var의 경우 게터와 세터가 생긴다.

 

그래서 Java에서 아래와 같이 접근할 수 있다.

 

/* Java */
FunCallFunctionKt.getOpCount();
FunCallFunctionKt.getUNIX_LINE_SEPARATOR();

 

겉으론 상수처럼 보이는데, 게터를 사용해야 하는 것이 이상하다.

 

이 때, const 변경자를 추가하면 public static final 필드로 컴파일 할 수 있다.

단, 원시 타입String 타입의 프로퍼티const로 지정할 수 있다.

 

const val UNIX_LINE_SEPARATOR = "\n"

 

📌 FYI.

컴파일러는 아래의 자바 코드와 동일한 바이트코드를 만들어낸다

public static final String UNIX_LINE_SEPARATOR = "\n";

 

Kotlin에서 정의한 최상위 프로퍼티를 Java에서 아래와 불러와 사용할 수 있다.

 

/* Java */
String constVal = FunCallFunctionKt.CONST_UNIX_LINE_SEPARATOR;

 

 

Extension functions

: 어떤 클래스의 멤버 메소드인 것처럼 호출할 수 있지만 그 클래스의 밖에 선언된 함수


기존 자바 API를 재작성하지 않고도 코틀린이 제공하는 여러 편리한 기능을 사용할 수 있다.

 

확장 함수를 만들려면 추가하려는 함수 이름 앞에 그 함수가 확장할 클래스의 이름을 덧붙이기만 하면 된다.
클래스 이름을 수신 객체 타입receiver type이라 부르며,

확장 함수가 호출되는 대상이 되는 값(객체)을 수신 객체receiver object라고 부른다

 

package strings

fun String.lastChar(): Char = this.get(this.length - 1)
// String : receiver type
// this : receiver object

println("Kotlin".lastChar())    // this 생략 가능 
// "Kotlin" : receiver object

 

String 객체에 lastChar이라는 함수를 정의하는 코드이다.

 

자바나 그루비와 같은 다른 JVM 언어로 작성된 클래스도 확장할 수 있고,
자바 클래스로 컴파일한 클래스 파일이 있는 한 그 클래스에 원하는 대로 확장을 추가할 수 있다.

 

일반 메소드와 마찬가지로 확장 함수에서도 this 를 쓸 수도, 생략할 수도 있다.

하지만, 확장 함수 내에서는 수신 객체 내부에서만 사용할 수 있는 비공개private 멤버나 보호된protected 멤버를 사용할 수 없다.

캡슐화를 깨지않기 위함이다.

 

 

이번엔 Collection에 해당하는 확장함수를 정의해보자.

 

fun <T> Collection<T>.joinToString(  // Collection〈T〉 에 대한 확장 함수를 선언한다.
    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()
}

 

아래와 같이 Collection의 확장이면서 Generic Type을 Number로 주는 List의 멤버인 것처럼 호출할 수 있다.

 

// joinToString을 클래스의 멤버인 것처럼 호출할 수 있다.
val list = listOf<Number>(1, 2, 3)

list.joinToString() // "1, 2, 3"
list.joinToString(", ", postfix = ";", prefix = "# ") // "# 1, 2, 3;"

 

 

Specific receiver type

확장 함수는 단지 정적 메소드 호출에 대한 문법적인 편의syntatic sugar일 뿐이다.
더 구체적인 타입을 수신 객체 타입으로 지정할 수도 있다.

 

문자열의 컬렉션에 대해서만 호출할 수 있는 join 함수를 정의하고 싶다면 다음과 같이 하면 된다.

 

fun Collection<String>.join(
    separator: String = ", ", 
    prefix: String = "",
    postfix: String = ""
) = joinToString(separator, prefix, postfix)

listOf("one", "two", "eight").join(" ")
// one two eight

 

문자열이 아닌 컬렉션에서는 호출이 불가능하다.

 

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

 

 

Imports

확장 함수를 사용하기 위해서는 임포트해야만 한다.
코틀린에서는 클래스를 임포트할 때처럼 확장 함수별로 임포트할 수 있다.

 

import strings.lastChar
// or 
import strings.*

val c = "Kotlin".lastChar()

 

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

 

as

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

이름을 바꿔서 임포트하면 이름 충돌을 막을 수 있다.

 

import strings.lastChar as last

val c = "Kotlin".last()


물론 일반적인 클래스나 함수라면 전체 이름FQN, Fully Qualified Name을 써도 된다.

하지만 코틀린 문법상 확장 함수는 반드시 짧은 이름을 써야 한다.

따라서 임포트할 때 이름을 바꾸는 것이 확장 함수 이름 충돌을 해결할 수 있는 유일한 방법이다.

 

 

Calling from Java

코틀린의 핵심 목표 중 하나가 기존 코드와 코틀린 코드를 자연스럽게 통합하는 것이다.

완전히 코틀린으로만 이뤄진 프로젝트조차도 JDK나 자바 라이브러리 등을 기반으로 만든다.

 

코틀린을 기존 자바 프로젝트에 통합하는 경우, 확장 함수를 통해 기존 자바 코드를 처리할 수 있다.

 

내부적으로 확장 함수는 수신 객체를 첫 번째 인자로 받는 정적 메소드다.
그래서 확장 함수를 호출해도 다른 어댑터adapter 객체나 실행 시점 부가 비용이 들지 않는다.

 

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

 

No overriding

확장 함수는 클래스의 일부가 아니다. 확장 함수는 클래스 밖에 선언된다.

파라미터가 완전히 같은 확장 함수를 상위 클래스와 하위 클래스에 동시에 정의해도,

정적 타입에 의해 어떤 확장 함수가 호출될지 결정된다.

 

즉, 멤버 메소드처럼 객체의 동적인 타입에 의해 확장 함수가 결정되지 않는다.

 

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

class Button: View() {  //Button은 View를 확장한다.
    override fun click() = println("Button clicked")
}

fun View.showOff() = println ("I’m a view! ")
fun Button.showOff() = println ("I ’m a button! ")

 

멤버 함수는 overriding이 가능하지만, 확장 함수는 불가능하다.

 

val view: View = Button()
view.click()        // Button clicked. | override function 호출하기 때문에 Button method 호출
view.showOff()      // I’m a view!     | 확장 함수는 override 되지 않기 때문에 View method 호출

 

 

Extension Properties

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

 

프로퍼티라는 이름으로 불리기는 하지만,

상태를 저장할 적절한 방법이 없기 때문에 실제로 확장 프로퍼티는 아무 상태도 가질 수 없다.

기존 클래스의 인스턴스 객체에 필드를 추가할 방법은 없다.

 

하지만 프로퍼티 문법으로 더 짧게 코드를 작성할 수 있어서 편한 경우가 있다.

 

val String.lastChar:Char get() = get(length - 1)
println("Kotlin".lastChar)  // n

 

자바에서 확장 프로퍼티를 사용하고 싶다면,

항상 StringUtilKt.getLastChar("Java") 처럼 게터나 세터를 명시적으로 호출해야 한다.

 

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

val sb = StringBuilder("Kotlin?")
sb.lastChar = '!'
println(sb.lastChar)        // !