BottleH Blog

Kotlin in Action - 10장 고차 함수: 람다를 파라미터와 반환값으로 사용

    Tags

  • Kotlin
Kotlin in Action - 10장 고차 함수: 람다를 파라미터와 반환값으로 사용 thumbnail

📖 10.1 다른 함수를 인자로 받거나 반환하는 함수 정의: 고차 함수

  • 고차 할수는 다른 할수를 인자로 받거나 함수를 반환하는 합수
  • 코물린에서는 다나 할수 참조를 사용해 함수를 값으로 표현할 수 있다.
list.filter { it > 0}
  • 표준 라이브러리 함수인 filter는 술어 함수를 인자로 받으므로 고차 함수

🔖 10.1.1 함수 타입은 람다의 파라미터 타입과 반환 타입을 지정한다

val sum = { x: Int, y: Int -> x + y } val action = { println(42) }
  • 컴파일러는 sum과 action이 함수 타입임을 추론
val sum: (Int, Int) -> Int = { x, y -> x + y } // Int 파라미터를 2개 받아서 Int 값을 반환하는 함수 val action: () -> Unit = { println(42) } // 아무 인자도 받지 않고 아무 값도 반환하지 않는 함수
  • 함수 타입을 정의하려면 함수 파라미터의 타입을 팔호 안에 넣고 그 뒤에 화살표(->)를 추가한 다음, 함수의 반환 타입을 지정
var canReturnNu11: (Int, Int) -> Int? = { x, y -> null }
  • 다른 함수와 마찬가지로 함수 타입에서도 반환 타입을 널이 될 수 있는 타입으로 지정할 수 있다.
var funOrNull: ((Int, Int) -> Int)? = null
  • 함수 전체가 널이 될 수 있는 타입임을 선언하기 위해 함수 타입을 괄호로 감싸고 그 뒤에 물음표를 붙여야만 한다.

🔖 10.1.2 인자로 전달 받은 함수 호출

fun twoAndThree(operation: (Int, Int) -> Int) { val result = operation(2, 3) println("The result is $result") } fun main() { twoAndThree { a, b -> a + b } // The result is 5 twoAndThree { a, b -> a * b } // The result is 6 }
  • 인자로 받은 함수를 호출하는 구문은 일반함수를 호출하는 구문과 같다.
  • 함수 타입에서 파라미터 이름을 지정할 수 있다.
fun String.filter(predicate: (Char) -> Boolean): String { return buildString { for (char in this@filter) { if (predicate(char)) append(char) } } }
  • filter 함수는 술어를 파라미터로 받는다.
  • 확장 함수와 buildString 함수 모두 수신 객체를 정의하기 때문에 this 앞에 레이블읇 붙여 buildString 람다의 수신 객체가 아니라 filter의 바깥쪽 수신 객체에 접근해야 한다.

🔖 10.1.3 자바에서 코틀린 함수 타입 사용

fun processTheAnswer (f: (Int) -> Int) { printin(f(42)) } processTheAnswer(number -> number + 1);
  • 자동 SAM 변환을 통해 코틀린 람다를 함수형 인터페이스를 요구하는 자바 메서드에게 넘길 수 있다. 즉, 자바에서 정의된 고차 함수를 아무 문제 없이 사용할 수 있다는 의미다.
  • 자바에서 람다를 인자로 받는 코틀린 확장함수를 사용할 때는 첫 번째 인자로 수신 객체를 명시적으로 전달해야 한다.

🔖 10.1.4 함수 타입의 파라미터에 대해 기본값을 지정할 수 있고, 널이 될 수도 있다.

fun <T> Collection<T>.joinToString(separator: String = ", ", prefix: String = "", postfix: String = ""): String { val result = StringBuilder(prefix) for ((index, element) in this.withIndex()) { if (index > 0) result.append(separator) result.append(element) } result.append(postfix) return result.toString() }
  • 이 구현은 유연하지만 핵심 요소인 컬렉션의 각 원소를 문자열로 변환하는 방법을 제어할 수 없다는 단점이 있다.
fun <T> Collection<T>.joinToString(separator: String = ", ", prefix: String = "", postfix: String = "", transform: (T) -> String = { it.toString() }): String { val result = StringBuilder(prefix) for ((index, element) in this.withIndex()) { if (index > 0) result.append(separator) result.append(transform(element)) } result.append(postfix) return result.toString() }
  • 함수 타입에 대한 기본값을 선언할 때 특별한 구문이 필요하지는 않다.
    • = 뒤에 람다를 넣으면 된다.
fun <T> Collection<T>.joinToString(separator: String = ", ", prefix: String = "", postfix: String = "", transform: ((T) -> String)? = null): String { val result = StringBuilder(prefix) for ((index, element) in this.withIndex()) { if (index > 0) result.append(separator) val str = transform?.invoke(element) ?: element.toString() result.append(str) } result.append(postfix) return result.toString() }
  • transform은 넘이 될 수 있는 함수 타입이지만 그 반환 타입은 널이 될 수 없는 타입이다.
  • transform은 자신이 null이 아니면 String 타입의 널이 아닌 값을 반환한다는 사실을 보장한다.

🔖 10.1.5 함수를 함수에서 반환

fun getShippingCostCalculator(delivery: Delivery): (Order) -> Double { if (delivery == Delivery.EXPEDITED) { return { order -> 6 + 2.1 * order.itemCount } } return { order -> 1.2 * order.itemCount } } fun main() { val calculator = getShippingCostCalculator(Delivery.EXPEDITED) println("Shipping costs ${calculator(Order(3))}") }
  • 다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정하면 된다.
class ContactListFilters { var prefix: String = "" var onlyWithPhoneNumber: Boolean = false fun getPredicate(): (Person) -> Boolean { val startWithPrefix = { p: Person -> p.firstName.startsWith(prefix) || p.lastName.startsWith(prefix)} if (!onlyWithPhoneNumber) { return startWithPrefix } return { startWithPrefix(it) && it.phoneNumber != null } } }
  • getPredicate method는 filter 함수에 인자로 넘길 수 있는 함수를 반환한다.
  • 고차 함수는 코드 구조를 개선하고 중복을 없앨 때 쓸 수 있는 아주 강력한 도구다.

🔖 10.1.6 람다를 활용해 중복을 줄여 코드 재사용성 높이기

data class SiteVisit( val path: String, val duration: Double, val os: OS ) enum class OS { WINDOWS, LINUX, MAC, IOS, ANDROID } val log = listOf( SiteVisit("/", 34.0, OS.WINDOWS), SiteVisit("/", 22.0, OS.MAC), SiteVisit("/login", 12.0, OS.WINDOWS), SiteVisit("/signup", 8.0, OS.IOS), SiteVisit("/", 16.3, OS.ANDROID), ) val averageWindowsDuration = log .filter { it.os == OS.WINDOWS } .map(SiteVisit::duration) .average()
  • 윈도우 사용자의 평균 방문 시간 출력은 average 함수로 쉽게 표현할 수 있다.
fun List<SiteVisit>.averageDurationFor(os: OS) = filter { it.os == os }.map(SiteVisit::duration).average()
  • 중복을 피하기 위해 OS를 파라미터로 뽑아낼 수 있다.
fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) = filter(SiteVisit).map(SiteVisit::duration).average()
  • 코드 중복을 줄일 때 함수 타입이 상당히 도움이 된다.

📖 10.2 인라인 함수를 사용해 람다의 부가 비용 없애기

  • inline 변경자를 어떤 함수에 붙이면 컴파일러는 그 함수가 쓰이는 위치에 함수 호출을 생성하는 대신에 함수를 구현하는 코드로 바꿔치기 해준다.

🔖 10.2.1 인라이닝이 작동하는 방식

inline fun <T> synchronized(lock: Lock, action: () -> T): T { lock.lock() try { return action() } finally { lock.unlock() } } fun main() { val l = ReentrantLock() synchronized(1) { // ... } }
  • 어떤 함수를 inline으로 선언하면 그 함수의 본문이 인라인된다.
    • 함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신에 함수 본문을 번역한 바이트코드로 컴파일한다는 뜻
  • synchronized 함수를 inline으로 선언했으므로 synchronized를 호출하는 코드는 모두 자바의 synchronized 문과 같아진다.
class LockOwner(val lock: Lock) { fun runUnderLock(body: () -> Unit) { synchronized(lock, body) } }
  • 인라인 함수를 호출하면서 람다를 넘기는 대신에 함수 타입의 변수를 넘길 수도 있다.
  • 함수에 더해 프로퍼티 접근자에도 inline을 붙일 수 있다.

🔖 10.2.2 인라인 함수의 제약

class FunctionStorage { var myStoredFunction: ((Int) -> Unit)? = null inline fun storeFunction(f: (Int) -> Unit) { myStoredFunction = f // error } }
  • 함수가 인라이닝될 때 그 함수에 인자로 전달된 람다식의 본문은 결과 코드에 직접 들어갈 수 있다. 하지만 이렇게 람다가 본문에 직접 펼쳐지기 때문에 함수가 파라미터로 전달받은 람다를 본문에 사용하는 방식이 한정될 수밖에 없다.
fun <T, R> Sequence<T>.map(transform: (T) -> R): Sequence<R> { return TransformingSequence(this, transform) }
  • Sequence.map 의 정의다.
  • transform 파라미터로 전달받은 함수 값을 호출하지 않는 대신, TransformingSequence라는 클래스의 생성자에게 그 함수 값을 넘긴다.
inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) { //.. }
  • 인라이닝하면 안 되는 람다를 파라미터로 받는다면 noinline 변경자를 파라미터 이름 앞에 붙여 인라이닝을 금지할 수 있다.

🔖 10.2.3 컬렉션 연산 인라이닝

data class Person(val name: String, val age: Int) val people = listOf(Person("Alice", 29), Person("Bob", 31)) fun main() { println(people.filter { it.age < 30 }) }
  • 위 예제를 람다식을 사용하지 않게 다시 쓰면 아래와 같다.
fun main() { val result = mutableListOf<Person>() for (person in people) { if (person.age < 30) { result.add(person) } } println(result) }
  • 코틀린에서 filter는 인라인 함수다.
  • 즉, filter를 써서 생긴 바이트코드와 위 예제는 거의 같다.
fun main() { println( people.filter { it.age < 30 } .map(Person::name) ) }
  • map도 인라인 함수다.
  • 시퀀스에 사용된 람다는 인라이닝되지 않는다. 따라서 지연 계산을 통해 성능을 향상시키려는 이유로 모든 컬렉션 연산에 asSequence를 붙이려고 해서는 안 된다.
  • 시퀀스를 통해 성능을 향상시킬 수 있는 경우는 컬렉션 크기가 큰 경우뿐이다.

🔖 10.2.4 언제 함수를 인라인으로 선언할지 결정

  • inline 키워드를 사용해도 람다를 인자로 받는 함수만 성능이 좋아질 가능성이 높다.
  • 일반 함수 호출의 경우 JVM은 이미 강력하게 인라이닝을 지원한다.
    • 바이트코드를 실제 기계어 코드로 번역하는 과정(JIT)에서 일어난다.
  • 바이트코드에서는 각 함수 구현이 정확히 한 번만 있으면 되고 그 함수를 호출하는 모든 부분에 따로 코드를 중복할 필요가 없다. 반면, 코틀린 인라인 함수는 코드 중복이 생긴다.
  • 람다를 인자로 받는 함수를 인라이닝하면 이익이 더 많다.
    • 함수 호출 비용 감소
    • 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체를 만들 필요도 없어진다.
    • JVM은 함수 호출과 람다를 인라이닝해줄 정도로 똑똑하지 못하다.
    • 추가 기능을 사용할 수 있다.
  • inline 변경자를 함수에 붙일 때는 코드 크기에 주의를 기울여야 한다.

🔖 10.2.5 withLock, use, useLines로 자원 관리를 위해 인라인된 람다 사용

val l: Lock = ReentrantLock() l.withLock { //.. }
  • withLock은 락을 획득한 후 작업하는 숙어를 별도의 함수로 분리
fun readFirstLineFromFile(fileName: String): String { BufferedReader(FileReader(fileName)).use { br -> return br.readLine() } }
  • use 함수는 닫을 수 있는 자원에 대해 호출하는 확장 함수다.
    • 람다를 호출하고 사용 후 자원이 확실히 닫히게 한다.
  • use 함수는 인라인 함수이며, 성능에는 영향이 없다.
fun readFirstLineFromFile(fileName: String): String { Path(fileName).useLines { return it.first() } }
  • use는 Closeable과 함께 쓰이지만 useLines는 File과 Path 객체에 정의돼 있고, 람다가 문자열 시퀀스에 접근하게 해준다.

📖 10.3 람다에서 반환: 고차 함수에서 흐름 제어

🔖 10.3.1 람다 안의 return 문: 람다를 둘러싼 함수에서 반환

data class Person(val name: String, val age: Int) val people = listOf(Person("Alice", 29), Person("Bob", 31)) fun lookForAlice(people: List<Person>) { for (person in people) { if (person.name == "Alice") { println("Found!") return } } println("Alice is not found") } fun lookForAlice(people: List<Person>) { people.forEach { if (it.name == "Alice") { println("Found!") return } } println("Alice is not found") }
  • 위 두 예제는 같은 의미다.
  • 람다 안에서 return을 사용하면 람다에서만 반환되는 것이 아니라 그 람다를 호출하는 함수가 실행을 끝내고 반환된다.
  • 비로컬 return: 자신을 둘러쌓고 있는 블록보다 더 바깥에 있는 다른 블록을 반환하게 만드는 return 문
  • return이 바깥쪽 함수를 반환시킬 수 있는 때는 람다를 인자로 받는 함수가 인라인 함수인 경우다.
    • 인라이닝되지 않는 함수에 전달되는 람다 안에서 return을 사용할 수는 없다.

🔖 10.3.2 람다로부터 반환: 레이블을 사용한 return

  • 로컬 return: 람다의 실행을 끝내고 람다를 호출했던 코드의 실행을 계속 이어간다.
  • 로컬 return과 비로컬 return을 구분하기 위해 레이블을 사용한다.
fun lookForAlice(people: List<Person>) { people.forEach label@{ if (it.name != "Alice") return@label println("Found Alice") } }
  • return으로 실행을 끝내고 싶은 람다식 앞에 레이블을 붙인다.
fun lookForAlice(people: List<Person>) { people.forEach { if (it.name != "Alice") return@forEach println("Found Alice!") } }
  • 람다식의 레이블을 명시하면 함수 이름을 레이블로 사용할 수 없다는 점에 유의

🔖 10.3.3 익명 함수: 기본적으로 로컬 return

fun lookForAlice(people: List<Person>) { people.forEach (fun (person) { if (person.name == "Alice") return println("${person.name} is not Alice") }) }
  • return은 가장 가까운 함수를 가리킨다.
  • 익명 함수 안에서 레이블이 붙지 않은 return 식은 익명 함수 자체를 반환시킬 뿐 익명 함수를 둘러싼 다른 함수를 반환시키지 않는다.
  • 익명 함수는 일반 함수와 비슷해 보이지만 실제로는 람다식에 대한 문법적 편의일 뿐이다.
Written by@BottleH
Back-End Developer

GitHub