devlog of ShinJe Kim

[Kotlin] 고차원 함수와 람다(Higher-Order Function and Lambdas)

|

코틀린 함수는 1급 객체입니다. 이것은 변수나 데이터 구조에 저장되어 인자로 전달될 수 있고, 다른 고차원 함수의 리턴값으로 사용될 수 있다는 의미입니다.

정적 타입을 사용하는 코틀린은 function types를 사용하여 함수를 나타내고 람다 식과 같은 특별한 언어 구조를 지원합니다.

고차원 함수(Higher-Order Functions)

고차원 함수는 함수를 인자로 받거나 리턴값으로 사용할 수 있는 함수를 의미합니다. 컬렉션 함수의 functional programming idiom fold가 좋은 예입니다. accumulator의 초기값과 결합 함수를 사용하여 현재 accumulator 값을 각 컬렉션 요소와 연속적으로 결합하여 accumulator를 대체한 리턴 값을 만듭니다. (which takes an initial accumulator value and a combining function and builds its return value by consecutively combining current accumulator value with each collection element, replacing the accumulator)

fun <T, R> Collection<T>.fold(
    initial: R,
    combine: (acc: R, nextElement: T) -> R
): R {
    var accumulator: R = initial
    for (element: T in this) {
        accumulator = combine(accumulator, element)
    }
    return accumulator
}

위의 예제코드에서 combine 파라미터의 타입은 (R, T) -> R이라는 함수입니다. 따라서 combine은 RT 두 가지를 인자로 받으며 R 타입을 리턴합니다. combine은 for 루프에서 호출되어 accumulator에 리턴 값이 할당됩니다.

fold를 호출하기 위해서는 함수 타입의 인스턴스를 인자로 전달 해야만하는데, 람다식(아래에서 더 자세히 설명)은 이를 위해 고차원 함수를 호출하는 곳에서 주로 사용됩니다.

val items = listOf(1, 2, 3, 4, 5)

// 람다는 중괄호 안의 코드블록을 의미합니다.
items.fold(0, {
    acc: Int, i: Int ->
        print("acc = $acc, i = $i, ")
    val result = acc + i
    println("result = $result")
    result
})

// 람다의 파라미터 타입은 (유추할 수 있다면) 생략가능합니다.
val joinedToString = items.fold("Elements:", { acc, i -> acc + " " + i })

// 고차원 함수 호출에서도 함수 레퍼런스를 사용할 수 있습니다.
val product = items.fold(1, Int::times)

함수 타입(Function types)

코틀린은 (Int) -> String 과 같이 함수를 선언하는 방식을 사용합니다: val onClick: () -> Unit = ...

이러한 합수 타입에는 함수의 서명, 즉 파라미터와 리턴 값을 표현하는 특수한 표기법이 있습니다.

  • 모든 함수 유형에는 괄호로 묶인 파라미터 타입 리스트와 리턴 타입 리스트가 있습니다. (A, B) -> CA 타입과 B 타입의 파라미터를 받아 C 타입의 값을 리턴하는 함수를 의미합니다. 파라미터 타입 리스트는 () -> A와 같이 생략할 수도 있지만, Unit return type은 생략할 수 없습니다.
  • 함수 타입은 선택적으로 추가적인 수신(receiver) 객체 타입을 가질 수 있으며 다음과 같이 dot(.) 앞에 표기합니다: A.(B) -> C 이 함수는 파라미터 B를 전달받는 리턴 값 CA 수신 객체에서 호출합니다. function literals with receiver(수신 객체 지정 함수 리터럴)는 주로 이러한 유형의 함수에 사용됩니다.
  • Suspending functions(지연 함수)suspend () -> Unit 혹은 suspend A.(B) -> C와 같이 표기법에 지연 수정자(suspend modifier)가 있는 특수한 함수 유형에 속합니다.

함수 유형 표기법은 다음과 같이 선택적으로 파라미터의 이름을 포함할 수 있습니다: (x: Int, y: Int) -> Point. 이러한 이름은 파라미터의 의미를 문서화하는데 사용될 수 있습니다.

// 함수가 null이 될 수 있는 타입이면 아래와 같이 표기합니다.
((Int, Int) -> Int)?

// 함수 타입은 중괄호를 사용하여 결합할 수 있습니다. 
(Int) -> ((Int) -> Unit)

(Int) -> (Int) -> Unit //이 코드는 위의 예제와 동일한 의미입니다.
((Int) -> (Int)) -> Unit // 이 코드는 위의 예제와 다른 의미입니다. 

아래와 같이 typealias를 이용하여 함수의 타입을 지정할 수도 있습니다.

typealias ClickHandler = (Button, ClickEvent) -> Unit

함수 타입을 인스턴스화 하기

함수 타입의 인스턴스를 얻는 방법에는 여러가지가 있습니다.

  • 함수 리터럴 내부의 코드 블록을 사용하여 아래 중 하나의 방식으로 할 수 있습니다.

    수신자(receiver)가 있는 함수 리터럴(function literals with receiver)은 수신자가 있는 함수 타입의 값으로 사용될 수 있습니다.

  • 기존 선언에 대한 호출가능한 참조를 사용하는 방법은 아래와 같습니다.
    • 최상위(top-level), 지역(local), 멤버(member), 혹은 확장 함수(extension function): ::isOdd, String::toInt,
    • 최상위(top-level), 멤버(member), 혹은 확장 프로퍼티(extension property): List<Int>::size,
    • 생성자(constructor): ::Regex

    여기에는 특정 인스턴스의 멤버를 가리키는 바인딩 된 호출 가능 참조(bound callable references)가 포함됩니다: foo::toString.

  • 함수 타입을 인터페이스로 구현하는 사용자 정의 클래스 사용하면 아래와 같습니다.
class IntTransforner: (Int) -> Int {
    override operator fun invoke(x: Int) : Int = TODO()
}

val intFunction: (Int) -> Int = IntTransformer()

아래와 같이 타입에 관한 정보가 있다면 컴파일러는 함수의 타입을 유추할 수 있습니다.

val a = { i: Int -> i + 1 } // 유추된 타입은 (Int) -> Int 입니다.

수신자가 있거나 없는 함수 타입의 non-literal 값은 서로 교환이 가능하므로, 수신자가 첫 번째 인자를 사용할 수 있으며 그 반대로도 가능합니다. 예를 들면, (A, B) -> C 타입의 값은 A.(B) -> C가 예상되는 곳이나 다른 방법으로 전달/할당 될 수 있습니다.

val repeatFun: String.(Int) -> String = { times -> this.repeat(times) }
val twoParameters: (String, Int) -> String = repeatFun // OK

fun runTransformation(f: (String, Int) -> String): String {
    return f("hello", 3)
}
val result = runTransformation(repeatFun) // OK

확장 함수를 참조하여 변수를 초기화하더라도, 수신자가 없는 함수 타입이 디폴트로 유추됩니다. 이를 변경하기 위해서는 변수 타입을 명시적으로 지정해야만 합니다.

함수형 인스턴스 호출(Invoking a function type instance)

함수 유형의 값은 invoke(…) 연산자를 사용하여 호출할 수 있습니다: f.invoke(x) 혹은 f(x)

값에 수신자 타입이 있으면 수신 객체가 첫 번째 인자로 전달되어야 합니다. 수신자와 함수 타입의 값을 함께 호출하는 다른 방법은, 해당 값이 확장 함수인것처럼 다음과 같이 수신 객체 앞에 추가하는 것입니다: 1.foo(2)

val stringPlus: (String, String) -> String = String::plus
val intPlus: Int.(Int) -> Int = Int::plus

println(stringPlus.invoke("<-", "->"))
println(stringPlus("Hello, ", "workd!"))

println(intPlus.invoke(1, 1))
println(intPlus(1, 2))
println(2.intPlus(3)) // 확장함수처럼 호출

인라인 함수(Inline functions)

때로는 고차원 함수가 유연한 제어 흐름을 가질 수 있도록 하는 인라인 함수를 쓰는 것이 좋습니다.

람다식과 익명 함수(Lambda Expressions and Anonymous Functions)

람다식과 익명 함수는 ‘함수 리터럴(function literals)’입니다. 함수는 선언되지 않고 표현식으로 즉시 전달됩니다. 아래의 예시를 보겠습니다.

max(strings, { a, b -> a.length < b.length })

max 함수는 고차원함수로서 함수 값을 두번째 인자로 가집니다. 두번째 인자는 함수 리터럴인데, 그 자체가 함수인 표현식입니다. 두번째 인자는 아래의 함수와 그 의미가 같다고 볼 수 있습니다.

fun compare(a: String, b: String): Boolean = a.length < b.length

람다 표현식(Lambda expression syntax)

람다식의 전체 구문 형태는 아래와 같습니다.

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

람다식은 항상 중괄호{}로 묶습니다. 전체 구문 형태(full syntactic form)로 선언된 파라미터는 중괄호 안에 들어가며, and have optional type annotations, 본문은 -> 기호 뒤에 옵니다. 람다의 리턴 타입이 Unit타입이 아닌 경우, 람다 본문 내부의 가장 마지막 표현식 혹은 단일 표현식이 람다의 리턴값으로 처리됩니다.

위의 예제 코드에서 생략 가능한 코드를 모두 생략한다면 아래와 같은 형식이 됩니다.

val sum = { x, y -> x + y }

람다를 마지막 매개변수로 전달하기(Passing a lambda to the last parameter)

코틀린에서는 함수의 마지막 매개변수가 함수이면 해당 인수로 전달된 람다식을 괄호 밖에 넣을 수 있습니다.

val product = items.fold(1) { acc, e -> acc * e }

이러한 문법을 trailing lambda라고 부릅니다.

만약 함수를 호출할 때 람다식이 유일한 인자이면, 소괄호 전체를 생략해도됩니다.

run { println("...") }

it: implicit name of a single parameter

람다식의 매개변수는 주로 한 개입니다. 컴파일러가 시그니처 자체를 알아낼 수 있는 경우에는 유일한 매개변수 이름을 선언하지 않고 ->를 생략할 수 있습니다. 이 때 매개변수는 it이라는 이름으로 암시적으로(implicitly) 선언될 수 있습니다.

ints.filter { it > 0 } // 이 리터럴은 '(it: Int) -> Boolean' 타입입니다.

람다식에서 값을 반환하기(Returning a value from a lambda expression)

qualified return 구문을 사용하여 람다에서 값을 명시적으로 리턴할 수 있습니다. 그렇지 않으면 마지막 표현식의 값이 암시적으로(implicitly) 리턴됩니다. 따라서 아래의 두 코드는 동일합니다.

ints.filter {
    val shouldFilter = it > 0
    shoulFilter
}

ints.filter {
    val shouldFilter = it > 0
    return@filter shouldFilter
}

이러한 규칙은 소괄호 밖에서 람다식을 전달하는 것과 더불어 LINQ 스타일의 코드를 사용할 수 있게 해줍니다.

strings.filter { it.length == 5 }.sortedBy { it }.map { it.toUpperCase() }

사용하지 않는 변수는 언더스코어로 표현하기(Underscore for unused variables/코틀린 1.1부터)

만약 람다식에 사용되지 않는 매개변수가 있다면, 변수명 대신 언더스코어(_)를 사용할 수 있습니다.

map.forEach { _, value -> println("$value!")}

Destructuring in lambdas(코틀린 1.1부터)

destructuring declarations에서 확인할 수 있습니다.

익명 함수(Anonymous functions)

코틀린에서는 함수의 리턴 타입을 지정할 수 있습니다. 대부분의 경우에는 리턴 타입을 자동으로 유추 할 수 있으므로 굳이 필요하지 않습니다. 그러나 명시 적으로 리턴 타입을 지정해야하는 경우에는 익명 함수(Anonymous functions)를 사용할 수 있습니다.

fun(x: Int, y: Int): Int = x + y

익명 함수는 함수의 이름이 생략된다는 점을 제외하면 일반적인 함수 선언과 매우 비슷합니다. 본문에는 식 또는 블록(구문) 모두 사용할 수 있습니다.

fun(x: Int, y: Int): Int {
    return x + y
}

매개변수의 타입을 유추할 수 있다면 매개변수 타입을 생략할 수 있다는 점을 제외하면, 일반 함수 선언과 동일하게 매개변수와 리턴 타입을 선언할 수 있습니다.

ints.filter(fun(item) = item > 0)

익명 함수의 리턴 타입을 유추하는 과정은 일반 함수와 동일합니다: 본문이 식(expression) 익명 함수의 리턴 타입은 자동적으로 유추될 수 있습니다. 본문이 블록/문(block body/statement)인 익명 함수의 리턴 타입은 명시적으로 지정되어야 하며 그렇지 않으면 Unit으로 가정됩니다.

익명 함수의 매개변수는 항상 소괄호 안에서 전달되며, 함수를 소괄호 밖에 둘 수 있는 경우는 람다식에만 적용된다는 사실을 주의해야합니다.

람다식과 익명 함수의 또 다른 차이점은 둘 다 비지역 반환을 한다는 것입니다. label이 없는 return문은 항상 fun 키워드로 선언된 함수에서 반환됩니다. 즉, 람다식 내부의 return은 식을 둘러싸는 함수 안에서 반환되고, 익명함수의 return은 익명 함수 자기 자신에서 반환됩니다.

클로저(Closures)

람다식 또는 익명 함수(지역 함수-local function 혹은 객체식-object expression)는 해당 클로저(외부에 선언된 변수) 에 접근할 수 있습니다. 클로저로 접근한 변수(the variables captured in the closure)는 람다에서 수정할 수 있습니다.

var sum = 0
ints.filter { it > 0 }.forEach {
    sum += it
}
print(sum)

리시버가 있는 함수 리터럴(Function literals with receiver)

A.(B) -> C와 같이 리시버가 있는 함수 타입(function types)은 ‘리시버가 있는 함수 리터럴(function liteals with receiver)’이라는 특수한 형식의 함수 리터럴로 인스턴스화 할 수 있습니다.

위에서 언급했듯이 코틀린은 리시버에 수신객체(receiver object) 를 제공하여 함수 타입의 인스턴스를 호출할 수 있도록 합니다.

함수 리터럴의 본문 안에서, 호출되어 전달된 수신 객체는 암시적인 this가 됩니다. 따라서 추가적인 qualifier없이 수신 객체의 멤버에 접근할 수 있으며 this 표현식으로 수신 객체에 접근할 수 있습니다.

이러한 동작 방식은 확장 함수와 유사하며, 함수 본문 내에서 수신 객체의 멤버에 접근할 수도 있습니다.

다음은 타입이 존재하며 리시버가 있는 함수 리터럴의 예시입니다. 이곳에서 수신 객체로 plus가 호출됩니다.

val sum: Int.(Int) -> Int = { other -> plus(other) }

익명 함수를 사용하면 함수 리터럴의 리시버 타입을 직접 지정할 수 있습니다. 리시버로 함수 타입의 변수를 선언한 뒤 나중에 사용해야 할 때 유용하게 쓸 수 있습니다.

val sum = fun Int.(other: Int): Int = this + other

리시버 타입이 유추될 수 있는 경우에는 람다식을 리시버가 있는 함수 리터럴(function literals with receiver)로 사용할 수 있습니다. 이를 활용하는 방법 중 가장 중요한 예제는 type-safe builder입니다.

class HTML {
    fun body() { ... }
}

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()  // 수신 객체를 생성함.
    html.init()        // 수신 객체를 람다에 전달함.
    return html
}

html {       // 이곳부터 수신자가 있는 람다가 시작됨.
    body()   // 수신 객체에서 메소드를 호출함.
}

Comments