Kotlin

[Kotlin] 컬렉션에서의 함수 정의와 호출

sh1mj1 2024. 1. 1. 20:18

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

이미지 출처     https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

 

코틀린 컬렉션, 문자열, 정규식에서의 함수 정의와 호출 기능을 알아봅니다.

코틀린 컬렉션 만들기

아래와 같이 set 과 리스트, 맵을 만들 수 있다.

private val set = hashSetOf(1, 7, 53)
private val list = arrayListOf(1, 7, 53)
private val map = hashMapOf(1 to "one", 7 to "seven", 53 to "fifty-three")
여기서 `to` 는 코틀린의 키워드가 아닌 일반 함수이다. (나중에 조금 더 자세히 다룬다.)
@Test
fun printObjectType() {
    println(set.javaClass)
    println(list.javaClass)
    println(map.javaClass)
}

코틀린에서 `javaClass` 는 자바의 `getClass` 에 해당하는 코틀린 코드이다.

출력 결과
class java.util.HashSet
class java.util.ArrayList
class java.util.HashMap

위에서 만든 `set`, `list`, `map` 객체가 모두 `java.util` 패키지에 속한 클래스의 인스턴스임을 알 수 있다. 

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

 

자바 개발자가 코틀린을 새로 배우거나 언어를 이전할 시에 기존 자바 컬렉션을 활용할 수 있으며

코틀린이 자바와의 상호운용성이 굉장히 좋다는 근거 중 하나이다.

자바에서 코틀린 함수를 호출하거나 그 반대 경우일 때 자바와 코틀린 컬렉션을 서로 변환할 필요가 없다.

 

코틀린 컬렉션은 자바 컬렉션과 똑같은 클래스이지만, 코틀린에서는 아래 코드에서처럼 자바보다 더 많은 기능을 쓸 수 있다.

@Test
fun moreFeaturesOfKotlinCollection() {
    val string = listOf("first", "second", "third")
    println(string.last()) // 리스트의 마지막 원소를 가져온다

    val numbers = setOf(1, 14, 2)
    println(numbers.max()) // 컬렉션에서 최대 값을 가져온다
}

 

출력
third
14

 

이것은 코틀린에서의 확장 함수 개념 덕분에 가능한 것이다. 확장 함수는 다음 글에서 알아본다.

함수를 호출하기 쉽게

자바 컬렉션에서는 디폴트 `toString` 구현이 들어있다. 이 출력형식은 당연히 고정되어 있다.

@Test
fun defaultToStringOfCollection() {
    val list = listOf(1, 2, 3)
    println(list)
}
출력
[1, 2, 3]

그런데 만약 출력을 `[1;2;3]` 의 형태로 하고 싶다면 어떻게 해야할까?

 

자바 프로젝트에서는 다른 서드파티 프로젝트를 추가하거나 직접 관련 로직을 구현해야 하지만,

코틀린에서는 이것을 간단하게 할 수 있는 기능이 있다.

 

먼저 직접 함수를 구현해 보자.

Generic 한 함수를 하나 만들어서 모든 타입의 원소를 가진 컬렉션을 처리할 수 있도록 한다.

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

    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    return result.toString()
}

그리고 아래처럼 출력해보자.

@Test
fun changeToStringFormatOfCollection() {
    val list = listOf(1, 2, 3)
    println(joinToString(list, ";", "(", ")"))
}
출력
(1;2;3)

잘 작동하지만 함수 호출부가 조금 번잡하다. 

이름 붙인 argument

함수를 호출할 때마다 매번 네 인자를 모두 전달하지 않는 방법이 있다.

먼저 함수 호출 부분의 가독성이 나쁘다.

만약 `joinToString(list, " ", " ", " ")` 라고 함수를 사용하면, 각 argument 들이 어떤 역할을 하는지 알기 쉽지 않다.

(물론 아래처럼 IDE 를 사용하면 각 argument 가 어떤 패러미터의 값인지 알 수 있지만 깃허브의 PR 코드를 볼 때처럼 항상 IDE 를 사용하여 코드를 읽는 것은 아니다.)

 

코틀린에서는 아래처럼 함수에 전달하는 argument 의 이름을 명시할 수 있다. 

호출 시 어떤 argument 중 하나라도 이름을 명시하면, 보통 그 뒤에 오는 모든 인자는 혼동을 피하기 위해  이름을 명시한다.

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

Effective kotlin 에서 말하는 이름 붙인 argument

이 부분은 심화 내용이므로 간단히 이해하고 넘어가거나, 이해가 안되면 넘어가도 된다.

사용 시 장점

  • 이름을 기반으로 값이 무엇을 나타내는지 알 수 있다.
  • 파라미터 입력 순서와 상관 없으므로 안전하다.

사용을 추천하는 경우

  • 디폴트 argument 의 경우(디폴트 파라미터 값을 가진 파라미터의 argument)
    • 일반적으로 함수 이름은 필수 파라미터들과 관련되어 있다.
      그래서 함수 이름이 디폴트 값을 갖는 optional parameter(옵션 파라미터)의 설명은 충분히 해주지 못한다.
      그러므로 이 argument 는 이름을 붙이는 게 좋다.
  • 같은 타입의 파라미터가 많은 경우
    • 파라미터가 모두 다른 타입이라면, 위치를 잘못 입력하면 오류가 발생할 것이므로 쉽게 문제를 발견할 수 있다.
      하지만 파라미터에 같은 타입이 있다면 잘못 입력했을 때 문제 찾기가 어렵다.
  • 함수 타입의 파라미터가 있는 경우(마지막 파라미터인 경우는 제외):코틀린에서는 함수 타입을 함수의 argument 로 넘겨줄 수 있음
    • 함수 타입 파라미터는 보통 마지막 위치에 배치하는 것이 좋다. 
    • 함수 이름은 함수 타입 argument 를 설명해줄 때가 많다. 
      • 예를 들어 repeat 뒤에 오는 람다는 반복될 블록을 나타내고,
        thread 뒤에 오는 람다는 블록이 스레드의 바디라는 것을 쉽게 알 수 있다.
    • 그 밖에 모든 함수 타입 argument 는 이름 붙인 argument 를 사용하는 것이 이해하기 쉽다.
    • 아래 예시들
// view DSL 이 있다고 하면
val view = linearLayout {
    text("Click below")
    button({/* 1 */ }, {/* 2 */ })
}

// 아래처럼 button 함수의 이름 붙인 argument 를 사용하여 빌더 부분과 클릭 리스너 부분을 명확히 한다
val view = linearLayout {
    text("Click below")
    button(onClick = {/* 1 */}) {
    /* 2 */ 
    }
}
fun call(before: ()-> Unit = {}, after: ()-> Unit ={}){
    before()
    print("Middle")
    after()
}
// 이름 붙이지 않은 argument 로 함수 호출
call ({ print(" CALL ") }) //  CALL Middle 출력됨. after 가 디폴트 파라미터 값 {} 로 동작
call { print(" CALL ") }   // Middle CALL 출력됨.  before 가 디폴트 파라미터 값 {} 로 동작

// 이름 붙인 argument 로 함수 호출
call(before = { print(" CALL ") }) // CALL Middle 출력됨
call(after = { print(" CALL ") })  // Middle CALL 출력됨

위 예시에 대해 자세히 이해하려면 코틀린에서의 람다를 잘 알아야 한다. 이는 나중에 다루겠다.

/* RxJava 에서 Observable 을 구독할 때 함수를 설정한다.
* 각각의 아이템을 받을 때 onNext
* 오류가 발생했을 때 onError
* 전체가 완료되었을 때 onComplete
*/
Obervable.getUsers()
        .subsribe((List<User> users) -> {
            // ... onNext 부분
        }, (Throwable throwable) -> {
            // ... onError 부분
        }, () -> {
            // ... onCompleted 부분
        });
// 위 코드를 코틀린의 이름 붙인 argument 를 사용해서 의미를 더 명확히 한다.
Obervable.getUsers()
        .subscribeBy(
            onNext = { users: List<User> -> 
                // ... 
            }, 
            onError = {throwable: Throwable -> {
                // ... 
            },
            onCompleted = {
                // ...
            })

 

디폴트 파라미터 값

아래는 자바의 Thread 클래스에 내용 중 일부이다.

public Thread() { this(null, null, 0, null, 0, null); }

public Thread(Runnable task) { this(null, null, 0, task, 0, null); }

public Thread(ThreadGroup group, Runnable task) { this(group, null, 0, task, 0, null); }

public Thread(String name) { this(null, checkName(name), 0, null, 0, null);}

public Thread(ThreadGroup group, String name) { this(group, checkName(name), 0, null, 0, null); }

public Thread(Runnable task, String name) { this(null, checkName(name), 0, task, 0, null);}

public Thread(ThreadGroup group, Runnable task, String name) { this(group, checkName(name), 0, task, 0, null); }

public Thread(ThreadGroup group, Runnable task, String name, long stackSize) { this(group, checkName(name), 0, task, stackSize, null); }
...

자바의 일부 클래스에서는 overloading 한 메서드가 너무 많아진다는 문제가 있다.

 

오버로딩 메서드들은 하위 호환성을 유지하거나 API 사용자에게 편의를 더하는 등의 이유로 만들어진다.

하지만 파라미터 이름과 타입이 계속 반복되며, 설명 주석도 반복될 것이다. 

또 여러 오버로드 함수 중에서 일부 파라미터가 생략된 오버로드 함수를 호출하면 어떤 함수가 호출될지 모호한 경우도 생길 수 있다.

 

코틀린에서는 함수 선언에서 파라미터의 디폴트 값을 지정할 수 있으므로 이런 오버로드에서의 반복 중 상당수를 피할 수 있다. 

위에서 선언한 `joinToString` 함수를 디폴트 파라미터를 사용해서 개선해보자.

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

    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    return result.toString()
}

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

@Test
fun changeToStringFormatOfCollection() {
    val list = listOf(1, 2, 3)

    println(joinToString(list, ",", "", ""))
    println(joinToString(list))  // separator, prefix, postfix 생략 -> 기본값
    println(joinToString(list, "; ")) // prefix, postfix 생략 -> 기본값
}

 

결과 출력
1,2,3
1,2,3
1; 2; 3

당연히 함수 호출시에 argument 는 파라미터의 순서대로 지정해야 한다.

그래서 어떤 argument 를 생략하면, 뒤의 argument 도 생략해야 한다. 

하지만 만약 이름 붙인 argument 를 사용한다면 순서와 관계없이 사용할 수 있다.

println(joinToString(list, postfix = ";", prefix = "# "))
출력 결과
# 1, 2, 3;

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

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

 

만약 자바에서 코틀린 함수를 자주 호출해야 한다면, `@JvmOverloads` 애노테이션을 함수에 추가하는 것이 도움이 될 수 있다.

이것을 함수에 추가하면, 코틀린 컴파일러가 자동으로 맨 마지막 파라미터부터 파라미터를 하나씩 생략한 오버로딩한 자바 메서드를 추가해준다.

 

만약 디폴트 파라미터 값을 사용한 `joinToString` 함수에 `@JvmOverloads` 애노테이션을 붙이면 아래처럼 자바 함수가 만들어진다.

String joinToString(Collection<T> collection, String separator, String prefix, String postfix);

String joinToString(Collection<T> collection, String separator, String prefix);

String joinToString(Collection<T> collection, String separator);

String joinToString(Collection<T> collection);

그리고 각각의 오버로딩한 함수들은 시그니처에서 생략된 파라미터들에 대해서 코틀린 함수의 디폴트 파라미터 값을 사용한다. 즉, 자바 함수 구현부에 해당 디폴트 파라미터 값이 들어가서 만들어진다.

최상위(top-level) 함수 &  최상위 프로퍼티로 정적 유틸리티 클래스 없애기

최상위 함수

자바에서는 모든 동작을 클래스의 메서드로 작성해야 한다.

하지만 실전에서는 어느 한 클래스에 넣기 어려운 동작이 많이 생긴다.

  • 일부 연산에서는 비슷하게 중요한 역할을 하는 클래스가 둘 이상 있을 수 있음.
  • 중요한 객체는 하나지만 그 연산을 객체의 인스턴스 API 에 추가하면 API 가 너무 커지고 이렇게 구현하고 싶지 않을 수 있음.

결국 다양한 정적 메서드를 모아두는 역할만 담당하며 특별한 상태, 인스턴스, 정적이 아닌 메서드는 없는 클래스가 생긴다. (ex: JDK 의 `Collections` 클래스)

 

그런데 코틀린에서는 이런 무의미한 클래스를 만들 필요가 없다.

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

그런 함수들은 여전히 그 파일이 속한 패키지의 멤버이므로 다른 패키지에서 그 함수를 사용하고 싶다면 그 함수가 정의된 패키지를 `import` 하면 된다.

이 때 유틸리티 클래스 이름 (`~~Util`)이 `import` 문에 들어갈 필요가 없다.

 

`Join.kt` 라는 파일을 아래처럼 작성해보자.

package inaction.chap3

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

    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    return result.toString()
}

이렇게 작성하면 같은 패키지의 모든 곳에서 이 함수를 사용할 수 있으며 다른 패키지에서는 `Join.kt` 가 속한 패키지를 `import` 하여 사용할 수 있다.

 

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

코틀린 언어로 개발하는 개발자들은 이렇게 자유롭게 사용할 수 있다.

 

만약 자바 언어로 위 `Join.kt` 의 함수를 호출하려면 어떻게 해야할까?

코틀린이 `Join.kt` 를 컴파일했을 때 생기는 클래스를 자바 언어로는 아래와 같다.

package inaction.chap3

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

코틀린 컴파일러는 최상위 함수가 들어있는 코틀린 소스 파일과 비슷한 이름의 클래스를 생성해준다.

코틀린 파일의 모든 최상위 함수는 위처럼 정적 메서드가 된다.

자바에서는 아래처럼 호출하면 된다.

import inaction.chap3.JoinKt;

/* ... */

JoinKt.joinToString(list, ", ", "", "");

물론 컴파일러가 생성하는 클래스의 이름을 소스 파일의 이름과 다르게 직접 지정해줄 수도 있다.

파일에 `@JvmName` 이라는 애노테이션을 추가하면 된다. 이 애노테이션은 파일의 맨 앞, 패키지 선언보다 위에 위치해야 한다.

@file:JvmName("StringFunctions")

package inaction.chap3

fun <T> joinToString(...): String { ... }

최상위 프로퍼티

최상위 함수처럼 프로퍼티도 파일의 최상위 수준에 놓을 수 있다. 

사용 예시를 들어보면, 어떤 연산을 수행한 횟수를 저장하는 `var` 프로퍼티를 만들 수 있다.

var opCount = 0

fun performOperation() {
    opCount++
    // ....
}

fun reportOperationCount() = println("Operation performed $opCount times")

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

또 최상위 프로퍼티를 활용해서 코드에 상수를 추가할 수도 있다.

val UNIX_LINE_SEPARATOR = "\n"

기본적으로 최상위 프로퍼티도 다른 모든 프로퍼티처럼 접근자 메서드를 통해서 자바 코드에 노출된다.(`val` 의 경우 `getter`, `var` 의 경우 `getter`, `setter` 가 생김.)

 

상수인데 실제로는 getter 를 사용해야 한다면 부자연스럽다.

상수라면 `public static final` 필드로 컴파일하는 것이 더 자연스럽다.

코틀린에서는 `const` 변경자를 추가하면 프로퍼티를 `public static final` 필드로 컴파일하게 만들 수 있다. (원시 타입과 `String` 타입만 `const` 로 지정 가능)

const val UNIX_LINE_SEPARATOR = "\n"

이 코드는 아래 자바 코드와 같다.

public static final String UNIX_LINE_SEPARATOR = "\n";

다음 글에서는 확장 함수와 확장 프로퍼티를 알아보자.