코틀린 베이직 KB 18 함수 — 기본인자와 명명인자가 오버로딩을 대신한다
코틀린 베이직 · 18/26

KB 18 함수 — 기본인자와 명명인자가 오버로딩을 대신한다

gabury1고친 사람 github-actions[bot]

0. 자바는 메서드 셋, 코틀린은 함수 하나

금액을 15,000 KRW 처럼 찍는 메서드를 생각해 봅니다. 대개는 금액만 넘기고, 가끔 통화 단위를 바꾸고, 더 가끔 쉼표를 뺍니다. 자바에서 이렇게 부르는 방법을 여럿 주려면 같은 이름의 메서드를 인자 개수만 달리해 여러 벌 씁니다. 이것이 오버로딩(overloading)입니다.

Java
public class PriceJ {
    static String price(long amount) {
        return price(amount, "KRW");
    }

    static String price(long amount, String unit) {
        return price(amount, unit, true);
    }

    static String price(long amount, String unit, boolean comma) {
        String num = comma ? String.format("%,d", amount) : "" + amount;
        return num + " " + unit;
    }

    public static void main(String[] args) {
        System.out.println(price(15000));
        System.out.println(price(15000, "USD"));
        System.out.println(price(15000, "KRW", false));
    }
}

일을 하는 것은 마지막 메서드 하나입니다. 앞의 둘은 빠진 인자를 채워 다음 메서드로 넘기기만 합니다.

코틀린은 같은 일을 함수 하나로 씁니다.

Kotlin
fun price(
    amount: Long,
    unit: String = "KRW",
    comma: Boolean = true
): String {
    val num = if (comma) "%,d".format(amount) else "$amount"
    return "$num $unit"
}

fun main() {
    // ↓ 15,000 KRW
    println(price(15000))
    // ↓ 15,000 USD
    println(price(15000, "USD"))
    // ↓ 15000 KRW
    println(price(15000, comma = false))
}
  • fun price(...): String — 함수 선언입니다. 인자는 이름: 타입 순서로 적고, 괄호 뒤 콜론 다음이 반환 타입입니다
  • unit: String = "KRW" — 기본인자(default argument)입니다. 부를 때 이 인자를 빼면 = 뒤의 값이 들어갑니다
  • comma = false — 명명인자(named argument)입니다. 인자 이름을 적어서, 가운데 unit 을 건너뛰고 comma 만 넘겼습니다
  • "%,d".format(amount) — 자바의 String.format("%,d", amount) 와 같습니다. "$amount" 는 값을 문자열에 끼우는 문자열 템플릿입니다

Price.kt 로 저장해 코틀린 컴파일러 kotlinc 로 컴파일하고 kotlin 명령으로 돌렸습니다. -d out 은 결과물을 out 폴더에 쓰라는 옵션입니다. 출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.

터미널
$ kotlinc Price.kt -d out
$ kotlin -cp out PriceKt
$ javac -d jout PriceJ.java
$ java -cp jout PriceJ

자바도 같은 세 줄을 찍었습니다. 메서드 셋이 하던 일을 함수 하나가 했습니다. 셋째 호출을 눈여겨볼 만합니다. 자바 쪽은 쉼표만 빼고 싶어도 "KRW" 를 손으로 다시 적었습니다. (long, boolean) 오버로딩을 하나 더 만들면 피할 수 있지만, 조합이 늘 때마다 메서드도 늘어납니다. 코틀린은 comma = false 한 마디로 끝납니다.

이 편은 이 함수 하나를 뜯어 봅니다. 1절이 함수 선언, 2절이 기본인자, 3절이 명명인자, 4절이 자바에서 이 함수를 부를 때입니다. 5절과 6절은 인자 개수를 정하지 않는 vararg 와, 거기에 배열을 펼쳐 넘길 때 생기는 복사를 봅니다.

1. 함수 선언 — 블록 본문과 = 한 줄 본문

코틀린 함수는 본문을 두 가지로 씁니다. 중괄호 안에 문장을 쓰는 블록 본문과, = 뒤에 식 하나를 쓰는 식 본문입니다. 반환 타입을 생략할 수 있느냐가 둘에서 갈립니다.

Kotlin
fun twice(x: Int): Int {
    return x * 2
}

fun thrice(x: Int) = x * 3

fun hello(name: String) {
    println("hi $name")
}

fun main() {
    println(twice(4))  // 8
    println(thrice(4)) // 12
    hello("kotlin")    // hi kotlin
    println(hello("k")) // 두 줄 — 아래
}
  • fun twice(x: Int): Int { ... } — 블록 본문입니다. 자바의 static int twice(int x) 에 해당합니다. 자바처럼 return 으로 값을 돌려줍니다
  • fun thrice(x: Int) = x * 3 — 식 본문입니다. 중괄호와 return 없이 = 뒤의 식이 곧 반환값입니다. 반환 타입을 안 적었는데, 컴파일러가 x * 3 에서 Int 를 알아냅니다
  • fun hello(name: String) — 반환 타입이 없는 블록 본문입니다. 자바의 void 메서드에 해당합니다
  • 이 세 함수는 클래스 밖, 파일 바로 아래에 있습니다. 이런 함수를 톱레벨(top-level) 함수라고 부릅니다
터미널
$ kotlinc Twice.kt -d out
$ kotlin -cp out TwiceKt
...
hi k
kotlin.Unit

앞의 세 줄은 코드 옆에 적은 값이라 ... 로 줄였습니다. 마지막 println(hello("k")) 가 두 줄을 찍었습니다. hello 안에서 hi k 를 찍고, 돌아온 값을 바깥 println 이 kotlin.Unit 으로 찍었습니다.

반환 타입을 안 적은 블록 본문 함수는 Unit 을 돌려줍니다. Unit 은 「돌려줄 값이 없다」를 나타내는 타입인데, 자바의 void 와 달리 값이 하나 있는 진짜 타입입니다. 그래서 println 에 넘길 수 있고 kotlin.Unit 이라고 찍힙니다.

실행 명령에 쓴 TwiceKt 는 소스에 없는 이름입니다. JVM 에서는 모든 메서드가 어느 클래스엔가 속해야 해서, 컴파일러가 파일 이름 + Kt 클래스를 만들고 톱레벨 함수를 그 안의 static 메서드로 넣습니다. 4절에서 자바가 이 이름으로 함수를 부릅니다.

반환 타입을 꼭 적어야 하는 경우

식 본문에서는 반환 타입을 알아서 정해 주는데, 블록 본문에서도 그럴까요. 두 함수를 Bad.kt 에 적었습니다. 하나는 블록 본문에서 반환 타입을 뺐고, 하나는 자기 자신을 부르는 식 본문입니다.

Kotlin
fun twice(x: Int) {
    return x * 2
}

fun fact(n: Int) = if (n <= 1) 1 else n * fact(n - 1)

if (...) 1 else ... 는 if 가 값을 돌려주는 식으로 쓰인 것입니다. 자바의 삼항 연산자 ? : 에 해당합니다.

터미널
$ kotlinc Bad.kt -d out
Bad.kt:2:12: error: return type mismatch: expected 'Unit', actual 'Int'.
    return x * 2
           ^^^^^
Bad.kt:5:43: error: type checking has run into a recursive problem. Easiest workaround: specify the types of your declarations explicitly.
fun fact(n: Int) = if (n <= 1) 1 else n * fact(n - 1)
                                          ^^^^^^^^^^^

두 에러가 규칙 둘을 보여 줍니다. 첫 에러는 블록 본문이 반환 타입을 추론하지 않는다는 뜻입니다. 타입을 안 적으면 Unit 으로 정해지고, 그런데 Int 를 돌려주려 하니 막혔습니다. 두 번째 에러는 재귀 함수에서 납니다. fact 의 타입을 알려면 fact(n - 1) 의 타입을 알아야 해서, 추론이 제자리를 돕니다. 에러 문구대로 타입을 적으면(fun fact(n: Int): Int = ...) 풀립니다.

정리하면, 반환 타입을 생략할 수 있는 것은 식 본문뿐이고 재귀면 그것도 안 됩니다. 식 본문이라도 다른 파일에서 부르는 함수는 반환 타입을 적어 두는 편이 안전합니다. 본문을 고치다 추론된 반환 타입이 슬쩍 바뀌는 일을 막아 주기 때문입니다.

2. 기본인자 — 기본값은 부를 때마다 계산된다

기본값에는 "KRW" 같은 상수만 오는 것이 아니라 함수 호출도 올 수 있습니다. 그러면 궁금한 것이 하나 생깁니다. 그 호출은 언제 한 번 돌아서 값을 정해 두는 걸까요, 아니면 부를 때마다 도는 걸까요.

로그 한 줄에 시각을 붙이는 함수로 봅니다. 시각을 안 넘기면 now() 가 기본값을 대고, now() 는 불릴 때마다 한 줄을 찍습니다.

Kotlin
fun now(): Long {
    println("now() 호출")
    return System.currentTimeMillis()
}

fun log(msg: String, at: Long = now()) {
    println("[$at] $msg")
}

fun main() {
    log("a")
    log("b")
    log("c", 0L)
}
  • System.currentTimeMillis() — 자바 표준 라이브러리 메서드를 코틀린에서 그대로 부를 수 있습니다. 1970년 1월 1일부터 지난 밀리초입니다
  • 0L — Long 타입 숫자라는 표시입니다

now() 호출 이 몇 번 찍힐까요. 출력은 아래와 같습니다.

터미널
$ kotlinc Now.kt -d out
$ kotlin -cp out NowKt
now() 호출
[1789771454414] a
now() 호출
[1789771454415] b
[0] c

now() 는 두 번 불렸습니다. 시각을 안 넘긴 log("a") 와 log("b") 가 부를 때마다 한 번씩입니다. 시각을 넘긴 log("c", 0L) 에서는 불리지 않았습니다.

기본값은 선언할 때 한 번 계산해 두는 값이 아니라, 인자가 빠진 호출마다 새로 계산하는 식입니다. 그래서 두 시각이 1 밀리초 다릅니다. 빠진 인자가 있을 때만 계산하니, 값을 넘기면 기본값 식은 아예 돌지 않습니다.

그래서 기본값으로 새 객체를 줘도 안전하다

부를 때마다 계산된다는 성질은 기본값으로 변경 가능한 객체를 줄 때 효과가 드러납니다. 목록을 안 넘기면 새 목록을 만들어 거기에 더하는 함수를 두 번 불렀습니다. 기본값 식이 앞의 파라미터를 쓰는 함수도 하나 같이 넣었습니다.

Kotlin
fun add(
    x: String,
    to: MutableList<String> = mutableListOf()
): MutableList<String> {
    to.add(x)
    return to
}

fun window(from: Int, to: Int = from + 10) = "$from..$to"

fun main() {
    val a = add("x")
    val b = add("y")
    println(a)       // [x]
    println(b)       // [y]
    println(a === b) // false
    println(window(5)) // 5..15
}
  • MutableList<String> — 원소를 더하고 뺄 수 있는 목록 타입입니다. mutableListOf() 가 빈 목록을 만듭니다. 자바의 new ArrayList<>() 에 해당합니다
  • === — 두 변수가 같은 객체를 가리키는지 보는 참조 비교입니다. 자바의 == 에 해당합니다
  • to: Int = from + 10 — 기본값 식에서 앞에 선언한 파라미터 from 을 썼습니다
터미널
$ kotlinc Fresh.kt -d out
$ kotlin -cp out FreshKt

두 번째 호출의 결과가 [x, y] 가 아니라 [y] 입니다. a === b 도 false 라서, 두 호출이 서로 다른 목록을 받았습니다. 부를 때마다 mutableListOf() 가 새로 돌았기 때문입니다. 한 호출에서 넣은 원소가 다음 호출로 새어 나가는 일은 없습니다.

window(5) 는 5..15 를 찍었습니다. 기본값 식은 앞에 선언된 파라미터를 읽을 수 있습니다. 자바라면 window(int from) 오버로딩 안에서 window(from, from + 10) 으로 넘기던 일입니다.

반대쪽도 짚어 둡니다. 부를 때마다 도니, 기본값 식이 무거우면(파일을 읽거나 DB 에 묻는 식) 인자를 빼고 부를 때마다 그 비용을 냅니다. 기본값에는 가벼운 식을 두고, 무거운 값은 부르는 쪽이 한 번 구해서 넘기는 편이 낫습니다.

정리하면, 기본값은 인자가 빠진 호출마다 새로 계산됩니다. 그래서 새 객체를 기본값으로 줘도 호출끼리 섞이지 않고, 앞의 파라미터를 기본값 식에 쓸 수도 있습니다.

3. 명명인자 — 파라미터 이름이 호출부에 박힌다

0절의 자바 호출 price(15000, "KRW", false) 에서 false 는 무엇을 끄는 걸까요. 선언을 열어 보기 전에는 모릅니다. 명명인자는 이 false 옆에 이름을 붙이는 문법입니다. 이 절에서는 명명인자를 섞어 쓰는 규칙과, 이름이 호출부에 박혀서 생기는 일을 봅니다.

Kotlin
fun main() {
    // ↓ 15000 KRW
    println(price(15000, "KRW", false))
    // ↓ 15000 KRW
    println(price(15000, comma = false))
    // ↓ 15000 KRW
    println(price(comma = false, amount = 15000))
    // ↓ 15,000 USD
    println(price(amount = 15000, "USD"))
}

price 는 0절과 같은 함수라 선언은 생략했습니다. 파일 이름은 Named.kt 입니다.

터미널
$ kotlinc Named.kt -d out
$ kotlin -cp out NamedKt

위의 세 줄은 같은 값을 찍었습니다. 둘째 줄은 comma = false 가 무엇을 끄는지 호출만 보고도 읽힙니다. 셋째 줄처럼 이름을 다 붙이면 순서를 바꿔 적어도 됩니다.

넷째 줄은 명명인자 뒤에 이름 없는 "USD" 가 왔는데도 통과했습니다. 이름 붙인 amount 가 선언 순서대로 첫째 칸에 있어서, 뒤따르는 "USD" 를 둘째 파라미터 unit 으로 읽을 수 있기 때문입니다.

섞어 쓰는 규칙과, 이름이 바뀌면 깨지는 호출

그러면 순서가 안 맞게 섞으면 어떻게 될까요. 틀린 호출 둘을 Mix.kt 에 적었습니다. 하나는 unit 을 이름으로 먼저 주고 금액을 이름 없이 뒤에 준 것이고, 하나는 없는 이름 currency 를 쓴 것입니다.

Kotlin
fun main() {
    println(price(unit = "USD", 15000))
    println(price(15000, currency = "USD"))
}

price 선언은 파일 위쪽에 있고, 에러의 줄 번호는 그것까지 센 번호입니다.

터미널
$ kotlinc Mix.kt -d out
Mix.kt:8:33: error: mixing named and positional arguments is not allowed unless the order of the arguments matches the order of the parameters.
    println(price(unit = "USD", 15000))
                                ^^^^^
Mix.kt:8:33: error: no value passed for parameter 'amount'.
    println(price(unit = "USD", 15000))
                                ^^^^^^
Mix.kt:9:26: error: no parameter with name 'currency' found.
    println(price(15000, currency = "USD"))
                         ^^^^^^^^

첫 에러가 섞어 쓰는 규칙을 그대로 말합니다. 이름 있는 인자와 없는 인자를 섞는 것은 인자 순서가 파라미터 순서와 맞을 때만 됩니다. unit 을 첫 칸에 두는 바람에 뒤의 15000 이 어느 파라미터인지 정할 수 없어졌습니다. 안전하게 쓰려면 이름 없는 인자를 앞에, 이름 붙인 인자를 뒤에 둡니다.

둘째 에러가 이 절 제목의 뜻입니다. 선언에서 unit 을 currency 로 바꿨다고 해 봅시다. 그러면 unit = "USD" 로 부르던 곳마다 이 에러가 납니다. 명명인자를 쓰는 순간 파라미터 이름이 호출부에 적히고, 그 이름도 함수 모양의 일부가 됩니다.

한 프로젝트 안이라면 IDE 의 이름 바꾸기 기능이 부르는 쪽까지 같이 고칩니다. 조심할 것은 남이 쓰는 라이브러리입니다. 자바에서는 파라미터 이름을 바꿔도 부르는 쪽이 멀쩡했지만, 코틀린에서는 이름을 바꾸는 것만으로 사용자의 코드가 깨질 수 있습니다.

자바 메서드에는 명명인자를 못 쓴다

거꾸로 코틀린에서 자바 메서드를 부를 때는 명명인자를 못 씁니다. greet(String name) 하나만 든 자바 클래스 Greeter 를 javac 로 컴파일하고, 코틀린에서 Greeter.greet(name = "kotlin") 으로 불렀습니다. kotlinc 는 named arguments are prohibited for non-Kotlin functions. 에러를 냈습니다. 같은 파일의 Greeter.greet("kotlin") 줄에는 에러가 없었습니다. 자바 메서드는 위치로만 부릅니다.

정리하면, 명명인자는 순서가 맞을 때만 이름 없는 인자와 섞이고, 이름을 다 붙이면 순서가 자유롭습니다. 대신 파라미터 이름이 호출부에 적히니, 공개한 함수의 파라미터 이름은 함부로 바꾸지 않습니다.

4. 자바에서 부를 때 — 기본인자가 안 보인다

코틀린 코드를 자바가 부르는 일은 흔합니다. 한 프로젝트에 두 언어가 섞여 있거나, 코틀린으로 만든 라이브러리를 자바 프로젝트가 가져다 쓸 때입니다. 이 절에서는 0절의 price 를 자바에서 불러 봅니다. 기본인자가 자바에서도 통하는지는 자바 쪽 호출이 컴파일되느냐로 가립니다.

생성자의 기본인자는 KB 12 클래스와 주생성자 — 한 줄 클래스가 만드는 것 5절에서 봤습니다. 거기서 자바는 인자를 전부 채우거나 전부 빼는 두 가지로만 부를 수 있었습니다. 함수는 어떤지 봅니다.

price 만 든 Price.kt 를 컴파일하고, 자바 UsePrice.java 에서 두 번 불렀습니다. 톱레벨 함수라서 자바에서는 1절에서 본 PriceKt 클래스의 static 메서드로 부릅니다.

Java
public class UsePrice {
    public static void main(String[] args) {
        System.out.println(PriceKt.price(15000L, "USD", false));
        System.out.println(PriceKt.price(15000L));
    }
}
터미널
$ kotlinc Price.kt -d out
$ javac -cp out -d jout j/UsePrice.java
j/UsePrice.java:4: error: method price in class PriceKt cannot be applied to given types;
        System.out.println(PriceKt.price(15000L));
                                  ^
  required: long,String,boolean
  found:    long
  reason: actual and formal argument lists differ in length
1 error
  • -cp out — 코틀린이 만든 PriceKt.class 를 찾을 경로(classpath)를 javac 에 알려 줍니다

인자를 셋 다 채운 3번째 줄은 통과하고, 금액만 넘긴 4번째 줄이 막혔습니다. required: long,String,boolean 이 자바가 본 price 의 전부입니다. 자바에서는 기본인자가 없는 것과 같습니다. 코틀린에서는 price(15000) 으로 부르던 함수를, 자바에서는 인자를 셋 다 적어야 부를 수 있습니다.

@JvmOverloads — 자바용 오버로딩을 만들어 달라는 표시

자바에서도 빼고 부르게 하려면 함수에 @JvmOverloads 애노테이션을 붙입니다. 애노테이션은 선언에 붙이는 표시이고, 이것은 「기본인자를 뺀 오버로딩을 자바용으로 만들어 달라」는 표시입니다.

Kotlin
@JvmOverloads
fun price(
    amount: Long,
    unit: String = "KRW",
    comma: Boolean = true
): String {
    val num = if (comma) "%,d".format(amount) else "$amount"
    return "$num $unit"
}

본문은 0절과 같고 달라진 것은 첫 줄뿐입니다. 이번 자바 코드는 인자를 하나, 둘, 셋 넘기는 세 줄입니다.

Java
public class UsePrice {
    public static void main(String[] args) {
        // ↓ 15,000 KRW
        System.out.println(PriceKt.price(15000L));
        // ↓ 15,000 USD
        System.out.println(PriceKt.price(15000L, "USD"));
        // ↓ 15000 USD
        System.out.println(PriceKt.price(15000L, "USD", false));
    }
}
터미널
$ kotlinc Price.kt -d out
$ javac -cp out -d jout j/UsePrice.java
$ kotlin -cp out:jout UsePrice

세 줄 다 컴파일되고 돌았습니다. -cp out:jout 은 코틀린 결과물과 자바 결과물 두 폴더를 다 경로에 넣은 것입니다. 그러면 0절의 셋째 호출처럼 가운데를 건너뛰는 것도 될까요. 금액과 false 만 넘기는 Skip.java 를 같은 방법으로 컴파일했습니다.

Java
public class Skip {
    public static void main(String[] args) {
        System.out.println(PriceKt.price(15000L, false));
    }
}
터미널
$ javac -cp out -d jout2 skip/Skip.java
skip/Skip.java:3: error: incompatible types: boolean cannot be converted to String
        System.out.println(PriceKt.price(15000L, false));
                                                 ^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

막혔습니다. 에러가 인자 둘짜리 price 의 둘째 칸이 String 이라고 말합니다. @JvmOverloads 는 기본값이 있는 인자를 뒤에서부터 하나씩 뺀 오버로딩만 만들기 때문입니다(Overloads generation). 이 함수라면 (long), (long, String), (long, String, boolean) 셋입니다.

그래서 기본인자의 순서가 자바 쪽 쓰임새를 정합니다. 자바에서 자주 바꿀 인자를 앞에, 거의 안 바꿀 인자를 뒤에 두면 만들어지는 오버로딩이 쓸모 있어집니다.

정리하면, 자바는 기본인자를 모르고 코틀린 함수의 인자 전부를 요구합니다. 자바가 부를 함수에는 @JvmOverloads 를 붙이고, 이때 자바는 뒤쪽 인자를 빼는 호출만 할 수 있습니다. 코틀린끼리만 부르는 함수에는 붙일 까닭이 없습니다.

5. vararg 와 스프레드 *

listOf(1, 2, 3) 처럼 인자 개수를 정하지 않는 함수는 어떻게 만들까요. 자바의 int... xs 에 해당하는 것이 코틀린의 vararg 입니다. 이 절에서는 정수를 몇 개든 받아 더하는 함수로, 낱개로 넘기는 호출과 배열을 펼쳐 넘기는 호출을 봅니다.

Kotlin
fun sum(vararg xs: Int): Int {
    var total = 0
    for (x in xs) total += x
    return total
}

fun main() {
    val arr = intArrayOf(1, 2, 3)
    val list = listOf(4, 5)
    println(sum())                     // 0
    println(sum(1, 2, 3))              // 6
    println(sum(*arr))                 // 6
    println(sum(10, *arr, 20))         // 36
    println(sum(*list.toIntArray()))   // 9
}
  • vararg xs: Int — 인자를 몇 개든 받습니다. 함수 안에서 xs 는 정수 배열 타입 IntArray 입니다. 자바의 int[] 에 해당합니다
  • intArrayOf(1, 2, 3) — IntArray 를 만듭니다. listOf(4, 5) 는 바꿀 수 없는 목록을 만듭니다
  • *arr — 스프레드(spread) 연산자입니다. 배열 앞에 * 를 붙이면 원소를 하나씩 풀어서 넘깁니다
  • list.toIntArray() — 목록을 IntArray 로 바꿉니다. 목록은 바로 펼칠 수 없어서 배열로 바꾼 뒤 * 를 붙였습니다
터미널
$ kotlinc Sum.kt -d out
$ kotlin -cp out SumKt

인자가 없으면 빈 배열이라 0 이고, 낱개 셋은 6 입니다. *arr 도 6 이라 배열의 원소가 낱개로 넘어갔습니다. sum(10, *arr, 20) 이 36 인 것을 보면, 펼친 배열 앞뒤에 낱개를 섞을 수도 있습니다.

* 를 빼면, 그리고 vararg 뒤의 파라미터

자바는 int... 메서드에 배열을 그냥 넘겨도 됩니다. 코틀린에서 * 를 빼면 어떻게 될까요. vararg 뒤에 파라미터가 하나 더 있는 함수도 같이 봅니다.

Kotlin
fun sum(vararg xs: Int): Int = xs.sum()

fun tag(vararg names: String, sep: String) =
    names.joinToString(sep)

fun main() {
    val arr = intArrayOf(1, 2, 3)
    println(sum(arr))
    println(tag("a", "b", sep = "/"))
    println(tag("a", "b", "/"))
}
  • xs.sum() — 배열의 원소를 다 더하는 표준 라이브러리 함수입니다
  • names.joinToString(sep) — 원소를 sep 로 이어 붙인 문자열을 만듭니다
터미널
$ kotlinc NoSpread.kt -d out
NoSpread.kt:8:17: error: argument type mismatch: actual type is 'IntArray', but 'Int' was expected.
    println(sum(arr))
                ^^^
NoSpread.kt:10:27: error: no value passed for parameter 'sep'.
    println(tag("a", "b", "/"))
                          ^^^^

에러가 두 개입니다. 첫째는 * 없이 넘긴 배열을 원소 하나로 봤다는 뜻입니다. Int 가 와야 할 인자로 IntArray 가 왔으니 타입이 안 맞습니다. 코틀린에서는 배열을 펼치려면 반드시 * 를 붙입니다.

둘째는 vararg 뒤의 파라미터 이야기입니다. "a", "b", "/" 셋이 전부 names 로 들어가 sep 에 줄 값이 남지 않았습니다. vararg 뒤에 오는 파라미터는 명명인자로만 넘길 수 있고, 그래서 가운데 줄 sep = "/" 은 통과했습니다. 한 함수에 vararg 는 하나만 둘 수 있습니다(Functions).

정리하면, vararg 파라미터는 함수 안에서 배열이고, 이미 있는 배열을 넘길 때는 * 로 펼칩니다. vararg 는 되도록 마지막 파라미터로 두고, 뒤에 무언가 둔다면 명명인자로 받습니다.

6. 성능 — * 는 배열을 복사한다

5절에서 *arr 은 「원소를 풀어서 넘긴다」고 했습니다. 그런데 함수 안에서 xs 는 배열이었습니다. 그러면 함수가 받은 배열은 arr 자체일까요, 새 배열일까요. 받은 배열을 그대로 돌려주는 함수로 가려 봤습니다.

Kotlin
fun keep(vararg xs: Int): IntArray = xs

fun keepArr(xs: IntArray): IntArray = xs

fun main() {
    val arr = intArrayOf(1, 2, 3)
    val got = keep(*arr)
    arr[0] = 100
    println(got[0])               // 1
    println(got === arr)          // false
    println(keepArr(arr) === arr) // true
}
  • keep 은 vararg 로 받고, keepArr 는 IntArray 파라미터로 받습니다. 둘 다 받은 배열을 돌려주기만 합니다
  • arr[0] = 100 — keep 을 부른 뒤에 원본 배열의 첫 원소를 바꿨습니다
터미널
$ kotlinc Copy.kt -d out
$ kotlin -cp out CopyKt

원본을 100 으로 바꿨는데 got[0] 은 1 입니다. got === arr 도 false 라서 둘은 다른 배열입니다. *arr 로 넘기면 arr 을 복사한 새 배열이 함수로 들어갑니다. 반면 IntArray 파라미터로 받은 keepArr 는 true, 곧 원본 배열 그 자체를 받았습니다.

같은 일을 자바의 int... 로 해 봤습니다.

Java
public class CopyJ {
    static int[] keep(int... xs) {
        return xs;
    }

    public static void main(String[] args) {
        int[] arr = {1, 2, 3};
        int[] got = keep(arr);
        arr[0] = 100;
        // ↓ 100
        System.out.println(got[0]);
        // ↓ true
        System.out.println(got == arr);
    }
}
터미널
$ javac -d jout CopyJ.java
$ java -cp jout CopyJ

자바는 복사하지 않았습니다. int... 에 넘긴 배열 자체가 함수에 들어가서, 원본을 바꾸자 got[0] 도 100 이 됐습니다.

복사는 함수가 아니라 부르는 쪽의 * 가 합니다. 자바 코드에서 코틀린 keep 에 배열을 넘기면(CopyKt.keep(arr) == arr) true 가 찍혔습니다. 자바 호출에는 * 가 없으니 복사도 없습니다.

언제 신경 쓰나

복사는 배열 길이만큼 원소를 옮기는 일이고, * 를 붙인 호출마다 한 번씩 일어납니다. sum(*arr) 처럼 원소 몇 개짜리 배열을 넘기는 대부분의 코드에서는 신경 쓸 필요가 없습니다.

문제가 되는 것은 큰 배열을 반복해서 넘길 때입니다. 원소가 수십만 개인 배열을 반복문 안에서 * 로 넘기면 부를 때마다 그만큼 새 배열을 만들고 채웁니다. 이런 함수는 vararg 대신 keepArr 처럼 IntArray 파라미터로 받으면 복사 없이 원본을 넘깁니다. 이 편에서는 복사 비용을 시간으로 재지 않았습니다.

복사가 이로운 쪽도 있습니다. 함수가 받은 배열을 필드에 저장해 두는 경우, * 로 받은 코틀린 쪽은 부르는 쪽이 나중에 원본을 고쳐도 영향이 없습니다. 다만 자바에서 부르면 복사가 없으니 이 보호는 사라집니다. 받은 배열을 오래 쥐고 있을 함수라면 함수 안에서 xs.copyOf() 로 직접 복사해 두는 편이 두 언어 모두에서 안전합니다.

정리하면, * 로 펼친 배열은 복사돼 들어갑니다. 작은 배열이면 무시해도 되고, 큰 배열을 자주 넘기는 함수라면 vararg 대신 배열 파라미터로 받습니다.

7. 한 장 요약

코틀린은 기본인자와 명명인자로, 자바가 오버로딩 여러 벌로 하던 일을 함수 하나로 한다. 기본값은 인자가 빠진 호출마다 새로 계산된다. 명명인자를 쓰면 파라미터 이름이 호출부에 적힌다. 자바는 기본인자를 모르니 @JvmOverloads 로 오버로딩을 만들어 주고, 그때도 뒤쪽 인자만 뺄 수 있다. vararg 에 배열을 * 로 펼쳐 넘기면 복사가 일어난다.

하는 일 자바 코틀린 확인한 방법
인자 일부를 빼고 부르기 오버로딩 여러 벌 기본인자 unit: String = "KRW" 양쪽 실행
가운데 인자만 바꾸기 앞 인자를 손으로 다시 적거나 오버로딩 추가 명명인자 comma = false 양쪽 실행
식 하나짜리 함수 해당 없음 fun thrice(x: Int) = x * 3. 반환 타입 추론, 재귀면 적어야 함 실행 · kotlinc 에러
반환값 없음 void Unit. 값이 있는 타입이라 kotlin.Unit 으로 찍힘 실행
기본값 계산 시점 해당 없음 인자가 빠진 호출마다. 새 객체를 줘도 호출끼리 안 섞임 실행 · ===
이름과 위치 섞기 해당 없음 순서가 맞을 때만. 없는 이름은 에러 kotlinc 에러
자바 메서드에 명명인자 해당 없음 에러 kotlinc 에러
자바에서 기본인자 함수 부르기 인자 전부 요구 @JvmOverloads 로 뒤에서부터 뺀 오버로딩 생성 javac 에러 · 실행
가변 인자 int... xs. 배열을 그냥 넘김 vararg xs: Int. 배열은 *arr 로 펼침 실행 · kotlinc 에러
배열을 펼쳐 넘길 때 복사 없음 복사됨. 큰 배열을 반복해 넘기면 배열 파라미터로 실행 · ===

관련 항목

기본인자와 명명인자가 대신하는 자바의 수단

오버로딩 · 메서드 시그니처 · 생성자 오버로딩 · 빌더 패턴 · 점층적 생성자 패턴

코틀린 함수 선언을 이루는 문법 요소

기본 인자 · 이름 붙인 인자 · 식 본문 · 블록 본문 · 반환 타입 추론 · Unit · 톱레벨 함수 · 타입 추론

코틀린 함수를 자바에서 부르게 하는 상호 운용 수단

@JvmOverloads · 애노테이션 · 자바 상호 운용성 · static 메서드 · 클래스패스 · 하위 호환

가변 인자 호출을 이루는 문법과 배열 연산

vararg · 가변 인자 · 스프레드 연산자 · 배열 · IntArray · 배열 복사 · 방어적 복사

이 편의 코드를 이루는 그 밖의 코틀린 문법 요소

문자열 템플릿 · if 식 · 참조 비교 · MutableList · 재귀 함수

이 편에 쓰인 언어와 도구

코틀린 · 자바 · JVM · 컴파일러 · kotlinc · javac · 클래스 파일