본문 바로가기
프로그래밍/코틀린

8장. 고차 함수: 파라미터와 반환 값으로 람다 사용

by 직장인 투자자 2021. 12. 18.

8장. 고차 함수: 파라미터와 반환 값으로 람다 사용

개요

  • 함수 타입
  • 고차 함수 작성 & 사용법
  • 인라인 함수
  • 비 로컬 return과 레이블
  • 무명 함수

8.1 고차 함수의 정의

함수(람다)를 인자로 받거나, 반환(리턴)하는 함수. //ex) filter, map, with

list.filter { x > 0 }
list.filter({x -> x > 0})

8.1.1 함수 타입

람다를 받는 변수 정의.

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

람다를 인자로 받는 함수 타입 정의

//함수타입을 정의하여, x,y를 컴파일러가 추론가능
val sum: (Int, Int) -> Int = {x,y -> x+y}

//Unit : 의미있는값을 반환하지않는다는 타입 정의.
val action: () -> Unit = {println(42)}

함수 타입의 널값 핸들링

//반환 타입으로 널이 가능한 타입 가능.
val canReturnNull: (Int, Int) -> Int? = {x,y ->  null}

//함수타입 변수가 null이 될 수 있음을 의미
val funOrNull: ((Int, Int) -> Int)? = null
funOrNull = canReturnNull

놀이 가능한 함수 타입의 경우, 컴파일 타임에 NPE 검사처리가 진행됨.

// 8.1.4 일부내용
// 명시적인 null 체크 필요.
if (funOrNull != null) {
    funOrNull(1,2)
}
//안전한 호출 사용가능.
funOrNull(1,2)?.invoke()
fun performRequest (
    url: String,
    callback: (code: Int, content:String) -> Unit
) { 
   .....
}

>>> val url = "http://kotl.in"
>>> performRequest(url) {code, content -> .......}
>>> performRequest(url) {code, data -> ...... }

>>> performRequest(url, {code, data -> ...... })

8.1.2 인자로 받은 함수 호출

fun twoAndThree(operation:(Int, Int) -> Int) {
    val result = operation(2,3)
    println("The result is $result")
}

>>> twoAndThree {a,b -> a+b}
The result is 5
>>> twoAndThree {a,b -> a*b}
The result is 6

String에 대한 filter 구현 예제

fun String.filter(predicate: (Char) -> Boolean) : String {
    val sb = StringBuilder()
    for (index in 0 until length) {
        val element = get(index)
        if (predicate(element))sb.append(element)    
    }
    return sb.toString()
}

>>> println("ab1c".filter {it in 'a' .. 'z'})
abc

** 인텔리 J 아이디어 팁
인텔리J 아이디어에서는 람다 코드의 디버깅을 위해 스마트 스테핑(smart stepping) 지원.

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

컴파일된 코드에서 함수 타입은 일반 인터페이스로 변경된다.
즉, 함수 타입의 변수는 FunctionN 인터페이스를 구현하는 객체를 저장한다. (N : 인자의 개수)

Function0 (인자가 없는)
Function1 <P1, R>(인자가 한 개)

각 인터페이스는 invoke() 메서드 정의가 들어있으며, 호출을 통해 함수를 실행한다.

// 코틀린에서 선언된 함수타입을 자바8의 람다를통해 쉽게 호출하라 수 있다.
// 코틀린선언
fun processTheAnswer(f: (Int) -> Int) {
    println(f(42))
}

// 자바 호출
>>> processTheAnswer(number -> number + 1);
43
//자바 8 이전의 경우, FunctionN 인터페이스의  invoke()를 구현하는 무명클래스를 넘겨야한다.
>>> 
processTheAnswer(
    new Function1<Interger, Integer>() {
        @Override
        public Integer invoke(Integer number) {
            System.out.println(number);
            return number + 1;
        }
    }
);

43

 

8.1.4 디폴트 값을 지정한 함수 타입 파라미터나 날이 될 수 있는 함수 타입 파라미터

아래는 예제는 Collection 타입으로 element = (o:Any?) 타입이 될 수 있지만,
항상 toString()을 통해 객체를 사용하고 있어 문제가 될 수 있다.

fun <T> Collection<T>.jointToString(
    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>.jointToString(
    separator: String = ",",
    prefix: String = "",
    postfix: String = "",
    transform: (T) -> String = {it.toString()}  //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()
}

>>> println(letters.jointToString {it.toLowerCase()})

디폴트 null가능 파라미터 세팅.

fun <T> Collection<T>.jointToString(
    separator: String = ",",
    prefix: String = "",
    postfix: String = "",
    transform: ((T) -> String)? = null // 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()
}

8.1.5 함수를 함수에서 반환 (함수 리턴)

enum class Delivery {STANDARD, EXPEDITED}
class Order(val itemCount:Int)
//(Order) -> Double 함수를 반환하는 getShippingCostCalculator구현.
fun getShippingCostCalculator (delivery: Delivery) : (Order) -> Double {
    if (delivery == Delivery.EXPEDITED) {
        return {order -> 6+3*order.itemCount}
    }
    return {order -> 2*order.itemCount}
}

>>> val cal = getShippingCostCalculator(Delivery.EXPEDITED)
>>> println(cal(Order(3)))

8.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("/", 12.0, OS.WINDOWS),
    SiteVisit("/", 8.0, OS.IOS),
    SiteVisit("/", 16.3, OS.ANDROID),
)

OS별, 사이트 방문 평균 시간

//1. 하드코딩
val averageWindowsDuration = log
.filter{it.os == OS.WINDOWS}
.map(SiteVisit::duration)
.average()

>>> println(averageWindowsDuration)
23.0
//2. 재사용을위한 확장함수 구현
fun List<SiteVisit>.averageDurationFor(os:OS) =
    filter {it.os == os}.map(SiteVisit::duration).average()

>>> println(log.averageDurationFor(OS.MAC))
23.0
>>> println(log.averageDurationFor(OS.WINDOWS))
22.0
// 3. 고차함수를 사용한 구현
// 람다구현을통해 OS뿐 아니라, 다른 조건들도 쉽게 적용할 수 있음)
fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) = 
    filter(predicate).map(SiteVisit::duration).average()

>>> println(log.averageDuratinoFor{it.os in setOf(OS.ANDROID, OS.IOS)})
12.15

>>> println(log.averageDurationFor{it.os == OS.IOS && it.path=="/signup"})

8.2 인라인 함수: 람다의 부가 비용 없애기

5장에서 코틀린이 보통은 람다를 무명 클래스로 컴파일하지만, 사용할때마다 클래스를 새로 생성하지는 않음.
하지만, 람다가 변수를 포획하면 람다가 생성되는 시점마다 새로운 무명클래스 객체가 생김. (클래스 생성 부가비용이 듦)
따라서, 람다를 사용하는 구현은 똑같은 작업을 수행하는 일반 함수를 사용한 구현보다 덜 효율적이다.

**
inline 변경자를 통해, 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트코드로 바꿔치기하고, 자바의 일반 명령문만큼 효율적인 코드를 생성할 수 있다.

8.2.1 인 라이닝이 작동하는 방식

inline으로 함수를 선언하면, 함수를 호출하는 코드를
[함수를 호출하는 바이트코드] 대신 [함수 본문을 번역한 바이트 코드]로 컴파일한다는 뜻이다.

//이 코드는 단지 예시이며, 코틀린은 아무 타입의 객체나 인자로 받을 수 있는 synchronized 함수를 제공한다.
** Lock 기능이 필요할 경우, 8.2.5에서 나오는, 코틀린 표준 라이브러리가 제공하는 withLock 함수를 활용하는 것을 먼저 고려해보는 것이 좋다.


inline fun<T> synchronized(lock:Lock, action:() -> T) : T {
    lock.lock()
    try {
        return action()
    }
    finally {
        lock.unlock()
    }
}

val l = Lock()
synchronized(l) {
    ...... //action 구현.
}

synchronized 함수를 inline으로 선언했으므로, synchronized를 호출하는 코드는 모두 자바의 synchronized문과 같아진다.

fun foo(l: Lock) {
    println("Before sync")
    synchronized(l) {
        println("Action")
    }
    println("After Sync")
}

위 코드의 synchronized는 inline 구현체로, 아래와 같은 바이트 코드를 만들어낸다.

** inline은 적용된 함수 synchronized 뿐 아니라, 전달된 람다의 본문도 inline 적용대상이다.

//java
fun __foo__(l: Lock) {
    println("Before sync")

    // 람다 inline 적용 시작.
    l.lock()
    try {
        println("Action")
    }
    finally {
        l.unlock()
    }
    // 람다 inline 적용 종료 .

    println("After sync")
}

inline 함수 내부의 람다가 inline 되지 않는 예외 케이스.

//람다 대신, 함수타입의 변수 전달.
class LockOwner(val lock: Lock) {
    fun runUnderLock(body: () -> Unit) {
        synchronized(lock, body)
    }
}
// 인라인 함수를 호출하는 코드 위치에서는 변수에 저장된 람다의 코드를 알수없다.
// 따라서 람다 본문은 인라이닝되지않고,  synchronized함수의 본문만 inline 된다.
class LockOwner(val lock: Lock) {
    fun __foo__(body: () -> Unit) {
    // 람다 inline 적용 시작.
         lock.lock()
        try {
             body()   //람다를 알 수 없어, 인라이닝되지않음.
        }
        finally {
            lock.unlock()
        }
    // 람다 inline 적용 종료 .
}

8.2.2 인라인 함수의 한계

함수가 인 라이닝 될 때, 함수에 인자로 전달된 람다 식의 경우, 결과 코드에 직접 들어갈 수 있다.

** 하지만, 파라미터로 받은 람다를 다른 변수에 저장하고, 나중에 그 변수를 사용한다면,
람다를 표현하는 객체가 어딘가는 존재해야 하기 때문에, 람다를 인 라이닝 할 수 없다.

예시) 시퀀스에 대해 동작하는 메서드 중, 모든 시퀀스 원소에 그 람다를 적용한 새 시퀀스를 반환하는 함수가 많다.
그런 함수는 인자로 받은 람다를 시퀀스 객체 생성자의 인자로 넘기곤 한다.

//Sequence.map 정의
func <T,R> Sequence<T>.map(transform: (T) -> R) : Sequence<R> {
    return TransformingSequence(this, transform)
}

위 map 함수는 transform 파라미터로 전달받은 함숫값을 호출하지 않는 대신,
TransformingSequence라는 클래스의 생성자에게 그 함숫값을 넘긴다.
TransformingSequence 생성자는 전달받은 람다를 프로퍼티로 저장한다.

** 이를 위해서는 transform을 인라인 함수가 아닌, 함수 인터페이스를 구현하는 무명 클래스 인스턴스로 만들어야 한다.

둘 이사의 람다를 인자로 받는 함수에서, 일부 람다만 인 라이닝 하고 싶을 경우 noinline 변경자 활용.

  • 어떤 람다에 너무 많은 코드가 들어가거나,
  • 인 라이닝을 하면 안 되는 코드가 들어갈 가능성이 있는 경우.. (너무나 당연한..)
    // noinline을 사용해야 하는 여러 상황에 대해 9.2.4절에서 볼 수 있음.
inline fun foo(inlined:() -> Unit, noinline notInlined: () -> Unit) {.......}

8.2.3 컬렉션 연산 인 라이닝

컬렉션에 대해 작용하는 코틀린 표준 라이브러리의 함수는 대부분 람다를 인자로 받는다.
람다의 경우 성능상의 약점이 있다면, 직접 구현하는 것이 더 효율적일까?

data class Person(val name: String, val age:Int)
val people = listOf(Person("Alice", 29), Person("Bob", 31))

//람다를 통한 필터링
>>> println(people.filter {it.age > < 30})
[Person(name=Alice, age=29)]
// 직접 필터링 구현
>>> val result = mutableListOf<person>()
>>> for (person in people) {
           if (person.age<30) result.add(person) 
       }
>>>println(result)
[Person(name=Alice, age=29)]

코틀린의 filter 함수는 인라인 함수이다. 따라서 filter함수의 바이트코드와, 전달된 람다 본문의 바이트 코드 모두 호출한 위치에 들어간다.
그 결과 filter를 활용한 필터링과, 직접 구현한 예제는 거의 같은 바이트 코트를 생성한다.
그렇기에 컬렉션에 대해, 코틀린이 제공하는 함수 인 라이닝을 믿고 성능에 신경 쓰지 않아도 된다.

** filter와 map을 연쇄해서 사용하면 어떻게 될까?

>>> println(people.filter {it.age > 30}.map(Person::name))
[Bob]

여기서 사용한 filter와 map은 inline 함수이다. 따라서 두 함수의 본문은 인 라이닝 되며, 추가 객체나 클래스 생성은 없다.
** 하지만 이 코드는 중간 리스트를 만든다. (filter의 결과 저장)

처리할 원소가 많아지면 중간 리스트를 사용하는 부가비용이 걱정할 만큼 커진다.
** asSequence를 통해 리스트 대신 시퀀스를 사용하면, 중간 리스트로 인한 부가비용은 줄일 수 있다. (컬렉션이 큰 경우만)

asSequence의 각 중간 시퀀스는 라마를 필드에 저장하는 객체로 표현되고, 람다를 인 라이닝 하지 않기 때문에,
**컬렉션이 작은 경우, 오히려 성능이 나쁠 수 있다.

8.2.4 함수를 인라인으로 선언해야 하는 이유

inline 키워드의 이점을 알게 되면, 성능을 위해 inline을 남용하고 싶어질 것이다.
하지만 이는 좋은 생각이 아니다.

일반 함수 호출의 경우 JVM은 이미 강력한 인 라이닝을 지원하기 때문에, 좋지만,
**코틀린의 인라인 함수는 바이트코드에서 각 함수 호출 지점을 함수 본문으로 대치하기 때문에 코드 중복이 발생한다.

반면 람다를 인자로 받는 함수를 인 라이닝 하면 이익이 더 많다.

  • 함수 호출 비용을 줄일 수 있고, 람다 인스턴스에 해당하는 객체를 만들 필요가 없다.
  • 현재의 JVM은 함수 호출과 람다를 인 라이닝 해 줄 정도로 똑똑하지 못하다. (강력한 인 라이닝을 지원하는것이 아님?....)
  • 인라이닝을 통해 사용할 수 있는 추가적인 기능이 있다. (ex. non-local 반환)

8.2.5 자원 관리를 위해 인라인 된 람다 사용 (+ withLock/ try-with-resource)

람다를 잘 활용하는 패턴 중 한 가지는, 자원을 획득하고, 해제하는 과정이다.
보통 try/finally문을 사용하여, try 시작 전 자원을 획득하고, finally에서 자원을 해제한다.

//코틀린 라이브러리에서 제공하는 withLock 예제
val l:Lock=...
l.withLock {
    // 락에 의해 보호되는 자원을 사용
}
fun <T> Lock.withLock(action:() -> T) : T {
    lock()
    try {
        return action()
    } finally {
        unlock()
    }
}

비슷한 예로, 파일에 대해 사용할 수 있는 자바 7부터 지원하는 try-with-resource 구문.

//자바구현
static String readFirstLineFromFile(String path) throws IOException {
    try (BufferedReader br = new BufferedReader (new FileReader(path))) {
        return br.readLine(); 
    }
}

위의 구현에서 return br.readLine();은 readFirstLineFromFile() 함수에 종속되어 리턴된다.

// 코틀린은 비슷한 기능으로 use 라는 함수를 표준라이브러리로 제공한다.
// use는 closeable 자원에대한 확장함수이며, 람다를 인자로받아 호출한 후, 자원을 닫아준다.
// 예외발생시에도 자원은 확실히 닫는다.
fun readFirstLineFromFile(path:String) : String {
    BufferedReader(FileReader(path)).use {br ->
        return br.readLine() // 이부분 또한, fun readFirstLineFromFile()에서 리턴된다.
    }
}

위의 구현에서 람다 본문에 사용한 return은 넌로컬 return이다.

8.3 고차 함수 안에서 흐름 제어 (@non-local return)

8.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")
}

>>> lookForAlice(people)
Found!

이 코드를 forEach로 바꾸는 경우, 같은 의미일까? (결론 : 안전)

fun lookForAlice(people : List<Person>) {
    people.forEach {
        if (it.name == "Alice") {
            println("Found!")
            return
        }
    }
    println("Alice is not found")
}

람다 안에서 return을 사용하면, 람다로부터만 반환되는 게 아니라, 그 람다를 호출하는 함수가 실행을 끝내고 반환된다.
그렇게 자신을 둘러싸고 있는 블록보다 더 바깥에서 반환하게 만드는 return문을 넌로컬(non-local) return이라 한다.

** 이렇게 return이 바깥쪽 함수를 반환시킬 수 있는 경우는, 람다를 인자로 받는 함수가 인라인 함수인 경우뿐이다.
위 두 코드 예시의 바이트코드가 거의 동일하기 때문.

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

람다식에서도 로컬 return을 사용할 수 있다. // label@, a@, b@ 아무거나 상관없다.

fun lookForAlice(people: List<Person>) {
    peoplel.forEach label@ {
        if (it.name == "Alice")return@label
    }
    println("Alice might be somewhere")
}
>>> lookForAllice(people)
Alice might be somewhere

** 람다를 인자로 받는 인라인 함수에서, 레이블 선언 없이, 함수 이름을 레이블로 사용하기
(레이블이 붙어있을 경우, 함수 이름을 레이블로 사용할 수 없다)

fun lookForAlice(people: List<Person>) {
    peoplel.forEach {
        if (it.name == "Alice")return@forEach
    }
    println("Alice might be somewhere")
}

this에 대해서도 레이블을 적용할 수 있다. (11장에서 관련 내용 자세히)

StringBuilder().apply sb@ {  // this@sb를 통해,  이 람다의 묵시적 수신 객체에 접근할 수 있다.
    listOf(1, 2, 3).apply {
        this@sb.append(this.toString())
    }
}

8.3.3 무명 함수 : 기본적으로 로컬 return

fun lookForAlice(people: List<Person>) {
    people.forEach(fun (person) {
        if (person.name == "Alice")return // 무명함수 내부이기때문에, forEach()가 종료된다.
        println("${person.name} is not Alice")
    })
}
>>> lookForAlice(people)
Bob is not Alice

8.4 요약

  • 람다 또는 함수 타입을, 변수/인자/반환 값에 사용할 수 있다.
  • 인라인 함수를 컴파일할 때, 컴파일러는 그 함수의 본문과 그 함수에게 전달된 람다의 본문 모두 바이트코드로 변환한다
  • 인라인 함수 내부의 람다 안에 있는 return이 바깥쪽 함수를 반환시키는 넌 로컬 return을 사용할 수 있다.
  • 무명 함수는 람다식을 대신할 수 있지만, return을 처리하는 방식이 다르다.
반응형

댓글