조용한 담장

코틀린(Kotlin) : 고차함수 와 람다 (Higher-Order Functions and Lambdas) 본문

kotlin

코틀린(Kotlin) : 고차함수 와 람다 (Higher-Order Functions and Lambdas)

iosroid 2020. 1. 2. 17:25

코틀린의 고차함수(higher-order function) 와 람다(lambda)에 대해 살펴보자.

원문 https://kotlinlang.org/docs/reference/lambdas.html 을 보며 정리.

코틀린 함수는 first-class 이다.

이는 함수가 변수나 data structures 에 저장되거나 argument 로 전달되거나 다른 higher-order 함수로 부터 리턴될 수 있다는 의미이다.

이를 위해 코틀린은 statically typed programming language 로써, 함수를 나타내기 위해 함수 타입 (function types)을 사용하며 람다 표현식(lambda expression) 같은 특별한 언어 구성들을 제공한다.

Higher-Order Functions

고차 함수는 함수를 파라미터로 받거나 함수를 리턴하는 함수를 말한다.

예로 collections 와 사용되는 functional programming idiom fold 가 있다.

누산기(accumulator) 의 초기 값을 가지고 함수와 결합(combine)하여 현재 누산기의 값과 각 콜렉션(collection)의 element 들을 결합한 결과를 현재 누산기의 값과 바꾸는 동작을 연속적으로 수행하여 최종 결과 값을 만든다.

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 을 가지고 있고, R, T 타입의 argument 두개를 가지고 R 타입의 값을 리턴하는 함수를 받는다.

이 함수는 for 에서 호출되어 결과 값이 accumulator 에 저장된다.

fold 를 호출하려면 argument 로 함수타입의 인스턴스를 전달해야 하며, 람다 표현식이 주로 쓰인다.

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

    // Lambdas are code blocks enclosed in curly braces.
    items.fold(0, {
        // When a lambda has parameters, they go first, followed by '->'
        acc: Int, i: Int ->
        print("acc = $acc, i = $i, ")
        val result = acc + i
        println("result = $result")
        // The last expression in a lambda is considered the return value:
        result
    })

    // Parameter types in a lambda are optional if they can be inferred:
    val joinedToString = items.fold("Elements:", { acc, i -> acc + " " + i })

    // Function references can also be used for higher-order function calls:
    val product = items.fold(1, Int::times)

    println("joinedToString = $joinedToString")
    println("product = $product")
}

// output:
// acc = 0, i = 1, result = 1
// acc = 1, i = 2, result = 3
// acc = 3, i = 3, result = 6
// acc = 6, i = 4, result = 10
// acc = 10, i = 5, result = 15
// joinedToString = Elements: 1 2 3 4 5
// product = 120

Function types

코틀린은 함수타입을 함수를 다루는 선언을 위해 (Int) -> String 와 같이 사용한다.

val onClick: () -> Unit = ....

아래와 같은 표시법이 있다.

 

  • 모든 함수 타입은 괄호안에 파라미터 타입 리스트를 가지고 한개의 리턴 타입을 가진다: (A, B) -> C 는 argument type A, B 두개를 가지고 C 타입의 값을 리턴하는 함수를 나타낸다. () -> A 처럼 파라미터 타입 리스트는 비어 있을 수도 있다. Unit 리턴 타입은 생략할 수 없다.
  • 함수 타입은 옵션으로 . 을 사용하여 추가적인 receiver type 을 가질 수 있다. A.(B) -> C 타입은 B 타입의 파라미터를 가지고 C 타입의 값을 리턴하는 A receiver 오브젝트의 함수 호출을 나타낸다.
  • Suspending functionssuspend () -> Unitsuspend A.(B) -> C 와 같이 suspend 표시를 가지는 특별한 종류의 함수 타입에 속한다.

함수 타입 표기는 옵션으로 함수 파라미터의 이름을 포함할 수 있다 : (x: Int, y: Int) -> Point

이 이름들은 파라미터의 의미를 문서화 하는데 사용된다.

To specify that a function type is nullable, use parentheses: ((Int, Int) -> Int)?.
Function types can be combined using parentheses: (Int) -> ((Int) -> Unit)
The arrow notation is right-associative, (Int) -> (Int) -> Unit is equivalent to the previous example, but not to ((Int) -> (Int)) -> Unit.

함수 타입에 타입 별명 (type alias) 를 쓸 수도 있다.

typealias ClickHandler = (Button, ClickEvent) -> Unit

Instantiating a function type

함수 타입의 인스턴스를 얻는 방법은 아래처럼 여러가지가 있다.

  • 함수 코드 블럭을 사용하는 방법
    • lambda expression : { a, b -> a + b }
    • anonymous function : fun(s: String): Int { return s.toIntOrNull() ?: 0 }
  • 이미 있는 선언의 callable reference 를 사용하는 방법
    • a top-level, local, member, or extension function: ::isOdd, String::toInt,
    • a top-level, member, or extension property: List<Int>::size,
    • a constructor: ::Regex
    • 이 방법들은 foo::toString 처럼 특정 인스턴스의 멤버를 가리키는 bound callable reference 를 포함한다.
  • 함수 타입을 인터페이스로 구현하는 클래스를 만들어 그것의 인스턴스를 사용하는 방법
class IntTransformer: (Int) -> Int {
    override operator fun invoke(x: Int): Int = TODO()
}

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

컴파일러는 충분한 정보가 주어지면 변수들을 위한 함수 타입을 추론할 수 있다.

val a = { i: Int -> i + 1 } // The inferred type is (Int) -> Int

receiver 가 있거나 없는 함수타입의 non-literal 값들은 교환가능 하며, receiver 는 첫번째 파라미터를 대신하거나 그 반대가 된다.

예를 들어, (A, B) -> C 타입의 값은 A.(B) -> C 가 예상되는 곳으로 전달되거나 할당될 수 있고 그 반대가 될수 도 있다.

fun main() {
    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

    println("result = $result")
}

// output:
// result = hellohellohello

receiver 가 없는 함수타입은 존재하는 함수의 레퍼런스로 초기화 된 변수라 해도 기본적으로 추론이 이루어 지며, 이를 피하려면 명시적으로 변수의 타입을 설정해야 한다.

Invoking a function type instance

함수 타입의 값(value) 은 invoke(...) operator 를 이용하여 사용된다: f.invoke(x) 또는 f(x)

값(value) 이 receiver type 을 가지고 있으면 receiver object 는 첫번째 argument 로 전달되어야 한다.

receiver object 를 값이 확장 함수 인것 처럼 (코드 구조가 비슷하다) 붙여 사용할 수도 있다: 1.foo(2)

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

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

    println(intPlus.invoke(1, 1))
    println(intPlus(1, 2))
    println(2.intPlus(3)) // extension-like call

}

// output:
// <-->
// Hello, world!
// 2
// 3
// 5

Inline functions

경우에 따라 고차함수에 유연한 control flow 를 제공하는 inline function 을 쓰는것이 유리하다.

Lambda Expressions and Anonymous Functions

람다 표현식과 익명 함수는 선언되지 않은 함수이지만 표현식으로써 전달될 수 있는 function literals 이다.

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

max 함수는 고차함수 이고 두번째 argument 에 함수 값을 받는다.

두번째 argument 는 그 자신이 함수이기도 한 표현식이며 아래의 코드와 동등하다.

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

람다 표현식은 항상 { } 에 둘러 싸이고 파라미터 선언들은 그 안에 들어가며 부가적으로 type 표시를 가지며 body 는 -> 뒤에 온다.

람다의 추론된 리턴 타입이 Unit 이 아니면 람다 body 안에 있는 마지막 하나의 표현식이 리턴 타입으로 취급된다.

옵션 표시들을 최대한 생략하면 아래처럼 된다.

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

Passing trailing lambdas

함수의 마지막 파라미터가 함수이면, 람다 표현식이 괄호 바깥에 위치할 수 있는 argument 로써 전달된다.

val product = items.fold(1) { acc, e -> acc * e }
// val product = items.fold(1, {acc, e -> acc * e }) // lambda is last parameter

이를 trailing lambda 라 한다.

람다가 유일한 argument 이면 괄호는 생략될 수 있다.

run { println("...") }

it: implicit name of a single parameter

람다 표현식이 하나의 파라미터만 가지는 경우가 보통이다.

컴파일러가 스스로 signature 를 알아낼 수 있으면 단 하나의 파라미터를 선언하지 않고 -> 도 생략하는게 가능하다.

대신 파라미터는 암묵적으로 it 라는 이름으로 선언된다.

ints.filter { it > 0 } // this literal is of type '(it: Int) -> Boolean'

Returning a value from a lambda expression

qualified return 문법을 사용하여 람다의 결과 값을 명시적으로 리턴 할 수 있다.

그렇지 않으면 마지막 표현식의 값이 암묵적으로 리턴된다.

아래 두개의 예제 코드는 동등하다.

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

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

이런 컨벤션은 passing a lambda expression outside parentheses 에 따라 LINQ-style 코드를 가능하게 한다.

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

Underscore for unused variables (since 1.1)

안쓰이는 람다 파라미터는 이름 대신 _ 로 대체할 수 있다.

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

Anonymous functions

대부분의 케이스에는 자동적으로 추론되는 함수의 리턴타입을 명시하는 것이 필요하지 않지만, 꼭 명시해야 하는 경우라면 익명 함수를 쓰는 방법이 있다.

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

익명 함수는 일반 함수 선언처럼 생겼지만 함수 이름이 생략된다.

body 는 위 예제 코드처럼 expression 으로 쓰거나 아래처럼 블럭을 쓸 수 있다.

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

파라미터들과 리턴 타입은 문맥에서 추론이 가능해서 생략이 가능해지는 파라미터 타입을 제외하고 일반 함수와 같은 방법으로 지정된다.

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

익명 함수의 리턴 타입 추론은 일반 함수와 비슷하게 동작한다.

리턴 타입은 expression body 를 가진 익명 함수를 위해서 자동적으로 추론되고 block body 가지면 명시적으로 지정되어야 한다. (아니면 Unit 으로 지정된다.)

익명함수 파라미터들은 항상 괄호 안으로 전달된다.

함수를 괄호 밖에 위치하게 하는 짧은 문법은 람다 표현식에서만 가능하다.

익명 함수와 람다 표현식의 다른 차이점은 non-local return 의 동작이다.

label 없는 return 구문은 항상 fun 키워드로 선언된 함수로 부터 리턴한다.

이것은 람다 표현식 안에 있는 return 은 자신을 둘러싼 함수 로 부터 리턴할 것이라는 뜻이 된다.

반면에 익명 함수 안에 있는 return 은 익명 함수 자신으로 부터 리턴할 것이라는 뜻이 된다.

Closures

람다 표현식이나 익명 함수 (local function 와 object expression 도 마찬가지로) 는 (예를 들어 외부 scope 에 선언된 변수 같은) 자신의 closure 에 접근할 수 있다.

closure 에 있는 그 변수는 람다에서 수정될 수 있다.

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

Function literals with receiver

A.(B) -> C 처럼 receiver 를 가진 함수 타입은 function literal 의 특별한 형태로 인스턴스 화 될 수 있다.

위에서 얘기한 대로, 코틀린은 receiver object 를 제공하는 receiver 와 함수타입의 인스턴스를 호출하는 방법을 제공한다.

function literal 의 body 의 내부에서 호출하기 위해 전달되는 receiver object 는 암묵적인 this 가 되고, 추가적인 한정자(qualifiers) 없이 receiver object 의 멤버들에 접근할 수 있거나 this expression 을 쓰는 receiver object 를 접근 할 수 있다.

이런 동작은 함수의 body 내부에 있는 receiver object 의 멤버에 접근할 수 있는 확장 함수와 비슷하다.

receiver object 에서 plus 가 호출되는 곳에 자신의 type 을 가지는 receiver 를 가진 function literal 의 예제이다.

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

익명 함수 문법은 function literal 의 receiver type 을 직접 지정할수 있게 해준다.

이것은 receiver 를 가진 함수 타입의 변수를 선언할 필요가 있을 경우와 그것을 나중에 사용할 때 유용하다.

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

람다 표현식은 receiver type 이 문맥상 추론이 가능할때 receiver 를 가진 function literal 로써 사용될 수 있다.

type-safe builders 가 좋은 예제이다.

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

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()  // create the receiver object
    html.init()        // pass the receiver object to the lambda
    return html
}

html {       // lambda with receiver begins here
    body()   // calling a method on the receiver object
}

 

Comments