devseop08 님의 블로그

[API] 3. 고차 함수 본문

Language/Kotlin

[API] 3. 고차 함수

devseop08 2025. 7. 14. 02:08

고차 함수: 람다를 파라미터 반환값으로 사용

10.1. 다른 함수를 인자로 받거나 반환하는 함수 정의: 고차 함수

  • 고차 함수는 다른 함수를 인자로 받거나 함수를 반환하는 함수다.
  • 코틀린에서는 람다나 함수 참조를 사용해 함수를 값으로 표현할 수 있다.
  • 고차 함수를 사용하는 법에 더해 이제는 고차 함수를 정의하는 방법을 살펴본다.
  • 고차 함수를 정의하려면 함수 타입을 먼저 알아야 한다.

10.1.1 함수 타입은 람다의 파라미터 타입과 반환 타입을 지정한다.

  • 람다를 인자로 받는 함수를 정의하려면 먼저 람다 파라미터의 타입을 어떻게 선언할 수 있는지 알아야 한다. (코틀린 타입 중 함수 타입이라는 것이 있고, 이 함수 타입을 어떻게 선언할 수 있는지 알아야 한다. )
  • 파라미터 타입을 정의하기 전에 더 단순한 경우로 람다를 로컬 변수에 대입하는 경우를 보자.
  • 코틀린 타입 추론에 의해 변수의 타입을 지정하지 않고도 람다를 변수에 대입할 수 있다.
val sum = { x: Int, y: Int -> x + y }
val action = { println(42) }
  • 컴파일러는 sum과 action이 함수 타입임을 추론한다.
val sum: (Int, Int) -> Int = { x: Int, y: Int -> x + y }
val action: () -> Unit = { println(42) }
  • 함수 타입 문법(함수 타입 지정 형식)
(Int, String) -> Unit
  • Unit 타입은 의미있는 값을 반환하지 않는 함수 반환 타입에 쓰는 특별한 타입인데 함수의 반환 타입으로 쓰일 때는 생략이 가능했지만 함수 타입을 선언할 때는 반드시 Unit을 명시해줘야 한다.

  • 함수 타입 변수에 대입되는 람다식 안에서 { x, y -> x + y}처럼 x와 y의 타입을 생략해도 된다.

  • 변수 선언의 일부분인 함수 타입 안에 파라미터 타입을 지정했기 때문에 람다 자체에서는 파라미터 타입을 굳이 지정할 필요가 없다.

  • 함수 타입과 널

    • 다른 함수와 마찬가지로 함수 타입에서도 반환 타입을 널이 될 수 있는 타입으로 지정 가능

      var canReturnNull: (Int, Int) -> Int? = { x, y -> null }
    • 함수 타입 변수 자체를 널이 될 수 있는 타입으로 선언할 수도 있다.

      var funOrNull: ((Int, Int) -> Int)? = null

10.1.2 인자로 전달 받은 함수 호출

fun twoAndThree(operation: (Int, Int) -> Int) {
    val result = operation(2, 3) // 인자로 전달 받은 함수 호출
    println("The result is $result")
}

fun main() {
    twoAndThree{ a, b -> a + b } // 람다(식)를 인자로 전달
    // The result is 5
    twoAndThree{ a, b -> a * b } // 람다(식)를 인자로 전달
    // The result is 6
}
  • String에 대한 filter 확장 함수 선언: 함수 타입 파라미터 선언 이용
  • 술어 함수(람다)를 파라미터 받는 filter 함수 정의
fun String.filter(predicate: (Char) -> Boolean): String
fun String.filter(predicate: (Char) -> Boolean) : String {
    return buildString{
        for(char in this@filter){ this: StringBuilder가 아닌 String
            if(predicate(char)) append(char) // this: StringBuilder, this.append 
        }     
    }
}

fun main() {
    println("ab1c".filter{ it in 'a'..'z' })
    // abc
}
  • 확장 함수와 buildString 함수 모두 수신 객체를 정의하기 때문에 this 앞에 레이블을 붙여 buildString 람다의 수신 객체가 아니라 filter의 바깥쪽 수신 객체(입력 문자열)에 접근해야 한다.

    10.1.3 자바에서 코틀린 함수 타입 사용

  • 자동 SAM 변환을 통해 코틀린 람다를 함수형 인터페이스를 요구하는 자바 메서드에 넘길 수 있다.

  • 함수 타입을 사용하는 코틀린 코드도 자바에서 쉽게 호출 가능

/* 코틀린 선언 */
fun processTheAnswer(f: (Int) -> Int)) {
    println(f(42))
}

/* 자바 호출 */
processTheAnswer(n -> n + 1);
// 43
  • Unit을 반환하는 함수나 람다를 자바로 작성할 수도 있지만 코틀린 Unit 타입에는 값이 존재하므로 자바에서는 그 값을 명시적으로 반환해줘야 한다.
  • (String) -> Unit처럼 반환 타입이 Unit인 함수 타입의 파라미터 위치에 void를 반환하는 자바 람다를 넘길 수는 없다.

함수 타입: 자세한 구현

  • 내부에서 코틀린 함수 타입은 일반 인터페이스다.
  • 함수 타입의 변수는 FunctionN 인터페이스를 구현한다.
  • invoke라는 유일한 메서드가 정의돼있다.
interface Function1<P1, R> {
    operator fun invoke(p1: P1): R
}

interface Function2<P1, P2, R> {
    operator fun invoke(p1: P1, p2: P2): R
}
  • 함수 타입의 변수는 함수에 대응하는 FunctionN 인터페이스를 구현하는 클래스 인스턴스에 대한 참조이다.
  • invoke 메서드에는 람다 본문이 들어간다.
fun processTheAnswer(f: Function1<Int, Int>) {
    println(f.invoke(42))
}
  • 함수 타입 상속
class Adder: Function2<Int, Int, Int> { 
    override operator fun invoke(p1: Int, p2: Int): Int{
        return p1 + p2
    }
}
class Adder: (Int, Int) -> Int { 
    override operator fun invoke(p1: Int, p2: Int): Int{
        return p1 + p2
    }
}

10.1.4 함수 타입의 파라미터에 대해 기본값을 지정할 수 있고, 널이 될 수도 있다.

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

    for((index, element) in this.withIndex()){ // this 키워드는 수신 객체,
        if(index > 0) result.append(separator) // 즉 T 타입의 요소로 이루어진 
        result.append(element)                 // 컬렉션을 가리킨다. 
    }

    result.append(postfix)
    return result.toString()
}
  • 이 구현은 핵심 요소인 컬렉션의 각 원소를 문자열로 변환하는 방법을 제어할 수 없다는 단점이 있다.
  • 원소를 문자열로 바꾸는 방법을 람다로 전달하면 된다.
  • joinToString 함수를 호출할 때마다 매번 람다를 넘기게 만들면 함수 호출을 더 불편하게 만들 수도 있다.
  • 함수 타입의 파라미터에 대한 기본값으로 람다식을 대입해 놓음으로써 이런 문제를 해결할 수 있다.
fun <T> Collection<T>.joinToString( // Collection<T>에 대한 확장 함수 선언
    separator: String=", ",
    prefix: String="",
    postfix: String="",
    transform: (T) -> String = { it.toString() } // 함수 타입 파라미터를 선언, 
): String {                                    // 람다를 기본값으로 지정
    val result = StringBuilder(prefix)

    for((index, element) in this.withIndex()){ // this 키워드는 수신 객체,
        if(index > 0) result.append(separator) // 즉 T 타입의 요소로 이루어진 
        result.append(transform(element))      // 컬렉션을 가리킨다. 
    }

    result.append(postfix)
    return result.toString()
}
fun main() {
    val letters = listOf("Alpha", "Beta")
    println(letters.joinToString())
    // Alpha, Beta
    println(letters.joinToString{ it.lowercase() })
    // alpha, beta
    println(letters.joinToString(separator="! ", postfix="! ",
                transform= { it.uppercase() }))
    // ALPHA! BETA!
}
  • 널이 될 수 있는 함수 타입을 파라미터로 사용할 수도 있다.
fun foo(callback: (() -> Unit)? ) {
    if(callback != null) {
        callback()
    }
}
  • 널이 될 수 있는 함수 타입의 변수에 대해서 안전한 호출 구문(?., ?:)으로 invoke 함수를 안전하게 호출할 수 있다. (callback?.invoke())
fun <T> Collection<T>.joinToString( // Collection<T>에 대한 확장 함수 선언
    separator: String=", ",
    prefix: String="",
    postfix: String="",
    transform: ((T) -> String)? = null // 함수 타입 파라미터를 선언, 
): String {                                    // 람다를 기본값으로 지정
    val result = StringBuilder(prefix)

    for((index, element) in this.withIndex()){ 
        if(index > 0) result.append(separator) 
        result.append(transform?.invoke(element) ?: element.toString() ) 
    } // 안전한 호출을 사용해 함수를 호출, 엘비스 연산자를 사용해
      // 람다를 지정하지 않은 경우를 처리

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

10.1.5 함수를 함수에서 반환

  • 사용자가 선택한 배송 수단에 따라 배송비를 계산하는 방법이 달라질 수 있다.
  • 이럴 때 적절한 로직의 함수를 반환하는 함수를 선언해 사용할 수 있다.
enum class Delivery { STANDARD, EXPIRED }

class Order(val itemCount: Int)

fun getShippingCostCalculator(delivery: Delivery): (Order) -> Double {
    if(delivery == Delivery.EXPIRED){
        return { order -> 6 + 2.1 * order.itemCount } // 함수에서 람다 반환
    }
    return { order -> 1.2 * order.itemCount }
}

fun main() {
    val calculator = getShippingCostCalculator(Delivery.EXPIRED)
    print("Shipping consts ${calculator(Order(3))}")
    // Shipping costs 12.3
}

10.1.6 람다를 활용해 중복을 줄여 코드 재사용성 높이기

  • 람다를 사용할 수 없는 환경에서는 아주 복잡한 구조를 만들어야만 피할 수 있는 코드 중복도 람다를 활용하면 간결하고 쉽게 제거 가능
  • 웹 사이트 방문 기록을 분석하는 예
data class SiteVisit(
    val path: String, 
    val duration: Double, 
    val os: OS
)

enum class OS {WINDOWS, LINUX, MAC, IOS, ANDROID}

val log = listOf(
    SiteVisit("/", 34.0, OS.WINDOWS),
    SiteVisit("/", 22.0, OS.MAC),
    SiteVisit("/login", 12.0, OS.WINDOWS),
    SiteVisit("/signup", 8.0, OS.IOS),
    SiteVisit("/", 16.3, OS.ANDROID)
)
  • 윈도우 사용자의 평균 방문 시간을 출력하기: 사이트 방문 데이터를 하드 코딩
val averageWindowDuration = log
    .filter { it.os == OS.WINDOWS }
    .map(SiteVisit::duration)
    .average()

fun main() {
    println(averageWindowDuration)
}
  • 일반 함수(확장 함수)를 통해 중복 제거: 운영체제 타입 데이터를 파라미터화
fun List<SiteVisit>.averageDurationFor(os: Os) = 
    filter{ it.os == os }.map(SiteVisit::duration).average()

fun main() {
    println(log.averageDurationFor(OS.WINDOWS))
    println(log.averageDurationFor(OS.MAC))
}
  • 위의 일반 함수로는 다른 상황에서는 코드를 사용할 수 없고 또 다른 함수 선언 필요

  • 모바일 환경 접속자 평균 시간을 구한다면 모바일 환경일 때의 운영체제 타입 데이터는 OS.IOSOS.ANDROID이므로 현재 선언한 일반 함수로는 대응 불가

  • 모바일 환경 접속자 평균 시간을 구하기 위한 또다른 함수 선언 필요

  • 운영체제 타입 데이터를 파라미터화 하는 것이 아니라, 평균을 구할 방문 데이터를 추출(filter)하는 동작 자체를 파라미터화하여 이를 위한 함수 타입 파라미터를 선언하는 고차 함수를 선언하면 코드 중복을 최대한으로 제거할 수 있다.

  • 편리하게 원하는 대로 다양한 기준으로 방문 데이터를 추출할 수 있게 된다.

fun List<Sitevisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
    filter(predicate).map(SiteVisit::duration).average()

fun main() {
    println(
        log.averageDurationFor{
            it.os in setOf(OS.ANDROID, OS.IOS)
        }
    )

    println(
        log.averageDurationFor{
            it.os == OS.IOS && it.path == "/signup"
        }
    )
}
  • 코드 중복을 줄일 때 함수 타입이 상당히 도움이 된다.

  • 람다를 사용하면 데이터의 반복을 추출할 수 있을 뿐만 아니라 반복적인 행동도 추출할 수 있다.

    10.2 인라인 함수를 사용해 람다의 부가 비용 없애기

  • 코틀린은 보통 람다를 익명 클래스로 컴파일

  • 람다식마다 새로운 클래스가 생기고 람다가 변수를 캡처한 경우 람다 정의가 포함된 코드를 호출하는 시점마다 새로운 객체가 생긴다.

  • 이로 인해 부가 비용이 들고 람다를 사용하는 구현은 똑같은 코드를 직접 실행하는 함수보다 덜 효율적이다.

  • 반복되는 코드를 별도의 라이브러리 함수로 빼내되 직접 실행될 때만큼 효율적인 코드를 컴파일러가 생성하게 만들 수 있다.

  • inline 변경자를 어떤 함수에 붙이면 컴파일러는 그 함수가 쓰이는 위치에 함수 호출을 생성하는 대신에 함수를 구현하는 코드로 바꿔치기 해준다.

10.2.1 인라이닝이 작동하는 방식

  • 어떤 함수를 inline으로 선언하면 그 함수의 본문이 인라인된다.
  • 즉, 함수를 호출하는 코드를 함수를 호출하는 바이트 코드 대신에 함수 본문을 번역한 바이트 코드로 컴파일한다.
// 인라인 함수 정의
import java.util.concurrent.locks.Lock
import java.util.concurrent.ReentrantLock

inline fun <T> synchronized(lock: Lock, action:() -> T): T {
    lock.lock()
    try{
        return action()
    }finally{
        lock.unlock()
    }
}

fun main(){
    val l = ReentrantLock()
    synchronized(l){
        // ...
    }
}
  • synchronized 함수를 inline으로 선언했으므로 synchronized를 호출하는 코드는 모두 자바의 synchronized문과 같아진다.
fun foo(l: Lock){
    println("Before sync")
    synchronized(l){
        println("Action")
    }
    println("After sync")
}
  • 위 코드는 다음과 같이 컴파일 된다.
fun _foo_(l: Lock) {
    println("Before sync")
    l.lock()
    try{
        println("Action")
    }finally{
        l.unlock()
    }
    println("After sync")
}
  • 중요한 것은 synchronized 함수의 본문 뿐만 아니라 synchronized에 전달된 람다의 본문도 함께 인라이닝된다는 것이다.

  • 람다의 본문에 의해 만들어지는 바이트코드는 그 람다를 사용하는(호출하는) 코드 정의의 일부분으로 간주되기 때문에 코틀린 컴파일러는 그 람다를 함수 인터페이스를 구현하는 익명 클래스로 감싸지 않는다.

  • 인라인 함수를 호출하면서 람다를 넘기는 대신에 함수 타입의 변수를 넘길 수도 있는데

  • 이런 경우엔 인라인 함수를 호출하는 코드 위치에서는 변수에 저장된 람다의 코드를 알 수 없다

  • 따라서 함수 타입의 변수를 넘기는 형태로 인라인 함수를 호출하면 람다 본문은 인라이닝되지 않는다. 대신 synchronized 함수의 본문만 인라이닝된다.

class LockOwmner(val lock: Lock) {
    fun runUnderLock(body: () -> Unit){
        synchronized(lock, body)
    }
}
class LockOwmner(val lock: Lock) {
    fun runUnderLock(body: () -> Unit){
        lock.lock()
        try{
            body()
        }finally{
            lock.unlock()
        }
    }
}
  • 하나의 인라인 함수를 두 곳에서 각각 다른 람다를 사용해 호출한다면 그 두 호출은 각각 따로 인라이닝된다.
  • 인라인 함수의 본문 코드가 호출 지점에 복사되고 각 람다의 본문이 인라인 함수의 본문 코드에서 람다를 사용하는 위치에 복사된다.

10.2.2 인라인 함수의 제약

  • 인라이닝을 하는 방식으로 인해, 람다를 사용하는 모든 함수를 인라이닝 할 수 있는 것은 아니다.
  • 파라미터로 받은 람다를 다른 변수에 저장하고 나중에 그 변수를 사용한다면 람다를 표현하는 객체가 어딘가는 존재해야 하기 때문에 람다를 인라이닝 할 수 없다.
class FunctionStorage {
    val myStoredFunction: ((Int) -> Unit)? = null
    inline fun storeFunction(f: (Int) -> Unit) {
        myStoredFunction = f
    }
}
  • 일반적으로 인라인 함수의 본문에서 람다식을 바로 호출하거나 다른 인라인 함수의 인자로 전달하는 경우에는 그 람다를 인라이닝 할 수 있다.
  • 그런 경우가 아니라면 컴파일러는 "Illegal usage of inline-parameter"라는 메시지와 함께 인라이닝을 금지시킨다.
fun <T,R> Sequence<T>.map(transform: (T) -> R): Sequence<R>{
    return TransformingSequence(this, transform)
    // transform 인자를 함수 표현, 즉 함수형 인터페이스를 구현하는 
    // 익명 클래스 인스턴스로 만들 수 밖에 없다
}
  • 둘 이상의 람다를 인자로 받는 함수에서 일부 람다만 인라이닝하고 싶을 때가 있다.
  • 어떤 람다에 너무 많은 코드가 들어가거나 어떤 람다에 인라이닝을 하면 안 되는 코드가 들어갈 가능성이 있는 경우
  • 인라이닝하면 안 되는 람다를 파라미터로 받는다면 noinline 변경자를 파라미터 이름 앞에 붙여 인라이닝을 금지할 수 있다.
inline fun foo(inlined: () -> Unit, noinline notInlined: () ->Unit)
{...}
  • 자바에서도 코틀린에서 정의한 인라인 함수를 호출할 수 있지만 이런 경우 컴파일러는 인라인 함수를 인라이닝하지 않고 일반 함수 호출로 컴파일한다.

10.2.3 컬렉션 연산 인라이닝

  • 코틀린 컬렉션에 속하는 List 타입 객체는 filter 함수와 map 함수를 호출할 수 있는데 이 두 함수 모두 인라인 함수이다.
public actual interface List<out E> : Collection<E>
public actual interface Collection<out E> : Iterable<E>
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T>
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R>
  • filter 함수에 전달된 람다와 map 함수에 전달된 람다 모두 인라이닝된다.

  • 또한 filter와 map을 연쇄해서 사용하는 경우에도 각각 filter 함수와 map 함수에 전달한 람다가 인라이닝된다.

  • 전달한 람다가 모두 인라이닝되기 때문에 성능 상 이점이 있다고 볼 수도 있지만 filter와 map을 연쇄해서 사용하는 코드는 리스트를 걸러낸 결과를 저장하는 중간 리스트를 만들기 때문에 꼭 그렇지만도 않다.

  • 컬렌션 타입 객체에 대해 filter와 map을 연쇄해서 사용하면 중간 리스트를 만들기 때문에 컬렉션의 처리할 원소가 많아지면 중간 리스트를 사용하는 부가 비용도 커지게 된다.

  • asSequence를 통해 리스트 대신 시퀀스를 사용하면 중간 리스트로 인한 부가 비용을 줄일 수 있다.

  • 하지만 시퀀스에 사용된 람다는 인라이닝되지 않기 때문에 단순히 중간 리스트를 사용하지 않음으로써 성능 향상을 얻을 것이라 기대하여, 즉 지연 계산을 통한 성능 향상만을 기대하여 모든 컬렉션 연산에 asSequence를 붙이려고 해서는 안 된다.

  • 오히려 크기가 작은 컬렉션은 시퀀스 연산보다 일반 컬렉션 연산이 더 성능이 높을 수 있다.

  • 시퀀스를 통해 성능을 향상시킬 수 있는 경우는 컬렉션 크기가 큰 경우뿐이다.

10.2.4 언제 함수를 인라인으로 선언할지 결정

  • 그럼 결국. inline 선언이 가능한 모든 함수들에 대해선 그냥 전부 inline 선언을 하면 좋은 거 아닐까?

  • 그렇지 않다. 이는 좋은 생각이 아니다. inline 키워드를 사용해도 람다를 인자로 받는 함수만 성능이 좋아질 가능성이 높다.

  • 다른 경우엔 애플리케이션의 성능을 프로파일링하고 측정해보면서 따져봐야 한다.

  • 일반 함수 호출의 경우엔 JVM 쪽에서 이미 강력하게 인라이닝을 지원하고 있다.

  • JVM은 코드 실행을 분석해서 가장 이익이 되는 방향으로 호출을 인라이닝한다.

  • 이런 과정은 바이트 코드를 실제 기계어 코드로 번역하는 과정(JIT)에서 일어난다.

  • 바이트 코드에서는 각 함수 구현이 정확히 한 번만 있으면 되고 그 함수를 호출하는 모든 부분에따로 코드를 중복할 필요가 없다.

  • 하지만 코틀린 인라인 함수는 바이트 코드에서 각 함수 호출 지점을 모두 함수 본문으로 대치하기 때문에 코드 중복이 생긴다.

  • 추가로 함수를 직접 호출하면 스택 트레이스가 더 깔끔해지는 장점이 있다.

  • 람다를 인자로 받는 함수를 인라이닝하면 이익이 더 많다.

  • 인라이닝을 통햇 없앨 수 있는 부가 비용이 상당하다. 함수 호출 비용을 줄일 수 있을 뿐 아니라 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체를 만들 필요도 없어진다.

  • 현재의 JVM은 함수 호출과 람다를 인라이닝해줄 정도록 똑똑하지는 못하다.

  • 인라이닝을 사용하면 일반 람다에서는 사용할 수 없는 몇 가지 기능을 사용할 수 있다.

  • inline 변경자를 함수에 붙일 때는 코드 크기에 주의를 기울여야 한다.

  • 인라이닝하는 함수가 큰 경우 함수의 본문에 해당하는 바이트코드를 모든 호출 지점에 복사해 넣으면 바이트 코드가 전체적으로 너무 커질 수 있다.

10.2.5 withLock, use, useLines로 자원 관리를 위해 인라인된 람다 사용

  • 람다로 중복을 없앨 수 있는 일반적인 패턴 중 한 가지는 어떤 작업을 하기 전에 자원을 획득하고 작업을 마친 후 자원을 해제하는 자원 관리다.

  • 자원: 파일, 락, 데이터베이스 트랜잭션 등

  • 자원 관리 패턴을 만들 때 보통 사용하는 방법은 try/finally 문을 사용하되 try 블록을 시작하기 직전에 자원을 획득하고 finally 블록에서 자원을 해제하는 것이다.

  • 자원 획득과 해제 사이의 try 블록에서는 자원을 사용하는 람다 코드가 위치하고 호출될 것이다.

  • withLock은 Lock 인터페이스의 확장 함수로 코틀린 라이브러에 속하며 좀 더 코틀린다운 API를 통해 자바의 synchronized 문과 같은 기능을 제공한다.

inline fun <T> Lock.withLock(action:() -> T): T{
    lock()
    try{
        return action()
    } finally {
        unlock()
    }
}
val l: Lock = ReentrantLock()
l.withLock{
    // 락에 의해 보호되는 자원을 사용, 람다로 표현
}
  • 이런 패턴을 사용할 수 있는 다른 유형의 자원으로 파일이 있다.
  • use 함수는 Closable 인터페이스를 구현 객체에 해당하는 닫을 수 있는 자원에 대해 호출하는 확장 함수다.
  • 이 함수는 람다를 호출하고 사용 후 자원이 확실히 닫히게 한다.
fun readFirstLineFromFile(fileName: String): String {
    BufferedReader(FileReader(fileName)).use {
        br -> return br.readLine()
    }
}
  • use 함수는 inline 함수이다.
  • useLines는 File과 Path 객체에 대해 정의된 확장 함수이고 람다가 문자열 시퀀스에 접근하게 해준다.
fun readFirstLineFromFile(fileName: String): String {
    Path(fileName).useLines {
        return it.first()// 비로컬 return, readFirstLineFromFile 함수를 끝내면서 반환
    }
}

10.3 람다에서 반환: 고차 함수에서 흐름 제어

  • 루프 중간에 있는 return 문의 의미를 이해하기는 쉽다.
  • 하지만 filter와 같이 그 루프를 람다를 호출해 구현하는 함수로 바꾼다면 return문의 의미는 뭘까?

10.3.1 람다 안의 return 문: 람다를 둘러싼 함수에서 반환

  • 일반 루프 안에서 return 사용
data class Person(val name: Strig, val age: Int)

val people = listOf(Person("Alice", 29), Person("Bob", 31))

fun lookForAlice(people: List<Person>) {
    for(person in people) {
        if(person.name == "Alice"){
            println("Fount!")
            return // lookForAlice 함수를 빠져나온다.
        }
    }
    println("Alice is not found")
}

fun main() {
    lookForAlice(people)
    // Found!!
}
  • forEach 함수에 전달된 람다 안에서 return
fun lookForAlice(people: List<Person>) {
    people.forEach {
        if(it.name == "Alice"){
            println("Found!")
            return// 일반 루프 안에서의 return과 똑같이 lookForAlice 함수를 빠져나온다.
        }
    }
    println("Alice is not found")
}
  • 람다 안에서 return을 사용하면 람다에서만 반환되는 것이 아니라 그 람다를 호출하는 함수가 실행을 끝내고 반환된다.
  • 자신을 둘러싸고 있는 블록보다 더 바깥에 있는 다른 블록을 반환하게 만드는 return 문을 비로컬 return이라 부른다.
  • 자바 메서드 안에 있는 for 루프나 synchronized 블록 안에서 return 키워드가 동작하는 것과 같이 코틀린은 람다를 인자로 받는 함수 안에서 쓰이는 return이 같은 의미를 유지하게 한다.
  • 다만 이렇게 return이 바깥쪽 함수를 반환시킬 수 있는 때는 return이 쓰인 람다를 인자로 받는 함수가 인라인 함수인 경우뿐이다.
  • 인라이닝되지 않는 함수에 전달되는 람다 안에서 return을 사용할 수 없다.
  • 인라이닝되지 않는 함수는 람다를 변수에 저장할 수 있기 때문이다.
  • 만약 return을 사용한 람다를 변수에 저장하는 경우, 함수가 반환된 다음에 나중에 람다를 실행하는 상황에서 원래의 함수를 반환시키기에는 너무 늦은 시점일 수 있다.

10.3.2 람다로부터 반환: 레이블을 사용한 return

  • 람다식에서도 로컬 return을 사용할 수 있다.
  • 람다 안에서 return은 for 루프의 break와 비슷한 역할을 한다.
  • 로컬 return은 람다의 실행을 끝내고 람다를 호출했던 코드의 실행을 계속 이어간다.
  • 람다에서의 로컬 return과 비로컬 return을 구분하기 위해 레이블을 사용한다.
// 레이블을 통해 로컬 return 사용하기
fun lookForAlice(people: List<Person>) {
    people.forEach label@{ // 람다식 앞에 레이블을 붙인다.
        if(it.name != "Alice") return@label // 로컬 return: 람다로부터 빠져나온다
        print("Found Alice!")
    }
}
  • 함수 이름을 return 레이블로 사용하기
fun lookForAlice(people: List<Person>) {
    people.forEach { // 이 상황에서 람다식 앞에 레이블을 붙이면 함수 이름 레이블 사용 X
        if(it.name != "Alice") return@forEach
        print("Found Alice")
    }
}

10.3.3 익명 함수: 기본적으로 로컬 return

  • 비로컬 return은 장황하고, 람다 안에 return 식이 여럿 들어가야 하는 경우 사용하기 불편하다.
  • 코틀린은 코드 블록을 전달하기 위한 다른 해법으로 익명 함수를 제공한다.
  • 익명 함수는 다른 함수에 전달할 수 있는 코드 블록을 작성하는 다른 형태인데 람다와 익명 함수는 return 식을 쓰는 방식에서 차이가 있다.
fun lookForAlice(people: List<Person>) {
    people.forEach(fun (person) {
        if(person.name == "Alice") return // 로컬 return: 람다로부터 빠져나온다.
        println("${person.name} is not Alice")
    })
}

fun main() {
    lookForAlice(people)
    // Bob is not Alice
}
  • 익명 함수는 함수 이름을 생략하고 파라미터 타입을 컴파일러가 추론한다.
people.filter(fun (person): Boolean {
    return person.age < 30
})
people.filter(fun (person) = person.age < 30)
  • 사실 return에 적용되는 규칙은 단순히 return은 fun 키워드를 사용해 정의된 가장 안쪽 함수를 반환시킨다는 것이다.
  • 람다식은 fun을 사용해 정의되지 않으므로 람다 본문의 return은 람다 밖의 함수를 반환시킨 것이다.

'Language > Kotlin' 카테고리의 다른 글

[OOP] 5. 제네릭스  (2) 2025.07.21
[OOP] 4. 연산자 오버로딩  (1) 2025.07.14
[API] 2. 컬렉션과 시퀀스  (1) 2025.07.07
[OOP] 3. 기본 타입, 컬렉션, 배열  (1) 2025.06.09
[API] 1. 람다를 사용한 프로그래밍  (3) 2025.06.04