본문 바로가기
프로그래밍/Kotlin

Kotlin - 고차 함수(High-order function), 함수 타입(function type), 람다(lambda), 익명함수(anonymous function)

by pentode 2021. 8. 15.

코틀린 함수는 일급(first-class) 함수 입니다. 이것이 의미하는 것은 코틀린에서 함수가 변수와 자료구조에 저장될 수 있고, 함수의 매개변수로 보내질 수 있고, 다른 고차 함수로부터 반환되어질 수 있다는 것입니다. 코틀린의 함수는 일급 함수이므로 함수가 아닌 값에 대해 수행할 수 있는 모든 연산을 함수에 대해서도 수행할 수 있습니다.

 

1. 고차 함수(Higher-order functions)

고차 함수는 함수를 함수의 매개변수로 받을 수 있고, 함수를 반환할 수 있는 함수입니다.

2. 함수 타입(Function types)

코틀린은 함수 타입을 가집니다. 이것은 정수 타입, 실수 타입과 같은 타입을 말합니다. 함수 타입은 (Int) -> String 과 같이 쓰여질 수 있습니다. 이것은 정수를 매개변수로 받고, 문자열을 반환하는 함수의 타입을 나타냅니다.

 

// 정수를 받아 홀수 인지 확인하는 단일 표현식(Single-expression) 함수
var isOdd(x: Int) : Boolean = x % 2 != 0

// 정수를 받아서 불리언 값을 반환하는 함수 타입 변수를 선언하고,
// 위의 함수를 호출 가능한 참조(Callable reference)(::)를 사용해서 할당합니다.
var predicate: (Int) -> Boolean = ::isOdd

// 함수 타입 변수를 사용해서 함수를 호출 합니다.
println( predicate(1) )


함수 타입 선언 방법 예시 입니다.

(A, B) -> C : A타입과 B타입 값을 매개변수로 받고, C 타입의 값을 반환합니다.
- ( ) -> C : 매개변수가 없고, C타입의 값을 반환합니다.
- ( ) -> Unit : 매개변수도 없고, 반환값도 없습니다. Unit는 생략할 수 없습니다.
A.(B) -> C : 함수 타입은 추가적인 수신자(receiver)타입을 가질 수 있습니다. 수신자 타입 객체에 매개변수 B타입의 값을 주면 C타입의 값을 반환합니다. 이러한 방식은 수신자를 가지는 함수 리터럴(Function literals with receiver)에서 자주 사용됩니다(참고로 함수 타입의 리터럴은 람다와 익명함수 입니다).

예를 들어 보겠습니다.

// 수신자는 객체로써 자신의 함수가 실행되는 객체 입니다. 함수의 매개변수로 뭔가를 받으므로 수신자가 되는 것입니다.
var x: Int = 2

// 정수 객체 x의 plus 함수가 호출되므로 x 가 수신자가 되겠습니다.
println( x.plus(4) )

// 위에서 x 는 정수 타입의 변수이고, 2 는 정수 타입의 리터럴(literal) 입니다.
// 리터럴도 자신의 타입이 가진 함수를 사용할 수 있습니다.
println( 2.plus(4) )

// 이제 수신자를 가지는 함수 리터럴을 보겠습니다.
// 수진자를 가지는 함수 타입의 변수 sum 을 함수 본체는 함수 타입의 리터럴인 람다(lambda)식으로 만들었습니다.
val sum: Int.(Int) -> Int = { x -> plus(x) }

// 이제 정수 리터럴에 대해 위에서 정의한 sum 함수를 사용할 수 있습니다.
println( 2.sum(2) )


suspend () -> Unit 또는 suspend A.(B) -> C : suspend 지시자를 가지는 일시 중지 함수 타입을 만들 수 있습니다. suspend 함수는 여기서 자세히 설명하지 않겠습니다. 코틀린에서 경량 쓰레드라고 불리는 코루틴 내에서 일시중지가 가능한 함수를 만들때 사용 됩니다.
((Int, Int) -> Int)? : 함수 타입을 Nullable로 선언합니다. 소괄호로 둘러싼 다음 ? 를 붙이면 됩니다.
(Int) -> ((Int) -> Unit) : 소괄호를 사용해서 결합 우선 순위를 지정할 수 있습니다. 화살표(->) 표기법은 오른쪽 우선 입니다. 앞의 예는 (Int) -> (Int) -> Unit 와 같습니다.
typealias를 사용해서 함수 타입의 별칭을 줄 수 있습니다.

typealias ClickHandler = (Button, ClickEvent) -> Unit


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

함수 타입에 실제 함수를 할당하는 방법입니다.

- 람다 표현식(lambda expression)을 사용하여 할당합니다.

 

var sum: (Int, Int) -> Int = { a, b -> a + b }

// 컴파일러가 함수 타입을 추론할 수 있으므로 다음과 같이 쓸 수 있습니다.
var sum = { a: Int, b: Int -> a + b }


- 익명 함수(anonymous function)을 사용하여 할당합니다.

 

var sum = fun(a: Int, b: Int): Int = a + b


- 수신자를 가지는 함수 리터럴을 사용하여 할당합니다.

 

var sum: Int.(Int) -> Int = { x -> plus(x) }


- 최상위(top level), 로컬, 멤버 또는 확장 함수의 호출 가능한 참조(Callable reference)를 사용하여 할당합니다.

 

// 최상위 함수
fun isOdd(x: Int) : Boolean {
    return x % 2 != 0
}

// 최상위 함수의 호출가능한 참조를 사용하여 할당
var predicate: (Int) -> Boolean = ::isOdd

// 클래스의 멤버를 할당
var toInt: (String) -> Int  = String::toInt


- 최상위, 멤버 또는 확장 프로퍼티의 호출 가능한 참조를 사용하여 할당합니다.

 

// 리스트 생성
val strs = listOf("a", "b", "c")

// 프로퍼티 참조를 이용합니다.
val prop = List<String>::size

// 호출해 봅니다.(Reflection 사용)
println(prop.get(strs))


- 생성자를 이용하여 할당 합니다.

 

// 클래스를 선언합니다.
class Foo

// 호출 가능한 참조를 사용하여 생성자 변수를 만듭니다.
var con = ::Foo

// 실행합니다.
var x: Foo = con()

 


- 함수 타입을 인터페이스로 구현하는 사용자 정의 클래스의 인터스턴스를 사용합니다.

 

// 함수 타입은 invoke()라는 하나의 함수를 가지는 인터페이스 입니다.
(Int) -> Int

// 위의 함수 타입은 아래의 인터페이스와 같은 형태라고 생각하면 됩니다.
interface Foo {
    operator fun invoke(p: Int): Int
}


// 그렇다면 함수 타입을 인터페이스로 IntTransformer클래스를 구현할 수 있습니다.
class IntTransformer: (Int) -> Int {
    override operator fun invoke(x: Int): Int = TODO()
}

// IntTransfermer는 (Int) -> Int를 구현하므로 다형성에 따라 IntTransformer인스턴스는 함수 타입에 할당할 수 있습니다.
val intFunction: (Int) -> Int = IntTransformer()


수신자(receiver)가 있거나 없는 함수 타입의 리터럴이 아닌(Non-literal) 값들은 교환가능합니다. 그러므로 수신자는 첫 번째 매개변수를 대신할 수 있으며, 그 반대도 가능합니다.

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


용어 설명을 위해 예를 들어 보겠습니다.

 

// 정수 타입 변수 x 에 2를 할당합니다.
var x: Int = 2

// x 는 변수이고 Non-literal 입니다.
// Int 는 타입 입니다.
// 2 는 리터럴(literal) 입니다.


// 수신자를 가지는 함수 타입을 람다로 초기화 했습니다.
val repeatFun: String.(Int) -> String = { times -> this.repeat(times) }

// repeatFun은 변수이고, Non-literal 입니다.
// String.(Int) -> String 은 타입 입니다.
// { times -> this.repeat(times) } 는 함수 타입에 대한 리터럴 입니다.


수신자를 갖거나 갖지 않는 Non-literal 값을 교환 가능함을 보여주는 예제 입니다.

 

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)
    }

    // 교환 가능하므로 repeatFun을 넣어도 동작합니다.
    val result1 = runTransformation(repeatFun) // OK
    println("result1 = $result1")

    val result2 = twoParameters("hello", 3)
    println("result2 = $result2")
}


변수가 확장함수의 참조로 초기화 되더라도 기본적으로 수신자가 없는 함수 타입으로 추론되어집니다. 변경하려면 변수 타입을 명시적으로 지정해야 합니다.

// 명시적으로 타입을 지정하지 않고, 확장함수로 초기화 합니다.
val isEmptyStringList = List<String>::isEmpty

// 문자열 리스트를 하나 선언 합니다.
val list = listOf("1", "2", "3", "4")

// 확장함수로 초기화 되었지만 타입이 명시적으로 지정되지 않았으므로
// 수신자가 없는 타입으로 추론되어 컴파일이 안됩니다.
list.isEmptyStringList() // 컴파일 안됩니다.

// 수신자가 없는 형태로 호출됩니다.
isEmptyStringList(list)


명시적으로 타입을 지정한 경우의 예시 입니다.

 

// 명시적으로 타입을 지정합니다.
val isEmptyStringList: List<String>.() -> Boolean = List<String>::isEmpty

// 이경우 확장함수로 호출할 수 있습니다.
list.isEmptyStringList()

// 수신자가 없는 형태로 호출할 수도 있습니다.
isEmptyStringList(list)


※ 함수 타입의 인스턴스 호출하기

함수 타입의 인스턴스는 invoke() 연산자를 사용하여 호출할 수도 있고, 그냥 함수 처럼 호출 할 수도 있습니다.

예제 입니다.

 

// 수신자 없는 타입으로 선언
val stringPlus: (String, String) -> String = String::plus

// 수신자를 가지는 형태로 선언
val intPlus: Int.(Int) -> Int = Int::plus

// invoke() 연산자를 사용하여 호출
println(stringPlus.invoke("<-", "->"))

// 함수 형태로 호출
println(stringPlus("Hello, ", "world!"))

println(intPlus.invoke(1, 1))
println(intPlus(1, 2))

// 확장 함수처럼 호출
println(2.intPlus(3)) // extension-like call



※ 인라인 함수(Inline functions)

때로는 고차 함수에 대해 유연한 제어 흐름을 제공 하는 인라인 함수를 사용하는 것이 좋습니다.


3. 람다 표현식(Lambda expressions) 과 익명 함수(anonymous functions)

람다 표현식과 익명 함수는 함수 리터럴 입니다. 함수 리터럴은 선언되지 않았지만, 표현식처럼 즉시 전달되는 함수 입니다.

 

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


함수 max는 두 번째 매개변수로 함수 값을 받으므로 고차 함수 입니다. 이 두 번째 매개변수는 함수 리터럴로 불려지는 자체가 함수인 표현식으로 다음의 명명된 함수(named function)와 동일합니다.

 

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


※ 람다 표현식 구문

람다 표현식의 전체 구문 형식은 다음과 같습니다.

 

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


- 람다 표현식은 항상 중괄호({ })로 둘러싸여 있습니다.
- 전체 구문형식의 매개변수 선언은 중괄호 안에 들어가며, 추론 가능한 매개 변수의 타입 선언은 선택적입니다.
- 함수 본체는 -> 다음에 나옵니다.
- 람다의 추론된 반환 타입이 Unit가 아니면 본문 부분의 마지막 표현식이 반환값으로 처리됩니다.

선택적 주석을 모두 생략하면 다음과 같이 사용될 수 있습니다.

 

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

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

 

※ 후행 람다(trailing lambdas)로 전달하기

 

Kotlin 의 관습에 따르면 함수의 마지막 매개변수가 함수라면, 이 함수 매개변수로 보내진 람다는 소괄호 뒤에 위치할 수 있습니다.

 

// 마지막 매개변수가 람다인 경우
val product = items.fold(1, { acc, e -> acc * e })

// 람다를 소괄호 밖으로 보낼 수 있습니다.
val product = items.fold(1) { acc, e -> acc * e }


이러한 구문이 후행 람다(trailing lambda)로 알려져 있습니다.

만약 람다가 유일한 매개변수이면 소괄호도 생략할 수 있습니다.

 

// 함수의 매개변수가 람다 하나밖에 없습니다.
run({ println("...") })

// 이 경우 소괄호를 생략할 수 있습니다.
run { println("...") }


※ it: 단일 매개변수의 암시적 이름

 

람다 표현식이 하나의 매개변수만을 가지는 것은 자주 있는 일 입니다.

만약 컴파일러가 매개변수 없이 추론하여 구문 분석을 할 수 있다면, 매개변수는 선언할 필요가 없고, -> 도 생략할 수 있다. 매개변수는 it 이라는 이름으로 암시적으로 선언됩니다.

 

// 정수 하나를 받아서 0보다 큰지 확인하는 람다를 매개변수로 받습니다.
ints.filter {it: Int -> it > 0}

// 매개변수 선언부분은 생략할 수 있습니다.
ints.filter { it > 0 } // '(it: Int) -> Boolean' 타입의 리터럴 입니다.



※ 람다 표현식에서 값을 반환 하기

람다에서 return 구문을 사용하여 명시적으로 반환값을 지정할 수 있습니다. 그렇지 않으면 마지막 표현식의 값이 암시적으로 반환됩니다.

그러므로 다음 두 가지 코드는 동일합니다.

 

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

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


람다에서의 return 문장은 람다만을 반환하는 것이 아니라, 람다를 포함하고 있는 함수를 반환합니다. 람다에서의 return문이 람다만을 반환하게 하려면 명시적으로 label을 사용하거나, 위의 예 return@filter 처럼 자신을 포함하는 함수 이름을 사용하여 암시적으로 반환 하면 됩니다.

람다를 소괄호 밖으로 보내더라도 LINQ 스타일의 코드 작성이 가능합니다.

 

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

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


※  사용되지 않는 변수를 위한 밑줄(Underscore)

만약 람다의 매개변수가 사용되지 않는 다면 매개변수 이름 대신에 밑줄(underscore)를 사용할 수 있습니다.

 

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



※ 람다에서 매개변수의 구조 분해(Destructuring in lambdas)

예를 들어 설명하면 람다의 매개변수로 Map.Entry 타입의 값을 받는다면, Entry가 가지는 멤버인 key, value로 분해 할 수 있습니다.

 

map.mapValues { entry -> "${entry.value}!" }

map.mapValues { (key, value) -> "$value!" }



※ 익명 함수(Anonymous functions)

위의 람다 표현식 구문에는 함수의 반환 타입을 지정하는 기능이 빠져 있습니다. 대부분의 경우 반환 타입이 자동적으로 추론 될 수 있으므로 이는 필요하지 않습니다. 그러나 명시적으로 지정을 해야 하는 경우 대체 구문이 익명 함수(anonymous function)을 사용할 수 있습니다.

 

// 단일 표현식 함수 사용(single expression function)
fun(x: Int, y: Int): Int = x + y


익명 함수는 이름이 빠진다는 것을 제외하면 일반 함수의 선언과 매우 유사합니다. 본문은 표현식 또는 블록 일 수 있습니다.

 

// 일반적인 함수 블록을 사용
fun(x: Int, y: Int): Int {
    return x + y
}


매개변수와 반환 타입은 일반 함수와 동일한 방식으로 지정합니다. 매개변수 타입은 컨텍스트에서 유추될 수 있으면 생략할 수 있습니다.

 

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


익명 함수에 대한 반환 타입 유추는 일반 함수와 동일하게 작동합니다. 함수 본문이 표현식인 익명 함수의 반환 타입 유추는 자동적으로 일어납니다. 그러나, 함수 본문이 블록인 익명함수는 명시적으로 선언(혹은 Unit로 가정되어짐)해야 합니다.

익명 함수를 매개변수로 보낼때는 항상 소괄호 안으로 보내집니다. 소괄호 밖에 두는 약식 구문은 람다에서만 적용됩니다.

람다 표현식과 익명 함수의 또 다른 차이점은 non-local 반환 입니다.

레이블(label)없는 반환문(return)은 항상 fun 키워드로 선언된 함수로부터 반환합니다. 이것이 의미하는 것은 람다 표현식 내의 reutun 문장은 람다를 둘러싼 함수를 반환하고, 익명 함수내의 return 문장은 익명함수만 반환합니다. 람다가 람다 표현식에서만 반환하려면 return@레이블 을 사용합니다.


※ 클로저(Closures)


람다 표현식과 익명 함수(로컬 함수(local function)와 익명 객체(object expression)포함)는 그것의 클로저(closure)에 접근할 수 있습니다. 그것은 바깥 범위에서 선언된 변수를 포함합니다. 클로저에 캡쳐된 변수는 람다에서 수정될 수 있습니다.

 

var sum = 0

// forEach 내에수 실행되는 람다에서 sum을 사용할 수 있습니다.
ints.filter { it > 0 }.forEach {
    sum += it
}

// sum값이 변경된것을 확일할 수 있습니다.
print(sum)



※ 수신자가 있는 함수 리터럴

A.(B) -> C 와 같이  수신자가 있는 함수 타입은 수신자가 있는 함수 리터럴의 특수한 형태로 인스턴스화할 수 있습니다.

위에서 언급한 바와 같이, 코틀린은 수신자 객체를 제공하는 동시에 수신자 있는 함수 타입의 인스턴스를 호출할 수 있는 기능을 제공합니다.

함수 리터럴의 본문 안에서의 호출을 위해 전달된 수신자 객체는 암시적으로 "this" 가 되므로 추가 한정자 없이 해당 수신자 객체의 멤버에 접근하거나 "this" 표현을 사용하여 수신자 객체에 접근할 수 있습니다.

이 행동은 확장 함수의 동작과 유사하며, 함수 본문 내에 있는 수신자 객체의 맴버에 접근할 수도 있습니다.

다음은 그것의 타입과 함께 수신자가 있는 함수 리터럴의 예입니다. 여기서 plus는 수신자 객체에서 호출됩니다.

 

// 수신자가 있는 함수 타입의 sum을 선언합니다.
val sum: Int.(Int) -> Int = { other -> plus(other) }

// 정수 하나를 선언합니다.
var x: Int = 2

// x.plus(2) 가 실행되어 4가 출력됩니다.
println( x.sum(2) )

// 이렇게 호출해도 동일합니다.
println( sum(x, 2) )


익명 함수 구문은 사용하면 함수 리터럴의 수신자 타입을 직접 지정할 수 있습니다. 이것은 수신자가 있는 함수 타입의 변수를 선언하고, 나중에 사용할 필요가 있을때 유용합니다.

 

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


람다 표현식은 수신자 타입을 컨텍스트로부터 유추할 수 있을때 수신자가 있는 리터럴 함수처럼 사용될 수 있습니다. 가장 중요한 사용 사례중 하나는 type-safe builder입니다.

 

// 클래스를 하나 선언합니다.
class HTML {
    fun body() { ... }
}

// HTML 객체를 생성하는 함수 html을 선언합니다.
fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()  // 수신자 객체를 생성합니다.
    html.init()        // 수신자 객체를 람다에 전달합니다.
    return html
}

// HTML객체를 생성하여 body() 함수를 호출한 HTML 객체를 반환합니다.
html {       // 수신자가 있는 람다가 여기서 시작합니다.
    body()   // 수신자 객체의 메소드를 호출합니다. this.body()에서 this 가 생략된 형태.
}


이 글은 Kotlin v1.5.21 공식문서중 "High-order functions and lambdas"를 공부하면서 정리한 글입니다.

이 문서를 읽다가 암에 걸리는줄 알았습니다. 설명들이 이미 언어의 많은 다른 기능들을 알고 있다는 가정하에 쓰여져 있어서 단순히 읽어서는 이해가 되지 않았습니다.

사용된 예제 코드들도 너무 단순해서 그것만 보고는 암호같이 보이고, 예비 지식이 없으면 이해를 할 수가 없는 것들이 대부분이었습니다. 문서내에 링크가 제공되는 용어들의 링크를 따라가면서 공부를 하는데, 그 문서들도 이 문서나 다를바 없어서 다시 그 문서내의 링크를 따라 다니면서 이것저것 공부를 해야 했었습니다.

그래서 공부한 내용을 토대로 문서를 의역하고, 예제 코드의 앞뒤에 코드를 추가해서 이해가 가능하도록 하려고 노력했습니다. 문서를 모두 읽은 지금도 내용을 제대로 이해했는지 잘 모르겠네요.T.T

코틀린 쉽지 않네요. 뭔가 축약형이 많아서 이해하기 어려운 부분이 있는것 같습니다. 코드를 작성할때 타이핑 양은 줄겠다는 생각은 드는데, 코드를 읽기는 왠지 더 어려운것 같습니다. 익숙해지기 나름일까요? 문서 하나 읽는데 시간이 너무 걸린것 같습니다. 혹시 잘못된 내용이 있으면 알려주시기 바랍니다.



반응형