코틀린 베이직 KB 25 람다와 함수 타입 — 함수를 값으로 넘긴다
코틀린 베이직 · 25/26

KB 25 람다와 함수 타입 — 함수를 값으로 넘긴다

gabury1고친 사람 github-actions[bot]

0. stream() 도 collect 도 없다

짝수만 골라내는 흔한 일을 자바로 먼저 씁니다. 리스트에서 stream() 을 열고, filter 를 걸고, 다시 리스트로 모읍니다.

Java
import java.util.List;

public class Filter {
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3, 4, 5, 6);
        List<Integer> evens = nums.stream()
            .filter(n -> n % 2 == 0)
            .toList();
        // ↓ [2, 4, 6]
        System.out.println(evens);
    }
}

n -> n % 2 == 0 이 자바의 람다(lambda)입니다. 이름 없이 그 대목에 바로 적는 작은 함수이고, 여기서는 「짝수면 참」이라는 판정을 filter 에 넘깁니다.

같은 일을 코틀린으로 썼습니다.

Kotlin
fun main() {
    val nums = listOf(1, 2, 3, 4, 5, 6)
    val evens = nums.filter { it % 2 == 0 }
    println(evens)  // [2, 4, 6]
    // ↓ [20, 40, 60]
    println(nums.filter { it % 2 == 0 }.map { it * 10 })
}
  • val — 다시 대입할 수 없는 변수입니다. 타입을 안 적으면 오른쪽 값에서 알아냅니다
  • listOf(1, 2, 3) — 읽기 전용 리스트를 만드는 함수입니다. 자바의 List.of 에 해당합니다
  • { it % 2 == 0 } — 코틀린의 람다입니다. 중괄호가 람다의 처음과 끝입니다
  • println — 한 줄 출력입니다. System.out. 이 없습니다

Filter.kt 와 Filter.java 를 각각 컴파일해 돌렸습니다. 출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.

터미널
$ kotlinc Filter.kt -d out
$ kotlin -cp out FilterKt
$ javac -d jout Filter.java
$ java -cp jout Filter

kotlinc 는 코틀린 컴파일러이고 -d out 은 결과물을 out 폴더에 쓰라는 옵션입니다. kotlin 은 java 와 같은 일을 하되 코틀린 표준 라이브러리를 스스로 찾아 붙여 주는 실행 명령입니다.

결과는 같은데 코틀린 쪽에서 세 가지가 사라졌습니다. stream() 과 toList() 가 없고, 람다를 감싸던 괄호가 없고, 매개변수 이름 n 도 없습니다. 대신 it 이라는, 선언한 적 없는 이름이 나왔습니다.

stream() 이 없는 까닭은 간단합니다. 코틀린의 filter·map 은 리스트에 바로 붙은 함수이고, 부르면 곧바로 새 리스트를 돌려줍니다. 그래서 .map 을 이어 붙이면 그 결과가 곧 리스트입니다.

나머지 두 가지, 괄호와 it 은 람다 문법의 이야기입니다. 1절이 그 문법, 2절이 람다가 바깥 변수를 다루는 법입니다. 3절부터는 함수 참조, 자바 인터페이스와의 연결, inline 을 차례로 보고 6절에서 람다가 객체를 만드는 때를 짚습니다.

1. 함수 타입과 람다 문법

자바에서 「정수를 받아 문자열을 돌려주는 함수」를 변수에 담으려면 Function<Integer, String> 같은 인터페이스 이름을 골라야 합니다. 인자가 둘이면 BiFunction, 없으면 Supplier 로 이름이 바뀝니다.

코틀린은 이름 대신 모양을 적습니다. (Int) -> String 이 「Int 하나를 받아 String 을 돌려주는 함수」라는 타입이고, 이것을 함수 타입(function type)이라 부릅니다. 이 절에서는 함수 타입 변수에 람다를 담고, 함수 타입 매개변수에 람다를 넘기는 모양을 한 파일에서 봅니다.

Kotlin
fun applyTwice(x: Int, f: (Int) -> Int): Int = f(f(x))

fun main() {
    val label: (Int) -> String = { n -> "#$n" }
    val twice = { n: Int -> n * 2 }
    println(label(7))   // #7
    println(twice(21))  // 42
    // ↓ 23
    println(applyTwice(3, { n -> n + 10 }))
    // ↓ 23
    println(applyTwice(3) { it + 10 })
    val sum = { a: Int, b: Int ->
        println("adding $a + $b")
        a + b
    }
    println(sum(1, 2))
}
  • fun applyTwice(...): Int = f(f(x)) — 함수 선언입니다. 매개변수는 이름 뒤에 콜론, 그 뒤에 타입을 쓰고, 등호 뒤에 돌려줄 값을 바로 적었습니다
  • { n -> "#$n" } — 화살표 왼쪽이 매개변수, 오른쪽이 본문입니다. "#$n" 은 문자열 안에 n 의 값을 끼우는 문자열 템플릿입니다
  • { n: Int -> n * 2 } — 담을 변수에 타입이 없으면 람다 쪽 매개변수에 타입을 적습니다
  • label(7) — 함수 타입 변수는 함수처럼 괄호로 부릅니다. 자바처럼 .apply(7) 을 찾아 부르지 않습니다

sum 은 두 줄을 찍으니 옆에 붙이지 않고 아래에 싣습니다.

터미널
$ kotlinc FnType.kt -d out
$ kotlin -cp out FnTypeKt
#7
42
23
23
adding 1 + 2
3

마지막 두 줄에서 볼 것이 있습니다. sum 의 본문은 두 줄인데 return 이 없습니다. 람다는 본문 마지막 식의 값을 돌려줍니다. 그래서 a + b 의 3 이 나왔습니다.

it 과 괄호 밖의 람다

applyTwice 를 부른 두 줄은 같은 일을 합니다. 모양이 둘로 갈리는 규칙이 두 개 있습니다.

하나는 it 입니다. 매개변수가 하나뿐인 람다는 n -> 을 생략할 수 있고, 그러면 그 매개변수의 이름이 it 이 됩니다(공식 문서). 0절의 { it % 2 == 0 } 가 이 규칙입니다.

다른 하나는 괄호 밖으로 빼는 람다입니다. 함수의 마지막 매개변수가 함수 타입이면, 그 인자로 넘기는 람다를 괄호 밖에 둘 수 있습니다. 이런 람다를 흔히 트레일링 람다(trailing lambda)라 부릅니다. 람다가 유일한 인자면 빈 괄호까지 뺍니다. nums.filter { ... } 에 괄호가 없는 까닭이 이것입니다.

함수 타입을 매개변수로 받거나 돌려주는 함수를 고차 함수(higher-order function)라 부릅니다. applyTwice 도, 표준 라이브러리의 filter·map 도 고차 함수입니다.

모양이 안 맞으면 컴파일에서 막힌다

함수 타입은 모양까지가 타입입니다. (Int) -> String 변수에 숫자를 돌려주는 람다를 넣어 봤습니다.

Kotlin
fun main() {
    // ↓ 컴파일 오류
    val label: (Int) -> String = { n -> n * 2 }
    println(label(1))
}
터미널
$ kotlinc FnBad.kt -d out
FnBad.kt:3:41: error: return type mismatch: expected 'String', actual 'Int'.
    val label: (Int) -> String = { n -> n * 2 }
                                        ^^^^^

람다 본문의 마지막 식 n * 2 를 짚어 「String 이어야 하는데 Int 」라고 알려 줍니다. 돌려주는 값이 곧 마지막 식이라, 에러도 그 식에 붙습니다.

정리하면, 함수 타입은 인터페이스 이름 대신 (받는 타입) -> 돌려주는 타입 으로 적습니다. 람다는 마지막 식을 돌려주고, 매개변수가 하나면 it, 마지막 인자면 괄호 밖으로 뺍니다.

2. 바깥 변수 캡처 — var 도 바꿀 수 있다

람다 안에서 람다 바깥의 변수를 쓰는 것을 캡처(capture)라 부릅니다. 자바 람다도 캡처를 하지만 조건이 붙습니다. 이 절에서는 짝수의 개수를 바깥 변수에 세는 코드를 두 언어로 돌립니다.

Java
import java.util.List;

public class Capture {
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3, 4, 5, 6);
        int count = 0;
        nums.forEach(n -> { if (n % 2 == 0) count++; });
        System.out.println(count);
    }
}
터미널
$ javac -d jout Capture.java
Capture.java:7: error: local variables referenced from a lambda expression must be final or effectively final
        nums.forEach(n -> { if (n % 2 == 0) count++; });
                                            ^
1 error

자바는 람다가 캡처하는 지역 변수를 final 이거나, 선언 뒤 한 번도 안 바뀌는 변수(effectively final)로 제한합니다. count++ 가 그 조건을 깨서 막혔습니다. 자바에서는 int[] count = {0} 처럼 배열 한 칸에 담거나 AtomicInteger 를 쓰는 식으로 돌아갑니다.

같은 코드를 코틀린으로 옮기고, 캡처한 변수를 람다를 만든 뒤에 바꾸는 줄도 넣었습니다.

Kotlin
fun main() {
    val nums = listOf(1, 2, 3, 4, 5, 6)
    var count = 0
    nums.forEach { if (it % 2 == 0) count++ }
    println(count)    // 3

    var name = "kim"
    val greet = { "hi $name" }
    name = "lee"
    println(greet())  // hi lee
}
  • var — 다시 대입할 수 있는 변수입니다. 자바의 평범한 지역 변수에 해당합니다
  • forEach { ... } — 원소마다 람다를 한 번씩 부릅니다
터미널
$ kotlinc Capture.kt -d out
$ kotlin -cp out CaptureKt

count 는 컴파일되고 3 을 찍었습니다. 공식 문서도 캡처한 변수를 람다 안에서 바꿀 수 있다고 적습니다(공식 문서).

아래쪽 결과가 조심할 대목입니다. greet 을 만들 때 name 은 "kim" 이었는데, 부를 때 "lee" 가 찍혔습니다. 람다는 캡처할 때의 값을 복사해 두지 않고, 변수 자체를 붙들고 있다가 부를 때 읽습니다. 람다를 저장해 두고 나중에 부른다면, 그사이에 바뀐 값을 읽는다는 뜻입니다.

같은 까닭으로, 캡처한 var 를 여러 스레드에서 도는 람다가 함께 바꾸면 자바의 공유 변수와 똑같이 값이 꼬일 수 있습니다. 컴파일러가 막아 주지 않는 쪽이니, 그럴 때는 자바에서처럼 AtomicInteger 같은 도구를 씁니다.

정리하면, 코틀린 람다는 val 이든 var 든 캡처하고 바꿀 수 있습니다. 대신 값이 아니라 변수를 붙드므로, 나중에 부르는 람다는 그때의 값을 봅니다.

3. 함수 참조 — 람다 대신 이름을 넘긴다

{ isEven(it) } 처럼 이미 있는 함수를 부르기만 하는 람다가 자주 생깁니다. 그럴 때는 람다 대신 함수의 이름을 넘길 수 있습니다. 이것을 함수 참조(function reference)라 부르고, 이름 앞에 :: 를 붙입니다. 자바의 메서드 참조 Integer::parseInt 와 같은 쓰임입니다.

이 절에서는 참조의 네 가지 모양을 한 파일에서 돌립니다.

Kotlin
fun isEven(n: Int) = n % 2 == 0

class User(val name: String)

class Greeter(val word: String) {
    fun greet(who: String) = "$word, $who"
}

fun main() {
    val nums = listOf(1, 2, 3, 4)
    val words = listOf("kotlin", "jvm")
    // ↓ [2, 4]
    println(nums.filter(::isEven))
    // ↓ [6, 3]
    println(words.map(String::length))
    val users = words.map(::User)
    // ↓ [kotlin, jvm]
    println(users.map(User::name))
    val hi = Greeter("hi")
    // ↓ [hi, kotlin, hi, jvm]
    println(words.map(hi::greet))
    val f: (Int) -> Boolean = ::isEven
    println(f(3))  // false
}
  • class User(val name: String) — 괄호 안이 생성자 매개변수이고, val 을 붙이면 같은 이름의 프로퍼티(필드와 게터를 한 이름으로 묶은 것)가 됩니다
  • String::length — length 는 자바의 length() 에 해당하는 프로퍼티입니다. 프로퍼티도 참조로 넘깁니다
  • hi::greet — 특정 객체 hi 에 묶인 참조입니다. 부를 때마다 hi.greet(원소) 가 됩니다
터미널
$ kotlinc Refs.kt -d out
$ kotlin -cp out RefsKt

[hi, kotlin, hi, jvm] 은 문자열 둘 "hi, kotlin" 과 "hi, jvm" 이 든 리스트입니다. 리스트를 찍을 때 원소를 쉼표로 이어서, 원소 안의 쉼표와 섞여 보입니다.

마지막 줄은 함수 참조도 함수 타입 값이라는 것을 보입니다. ::isEven 을 (Int) -> Boolean 변수에 담아 람다처럼 불렀습니다.

자바의 메서드 참조와 나란히 놓으면 이렇습니다. 표의 톱레벨 함수는 클래스 밖, 파일 바로 아래에 쓴 함수입니다.

무엇을 넘기나 자바 코틀린
톱레벨·static 함수 Util::isEven ::isEven
원소의 메서드·프로퍼티 String::length String::length
생성자 User::new ::User
특정 객체의 메서드 hi::greet hi::greet

갈리는 것은 두 줄입니다. 톱레벨 함수는 앞에 붙일 클래스가 없어서 :: 로 시작합니다. 생성자는 new 대신 클래스 이름 자체를 참조합니다. 코틀린에서 생성자 호출이 User("kim") 처럼 new 없이 이름으로 부르는 모양이기 때문입니다.

정리하면, 이미 있는 함수 하나를 부르기만 하는 람다는 ::이름 으로 바꿔 넘깁니다. 생성자 참조는 ::클래스이름 입니다.

4. 자바 인터페이스에 람다 넘기기 — SAM 변환과 fun interface

자바 라이브러리에는 추상 메서드가 하나뿐인 인터페이스를 받는 메서드가 많습니다. Runnable, Comparator, 이벤트 리스너가 그렇습니다. 이런 인터페이스를 SAM(Single Abstract Method, 추상 메서드 하나) 인터페이스, 또는 함수형 인터페이스라 부릅니다.

이 절에서는 자바 클래스 하나를 코틀린에서 불러, 람다를 그 인터페이스 타입 매개변수에 넘길 수 있는지 봅니다. 자바 쪽은 클릭 리스너를 받는 버튼입니다.

Java
public class Button {
    public interface OnClick {
        void onClick(String who);
    }

    private OnClick listener;

    public void setOnClick(OnClick l) {
        listener = l;
    }

    public void click(String who) {
        listener.onClick(who);
    }
}
Kotlin
fun main() {
    val button = Button()
    button.setOnClick { who -> println("clicked by $who") }
    button.click("kim")
}

Button() 은 자바 생성자 호출입니다. 코틀린은 new 없이 부릅니다.

자바 클래스를 먼저 컴파일하고, 그 결과를 -cp(클래스를 찾을 경로) 로 넘겨 코틀린을 컴파일했습니다.

터미널
$ javac -d jout Button.java
$ kotlinc Sam.kt -cp jout -d out
$ kotlin -cp out:jout SamKt
clicked by kim

setOnClick 은 OnClick 객체를 받는데 람다를 넘겼고, 컴파일이 됐습니다. 코틀린 컴파일러가 람다를 그 인터페이스의 구현으로 바꿔 넘긴 것입니다. 이것을 SAM 변환(SAM conversion)이라 부릅니다. 자바 쪽에서 button.setOnClick(who -> ...) 로 쓰던 것과 같은 일입니다.

코틀린 인터페이스에는 fun 을 붙여야 한다

같은 모양을 코틀린 인터페이스로 만들면 어떻게 될까요. 추상 메서드가 하나인 인터페이스를 두 개 만들고, 하나에만 fun 을 앞에 붙였습니다.

Kotlin
interface Rule {
    fun check(n: Int): Boolean
}

fun interface FunRule {
    fun check(n: Int): Boolean
}

fun countBy(nums: List<Int>, rule: Rule) =
    nums.count { rule.check(it) }
fun countByF(nums: List<Int>, rule: FunRule) =
    nums.count { rule.check(it) }

fun main() {
    val nums = listOf(1, 2, 3, 4, 5, 6)
    // ↓ 컴파일 오류
    println(countBy(nums) { it > 3 })
    println(countByF(nums) { it > 3 })
}

count { ... } 는 람다가 참을 돌려준 원소의 개수를 셉니다.

터미널
$ kotlinc FunIface.kt -d out
FunIface.kt:17:27: error: argument type mismatch: actual type is '() -> Boolean', but 'Rule' was expected.
    println(countBy(nums) { it > 3 })
                          ^^^^^^^^^^
FunIface.kt:17:29: error: unresolved reference 'it'.
    println(countBy(nums) { it > 3 })
                            ^^

에러는 17번째 줄, 그냥 interface 인 Rule 쪽에만 났습니다. 람다가 Rule 로 바뀌지 않아서, 컴파일러가 람다를 매개변수 없는 () -> Boolean 으로 읽었습니다. 그러니 it 도 없는 이름이 됐습니다.

fun interface 로 선언한 FunRule 쪽은 에러가 없습니다. 에러 난 줄을 뺀 파일은 컴파일되고 3 을 찍었습니다. 코틀린 인터페이스는 fun interface 로 선언해야 SAM 변환이 됩니다(공식 문서).

그럼 코틀린끼리 쓸 때 함수 타입과 fun interface 중 무엇을 고를까요. 받는 쪽이 「정수를 받아 참거짓」 정도만 알면 되면 함수 타입 (Int) -> Boolean 이 가볍습니다. 이름이 뜻을 지거나, 자바 쪽에서도 구현하게 하거나, 기본 구현이 있는 메서드를 곁들이고 싶으면 fun interface 입니다.

반대 방향 — 자바가 코틀린 함수 타입을 받을 때

코틀린 함수 fun repeat3(action: (Int) -> Unit) 을 자바에서 부르면 조심할 것이 하나 있습니다. Unit 은 자바의 void 에 해당하는 코틀린 타입인데, 자바에서는 진짜 객체 타입으로 보입니다.

그래서 자바에서 RepeatKt.repeat3(n -> System.out.println(n)); 로 부르면 javac 가 bad return type in lambda expression 과 void cannot be converted to Unit 을 내며 막습니다. 람다 끝에 return Unit.INSTANCE; 를 적어 주면 컴파일되고 1·2·3 을 찍었습니다. 자바에서 부를 API 라면 Unit 을 돌려주는 함수 타입 대신 fun interface 로 받는 쪽이 자바 사용자에게 편합니다.

정리하면, 자바의 SAM 인터페이스에는 람다를 바로 넘깁니다. 코틀린 인터페이스는 fun interface 여야 하고, 자바에 노출할 콜백은 fun interface 가 부르기 쉽습니다.

5. inline 함수 — filter 와 map 에서 return 이 되는 까닭

리스트를 돌다가 원하는 원소를 찾으면 함수를 바로 빠져나오고 싶을 때가 있습니다. forEach 안에서 return 을 쓰면 됩니다. 그런데 똑같이 생긴 함수를 손수 만들면 막힙니다. 이 절에서는 그 차이를 만드는 inline 을 봅니다.

먼저 forEach 를 흉내 낸 each 를 만들고, 그 안에서 첫 짝수를 찾아 return 했습니다.

Kotlin
fun <T> each(xs: List<T>, f: (T) -> Unit) {
    for (x in xs) f(x)
}

fun firstEven(nums: List<Int>): Int? {
    each(nums) {
        // ↓ 컴파일 오류
        if (it % 2 == 0) return it
    }
    return null
}

fun main() {
    println(firstEven(listOf(1, 3, 4, 6)))
}
  • fun <T> each(...) — T 는 타입 매개변수입니다. 자바의 제네릭 메서드 <T> void each(...) 에 해당합니다
  • Int? — null 도 담을 수 있는 Int 입니다. 짝수가 없으면 null 을 돌려줍니다
  • (T) -> Unit — 돌려주는 값이 없는 함수 타입입니다. Unit 은 자바의 void 에 해당합니다
터미널
$ kotlinc Each.kt -d out
Each.kt:8:26: error: 'return' is prohibited here.
        if (it % 2 == 0) return it
                         ^^^^^^

람다 안의 return 은 막혔습니다. 이 return 은 람다가 아니라 바깥 함수 firstEven 을 끝내라는 뜻인데, 람다는 each 가 나중에 부르는 별개의 함수라 firstEven 을 끝낼 방법이 없기 때문입니다.

inline 을 붙이면 람다가 호출부에 펼쳐진다

each 앞에 inline 을 붙인 eachInline 을 만들었습니다. inline 함수는 부르는 쪽에 함수 본문이 복사되고, 넘긴 람다의 본문도 함께 복사됩니다(공식 문서).

복사됐는지는 람다 안에서 「지금 어느 메서드 안에 있나」를 찍어 보면 보입니다. caller() 는 자기를 부른 메서드의 이름을 스택 트레이스(지금까지 거쳐 온 메서드 호출의 목록)에서 꺼내 돌려줍니다.

Kotlin
fun caller() = Throwable().stackTrace[1].methodName

fun <T> each(xs: List<T>, f: (T) -> Unit) {
    for (x in xs) f(x)
}

inline fun <T> eachInline(xs: List<T>, f: (T) -> Unit) {
    for (x in xs) f(x)
}

fun firstEven(nums: List<Int>): Int? {
    eachInline(nums) {
        if (it % 2 == 0) return it
    }
    return null
}

fun main() {
    // ↓ 4
    println(firstEven(listOf(1, 3, 4, 6)))
    val one = listOf(1)
    // ↓ main$lambda$0
    each(one) { println(caller()) }
    // ↓ main
    eachInline(one) { println(caller()) }
    // ↓ main
    one.filter { println(caller()); true }
    one.map { println(caller()) }  // main
}
  • Throwable().stackTrace[1] — 예외 객체를 만들기만 하고 던지지는 않습니다. 호출 스택의 0번은 caller 자신, 1번이 그것을 부른 메서드입니다
  • { println(caller()); true } — 세미콜론으로 두 식을 한 줄에 적었습니다. 마지막 식 true 가 돌려주는 값이라 원소를 남깁니다
터미널
$ kotlinc Inline.kt -d out
$ kotlin -cp out InlineKt

firstEven 은 이번에는 컴파일되고 4 를 찍었습니다. 람다 안의 return 이 firstEven 을 끝냈습니다. 이렇게 람다 바깥 함수에서 빠져나오는 return 을 비지역 반환(non-local return)이라 부릅니다.

아래 네 줄이 까닭을 보여 줍니다. 그냥 each 에 넘긴 람다는 main$lambda$0 이라는 별도 메서드 안에서 돌았습니다. eachInline·filter·map 에 넘긴 람다는 main 안에서 돌았습니다. 람다 본문이 main 에 복사돼 들어갔으니, 그 안의 return 은 곧 main 이나 firstEven 의 return 입니다.

표준 라이브러리의 filter·map·forEach·count 는 전부 inline 입니다. 그래서 컬렉션을 도는 람다에서 return 으로 바깥 함수를 끝낼 수 있고, 6절에서 보듯 람다 객체도 생기지 않습니다.

inline 함수에 넘긴 람다는 값으로 못 쓴다

본문에 복사되는 람다는 따로 떨어진 값이 아닙니다. 그래서 inline 함수 안에서 그 람다를 변수에 담거나 돌려주면 컴파일이 막힙니다. inline fun later(f: () -> Unit) 안에서 val saved = f 라고 쓰면 kotlinc 가 illegal usage of inline parameter 'f: () -> Unit'. Add 'noinline' modifier to the parameter declaration. 을 냅니다.

에러가 알려 준 대로 매개변수에 noinline 을 붙이면 컴파일되고 돕니다. 다만 이번에는 expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of function types. 라는 경고가 붙습니다.

함수 타입 매개변수가 전부 noinline 이면 펼칠 람다가 없어서 inline 을 붙일 까닭이 없다는 뜻입니다. inline 은 람다를 받는 함수에 붙이는 것이고, 평범한 함수에 붙이면 컴파일러가 이렇게 알려 줍니다.

정리하면, inline 함수에 넘긴 람다는 호출부에 펼쳐집니다. 그래서 람다 안의 return 이 바깥 함수를 끝내고, 대신 그 람다를 값으로 담으려면 noinline 이 필요합니다.

6. 성능 — 람다가 객체를 만드는 때

JVM 에서 함수를 값으로 넘기려면 무언가 객체가 있어야 합니다. inline 이 아닌 함수에 넘기는 람다는 JVM 위에서 객체가 됩니다. 공식 문서도 고차 함수의 비용으로 함수 객체와 캡처한 변수, 그 메모리 할당을 듭니다(공식 문서).

그러면 람다를 넘길 때마다 새 객체가 생길까요. 캡처하지 않는 람다와, 매개변수 x 를 캡처하는 람다를 두 번씩 만들어 === 로 비교했습니다. === 는 두 값이 같은 객체인지를 보는 비교입니다. 자바의 == 가 객체에 하는 일과 같습니다.

Kotlin
fun noCapture(): () -> Int = { 42 }
fun capture(x: Int): () -> Int = { x }

fun main() {
    // ↓ true
    println(noCapture() === noCapture())
    // ↓ false
    println(capture(1) === capture(1))
}
터미널
$ kotlinc Same.kt -d out
$ kotlin -cp out SameKt

캡처하지 않는 람다는 두 번 만들어도 같은 객체였습니다. 바깥 값을 안 쓰니 매번 같은 내용이고, 그래서 하나를 만들어 두고 다시 씁니다. 캡처하는 람다는 capture 를 부를 때마다 다른 객체였습니다. 캡처한 x 를 람다 객체가 들고 있어야 하니, 람다 식을 지날 때마다 새로 만듭니다.

코틀린 2.0 부터 람다는 자바 람다와 같은 장치, 곧 JDK 의 LambdaMetafactory 클래스로 만들어집니다(공식 문서). 그래서 이 동작은 자바 람다와 같은 방식에서 나옵니다.

캡처한 var 는 상자에 담긴다

2절에서 코틀린은 캡처한 var 를 람다 안에서 바꿀 수 있었습니다. 자바가 막는 일을 어떻게 하는지, 람다 객체가 들고 있는 필드의 타입을 리플렉션(실행 중에 클래스 정보를 읽는 기능)으로 찍었습니다.

Kotlin
fun main() {
    var hits = 0
    val hit = { hits++ }
    hit()
    for (f in hit.javaClass.declaredFields) {
        println(f.type.name)
    }
}
터미널
$ kotlinc Box.kt -d out
$ kotlin -cp out BoxKt
kotlin.jvm.internal.Ref$IntRef

람다가 들고 있는 것은 int 가 아니라 Ref$IntRef 라는 객체였습니다. 캡처한 var 는 이런 작은 상자 객체에 담기고, 바깥 함수와 람다가 같은 상자를 나눠 씁니다. 2절에서 자바가 int[] count = {0} 으로 돌아가던 것을 컴파일러가 대신 해 준 셈입니다. 그만큼 객체가 하나 더 생기고, 변수를 읽고 쓸 때마다 상자를 거칩니다.

언제 신경 쓰나

대부분은 신경 쓸 필요가 없습니다. 람다 객체는 작고 금방 버려지는 객체라 대개 가비지 컬렉션이 큰 부담 없이 치웁니다. 게다가 filter·map·forEach 처럼 inline 함수에 넘긴 람다는 5절에서 본 대로 main 안에 펼쳐지니, 람다 객체 자체가 생기지 않습니다.

따져 볼 만한 것은 조건이 겹칠 때입니다. inline 이 아닌 함수에, 캡처하는 람다를, 아주 자주 도는 반복문 안에서 넘길 때입니다. 그때는 반복마다 객체가 생깁니다. 이 편에서 그 비용을 숫자로 재지는 않았습니다. 이런 경로라면 람다를 반복문 밖에서 한 번 만들거나, 받는 함수를 inline 으로 두는 방법이 있습니다.

컬렉션 연산을 잇는 체인에도 비슷한 대목이 하나 있습니다. nums.filter { ... }.map { ... } 는 단계마다 중간 리스트를 하나씩 새로 만듭니다. 원소가 아주 많고 체인이 길 때는 중간 리스트 없이 원소를 하나씩 흘려보내는 Sequence(asSequence())가 있는데, 뒤에 나올 편에서 다룹니다.

정리하면, 캡처 안 하는 람다는 하나를 다시 쓰고, 캡처하는 람다와 캡처한 var 는 객체를 만듭니다. inline 함수에 넘긴 람다는 객체가 없습니다. 비용을 따질 곳은 inline 아닌 함수에 캡처 람다를 넘기는 뜨거운 반복문 정도입니다.

7. 한 장 요약

코틀린은 함수를 값으로 다룬다. (Int) -> String 처럼 모양으로 적는 함수 타입이 자바의 Function·Supplier 같은 인터페이스 이름을 대신한다. 람다는 마지막 식을 돌려주고, 매개변수 하나면 it, 마지막 인자면 괄호 밖으로 뺀다. 자바와 달리 캡처한 var 를 바꿀 수 있지만 값이 아니라 변수를 붙든다. 자바 SAM 인터페이스에는 람다를 바로 넘기고, 코틀린 인터페이스는 fun interface 여야 한다. filter·map 은 inline 이라 람다가 호출부에 펼쳐지고, 그래서 return 이 바깥 함수를 끝내며 람다 객체도 생기지 않는다.

주제 자바 코틀린 확인한 방법
컬렉션 거르기 stream().filter(...).toList() filter { ... } 가 곧 리스트를 돌려준다 실행 출력
함수를 담는 타입 Function<Integer, String> 등 인터페이스 (Int) -> String 실행 · kotlinc 에러
람다 문법 n -> n * 2 { n -> n * 2 } · { it * 2 } · 마지막 람다는 괄호 밖 실행 출력
람다가 돌려주는 값 식 하나거나 return 본문 마지막 식 실행 출력
지역 변수 캡처 effectively final 만 var 도 바꾼다. 변수를 붙들어 나중 값이 보인다 javac 에러 · 실행 출력
메서드 참조 Util::f · User::new ::f · ::User 실행 출력
자바 SAM 인터페이스에 람다 된다 된다(SAM 변환) 실행 출력
코틀린 인터페이스에 람다 — fun interface 만 된다 kotlinc 에러
자바에서 (Int) -> Unit 받기 return Unit.INSTANCE; 가 필요하다 — javac 에러 · 실행 출력
람다 안 return 람다만 끝낸다 inline 함수에 넘긴 람다면 바깥 함수를 끝낸다. 아니면 에러 kotlinc 에러 · 스택 트레이스
람다 객체 — 캡처 없으면 재사용 · 캡처하면 매번 새로 · inline 이면 없음 === · 스택 트레이스
캡처한 var 배열 한 칸 등으로 손수 Ref$IntRef 상자에 담긴다 리플렉션 출력

관련 항목

함수를 값으로 다루는 문법

람다 · 함수 타입 · 고차 함수 · 트레일링 람다 · 익명 함수 · 일급 함수

람다가 바깥 변수를 붙드는 방식

캡처 · 클로저 · effectively final · AtomicInteger · 경쟁 상태

람다 대신 넘기는 참조

함수 참조 · 메서드 참조 · 생성자 참조 · 바운드 참조

자바 인터페이스와 람다를 잇는 장치

SAM 변환 · 함수형 인터페이스 · fun interface · 인터페이스 · Unit · Runnable · Comparator

람다를 호출부에 펼치는 방식

inline 함수 · 비지역 반환 · noinline · crossinline · reified · 인라이닝

람다를 받는 컬렉션 연산

filter · map · forEach · Sequence · 자바 스트림 · 지연 평가 · 컬렉션

람다가 JVM 에서 치르는 비용

LambdaMetafactory · 객체 할당 · 가비지 컬렉션 · JIT 컴파일 · 스택 트레이스 · 리플렉션