5장 고급 함수와 함수형 프로그래밍 활용하기

이 장에서는 함수형 프로그래밍을 돕는 고차 함수, 람다, 호출 가능 참조 등의 코틀린 언어기능을 배우고, 기존 타입을 더 보완할 수 있는 확장 함수나 프로퍼티 사용법을 배운다. 

 

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

함수형 프로그래밍은 프로그램 코드를 불변 값을 변환하는 함수의 합성으로 구성할 수 있다는 아이디어를 바탕으로, 함수형 언어는 함수를일급 시민(first class) 값으로 취급한다. 일급 시민1. 함수의 매개변수가 될 수 있고, 2. 함수의 결과로 반환할 수 있고, 3. 변수에 값을 대입할 수 있으며, 4. 동등성 체크가 가능한 것을 의미한다. 이러한 특징으로 인해 고차함수라는 함수를 정의할 수 있게 해주며, 코드 추상화와 합성이 더 쉽게 가능한 유연성을 제공한다.

 

5.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 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(result>op) result else op }

op 파라미터가 다른 파라미터와 다른 점은 함수 타입인 (Int, Int) -> Int 라는 점이다. 이 말은 op를 함수처럼 호출할 수 있다는 뜻이다. aggregate를 호출하는 쪽에서는 함숫값을 표현하는 람다식을 인자로 넘기며, 람다식은 기본적으로 이름없는 지역 함수이다. result와 op는 함수 파라미터 역할을 하며 -> 다음에 오는 식은 결과를 계산하는 식이다. 

 

5.1.2 함수 타입

함수 타입은 함수처럼 쓰일 수 있는 값들을 표시하는 타입이다. 문법적으로 다음과 같이 구성된다.

  • 파라미터 타입 목록은 괄호로 둘러싸여 있으며, 함수값에 전달될 데이터의 종류와 수를 정의
  • 반환 타입은 함수 타입의 함숫값을 호출하면 돌려받게 되는 값의 타입을 정의

반환값이 없는 함수라도 함수 타입에서는 반환 타입(Unit)을 반드시 명시해야한다. 함수 정의에서와 달리 함수 타입 표기에서는 인자 타입 목록과 반환타입 사이를 :이 아닌 ->로 구분한다.

 

자바8부터 제공하는 단일 추상 메서드(Single Abstract Method, SAM) 인터페이스를 문맥에 따라 적절히 함수 타입처럼 취급한다. 이로 인해 에 람다식이나 메서드 참조로 SAM 인터페이스를 인스턴스화 할 수 있다. 코틀린에서 함숫값은 항상 (P1, ..., Pn) -> R 형태의 함수 타입에 속하기 때문에 임의의 SAM 인터페이스로 암시적으로 변환할 수 없다. 상호 운용성을 위해 SAM 대신 코틀린 함수 타입을 사용할 수 있다. 코틀린 인터페이스 앞에 fun을 붙이면 코틀린 인터페이스를 SAM 인터페이스로 취급한다. 

fun interface StringConsumer { 
    fun accept(s: String) 
}

fun main() {
    val consume = StringConsumer { s -> println(s) }
}

 

함수가 인자를 받지 않는 경우에도 함수 타입의 파라미터를 둘러싼 괄호는 필수이므로 목록에 빈 괄호를 사용해야한다. 함수 타입의 값을 함수의 파라미터에만 사용할 수 있는 것도 아니다. 함수 타입을 다른 타입이 쓰일 수 있는 모든 장소에 사용할 수 있으며, 함숫값을 변수에 저장할 수도 있다.

// OK
val lessThan1: (Int, Int) -> Boolean = { a, b -> a<b }

// Syntax Error : 타입 추론 에러
val lessThan2 = { a, b -> a<b }

// OK
val lessThan3 = { a: Int, b: Int -> a<b }

 

함수 타입도 널이 될 수 있는 타입으로 지정할 수 있으며, 함수 타입 전체를 괄호로 둘러싼 다음 물음표를 붙인다. 만약 괄호로 둘러싸지 않으면 타입 반환 값을 널이 될 수 있는 타입으로 지정한 것이다. 

// 함숫값이 널이 가능한 값임을 지정하기 위해서는 괄호를 반드시 써야함 
// 반환 타입이 널이 가능한 값임을 지정하는 예 : fun measureTime(action: () -> Unit?): Long { 
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 shifter: (Int) -> ( (Int) -> Int ) = { n -> { i -> i+n } }
    
    val inc = shifter(1)
    // val inc = { i -> i+1 } 
    
    val dec = shifter(-1)
    // val dec = { i -> i-1 } 
    
    println(inc(10))
    // inc(10) = 10 + 1
    
    println(dec(10))
    // dec(10) = 10 - 1
    
    
    val evalAtZero: ((Int) -> (Int)) -> Int = { f -> f(0) }
    println(evalAtZero { n -> n + 1 }) 
    // f(n) = n + 1  
    // f(0) = 1
}

-> 는 오른쪽 결합이다. 따라서 shifter 함수는 Int 값을 인자로 받아서 함수를 반환하는  함수를 뜻한다. 만약 Int를 받아서 Int를 내놓는 함수를 인자로 받아서 Int를 결과로 돌려주는 함수를 표현하고 싶다면 evalAtZero 처럼 사용해야한다. 

 

5.1.3 람다와 익명 함수

함수형 타입의 구체적인 값을 만드는 방법에는 람다식 혹은 익명 함수를 사용하여 만들 수 있다.

람다식 정의는 함수 정의와 비슷하다.

  • 파라미터 목록
    • 0개 : 화살표 기호(->) 생략 가능
    • 1개 : 화살표 기호(->) 생략 및 인자를 it으로 표현 가능
    • n개 : result, op 등 파라미터 이름으로 표현하며 목록을 괄호로 둘러싸지 않으며, 괄호로 감싸면 다른 의미(구조 분해 선언)가 된다. 사용하지 않는 파라미터는 _로 지정 가능하다.
  • 람다식의 몸통(본문)이 되는 식이나 문의 목록 : result + op

반환 타입을 지정할 필요가 없으며, 람다 본문에서 맨 마지막에 있는 식을 반환 타입이 자동으로 추론한다. 

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

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

fun main() {

    println(check1("Hello") { c -> c.isLetter() })              // true
    println(check1("Hello") { it.isLowerCase() })               // false
    
    println(check2("Hello") { _, c -> c.isLetter() })           // true
    println(check2("Hello") { i, c -> i==0 || c.isLetter() })   // true
}

 

익명 함수는 일반 함수의 문법과 유사하지만 몇가지 차이점이 있다.

  • 이름을 지정하지 않는다. fun 키워드 다음에 바로 파라미터 목록이 온다
  • 파라미터 타입을 추론할 수 있으면 파라미터 타입을 지정하지 않아도 된다.
  • 인자로 함수에 넘기거나 변수에 대입하는 등 일반 값처럼 쓸 수 있다.

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

fun sum(numbers: IntArray) =
    // 함수 본문이 블록인 경우
    aggregate(numbers, fun(result, op): Int { return result + op })
    
    // 함수 본문이 식인 경우
    // aggregate(nubers, fun(result, op) = result + op )

 

지역 함수와 마찬가지로 람다나 익명 함수도 클로저, 또는 자신을 포함하는 외부 선언에 정의된 변수에 접근할 수 있다.

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
}

 

 

5.1.4 호출 가능 참조

코틀린에는 이미 존재하는 함수 정의를 함수 타입의 식으로 사용할 수 있는 더 단순한 방법인 호출 가능 참조(callable reference)를 사용할 수 있다. 가장 간단한 형태의 호출 가능 참조는 최상위나 지역 함수를 가리키는 참조다. 이런 함수를 가리키는 참조를 만들려면 함수 이름 앞에 :: 을 붙이면 된다.

fun isCaptialLeter(c: Char) = ... 

class Person(var firstName: String, var familyName: String) {
    fun hasNameOf(name: String) = name.equals(firstName, ignoreCase = true)
}

fun main() {

    // 취상위 함수를 가리키는 호출 가능 참조
    println(check("Hello", ::isCaptialLeter))
 
    // 클래스 생성자에 대한 호출 가능 참조
    val createPerson = ::Person
    val person = createPerson("John", "Doe")
    
    // 바인딩된 호출 가능 참조
    val isJohn = Person("John", "Doe")::hasNameOf
    
    println(isJohn("JOHN")  // true
    println(isJohn("Jake")  // false
    
    // 프로퍼티에 대한 호출 가능 참조
    val readName = person::firstName.getter           // 게터 참조
    val writeFamily = person::familyName.setter       // 세터 참조
    
    println(readName())            // John
    wirteFamily("Smith")
    println(person.familyName)     // Smith
}

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

// OK
val f: (Int, Int) -> Int = ::max 

// Error : 불명확한 오버로드
val g = ::max

호출 가능 참조를 만들 때는 함수 이름을 간단한 형태로만 써야하며, ::을 클래스 이름 앞에 적용하면 클래스의 생성자에 대한 호출 가능 참조를 얻는다. 

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

호출 가능 참조 자체에는 오버로딩된 함수를 구분할 수 없다. 어떤 함수를 참조할지 명확히 하려면 타입을 지정해줘야한다. 

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

 

 

5.1.5 인라인 함수와 프로퍼티

고차 함수와 함수값을 사용하면 함수가 객체로 표현되기 때문에 성능 차원에서 부가 비용이 발생하며, 익명 함수나 람다가 외부 영역의 변수를 참조하면 고차 함수에 함숫값을 넘길때 마다 외부 영역의 변수를 capture 할 수 있는 구조도 만들어서 넘여야 하여 오버헤드가 발생한다. 코틀린은 함숫값을 사용할 때 발생하는 런타임 비용을 줄일 수 있는 인라인 기법을 사용할 수 있다. 인라인 기법이란 함숫값을 사용하는 고차 함수를 호출하는 부분을 해당 함수의 본문으로 대체하는 것이다. 인라인될 수 있는 함수를 구별하기 위해 inline 변경자를 함수 앞에 붙여 사용한다. 

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(intArrayOf(4,3,2,1)) { it<3 }) // 2
}

// 컴파일 이후 메인함수 코드는 아래와 같이 번역됨
fun main() {
    val numbers = intArrayOf(4,3,2,1)
    var index = -1
    
    for (i in numbers.indices) {
        if (numbers[i] < 3) {     // 함수의 파라미터로 전달되는 함숫값도 인라인됨 
            index = i
            break
        }
    }
    
    println(index)
}

인라인함수를 쓰면 컴파일된 코드의 크기가 커지지만, 잘 사용하면 성능을 크게 높일 수 있다. 특히 대상 함수가 상대적으로 작은 경우 성능이 크게 향상된다. 코틀린의 inline 변경자는 컴파일러가 상황에 따라 무시해도 되는 최적화를 위한 힌트가 아니며, 불가능한 경우 컴파일 오류로 간주한다. 

함수의 파리미터로 전달되는 함숫값도 인라인이 되며, 이로 인해 함수에 대한 조작이 제한된다. 예를 들어 인라인 함수는 실행 시점에 별도의 존재가 아니므로 변수에 저장되거나 인라인 함수가 아닌 함수에 전달될 수 없다. 인라인이 될 수 있는 람다를 사용해 할 수 있는 일은 람다를 호출하거나 다른 인라인 함수에 인라인이 되도록 넘기는 두 가지 경우 뿐이다. 

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

인라인 함수가 널이 될 수 있는 함수 타입의 인자를 받을 수 없으며, 필요시 함수 타입의 인자 앞에 noinline 변경자를 붙여 사용한다. 

// Syntax Error : Public-API inline function cannot access non-public-API 'private final val firstName: String defined in Person1'
class Person1(private val firstName: String, private val familyName: String) {
    inline fun sendMessage(message: () -> String) {
        println("$firstName $familyName: ${message()}")
    }
}


// inline 프로퍼티 접근자
class Person2(var firstName: String, var familyName: String) {
    var fullName
    inline get() = "$firstName $familyName" 
    set(value) {...}
}


// inline 프로퍼티 
class Person3(var firstName: String, var familyName: String) {
    inline var fullName
    get() = "$firstName $familyName"
    set(value) {...}
}

공개 인라인 함수에 비공개 멤버를 넘기는 것은 비공개 코드가 외부로 노출되는 일이 발생할 수 있으므로 이러한 행위를 금지한다. 또한, 프로퍼티 접근자를 인라인하도록 허용하며, 개별 접근자를 인라인하는 것 이외에 프로퍼티 자체에 inline 변경자를 붙일 수 있다. 프로퍼티에 대한 인라인은 뒷받침하는 필드가 없는 프로퍼티에 대해서만 가능하며, 프로퍼티의 게터나 세터 안에서 비공개 선언을 참조하면 인라인이 불가능하다.

 

5.1.5 비지역적 제어 흐름

고차 함수를 사용하면 return 문과 같이 일반적인 제어 흐름을 깨는 명령을 사용할 때 문제가 발생할 수 있다. 

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

fun main() {
    forEach(intArrayOf(1,2,3,4)) {
        //Syntax Error : 'return' is not allowed here
        if (it <2 || it >3) return
            println(it)
    }
}

프로그램의 의도는 어떤 범위 안에 들어있지 않은 수를 출력하기 전에 람다를 return을 사용하여 종료하는 것이지만 컴파일되지 않는다. return 문은 디폴트로 자신을 둘러싸고 있는 fun, get, set으로 정의된 가장 안쪽 함수부터 제어 흐름을 반환한다. 해당 함수는 main 함수로부터 반환을 시도하는 코드이며 이 경우에 함수 이름을 레이블로 사용하거나 익명 함수를 사용하여 해결할 수 있다.

fun main() {

	// 함수 이름을 레이블로 사용
    forEach(intArrayOf(1,2,3,4)) {
        if (it <2 || it >3) return@forEach
            println(it)
    }

    // 람다 대신 익명 함수를 사용
    forEach(intArrayOf(1,2,3,4), fun(it: Int) {
        if (it <2 || it >3) return
        println(it)
    })
}

한정시킨 return (return@forEach)는 일반 함수에서도 사용할 수 있다. (ex. return@main)

 

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

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

람다가 인라인된 경우는 함수에서 반환할 때 return 문을 사용할 수 있다. 고차 함수를 호출하는 코드를 고차 함수 본문과 람다 본문으로 대체되기 때문에 문제가 없는 것이다.

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

만약 인라인된 고차함수 본문에서 람다를 직접 호출하지 않고 간접 호출하는 경우에는 return문이 고차 함수를 호출하는 쪽의 함수를 반환할 수 없다. 람다의 return과 람다를 실행해주는 함수가 서로 다른 실행 스택 프레임을 차지하기 때문이다. 이 경우 함수형 파라미터 앞에 crossinline 변경자를 붙여야한다. 이 변경자는 함숫값을 인라인시키도록 남겨두는 대신 람다 안에서 비지역 return 을 사용하지 못하게 막는 역할을 한다. 

loop 문에서 break나 continue를 쓸 때도 비지역적 제어 흐름을 함수의 return과 유사하게 만들 수 있다. 

 

5.2 확장

클래스에 새 함수나 프로퍼티를 추가해서 해당 클래스가 제공하는 API를 확장하고 싶을 수 있다. 코틀린에서는 마치 멤버인 것처럼 쓸 수 있는 함수나 프로퍼티를 클래스 밖에서 선언할 수 잇게 해주는 확장이라는 기능을 제공한다. 사용되는 이유는 다양하다.

  • 대상 클래스가 다른 라이브러리에 들어있어서 코드를 변경할 수 없음
  • 클래스를 변경하는 비용이 너무 커서 코드를 변경할 수 없음
  • 한 클래스 내에 속한 메서드를 모두 함께 사용하지 않는데, 모든 가능한 메서드를 다 넣어두면 실용적이지 못할 수 있음 
  • 자바의 유틸리티 클래스처럼 추가 메서드를 사용할 수 있지만 불필요한 준비 코드가 너무 많음 

5.2.1 확장 함수

확장 함수는 어떤 클래스의 멤버인 것처럼 호출할 수 있는 함수를 뜻한다. 정의할 때는 함수를 호출할 때 사용할 수신 객체의 클래스 이름을 먼저 표시하고, 점을 추가한 다음 함수 이름을 표기한다. 정의 이후에는 다른 클래스 멤버와 마찬가지로 이 함수를 사용할 수 있다.

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

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

일반 멤버와 비슷하게, 확장 함수의 본문 안에서 수신 객체에 this로 접근할 수 있다. 확장 함수 자체는 수신 객체가 속한 타입의 캡슐화를 깰 수 없으며, 예를 들어 수신 객체가 속한 클래스의 비공개 멤버에 대해서 확장 함수는 접근할 수 없다.

 

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
}

확장 함수는 일반 클래스 멤버와 비슷하게 바인딩된 호출 가능 참조 위치에 사용할 수도 있다. 클래스 멤버와 확장의 시그니처가 같다면 컴파일러는 항상 멤버 함수를 우선적으로 선택한다. 확장보다 멤버를 우선 선택함으로써 기존 클래스의 동작이 사고로 변경되는 것을 방지한다. 

만약 확장을 우선 순위에서 높게 지정하려면 import 디렉티브를 통해 명시적으로 선언해야한다.  이렇게 확장이 가려짐으로 인한 단점도 존재한다. 확장 함수를 먼저 정의하고 확장과 똑같은 멤버를 클래스 안에 추가하면, 확장을 사용하던 모든 호출은 의미가 달라진다. 

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

private fun String.truncator(max: Int) = object: Truncated {
    override val truncated
        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)
    println(truncator.truncated)
}

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

 

다른 패키지에서 최상위 확장함수가 정의된 경우, 확장 함수를 호출하기 전에 반드시 확장을 임포트해야만 하며, 확장 함수를 전체 이름으로 호출할 수 없는 이유는 전체 이름에서 패키지 이름과 클래스 이름이 차지할 부분에 수신 객체 식이 오기 때문이다. 확장 함수는 수신 객체를 가리키는 파라미터가 추가된 정적 메서드로 컴파일된다. 일반 함수를 마치 클래스 멤버인 것처럼 쓸 수 있게 해주는 편의 문법일 뿐이다. 

 

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()
    println(s.truncate(3))
}

널이 될 수 있는 타입은 자체 멤버를 포함하지 않기 때문에 확장 함수를 바깥에서 정의함으로써 기능을 풍부하게 할 수 있다. 안전한 호출 연산자를 호용하지 않고도 이런 확장을 호출함으로써 널 값을 처리하는 책임을 확장 함수 쪽에 있게 만드는 것이다.

 

 

5.3 확장 프로퍼티

코틀린에서는 확장 함수와 비슷하게 확장 프로퍼티를 정의할 수 있다. 확장 프로퍼티를 정의하려면 프로퍼티 이름 앞에 수신 객체의 타입을 정의하면 된다. 

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

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

멤버와 확장 프로퍼티의 결정적인 차이는 어떤 클래스의 인스턴스에 안정적으로 상태를 추가할 방법이 없기 때문에 확장 프로퍼티에 뒷받침 하는 필드를 쓸 수 없다는 점이다. 확장 프로퍼티를 초기화할 수 없고, 접근자 안에서 field를 사용할 수도 없다. 이와 더불에 뒷받침 필드에 의존하는 lateinit 를 쓸 수 없다. 확장 프로퍼티 정의에서는 항상 명시적으로 게터와 세터를 정의해야한다. 

 

5.4 동반 확장

어떤 클래스의 동반 객체는 이 클래스에 내포된 객체 중에서 바깥 클래스의 이름을 통해 객체 멤버에 접근할 수 있는 특별한 객체이다. 이런 유용한 성질이 확장의 경우에도 성립한다. 동반 객체 이름을 사용해 함수를 호출할 수 있으며, 확장 프로퍼티도 마찬가지이다. 

fun IntRange.Companion.singletonRange(n: Int) = n..n

fun main() {
    println(IntRange.singletonRange(5))
    println(IntRange.Companion.singletonRange(3))
}

동반 객체가 존재하는 경우에만 동반 객체에 대한 확징이 가능하다. IntRange 클래스 내에는 동반 객체가 아래와 같이 존재한다.

 

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

코틀린에서는 람다나 익명 함수에 대해서도 확장 수신 객체를 활용할 수 있다. 함숫값들은 수신 객체 지정 함수 타입이라는 특별한 타입으로 표현된다. 아래 aggregate() 예제에서 함수 타입 파라미터가 인자를 두 개 받는 대신 수신 객체를 받도록 다시 정의해보자.

import java.lang.IllegalArgumentException

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])
//    비확장 함수 형태로 호출 가능
//    for (i in 1..numbers.lastIndex) result = op(result, numbers[i])

    return result
}

// 람다를 통한 확장 함수 문법 사용
fun sum(numbers: IntArray) = aggregate(numbers) { op -> this + op}

// 익명 함수를 통한 확장 함수 문법 사용
fun sum(numbers: IntArray) = aggregate(numbers, fun Int.(op: Int) = this + op)

 

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

코틀린에서는 수신 객체가 있는 함숫값을 정의하는 호출 가능 참조를 만들 수 있다. 클래스 멤버 혹은 확장 선은을 바탕으로 참조를 만들 수 있으며, 문법적으로는 바인딩된 호출 가능 참조와 비슷하지만, 수신 객체를 계산하는 식 대신 수신 객체 타입이 앞에 붙는다는 점이 다르다.

import java.lang.IllegalArgumentException

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])
    return result
}

fun Int.max(other: Int) = if (this > other) this else other

fun main() {
    val numbers = intArrayOf(1,2,3,4)
    println(aggregate(numbers, Int::plus))
    println(aggregate(numbers, Int::max))
}

확장과 비확장 함수 타입 사이의 암시적 변환으로 인해, 수신 객체가 없는 호출 가능 참조를 수신 객체가 필요한 함수 타입의 값 대신 사용할 수 있다. 

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])
    return result
}

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

fun main() {
    val numbers = intArrayOf(1,2,3,4)
    println(aggregate(numbers, ::max))
}

역방향 대입도 가능하며, 수신 객체가 아닌 일반 함수 타입의 파라미터를 받는 함수에 수신 객체가 지정된 호출 가능 참조를 전달할 수 도 있다. 클래스 멤버인 동시에 확장 함수로 정의된 함수에 대한 호출 가능 참조는 지원하지 않는다. 함수의 경우 타입::함수 이름 형태로 여러 수신 객체 타입을 지정할 방법이 없기 때문이다. 

 

5.5.1 영역함수

코틀린 표준 라이브러리에는 어떤 식을 계산한 값을 문맥 내부에서 임시로 사용할 수 있도록 해주는 몇 가지 함수가 있다. 식의 값이 들어있는 암시적인 영역을 정의해서 코드를 단순화할 수 있는 경우가 있으며, 이런 함수를 보통 영역 함수(scope function)라고 부른다. 영역함수는 기본적으로 인자로 제공한 람다를 간단하게 실행해주는 영역을 하며 몇가지 관점의 조합이 있다. 

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

전체적으로는 run, let, with, apply, also 라는 다섯 가지 표준 영역 함수가 있다. 모든 영역 함수는 인라인 함수이기 때문에 런타임 부가 비용이 없으며, 남용하면 코드 가독성이 나빠지고 실수하기 쉬워진다. 

 

run과 with 함수

run() 함수는 확장 람다를 받는 확장 함수이며 람다의 결과를 돌려준다. 기본적인 사용 패턴은 객체 상태를 설정한 다음, 이 객체를 대상으로 어떤 결과를 만들어내는 람다를 호출하는 것이다. with() 함수는 run()과 상당히 비슷하며, 문맥 식의 멤버 함수와 프로퍼티에 대한 호출을 묶어 동일한 영역 내에서 실행한다. 코틀린 표준 라이브러리는 run()을 오버로딩한 함수도 제공한다. 이 함수는 문맥 식이 없고 람다의 값을 반환하기만 한다. 람다 자체에는 수신 객체도 없고 파라미터도 없다. 

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

// 다른 지역 변수와 같은 영역 안에 있으므로 바람직하지 않음 
fun main1() {
    val city = readLine()?: return
    val street = readLine()?: return
    val house = readLine()?: return
    val address = Address(city, street, house)
    println(address.asText())
}

// readLine()이 어떤 의미인지 알아보기 쉽지 않음
fun main2() {
    val address = Address(readLine()?: return, 
        readLine()?: return, 
        readLine()?: return)
    println(address.asText())
}

// run 방법을 활용
fun main3() {
    val address = run {
        val city = readLine()?: return
        val street = readLine()?: return
        val house = readLine()?: return
        Address(city, street, house)
    }

    println(address.asText())
}

// with 방법을 활용
fun main4() {
    val address = with() {
        val city = readLine()?: return
        val street = readLine()?: return
        val house = readLine()?: return
        Address(city, street, house)
    }

    println(address.asText())
}

run을 제외하고 일반 블록을 사용하면 람다로 취급하기 때문에 제대로 동작하지 않는다. 

 

let 함수

let 함수는 run과 비슷하지만 확장 함수 타입의 람다를 받지 않고 인자가 하나뿐인 함수 타입의 람다를 받는다는 점이 다르다. 따라서 문맥 식의 값은 람다의 인자로 전달된다. 

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

fun main1() {
    Address("London", "Baker Street", "221b").let {
        println("To city: ${it.city}")
        it.post("Hello")
    }

//    파라미터 값 지정 가능 
//    Address("London", "Baker Street", "221b").let { addr -> 
//        println("To city: ${addr.city}")
//        addr.post("Hello")
//    }
}

일반적인 let 사용법 중에는 널이 될 수 있는 값을 안전성 검사를 거쳐서 널이 될 수 없는 함수에 전달하는 용법이 있다. 물론 안전한 호출 연산자(?.)를 배웠지만 전달 값으로 null을 전달해야된다면 let을 사용하는 것이 좋다.

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

let 호출은 index가 널이 아닌 경우에만 호출 되기 때문에 컴파일러는 람다 안에서 it 파라미터가 널이 될 수 없는 값임을 알 수 있다.

 

apply/also 함수

apply() 함수는 확장 람다를 받는 확장 함수이며 자신의 수신 객체를 반환한다. 이 함수는 일반적으로 run()과 달리 반환값을 만들어내지 않고 객체의 상태를 설정하는 경우에 사용한다. 비슷한 함수로 also()가 있으며, 이 함수는 인자가 하나 있는 람다를 파라미터로 받는다.

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

 

5.5.2 클래스 멤버인 확장

 

+ Recent posts