[Kotlin in Action] 8. 고차 함수: 파라미터와 반환 값으로 람다 사용

akim·2023년 1월 11일
0

Kotlin in Action

목록 보기
9/12
post-thumbnail

고차 함수 정의

고차 함수는 다른 함수를 인자로 받거나 함수를 반환하는 함수다.
특히 코틀린에서는 람다나 함수 참조를 사용해 함수를 값으로 표현할 수 있다.

따라서 고차 함수는 람다나 함수 참조를 인자로 넘길 수 있거나 람다나 함수 참조를 반환하는 함수다.


1. 함수 타입

코틀린의 타입 추론으로 인해 변수 타입을 지정하지 않아도 람다를 변수에 대입할 수 있다.

val sum = { x: Int, y: Int -> x + y }
val action = { println(42) }

위의 경우 컴파일러는 sumaction 이 함수 타입임을 추론한다.

만약 각 변수에 구체적인 타입 선언을 추가하면 아래와 같이 쓸 수 있게 된다.

val sum: (Int, Int) -> Int = { x, y -> x + y } //Int 파라미터를 2개 받아서 Int 값을 반환하는 함수
val action: () -> Unit = { println(42) } // 아무 인자도 받지 않고 아무 값도 반환하지 않는 함수

위 코드에서 알 수 있듯,

함수 타입을 정의하려면 함수 파라미터의 타입을 괄호 안에 넣고, 그 뒤에 화살표 -> 를 추가한 다음, 함수의 반환 타입을 지정하면 된다.

Unit 타입은 의미 있는 값을 반환하지 않는 함수 반환 타입에 쓰는 특별한 타입이다.
그냥 함수를 정의한다면 함수의 파라미터 목록 뒤에 오는 Unit 반환 타입 지정을 생략해도 되지만, 함수 타입을 선언할 때는 반환 타입을 반드시 명시해야 하므로 Unit 을 빼먹어서는 안 된다.

이렇게 변수 타입을 함수 타입으로 지정하면 함수 타입에 있는 파라미터로부터 람다의 파라미터 타입을 유추할 수 있다. 따라서 람다 식 안에서 굳이 파라미터 타입을 적을 필요가 없다.
그래서 { x, y -> x + y } 처럼 xy 의 타입을 생략해도 된다.


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

var canReturnNull: (Int, Int) -> Int? = {x, y -> null}

물론 널이 될 수 있는 함수 타입 변수를 정의할 수도 있다.

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

다만 함수의 반환 타입이 아니라 함수 타입 전체가 널이 될 수 있는 타입임을 선언하기 위해 함수 타입을 괄호로 감싸고 그 뒤에 물음표를 붙여야 한다.


2. 인자로 받은 함수 호출

fun twoAndThree(operation: (Int, Int) -> Int) { // 함수 타입인 파라미터를 선언한다.
    val result = operation(2, 3) // 함수 타입인 파라미터를 호출한다.
    println("The result is $result")
}

인자로 받은 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 같다.

위의 filter 함수는 술어를 파라미터로 받는다.
predicate 파라미터는 Char 타입을 파라미터로 받고 Boolean 타입 결과 값을 반환한다.
술어는 인자로 받은 문자가 filter 함수가 돌려주는 결과 문자열에 남아 있기를 바라면 true 를 반환하고, 사라지기를 바라면 false 를 반환하면 된다.

fun String.filter(predicate: (Char) -> Boolean): String {
    val sb = StringBuilder()
    for (index in 0 until length) {
        val element = get(index)
        if (predicate(element)) sb.append(element)
    }
    return sb.toString()
}

filter 는 문자열의 각 문자를 술어에 넘겨서 반환 값이 true 면 결과를 담는 StringBuilder 뒤에 그 문자를 추가한다.


3. 디폴트 값을 지정한 함수 타입 파라미터나 널이 될 수 있는 함수 타입 파라미터

파라미터를 함수 타입으로 선언할 때도 디폴트 값을 정할 수 있다.

fun <T> Collection<T>.joinToString(
        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()
}

위 구현에서는 컬렉션의 각 원소를 문자열로 변환하는 방법을 제어할 수 없다.

위 코드에서는 항상 객체를 toString 메서드를 통해 문자열로 바꾼다. 이 메서드로 충분하지 않을 경우, 원소를 문자열로 바꾸는 방법을 람다로 전달하면 된다.
하지만 joinToString 을 호출할 때마다 매번 람다를 넘기게 만들면 기본 동작으로도 충분한 대부분의 경우 함수 호출을 오히려 더 불편하게 만든다는 문제가 있다.

이 때 함수 타입의 파라미터에 대한 디폴트 값을 지정하면 이런 문제를 해결할 수 있다.

디폴트 값으로 람다 식을 넣어보자.

fun <T> Collection<T>.joinToString(
        separator: String = ", ",
        prefix: String = "",
        postfix: String = "",
        transform: (T) -> String = { it.toString() }
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(transform(element))
    }
    result.append(postfix)
    return result.toString()
}

이 함수는 제네릭 함수다. 따라서 컬렉션의 원소 타입을 표현하는 T 를 타입 파라미터로 받는다. transform 람다는 그 T 타입의 값을 인자로 받는다.

함수 타입이 invoke 메서드를 구현하는 인터페이스라는 사실을 활용하면 이를 더 짧게 만들 수 있다. 일반 메서드처럼 invoke 도 안전 호출 구문으로 callback?.invoke() 처럼 호출할 수 있다.


4. 함수를 함수에서 반환

다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정해야 한다.

함수를 반환하려면 return 식에 람다나 멤버 참조나 함수 타입의 값을 계산하는 식 등을 넣으면 된다.


5. 람다를 활용한 중복 제거

코드 중복을 줄일 때 함수 타입이 상당히 도움이 된다.

코드의 일부분을 복사해 붙여넣고 싶은 경우가 있다면 그 코드를 람다로 만들면 중복을 제거할 수 있을 것이다.

게다가, 일부 잘 알려진 객체 지향 디자인 패턴을 함수 타입과 람다 식을 사용해 단순화할 수 있다.

전략 패턴의 경우 람다 식이 없다면 인터페이스를 선언하고 구현 클래스를 통해 전략을 정의해야 한다. 함수 타입을 언어가 지원하면 일반 함수 타입을 사용해 전략을 표현할 수 있고, 경우에 따라 다른 람다 식을 넘김으로써 여러 전략을 전달할 수 있다.


인라인 함수: 람다의 부가 비용 없애기

5장에서는 코틀린이 보통 람다를 무명 클래스로 컴파일하지만 그렇다고 람다 식을 사용할 때마다 새로운 클래스가 만들어지지는 않는다는 사실을 알아보았다. 또한 람다가 변수를 포획하면 람다가 생성되는 시점마다 새로운 무명 클래스 객체가 생긴다는 사실 또한 우리는 알고 있다.

이러한 경우 실행 시점에 무명 클래스 생성에 따른 부가 비용이 든다.
따라서 람다를 사용하는 구현은 똑같은 작업을 수행하는 일반 함수를 사용한 구현보다 비효율적이다.

코틀린 컴파일러에서는 inline 변경자를 이용해 이를 해결한다.

반복되는 코드를 별도의 라이브러리 함수로 빼내되 컴파일러가 자바의 일반 명령문만큼 효율적인 코드를 생성할 수 있도록 지원하는 것이다. inline 변경자를 어떤 함수에 붙이면 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 *바이트코드로 바꿔치기 해준다.

*코틀린으로 작성한 코드는 JVM에 의해 자바 바이트코드(Java Bytecode)로 변환된다.

이 과정에 대해 더 자세히 알아보자.


1. 인라이닝이 작동하는 방식

어떤 함수를 inline 으로 선언하면 함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신에 함수 본문을 번역한 바이트코드로 컴파일한다.

아래 함수는 다중 스레드 환경에서 어떤 공유 자원에 대한 동시 접근을 막기 위한 것이다.

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

val l = Lock()

synchronized(l) {
	// ... 	
}

synchronized 함수를 inline으로 선언했으므로 synchronized 를 호출하는 코드는 모두 자바의 synchronized 문과 같아진다.

출처: Kotlin in action

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


2. 인라인 함수의 한계

함수가 인라이닝될 때 그 함수에 인자로 전달된 람다 식의 본문은 결과 코드에 직접 들어갈 수 있다. 하지만 이렇게 람다가 본문에 직접 펼쳐지기 때문에 함수가 파라미터로 전달받은 람다를 본문에 사용하는 방식이 한정될 수 밖에 없다.

인라이닝 가능한 경우

일반적으로 인라인 함수의 본문에서 람다 식을 바로 호출하거나 람다 식을 인자로 전달받아 바로 호출하는 경우에는 그 람다를 인라이닝할 수 있다.

인라이닝 불가능한 경우

하지만 파라미터로 받은 람다를 다른 변수에 저장하고 나중에 그 변수를 사용한다면 람다를 표현하는 객체가 어딘가에는 존재해야 하기 때문에 람다를 인라이닝할 수 없다.

따라서 위에서 본 인라이닝 가능한 경우가 아니라면 컴파일러는 Illegal usage of inline-parameter 라는 메시지와 함께 인라이닝을 금지시킨다.

인라이닝하면 안 되는 람다를 파라미터로 받는 경우

noinline 변경자를 파라미터 이름 앞에 붙여서 인라이닝을 금지할 수 있다.


3. 컬렉션 연산 인라이닝

코틀린 표준 라이브러리의 컬렉션 함수는 대부분 람다를 인자로 받는다. 표준 라이브러리 함수를 사용하지 않고 직접 이런 연산을 구현한다면 더 효율적일 것이다.

다만 시퀀스는 람다를 저장해야하므로 람다를 인라인하지 않는다.

따라서 지연 계산을 통해 성능을 향상시키려는 이유로 모든 컬렉션 연산에 asSequence 를 붙여서는 안된다.

시퀀스 연산에서는 람다가 인라이닝되지 않기 때문에 크기가 작은 컬렉션은 오히려 일반 컬렉션 연산이 더 성능이 나을 수도 있다. 시퀀스를 통해 성능을 향상시킬 수 있는 경우는 컬렉션 크기가 큰 경우뿐이다.


4. 함수를 인라인으로 선언해야 하는 경우

inline 함수를 사용해도 대부분은 람다를 인자로 받는 함수만 성능이 좋아질 가능성이 높다.

일반 함수 호출의 경우 JVM은 이미 강력하게 인라이닝을 지원한다. JVM이 지원하는 최적화를 활용한다면 바이트코드에서는 각 함수 구현이 정확히 한 번만 있으면 되고, 그 함수를 호출하는 부분에서 따로 함수 코드를 중복할 필요가 없다.

반면 코틀린 인라인 함수는 바이트 코드에서 각 함수 호출 지점을 함수 본문으로 대치하기 때문에 코드 중복이 생긴다. 게다가 함수를 직접 호출하면 스택 트레이스가 더 깔끔해진다.


람다를 인자로 받는 함수를 인라이닝하는 경우에는 이익이 더 많다.

  • 인라이닝을 통해 없앨 수 있는 부가 비용이 상당하다.
    (함수 호출 비용 감소, 람다를 표현하는 클래스 필요 x, 람다 인스턴스에 해당하는 객체 필요 x)
  • 현재의 JVM은 함수 호출과 람다를 인라이닝해 줄 정도로 똑똑하지는 못하다.
  • 인라이닝을 사용하면 일반 람다에서는 사용할 수 없는 기능을 사용할 수 있다.

고차 함수 안에서 흐름 제어

1. 람다 안의 return문: 람다를 둘러싼 함수로부터 반환

다음 코드의 실행 결과를 보면 이름이 Alice인 경우에 lookForAlice 함수로부터 반환된다는 사실을 분명히 알 수 있다.

data class Person(val name: String, 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("Found!")
            return
        }
    }
    println("Alice is not found")
}

이 코드를 forEach로 바꿔 쓰면 아래와 같아진다.

fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") {
            println("Found!")
            return
        }
    }
    println("Alice is not found")
}

람다 안에서 return을 사용하면 람다로부터만 반환되는 게 아니라 그 람다를 호출하는 함수가 실행을 끝내고 반환된다.

이처럼 자신을 둘러싸고 있는 블록보다 더 바깥에 있는 다른 블록을 반환하게 만드는 return 문을 넌로컬(non-local) return이라 부른다.

이렇게 return이 바깥쪽 함수를 반환시킬 수 있는 때는 람다를 인자로 받는 함수가 인라인 함수인 경우뿐이다. 위 코드에서 forEach는 인라인 함수이므로 람다 본문과 함께 인라이닝된다. 따라서 return 식이 바깥쪽 함수(여기서는 lookForAlice)를 반환시키도록 쉽게 컴파일할 수 있다.


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

람다 식에서도 로컬 return 을 사용할 수 있다. 람다 안에서 로컬 returnfor루프의 break와 비슷한 역할을 한다.

로컬 return넌로컬 return을 구분하기 위해 레이블(label)을 사용해야 한다.

fun lookForAlice(people: List<Person>) {
    people.forEach label@{
        if (it.name == "Alice") return@label
    }
    println("Alice might be somewhere")
}

람다에 레이블을 붙여서 사용하는 대신 람다를 인자로 받는 인라인 함수의 이름을 return 뒤에 레이블로 사용해도 된다.

fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") return@forEach
    }
    println("Alice might be somewhere")
}

3. 무명 함수: 기본적으로 로컬 return

무명 함수는 코드 블록을 함수에 넘길 때 사용할 수 있는 다른 방법이다.

fun lookForAlice(people: List<Person>) {
    people.forEach(fun (person) {
        if (person.name == "Alice") return
        println("${person.name} is not Alice")
    })
}

무명 함수는 일반 함수와 비슷해 보인다. 차이는 함수 이름이나 파라미터 타입을 생략할 수 있다는 점뿐이다.

무명 함수 안에서 레이블이 붙지 않은 return 식은 무명 함수 자체를 반환시킬 뿐 무명 함수를 둘러싼 다른 함수를 반환시키지 않는다.

사실 return 에 적용되는 규칙은 단순히 returnfun 키워드를 사용해 정의된 가장 안쪽 함수를 반환시킨다는 점이다.

람다 식의 구현 방법이나 람다 식을 인라인 함수에 넘길 때 어떻게 본문이 인라이닝 되는지 등의 규칙을 무명 함수에도 모두 적용할 수 있다.

profile
학교 다니는 개발자

0개의 댓글