반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

[코틀린 완벽 가이드] 5장 : 고급 함수와 함수형 프로그래밍 활용하기 본문

Kotlin/코틀린 완벽 가이드

[코틀린 완벽 가이드] 5장 : 고급 함수와 함수형 프로그래밍 활용하기

어리둥절범고래 2022. 10. 4. 09:44
반응형

1. 코틀린을 활용한 함수형 프로그래밍

코드 추상화와 합성이 더 쉽게 가능한 유연성을 제공하는 고차 함수


1. 고차 함수

 

정수 배열의 원소의 합계를 계산하는 함수를 정의했다.

fun sum(numbers: IntArray): Int {
    var result = numbers.firstOrNull()
        ?: throw IllegalArgumentException("Empty array")
    
    for (i in 1..numbers.lastIndex) result += numbers[i]
    
    return result
}

fun main() {
    println(sum(intArrayOf(1, 2, 3))) // 6
}

이 함수를 곱셈이나 최댓값/최솟값처럼 다양한 집계 함수를 사용하게 하려면 함수 자체의 루프 로직은 그대로 두고 중간 값들을 함수의 파라미터로 추출하여 함수를 호출할 때 이 파라미터에 적당한 연산을 제공하면 된다.

fun aggregate(numbers: IntArray, op: (Int, Int) -> Int): Int {
    var result = numbers.firstOrNull()
        ?: throw IllegalArgumentException("Empty array")

    for (i in 1..numbers.lastIndex) result = op(result, numbers[i])

    return result
}

fun sum(numbers: IntArray) =
    aggregate(numbers, { result, op -> result + op})

fun max(numbers: IntArray) =
    aggregate(numbers, { result, op -> if (op > result) op else result})

fun main() {
    println(sum(intArrayOf(1, 2, 3))) // 6
    println(max(intArrayOf(1, 2, 3))) // 3
}

op 파라미터는 함수타입이며 호출도 함수처럼 할 수 있다. 이 예제에서는 Int 값을 한 쌍 받아서 Int를 결과로 내놓는다.

람다식은 단순한 형태의 문법을 사용해 정의하는 이름없는 지역 함수이다.

{ result , op -> result + op }

result와 op는 함수 파라미터 역할을 하며 ->  다음에 오는 식은 결과를 계산하는 식이다


2. 함수 타입

 

함수 타입은 함수처럼 쓰일 수 있는 값들을 표시하는 타입이며 두 가지 부분으로 구성된다.

  1. 괄호로 둘러싸인 파라미터 타입 목록은 함숫값에 전달될 데이터의 종류와 수를 정의한다.
  2. 반환 타입은 함수 타입의 함숫값을 호출하면 돌려받게 되는 값의 타입을 정의한다.

반환값이 없는 함수라도 반환 타입을 반드시 명시해야 한다. 이런 경우는 Unit을 반환 타입으로 사용한다.

함수 정의와 달리 함수 타입 표기에서는 인자 타입 목록과 반환 타입 사이를 :이 아닌 ->로 구분한다.

함숫값을 호출하는 다른 방법은 invoke() 메서드를 사용하는 것이다.

op(result, numbers[i])
op.invoke(result, numbers[i]) // 위와 아래는 같은 함숫값을 호출한다.

함수가 인자를 받지 않는 경우에는 함수 타입의 파라미터 목록에 빈 괄호를 사용한다. 파라미터 타입을 둘러싼 괄호는 필수이므로 함수 타입이 파라미터를 하나만 받거나 전혀 받지 않는 경우에도 괄호는 꼭 쳐야 한다.

fun measureTime(action: () -> Unit): Long {
    val start = System.nanoTime()
    
    action()
    
    return System.nanoTime() - start
}

val inc: (Int) -> Int = { n -> n + 1 } // Ok
val dec: Int -> Int = { n -> n - 1 } // Error

함수 타입은 다른 타입이 쓰일 수 있는 모든 장소에 사용할 수 있다. 예를 들어 함숫값을 변수에 저장할 수도 있다. 하지만 변수 타입을 생략하면 정보가 충분하지 못해 컴파일러가 람다 파라미터 타입을 추론할 수 없다. 이런 경우에는 파라미터의 타입을 명시하면 가능하다.

fun main() {
    val lessThan: (Int, Int) -> Boolean = { a, b -> a < b }
    println(lessThan(1, 2)) // true
}


val lessThan = { a, b -> a < b } // error
val lessThan = { a: Int, b: Int -> a < b } // Ok

함수 타입도 널이 될 수 있는 타입으로 지정할 수 있다. 함수 타입 전체를 괄호로 둘러싼 다음 물음표를 붙이면 되며. 괄호로 함수 타입을 둘러싸지 않고 물음표를 붙이게 되면 () -> Unit? 와 같이 Unit? 타입을 반환하는 함수가 되니 주의해야 한다.

fun measureTime(action: (() -> Unit)?): Long {
    val start = System.nanoTime()
    
    action()?.invoke()
    
    return System.nanoTime() - start
}

fun main() {
    println(measureTime(null))
}

함수 타입을 다른 함수 타입 안에 내포시켜서 고차 함수 타입을 정의할 수 있다.

fun main() {
    val shifter: (Int) -> (Int) -> Int = { n -> { i -> i + n } }

    val inc = shifter(1)
    val dec = shifter(-1)

    println(inc(10)) // 10
    println(dec(10)) // 9
}

위의 shifter의 (Int) -> (Int) -> Int는 실제로 (Int) -> ((Int) -> Int) 이므로, 'Int값'을 인자로 받아서 '다른 Int를 내놓는 함수 타입'이 된다. 'Int를 받아서 Int를 내놓는 함수'를 인자로 받아서 'Int를 결과로 돌려주는 함수'를 표현하고 싶다면 다음과 같이 하면 된다.

fun main() {
    val evalAtZero: ((Int) -> (Int)) -> Int = { f -> f(0) }

    println(evalAtZero { n -> n + 1}) // 1
    println(evalAtZero { n -> n - 1}) // -1
}

함수 타입의 파라미터 목록에 파라미터 이름을 붙일 수 도 있다. 이 경우 이름은 그냥 문서화를 위한 것이며, 함숫값에는 영향을 미치지 못한다.

fun aggregate(
    numbers: IntArray,
    op: (resultSoFar: Int, nextValue: Int) -> Int
): Int {...}

3. 람다와 익명 함수

 

{ result, op -> result + op } 라는 식을 람다식이라고 부른다. 람다식 정의는 다음 요소로 이뤄진다.

  • 파라미터 목록: result, op
  • 람다식의 본문이 되는 식이나 문의 목록: result + op

람다 본문에서 맨 마지막에 있는 식이 람다의 결괏값이 되며 람다의 본문으로부터 반환 타입이 자동으로 추론되기 때문에 반환 타입을 지정할 필요가 없다. 람다의 파라미터 목록은 괄호로 둘러싸지 않으며, 괄호로 감싸게 되면 구조 분해(destucturing)로 선언된다.

람다가 함수의 마지막 파라미터인 경우, 함수를 호출할 때 인자를 둘러싸는 괄호 밖에 이 람다를 위치할 수 있다.

fun sum(numbers: IntArray) =
    aggregate(numbers) { result, op -> result + op }
fun max(numbers: IntArray) =
    aggregate(numbers) { result, op -> if (op > result) op else result }

람다에 인자가 없으면 화살표 기호(->)를 생략할 수 있다.

fun measureTime(action: () -> Unit): Long {
    val start = System.nanoTime()
    action()
    return System.nanoTime() - start
}

val time = measureTime { 1 + 2 }

람다 인자가 하나인 경우, 파라미터 목록과 화살표 기호를 생략하고, 미리 정해진 it이라는 이름을 사용해 가리킬 수 있다.

fun check(s: String, condition: (Char) -> Boolean): Boolean {
    for (c in s) {
        if (!condition(c)) return false
    }
    return true
}

fun main() {
    println(check("Hello") { c -> c.isLetter() }) // true
    println(check("Hello") { it.isLowerCase() })  // false
}

또한, 사용하지 않는 람다 파라미터를 밑줄 기호(_)로 지정할 수 있다

fun check(s: String, condition: (Int, Char) -> Boolean): Boolean {
    for (i in s.indices) {
        if (!condition(i, s[i])) return false
    }
    return true
}

fun main() {
    println(check("Hello") { _, c -> c.isLetter() })             // true
    println(check("Hello") { i, c -> i==0 || c.isLowerCase() })  // true
    
    
    println(check("Hello", fun(_: Int, c: Char): Boolean {
        return c.isLetter()
    }))  // true
}

 

인텔리J 코틀린 플러그인은 람다와 익명 함수를 자동으로 변환해주는 기능을 제공한다.
람다를 여는 중괄호나 익명 함수의 fun에 위치하고 Alt + Enter를 누르면 된다.
Convert to ( anonymous function, lambda expression )

 

 

익명 함수는 함숫값을 만드는 다른 방법이다.

fun sum(numbers: IntArray) =
    aggregate(numbers, fun(result, op) = result + op)
  • 익명 함수에는 이름이 없다.
  • 파라미터 타입을 추론할 수 있으면 타입을 지정하지 않는다.
  • 익명 함수는 식이기 때문에 인자로 함수에 넘기거나 변수에 대입하는 등 일반 값처럼 쓸 수 있다.

익명 함수는 람다와 달리 반환 타입을 적을 수 있다.

함수 본문이 식인 경우, 반환 타입을 생략할 수 있고, 함수 본문이 블록인 경우, 명시적으로 반환 타입을 지정해야 한다.

fun sum(numbers: IntArray) =
    aggregate(numbers, fun(result, op): Int { return result + op } // :Int 를 생략해도 된다.

익명 함수는 람다와 달리 인자 목록의 밖으로 내보낼 수는 없으며, 지역 함수와 마찬가지로 익명함수나 람다도 자신을 포함하는 외부 선언에 정의된 변수에는 접근할 수 없지만 외부 영역의 가변 변수 값을 변경할 수는 있다. (자바 람다는 불가능)

fun forEach(a: IntArray, action: (Int) -> Unit) {
    for (n in a) {
        action(n)
    }
}

fun main() {
    var sum = 0
    forEach(intArrayOf(1, 2, 3, 4)) {
        sum += it
    }
    println(sum) // 10
}

4. 호출 가능 참조

 

정의된 함수를 함숫값으로 고차 함수에 넘기고 싶을 때 람다식으로 감싸서 전달하는 방법이 있다.

fun check(s: String, condition: (Char) -> Boolean): Boolean {
    for (c in s) {
        if (!condition(c)) return false
    }
    return true
}

fun isCapitalLetter(c: Char) = c.isUpperCase() && c.isLetter()

fun main() {
    println(check("Hello") { c -> isCapitalLetter(c) }) // false
    println(check("Hello") { isCapitalLetter(it) })     // false
}

이미 존재하는 함수 정의를 함수 타입의 식으로 사용할 수 있는 호출 가능 참조를 사용하면 더 단순하다.

fun main() {
    println(check("Hello", ::isCapitalLetter)) // false
}

 

호출 가능 참조는 최상위나 지역 함수를 가리키는 참조다. 함수 이름 앞에 ::을 붙이면 함수를 가리키는 참조를 만들 수 있으며,

클래스 이름 앞에 붙이면 클래스의 생성자에 대한 호출 가능 참조를 얻는다.

class Person(val firstName: String, val familyName: String)

fun main() {
    val createPerson = ::Person
    createPerson("John","Doe")
}

주어진 클래스 인스턴스의 안에 있는 멤버 함수를 호출하고 싶을 때는 바인딩된 호출 가능 참조라는 사용법을 사용한다.

class Person(val firstName: String, val familyName: String) {
    fun hasNameOf(name: String): Boolean = name.equals(firstName, ignoreCase = true) // ignoreCase는 디폴트값
}

fun main() {
    val isJohn = Person("John","Doe")::hasNameOf // 바인딩된 호출 가능 참조

    println(isJohn("JOHN")) // true
    println(isJohn("JAKE")) // false
}

호출 가능 참조 자체는 오버로딩된 함수를 구분할 수 없다. 오버로딩된 함수 중 어떤 함수를 참조할지 명확히 지정해야 한다.

fun max(a: Int, b: Int) = if (a > b) a else b
fun max(a: Double, b: Double) = if (a > b) a else b

val f: (Int, Int) -> Int = ::max // Ok
val g = ::max                    // Error

호출 가능 참조를 직접 호출하고 싶다면 참조 전체를 괄호로 둘러싼 다음 인자를 지정해야 한다.

fun max(a: Int, b: Int) = if (a > b) a else b

fun main() {
    println((::max)(1, 2)) //2
    println(::max(1, 2))   // error
}

프로퍼티에 대한 호출 가능 참조를 만들 수 있다. 이런 참조는 프로퍼티 정보를 담고 있는 리플렉션(reflection) 객체로, 이 객체의 getter/setter 프로퍼티를 사용하면 게터/세터 함수에 해당하는 함숫값에 접근할 수 있다.

class Person(var firstName: String, var familyName: String)

fun main() {
    val person = Person("John", "Doe")
    val readName = person::firstName.getter // 게터 참조
    val writeFamily = person::familyName.setter // 세터 참조
    
    println(readName())       // John
    writeFamily("Smith")
    println(person.familyName) // Smith
}

5. 인라인 함수와 프로퍼티

 

고차 함수와 함숫값을 사용하면 함수가 객체로 표현되기 때문에 성능 차원에서 부가 비용이 발생한다. 함숫값을 사용하는 고차 함수를 호출하는 부분을 해당 함수의 본문으로 대채하는 인라인 기법을 사용하면 이러한 비용을 줄일 수 있다.

정수 배열에서 주어진 술어(predicate)를 만족하는 값을 찾는 함수가 있다.

inline fun indexOf(numbers: IntArray, condition: (Int) -> Boolean): Int {
    for (i in numbers.indices) {
        if (condition(numbers[i])) return i
    }
    return  -1
}

fun main() {
    println(indexOf(intArray(4, 3, 2, 1)) { it < 3 }) // 2
}

컴파일러는 인라인 함수 호출을 함수의 본문으로 대체한다. main() 함수가 다음과 같이 번역된다는 뜻이다.

fun main() {
    val numbers = intArray(4, 3, 2, 1)
    var index = -1
    
    for (i in numbers.indices) {
        if (numbers[i] < 3) {
            index = i
            break
        }
    }
    println(index)
}

인라인 함수를 쓰면 컴파일된 코드의 크기가 커지지만 잘 쓰면 성능을 크게 높일 수 있다. 특히 대상 함수가 상대적으로 작은 경우 성능이 크게 향상된다.

 

인라인이 될 수 있는 람다를 사용해 할 수 있는 일은 람다를 호출하거나 다른 인라인 함수에 인라인이 되도록 넘기는 두 가지 경우 뿐이다.

inline 변경자가 붙은 함수 뿐만 아니라 함수의 파라미터로 전달된 함수값도 인라인 되기 때문이다. 

val lastAction: () -> Unit = {}

inline fun runAndMemorize(action: () -> Unit) {
    action()
    lastAction = action // Error
}

inline fun forEach(a: IntArray, action: ((Int) -> Unit)?) { // Error
    if (action == null) return
    for (n in a) action(n)
}

이러한 이유로 인라인 함수가 널이 될 수 있는 함수 타입의 인자를 받을 수도 없다. 이런 경우 noinline 변경자를 붙이면 가능하다.

inline fun forEach(a: IntArray, noinline action: ((Int) -> Unit)?) { // Error
    if (action == null) return
    for (n in a) action(n)
}

 

 

공개 인라인 함수에 비공개 멤버를 넘길 수 없다. 인라인 함수의 본문이 호출 지점을 대신하게 되므로, 외부에서 캡슐화를 깰 수 있기 때문이다.

class Person(private val firstName: String, private val familyName: String) {
    inline fun sendMessage(message: () -> String) {
        println("$firstName $familyName ${message()}") // Error
    }
}

뒷받침하는 필드가 없는 프로퍼티와 프로퍼티 접근자를 인라인할 수도 있다. 프로퍼티 자체를 인라인하게 되면 게터와 세터 모두 인라인하게 된다.

class Person(val firstName: String, val familyName: String) {
    var fullName1
    inline get() = "$firstName $familyName"  // inline 게터
    set(value) { ... }                       // inline 세터
    
    inline var fullName2 // inline 게터와 세터
    get() = "$firstName $familyName"
    set(value) { ... }
    
    inline var age = 0 // error: 뒷받침하는 필드가 있다.
}

6. 비지역적 제어 흐름

 

람다 자체로부터 제어 흐름을 반환하고 싶다면 break나 continue에 대해 레이블을 사용했던 것처럼, return문에 문맥 이름을 추가하면 된다.

val action: (Int) -> Unit = myFun@ {
    if (it < 2 || it > 3) return@myFun
    println(it)
}

람다를 고차함수의 함수의 인자로 넘기는 경우에는 레이블을 선언하지 않아도 함수 이름을 문맥으로 사용할 수 있다.

forEach(intArrayOf(1, 2, 3, 4)) {
    if (it < 2 || it > 3) return@forEach
    println(it)
|

람다가 인라인될 경우에는 인라인된 코드를 둘러싸고 있는 함수에서 반환할 때 return 문을 사용할 수 있다. 둘러싸고 있는 함수를 반환되는 것이므로 사용에 유의해야 한다.

inline fun forEach(a: IntArray, action: (Int) -> Unit) { ... }

fun main() {
    forEach(intArray(1, 2, 3, 4)) {
        if (it < 2 || it > 3) return // main에서 반환됨
        println(it)
    }
}

고차 함수가 인라인이 될 수 있는 람다를 받는데, 이 고차 함수의 본문에서 람다를 직접 호출하지는 않고 지역 함수나 지역 클래스의 메서드 등의 다른 문맥에서 간접적으로 호출할 수도 있다. 이런 경우에도 람다를 인라인할 수는 있지만, 인라인을 한 이후 람다에서 사용하는 return 문이 고차 함수를 호출하는 쪽의 함수를 반환시킬 수는 없다.

inline fun forEach(a: IntArray, action: (Int) -> Unit) = object {
    fun run() {
        for (n in a) {
            action(n)  // error
        }
    }
}

이런 호출을 허용하려면 함수형 파라미터 앞에 crossinline 변경자를 붙여야 한다. 이 변경자는 함숫값을 인라인시키도록 남겨두는 대신 람다 안에서 비지역 return을 사용하지 못하게 막는 역할을 한다.

inline fun forEach(
    a: IntArray, crossinline action: (Int) -> Unit
) = object {
    fun run() {
        for (n in a) {
            action(n) // Ok
        }
    }
}

fun main() {
    forEach(intArrayOf(1, 2, 3, 4)) {
        if (it < 2 || it > 3) return // Error
        println(it)
    }
}

2. 확장


1. 확장 함수

 

확장 함수는 어떤 클래스의 멤버인 것처럼 호출할 수 있는 함수를 뜻한다. 이런 함수를 정의는 [수신 객체의 클래스 이름] . [함수이름] 으로 할 수 있다. 문자열의 길이를 지정한 길이 이하로 제한하는 함수를 추가해서 확장한다면 아래와 같이 사용할 수 있다.

fun String.truncate(maxLength: Int): String { // 확장 함수
    return if (length <= maxLength) this else this.substring(0, maxLength)
}

fun normalTruncate(str: String, maxLength: Int): String {
    return if (str.length <= maxLength) str else str.substring(0, maxLength)
}

fun main() {
    println("Hello".truncate(10)) // Hello
    println("Hello".truncate(3))  // Hel
    
    println(normalTruncate("Hello", 3))  // Hel
}

확장 함수 본문 안에서 수신 객체에 this로 접근할 수 있다. 앞의 truncate 함수의 this.substring()처럼 this를 명시하지 않아도 암시적으로 생략이 가능하다.

 

확장 함수는 클래스 밖에 정의된 함수이므로 수신 객체가 속한 클래스의 비공개 멤버에 접근할 수 없다.

class Person(val name: String, private val age: Int) {
    fun Person.showInfo() = println("$name, $age") // Ok
}

fun Person.showInfo() = println("$name, $age") // Error: can't access age

 

 

확장 함수는 바인딩된 호출 가능 참조 위치에 사용할 수도 있다.

class Person(val name: String, val age: Int)

fun Person.hasName(name: String) = name.equals(this.name, ignoreCase = true)

fun main() {
    val f = Person("John", 25)::hasName
    println(f("JOHN")) // true
    println(f("JAKE")) // false
}

컴파일러는 클래스 멤버 함수와 확장 함수 이름이 같다면 멤버 함수를 우선적으로 선택한다.

class Person(val firstName: String, val familyName: String) {
    fun fullName() = "$firstName $familyName"
}

fun Person.fullName() = "$familyName $firstName"

fun main() {
    println(Person("John", "Doe").fullName()) // John Doe 
}

확장 함수는 사용되지 않았다.

다른 확장 함수 안에 확장 함수를 내포시켜 지역 확장 함수를 정의할 수도 있다. 이 경우 this 식은 가장 안쪽에 있는 함수의 수신 객체를 뜻한다. 바깥쪽 수신 객체를 참조하고 싶다면 한정시킨 this를 사용해 함수 이름을 명시해야 한다.

interface Truncated {
    val truncated: String
    val original: String
}

private fun String.truncator(max: Int) = object : Truncated {
    override val truncated: String
        get() = if (length <= max) this@truncator else substring(0, max)

    override val original: String
        get() = this@truncator
}

fun main() {
    val truncator = "Hello".truncator(3)

    println(truncator.original) // Hello
    println(truncator.truncated)// Hel
}

다른 패키지에 함수가 정의된 경우 임포트해야만 한다.

// util.kt
package util

fun String.truncate(maxLength: Int): String {
    return if (length <= maxLength) this else substring(0, maxLength)
}

// main.kt
package main

import util.truncate

fun main() {
    println("Hello".truncate(3))
}

자바에서의 확장 함수는 수신 객체를 가리키는 파라미터가 추가된 정적 메서드로 컴파일된다.

public final class UtilKt {
    public static String truncate(String s, int maxLength) {
        return s.length() <= maxLength ? s : s.substring(0, maxLength)
    }
}

// 비확장 코틀린 함수
fun truncate(s: String, maxLength: Int) =
    if (s.length <= maxLength) s else s.substring(0, maxLength)

즉 확장 함수는 일반 함수를 마치 클래스 멤버인 것처럼 쓸 수 있게 해주는 편의 문법이라는 것을 알 수 있다.

 

확장 함수는 널이 될 수 있는 타입에 대해서도 정의할 수 있다.

// 널이 될 수 있는 수신 객체 타입
fun String?.truncate(maxLength: Int): String? {
    if (this == null) return null // 스마트 캐스트
    return if (length <= maxLength) this else substring(0, maxLength)
}

fun main() {
    val s = readLine() // 널이 될 수 있는 String
    println(s.truncate(3)) // 여기서 safe call을 쓰지 않아도 된다.
}

3. 확장 프로퍼티

 

확장 함수와 비슷하게 확장 프로퍼티도 정의할 수 있다. 프로퍼티 이름 앞에 수신 객체의 타입을 지정하면 된다.

val IntRange.leftHalf: IntRange
get() = start..(start + endInclusive)/2

fun main() {
    println((1..3).leftHalf) // 1..2
    println((3..6).leftHalf) // 3..4
}

멤버와 확장 프로퍼티의 차이는 확장 프로퍼티는 초기화 할 수 없다는 것이다. 때문에 확장 프로퍼티는 뒷받침 하는 필드를 쓸 수 없어서, 접근자 안에서 field를 사용할 수도 없으며 lateinit으로 정의할 수도 없다. 마찬가지로 항상 명시적인 게터나 세터를 정의해야만 한다.

val IntArray.midIndex
    get() = lastIndex/2

var IntArray.midValue
    get() = this[midIndex]
    set(value) {
        this[midIndex] = value
    }

fun main() {
    val numbers = IntArray(6) { it*it } // 0, 1, 4, 9, 16 ..
    
    println(numbers.midValue)  // 4
    numbers.midValue *= 10
    println(numbers.midValue)  // 40
}

하지만 확장 프로퍼티에서도 위임(lazy by)을 사용할 수 있다. 다만 위임식이 수신 객체에 접근할 수 없다.

val String.message by lazy { "Hello" }

fun main() {
    println("Hello".message) // Hello
    println("Bye".message)   // Hello
} // 사용상 이점은 그다지 없다.

4. 동반 확장


1. 람다와 수신 객체 지정 함수 타입

 

람다나 익명 함수에 대해서도 확장 수신 객체를 활용할 수 있다. 이런 함숫값들은 수신 객체 지정 함수 타입이라는 특별한 타입으로 표현된다.

fun aggregate(numbers: IntArray, op: Int.(Int) -> Int): Int {
    var result = numbers.firstOrNull() ?: throw IllegalArgumentException("Empty array")
    
    for (i in 1..numbers.lastIndex) result = result.op(numbers[i]) // result + numbers[i]
    
    return result
}

fun sum(numbers: IntArray) = aggregate(numbers) { op -> this + op } // 람다 표현

함수 타입 파라미터가 인자를 두 개 받는 대신 수신 객체를 받도록 정의했다. Int.(Int) -> Int 로 정의한 경우 이 함수에 전달된 람다는 암시적으로 수신 객체를 가진다. this를 사용해 이 객체에 접근할 수 있다. { op -> this + op }

 

마찬가지로 익명 함수에 대해서도 확장 함수 문법을 사용할 수 있다. 수신 객체 타입을 함수의 파라미터 목록 앞에 추가하면 된다.

fun sum(numbers: IntArray) = aggregate(numbers, fun Int.(op: Int) = this + op) // 익명 함수

수신 객체를 첫 번째 파라미터로 넣어서 확장 함수가 아닌 일반 함수 형태로 호출할 수도 있다.

fun aggregate(numbers: IntArray, op: Int.(Int) -> Int): Int {
    var result = numbers.firstOrNull() ?: throw IllegalArgumentException("Empty array")

    for (i in 1..numbers.lastIndex) {
        result = op(result, numbers[i]) // 비확장 함수 호출
    }

    return result
}

기본적으로 리터럴이 아닌 함숫값은 자유롭게 '수신 객체가 있는 함수 타입 값'과 '수신 객체가 첫 번째 파라미터인 일반 함수 타입 값'의 역할을 할 수 있다.

val min1: Int.(Int) -> Int = { if (this < it) this else it } // this가 수신객체, it이 파라미터
val min2: (Int, Int) -> Int = min1  // 수신 객체가 첫 번째 파라미터인 일반 함수 타입
val min3: Int.(Int) -> Int = min2   // 수신 객체가 있는 함수 타입

5. 수신 객체가 있는 호출 가능 참조


1. 영역 함수

 

코틀린 표준 라이브러리에는 어떤 식을 계산한 값을 문맥 내부에서 임시로 사용할 수 있도록 해주는 영역 함수(scope function)라는 몇 가지 함수가 있다. 영역 함수는 인자로 제공한 람다를 간단하게 실행해주는 것이며, 그 차이는 다음과 같은 몇 가지 관점의 조합에 있다.

  • 문맥 식을 계산한 값을 영역 함수로 전달할 때 수신 객체로 전달하는가, 일반적인 함수 인자로 전달하는가?
  • 영역 함수의 람다 파라미터가 수신 객체 지정 람다(확장 람다)인가, 아닌가?
  • 영역 함수가 반환하는 값이 람다의 결괏값인가, 컨텍스트 식을 계산한 값인가?

 

 

영역 함수는 인라인 함수이기 때문에 런타임 비용이 없으며 run, let, with, apply, also 다섯 가지 표준 영역 함수가 있다.

영역 함수를 남용하면 코드 가독성이 나빠지고 실수를 유발하기도 하며, 여러 겹으로 내포시켜 사용하게 되면 this나 it이 어떤 대상을 가리키는지 어려워지므로 겹쳐 사용하지 않는 편을 권장한다.

 

  • run과 with 함수

run() 함수는 확장 람다를 받는 확장 함수이며 람다의 결과를 돌려준다. 기본적으로 run() 함수는 객체 상태를 설정한 다음, 이 객체를 대상으로 어떤 결과를 만들어내는 람다를 호출할 때 사용한다.

class Address {
    var zipCode: Int = 0
    var city: String = ""
    var street: String = ""
    var house: String = ""
    
    fun post(messsage: String): Boolean {
        "Meesage for {$zipCode, $city, $street, $house}: $messsage"
        return readLine() == "OK"
    }
}

fun main() {
    val isReceived = Address().run { 
        // Address 인스턴스를 this로 사용할 수 있다.
        zipCode = 123456
        city = "London"
        street = "Baker Street"
        house = "221b"
        post("Hello!") // 반환값
    }
    
    if (!isReceived) {
        println("Message is not delivered")
    }
}

run 함수가 없으면 Address 인스턴스를 담을 변수를 추가해야 한다. 하지만 이 인스턴스를 한번만 써야 한다면, 함수의 나머지 부분에서 이 인스턴스에 마음대로 접근할 수 있는 것은 바람직하지 않다.

class Address {
    var zipCode: Int = 0
    var city: String = ""
    var street: String = ""
    var house: String = ""

    fun showCityAddress() = println("$street, $house")
}

fun main() {
    Address().run {
        zipCode = 123456
        city = "London"
        street = "Baker Street"
        house = "221b"
        showCityAddress() // Baker Street, 221b
    }
}

결과의 타입이 Unit일 수도 있다.

 

with() 함수는 run()과 비슷하다.유일한 차이는 with()가 확장 함수 타입이 아니므로 문맥 식을 with의 인자로 전달해야 한다는 점이다. 일반적으로 with() 함수는 문맥 식의 멤버 함수와 프로퍼티에 대한 호출을 묶어 동일한 영역 내에서 실행하는 경우다.

fun main() {
    val message = with(Address("London", "Baker Street", "221b")) {
        "Address: $city, $street, $house"
    }
    println(message)
}

 

 

  • 문맥이 없는 run

run()을 오버로딩한 함수도 제공한다. 이 함수는 문맥 식이 없고 람다의 값을 반환한다. 람다 자체에는 수신 객체도 없고 파라미터도 없다.

어떤 식이 필요한 부분에서 블록을 사용하고 싶을 때 사용한다.

class Address(val city: String, val street: String, val house: String) {
    fun asText() = "$city, $street, $house"
}

fun main() {
    val address = Address(
        readLine() ?: return,
        readLine() ?: return,
        readLine() ?: return
    )
    println(address.asText())
}

위 예제는 address를 입력받아 출력한다. 이 경우 각 readLine()이 어떤 의미인지 알아보기 어렵다.

fun main() {
    val address = run {
        val city = readLine() ?: return
        val street = readLine() ?: return
        val house = readLine() ?: return
        Address(city, street, house)
    }
    println(address.asText())
}

run은 인라인 함수이므로 바깥쪽 함수의 제어를 반환시키기 위해 return을 사용해도 된다. 이로 인해 run이 다른 일반적인 제어 구조와 똑같아 보인다.

 

  • let 함수

let 함수는 run과 비슷하지만 확장 함수 타입이 아닌 인자가 하나뿐인 함수 타입의 람다를 받는다는 점이 다르다. let의 반환값은 람다가 반환하는 값과 같다. 외부 영역에 새로운 변수를 도입하는 일을 피하고 싶을 때 사용한다.

class Address(val city: String, val street: String, val house: String) {
    fun post(message: String) {}
}

fun main() {
    Address("London", "Baker Street", "221b").let { addr ->
        // 이 안에서는 addr 파라미터를 통해 Address 인스턴스에 접근할 수 있음
        println("To city: ${addr.city}")
        addr.post("Hello")
    }
}

let의 일반적인 사용법 중에는 널이 될 수 있는 값을 안정성 검사를 거쳐 널이 될 수 없는 함수에 전달하는 용법이 있다. (assert not-null)

fun readInt() = try {
    readLine()?.toInt()
} catch (e: java.lang.NumberFormatException) {
    null
}

fun main(args: Array<String>) {
    val index = readInt()
    val arg = if (index != null) args.getOrNull(index) else null
    if (arg != null) {
        println(arg)
    }
}

getOrNull() 파라미터가 널이 될 수 없는 타입이기 때문에 readInt()의 결과를 getOrNull()에 직접 전달할 수 없기 때문에 스마트 캐스트를 통해 널이 아닌 값을 getOrNull()에 전달했다. 하지만 let을 사용하면 이 코드를 단순화할 수 있다.

val arg = index?.let { args.getOrNull(it) }

 

  • apply/also 함수

apply() 함수는 확장 람다를 받는 확장 함수이며 자신의 수신 객체를 반환한다. 이 함수는 일반적으로 run()과 달리 반환값을 만들어내지 않고 객체의 상태를 설정할 때 사용한다.

class Address {
    var city: String = ""
    var street: String = ""
    var house: String = ""
    
    fun post(message: String) {}
}

fun main() {
    val message = readLine() ?: return
    Address().apply { 
        city = "London"
        street = "Baker Street"
        house = "221b"
    }.post(message)
}

also()는 apply()와 달리 인자가 하나 있는 람다를 파라미터로 받는다.

fun main() {
    val message = readLine() ?: return
    Address().also {
        it.city = "London"
        it.street = "Baker Street"
        it.house = "221b"
    }.post(message)
}

2. 클래스 멤버인 확장

 

클래스 안에서 확장 함수나 프로퍼티를 선언하면 수신 객체가 두 개 있다.

'확장 정의의 수신 객체 타입'의 인스턴스를 확장 수신 객체(extension receiver)라 부르고,

'확장이 포함된 클래스 타입'의 인스턴스를 디스패치 수신 객체(dispatch receiver)라고 부른다.

두 수신 객체를 가리킬 때는 this 앞에 클래스 이름이나 확장 이름을 붙여 한정시킨다.

class Address(val city: String, val street: String, val house: String)

class Person(val firstName: String, val familyName: String) {
    fun Address.post(message: String) {
        // 암시적 this: 확장 수신 객체 (Address)
        val city = city
        // 한정시키지 않은 this: 확장 수신 객체 (Address)
        val street = this.street
        // 한정 시킨 this: 확장 수신 객체 (Address)
        val house = this@post.house
        // 암시적 this: 디스패치 수신 객체 (Person)
        val firstName = firstName
        // 한정시킨 this: 디스패치 수신 객체 (Person)
        val familyName = this@Person.familyName
        
        println("From $firstName, $familyName, at $city, $street, $house:")
        println(message)
    }
    
    fun test(address: Address) {
        // 디스패치 수신 객체: 암시적
        // 확장 수신 객체: 명시적
        address.post("Hello")
    }
}

test() 안에서 post() 함수를 호출하면 test()가 Person의 멤버이므로 디스패치 수신 객체가 자동으로 제공된다.

반면 확장 수신 객체는 address 식을 통해 명시적으로 전달된다.

 

확장 수신 객체나 외부 클래스의 인스턴스로 Person 타입의 값이 공급될 수도 있다.

class Address(val city: String, val street: String, val house: String)

class Person(val firstName: String, val familyName: String) {
    fun Address.post(message: String) { }
    inner class Mailbox {
        fun Person.testExt(address: Address) {
            address.post("Hello")
        }
    }

    fun Person.testExt(address: Address) {
        address.post("Hello")
    }
}

Person 타입의 디스패처 수신 객체가 현재 영역에 존재하지 않기 때문에 Address 타입의 수신 객체가 있을때 Address클래스 본문 안에서 post()를 호출할 수 없다. Person 타입의 디스패치 수신 객체가 있어야 가능하다.

class Address(val city: String, val street: String, val house: String) {
    fun test(person: Person) {
        person.post("Hello") // Error: method post() is not defined
    }
}

class Person(val firstName: String, val familyName: String) {
    fun Address.post(message: String) { }
}

이런 경우에는 post() 호출을 Person 수신 객체를 받는 확장 람다로 감싸면 가능하다. 

class Address(val city: String, val street: String, val house: String) {
    fun test(person: Person) {
        with(person) {
            post("Hello")
        }
    }
}

class Person(val firstName: String, val familyName: String) {
    fun Address.post(message: String) { }
}

이 트릭을 Address나 Person클래스, 혹은 이들의 확장 밖에서 post()를 호출할 때도 사용할 수 있다.

class Address(val city: String, val street: String, val house: String)

class Person(val firstName: String, val familyName: String) {
    fun Address.post(message: String) { }
}

fun main() {
    with(Person("John","Watson")) {
        Address("London","Baker Street", "221b").post("Hello")
    }
}

 

앞 예제들은 이중 수신 객체 규칙이 큰 혼란을 만들어낼 수 있다는 것을 보여준다.

이런 이유로 수신 객체의 영역을 자신이 포함된 선언 내부로 제한하는 쪽을 권장한다.

class Address(val city: String, val street: String, val house: String)

class Person(val firstName: String, val familyName: String) {
    // Person 클래스 밖에서는 쓸 수 없음
    private fun Address.post(message: String) { }
    fun test(address: Address) = address.post("Hello")
}

오류가 생기기 쉬운 사용 예로는 다음 코드처럼 디스패치와 확장 수신 객체가 똑같은 타입인 경우다.

class Address(val city: String, val street: String, val house: String) {
    fun Address.post(message: String) { }
}

이중 수신 객체 멤버를 사용하는 흥미로운 예로는 객체 안에 선언된 확장 함수가 있다. 이런 확장을 최상위로 임포트해서 최상위 확장처럼 사용할 수 있다.

import Person.Companion.parsePerson

class Person(val firstName: String, val familyName: String) {
    companion object {
        fun String.parsePerson(): Person? {
            val names = split(" ")
            return if (names.size == 2) Person(names[0], names[1]) else null
        }
    }
}

fun main() {
    // Person.Companion 인스턴스가 암시적으로 공급됨
    println("John Doe".parsePerson()?.firstName) // John
}
반응형