[Kotlin in Action] 8장 고차 함수: 파라미터와 반환 값으로 람다 사용

Sdoubleu·2023년 4월 3일
0

Kotlin in Action

목록 보기
7/9
post-thumbnail

8장에서 다루는 내용

  1. 함수 타입
  2. 고차 함수와 코드를 구조화할 때 고차 함수를 사용하는 방법
  3. 인라인 함수
  4. 비로컬 return과 레이블
  5. 무명 함수
  • 고차 함수
    람다를 인자로 받거나 반환하는 함수
    -> 코드를 간결, 중복 없애고, 더 나은 추상화를 구축

  • 인라인 함수(inline)
    람다를 사용함에 따라 발생할 수 있는 성능상 부가 비용을 없애고 람다 안에서 더 유연하게 흐름을 제어할 수 있는 특성인 함수


8.1 고차 함수 정의

  • 고차 함수는 람다/함수 참조를 인자로 넘길 수 있거나
    람다/함수참조를 반환하는 함수

고차 함수를 정의하려면 함수 타입에 대해 먼저 알아야 한다

8.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 }
val action: () -> Unit = { println(42) }

  • 그냥 함수를 정의한다면 함수의 파라미터 목록 뒤에 오는 Unit 반환 타입 지정을 생략해도 되지만,
    함수 타입을 선언할 때는 반환 타입을 반드시 명시해야 하므로 Unit을 빼먹어서는 안 된다

  • 함수 타입에서도 반환 타입을 널이 될 수 있는 타입으로 지정 할 수 있다

var canReturnNull:(Int, Int) -> Int? = {x, y -> null}
  • 널이 될 수 있는 함수 타입 변수를 정의할 수도 있다
    -> 함수 타입 전체가 널이 될 수있는 타입임을 선언하기 위해 함수 타입을 괄호로 감싸고 그 뒤에 물음표로 붙여야 한다
var funOrNull: ((Int, Int) -> Int)? = null

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
  • 인자로 받은 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 같다

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

  • 컴파일된 코드 안에서 함수 타입은 일반 인터페이스로 바뀐다
    -> 함수 타입의 변수는 FuntionN 인터페이스를 구현하는 객체를 저장한다

  • 함수 인자의 개수에 따라
    Funtion0<R>(인자가 없는 함수), Funtion1<P1,R>(인자가 하나인 함수) 등의 인터페이스를 제공
    -> 각 인터페이스에는 invoke 메소드 정의가 하나 들어 있다
    -> invoke를 호출하면 함수를 실행할 수 있다

  • 함수 타입인 변수는 인자 개수에 따라 적당한 FunctionN 인터페이스를 구현하는 클래스의 인스턴스를 저장하며, 그 클래스의 invoke 메소드 본문에는 람다의 본문이 들어간다

  • 자바 8에서는 람다를 넘기면 자동으로 함수 타입의 값으로 변환되고,
    자바 8 이전의 자바에서는 필요한 FunctionN 인터페이스의 invoke 메소드를 구현하는 무명 클래스를 넘기면 된다

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

  • 파라미터를 함수 타입으로 선언할 때도 디폴트 값을 정할 수 있다

  • JoinToString을 호출할 때마다 매번 람다를 넘기게 만들면 불편할 때가 있다
    -> 함수 타입의 파라미터에 대한 디폴트 값을 지정하면 문제 해결
    -> 디폴트 값으로 람다 식을 넣으면 된다

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)) // "transform" 파라미터로 받은 함수를 호출한다. 
    }

    result.append(postfix)
    return result.toString()
}

fun main(args: Array<String>) {
    val letters = listOf("Alpha", "Beta")
    println(letters.joinToString()) // 디폴트 변환 함수를 사용한다. -> Alpha, Beta
    println(letters.joinToString { it.toLowerCase() }) // 람다를 인자로 전달한다. -> alpha, beta
    println(letters.joinToString(separator = "! ", postfix = "! ",
           transform = { it.toUpperCase() })) // 이름 붙은 인자 구문을 사용해 람다를 포함하는 여러 인자를 전달한다. -> ALPHA! BETA!
}
  • 함수 타입에 대한 디폴트 값을 선언할 때 특별한 구문이 필요하지 않다
    -> = 뒤에 람다를 넣으면 된다

  • 널이 될 수 있는 함수 타입을 사용할 수 도 있다
    -> 널이 될 수 있는 함수 타입으로 함수를 받으면 그 함수 타입을 직접 호출할 수 없다는 점에 유의

8.1.5 함수를 함수에서 반환

  • 다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정해야 한다
data class Person(
        val firstName: String,
        val lastName: String,
        val phoneNumber: String?
)

class ContactListFilters {
    var prefix: String = ""
    var onlyWithPhoneNumber: Boolean = false

    fun getPredicate(): (Person) -> Boolean { // 함수를 반환하는 함수를 정의한다. 
        val startsWithPrefix = { p: Person ->
            p.firstName.startsWith(prefix) || p.lastName.startsWith(prefix)
        }
        if (!onlyWithPhoneNumber) {
            return startsWithPrefix // 함수 타입의 변수를 반환한다. 
        }
        return { startsWithPrefix(it)
                    && it.phoneNumber != null } // 람다를 반환한다. 
    }
}

fun main(args: Array<String>) {
    val contacts = listOf(Person("Dmitry", "Jemerov", "123-4567"),
                          Person("Svetlana", "Isakova", null))
    val contactListFilters = ContactListFilters()
    with (contactListFilters) {
        prefix = "Dm"
        onlyWithPhoneNumber = true
    }
    println(contacts.filter(
        contactListFilters.getPredicate()))
}
  • 고차 함수는 코드 구조를 개선하고 중복을 없앨 때 좋은 도구

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("/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()
    
    
// 일반 함수를 통해 중복 제거하기
// mobile 사용자의 평균 방문 시간
fun List<SiteVisit>.averageDurationFor(os: OS) =
        filter { it.os == os }.map(SiteVisit::duration).average()

fun main(args: Array<String>) {
    println(log.averageDurationFor(OS.WINDOWS))
    println(log.averageDurationFor(OS.MAC))
}

// 고차 함수를 통해 중복 제거하기
// ios 사용자의 /signup 페이지 평균 방문 시간
fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
        filter(predicate).map(SiteVisit::duration).average()

fun main(args: Array<String>) {
    println(log.averageDurationFor {
        it.os in setOf(OS.ANDROID, OS.IOS) })
    println(log.averageDurationFor {
        it.os == OS.IOS && it.path == "/signup" })
}
  • 코드 중복을 줄일 때 함수 타입이 상당히 도움이 된다

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

  • inline 변경자를 어떤 함수에 붙이면 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트코드로 바꿔치기 해준다

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

  • 어떤 함수를 inline으로 선언하면 그 함수의 본문이 인라인된다
    -> 함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신에 함수 본문을 번역한 바이트 코드로 컴파일한다는 뜻이다
  • 👉 인라이닝(inlining)된다 :
    함수의 본문이 코드에 그대로 들어간다.

인라인 함수를 사용한다면
람다식을 사용했을 때 무의미한 객체 생성을 막을 수 있습니다

// 일반 함수로 작성 시
fun print()
{
    println("Hello, world!")
}

fun main()
{
    print()
}
  1. main 시작점
  2. print 함수 시작점으로 점프 (현재 IP를 백업한다)
  3. 스택 프레임을 다시 쌓음
  4. print("Hello, world!n")
  5. 스택 포인터를 원래대로 되돌리기
  6. main으로 돌아옴 (IP 복귀)
// 인라인 함수 사용 시
inline fun print()
{
    println("Hello, world!")
}

fun main()
{
    print()
}

// 컴파일 시에 아래 처럼 컴파일 된다
fun main()
{
    println("Hello, world!")
}





// 다른 예시

fun doSomething() {
    println("Before lambda")
    doSomethingElse {
        println("Inside lambda")
    }
    println("After lambda")
}

1)
fun doSomethingElse(lambda: () -> Unit) {
    println("Doing something else")
    lambda()
}

위에를 컴파일 한다면
public static final void doSomething() {
    System.out.println("Before lambda");
    doSomethingElse(new Function() {
            public final void invoke() {
            System.out.println("Inside lambda");
        }
    });
    System.out.println("After lambda");
}

-> 새로운 객체를 생성하여 넘겨준다



2)
inline fun doSomethingElse(lambda: () -> Unit) {
    println("Doing something else")
    lambda()
}

위에를 컴파일 한다면
public static final void doSomething() {
    System.out.println("Before lambda");
    System.out.println("Doing something else");
    System.out.println("Inside lambda");
    System.out.println("After lambda");
}

-> 무의미하게 Function 객체를 항상 만들어내는 것이 없어졌다
  • 한 인라인 함수를 두 곳에서 각각 다른 람다를 사용해 호출한다면 그 두 호출은 각각 따로 인라인된다

8.2.2 인라인 함수의 한계

  • 인라이닝을 하는 방식으로 인해 람다를 사용하는 모든 함수를 인라이닝할 수는 없다
    -> 람다가 본문에 직접 펼쳐지기 때문에 함수가 파라미터로 전달받은 람다를 본문에 사용하는 방식이 한정될 수 밖에 없다
    -> 파라미터로 받은 람다를 다른 변수에 저장하고 나중에 그 변수를 사용한다면 람다를 표현하는 객체가 어딘가는 존재해야 하기 때문에 람다를 인라이닝할 수 없다

  • 둘 이상의 람다를 인자로 받는 함수에서 일부 람다만 인라이닝하고 싶을 때가 있다면
    -> 인라이닝하면 안되는 람다를 파라미터로 받는다면
    noinline 변경자를 파라미터 이름 앞에 붙여서 인라이닝을 금지할 수 있다

inline fun doSomething(action1: () -> Unit, action2: () -> Unit) {
    action1()
    anotherFunc(action2) // error
}

fun anotherFunc(action: () -> Unit) {
    action()
}

fun main() {
    doSomething({
        println("1")
    }, {
        println("2")
    })
}
  • inline 함수는 내부적으로 코드를 복사하기 때문에, 인자로 전달받은 함수는 다른 함수로 전달되거나 참조할 수 없다

  • 위의 코드에서 doSomething()에 두 번째 인자로 넘겨받은 action2를 또 다른 고차 함수인 anotherFunc()에 인자로 넘겨주려 하고 있다
    이때 doSomething()은 inline 함수로 선언되어 있기 때문에 인자로 전달받은 action2를 참조할 수 없기 때문에 전달하는 것이 불가능하다
    이렇게 모든 인자를 inline으로 처리하고 싶지 않을 때 사용하는 것이 noinline 키워드다

inline fun doSomething(action1: () -> Unit, noinline action2: () -> Unit) {
    action1()
    anotherFunc(action2)
}

fun anotherFunc(action: () -> Unit) {
    action()
}

fun main() {
    doSomething({
        println("1")
    }, {
        println("2")
    })
}

8.2.3 컬렉션 연산 인라이닝

  • 코틀린 표준 라이브러리의 컬렉션 함수는 대부분 람다를 인자로 받는다
    -> 표준 라이브러리를 사용하지 않고 직접 연산을 구현하면 효율적?
// 람다를 사용해 컬렉션 거르기
data class Person(val name: String, val age: Int)
val people = listOf(Person("Kim",29), Person("Park",30))

>>> println(people.filter { it.age < 30 }
[Person(name = Kim, age = 29)]

// 컬렉션 직접 거르기
>>> val result = mutableListOf<Person>()
>>> for(person in people) {
	if(person.age < 30) result.add(person
}

둘의 바이트코드는 거의 동일
  • filter 함수는 인라인 함수이다

  • filter와 map을 연쇄해서 사용하면 두 함수의 본문은 인라이닝되며, 추가 객체나 클래스 생성은 없다
    -> 💣 리스트를 걸러낸 결과를 저장하는 중간 리스트를 만든다

  • asSequence를 통해 리스트 대신 시퀀스를 사용하면 중간 리스트로 인한 부가 비용은 줄어든다
    -> 중간 시퀀스는 람다를 필드에 저장하는 개체로 표현, 최종 연산은 중간 시퀀스에 있는 여러 람다를 연쇄 호출

  • 크기가 큰 컬렉션에만 asSequence를 붙이는게 낫다!

8.2.4 함수를 인라인으로 선언해야 하는 경우

  • 일반 함수 호출의 경우 JVM은 이미 강력하게 인라이닝을 지원

  • 람다를 인자로 받는 함수를 인라이닝하면 얻게되는 이익

  1. 없앨 수 있는 부가 비용이 상당
    -> 함수 호출 비용을 줄일 수 있다
  2. 현재의 JVM은 함수 호출과 람다를 인라이닝해 줄 정도로 똑똑하지는 못하다
  3. 일반 람다에서는 사용할 수 없는 몇 가지 기능을 사용할 수 있다
    -> ex) non-local 반환

8.2.5 자원 관리를 위해 인라인된 람다 사용

  • 람다로 중복을 없앨 수 있는 일반적인 패턴 중 한 가지
    -> 어떤 작업을 하기 전에 자원을 획득하고 작업을 마친 후 자원을 해제하는 자원 관리

  • use 함수
    닫을 수 있는(closeable) 자원 에 대한 확장 함수, 람다를 인자로 받는다
    람다를 호출한 다음에 자원을 닫아준다
    -> 람다가 정상 종료한 경우는 물론 람다안에서 예외가 발생한 경우에도 자원을 확실히 닫는다

  • use 함수도 인라인 함수


8.3 고차 함수 안에서 흐름 제어

8.3.1 람다 안의 return문: 람다를 둘러싼 함수로부터 반환

  • 람다 안에서 return을 사용하면 람다로부터 반환되는 게 아니라 그 람다를 호출하는 함수가 실행을 끝내고 반환된다
// 일반 함수 사용시
fun lambdaParam(a: Int, b: Int, lambda: (Int, Int) -> Unit){
	lambda(a, b)
}

fun func(){
	println("start of func")
	lambdaParam(13, 3) { a, b ->
		val result = a+b
		if(result > 10) return
		println("result: $result")
	}
	println("end of func") 
}

>>> 에러 발생

// inline 함수 사용시
inline fun lambdaParam(a: Int, b: Int, lambda: (Int, Int) -> Unit){
	lambda(a, b)
}

fun func(){
	println("start of func")
	lambdaParam(13, 3) { a, b ->
		val result = a+b
		if(result > 10) return
		println("result: $result")
	}
	println("end of func") 
}
  • 자신을 둘러싸고 있는 블록보다 더 바깥에 있는 다른 블록을 반환하게 만드는 return문을 넌 로컬(non-local) return이라 부른다

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

  • 람다 식에서도 로컬 return을 사용할 수 있다

  • 람다 안에서 로컬 return은 for 루프의 break와 비슷한 역할을 한다

  • return으로 실행을 끝내고 싶은 람다 식 앞에 레이블을 붙이고,
    return 키워드 뒤에 그 레이블을 추가하면 된다

1) 레이블을 통해 로컬 리턴 사용
people.forEach label@{
	if(it.name == "Kim") return@label
}

2) 함수 이름을 return 레이블로 사용
people.forEach {
	if(it.name == "Kim") return@forEach // 람다식으로부터 반환 시킨다
}
  • 람다 식의 레이블을 명시하면 함수 이름을 레이블로 사용할 수 없다

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

  • 무명 함수는 코드 블록을 함수에 넘길 때 사용할 수 있는 방법
// 무명 함수 안에서 return 사용하기
fun lookForAlice(people: List<Person>) {
    people.forEach(fun (person) { // 람다 식 대신 무명 함수 사용
        if (person.name == "Alice") return 
        // return은 가장 가까운 함수를 가리키는데 
        // 이 위치에서 가장 가까운 함수는 무명 함수
        println("${person.name} is not Alice")
    })
}

// filter에 무명 함수 넘기기
people.filter (fun (person): Boolean {
	return person.age < 30
})
블록이 본문인 무명 함수는 반환 타입을 명시해야 하지만,
식을 본문으로 하는 무명 함수의 반환 타입은 생략 가능

// 식이 본문인 무명 함수 사용하기
people.filter(fun (person) = person.age < 30)
  • 무명 함수 안에서 레이블이 붙지 않은 return 식은 무명 함수 자체를 반환시킬 뿐 무명 함수를 둘러싼 다른 함수를 반환시키지 않는다

  • return은 fun 키워드를 사용해 정의된 가장 안쪽 함수를 반환시킨다
    -> 무명 함수는 fun을 사용해 정의되므로 그 함수 자신이 반환 된다

--

📌참고자료

invoke method

inline funtion
inline,noinline 예시

kotlin의 use 함수

label 표현식

profile
개발자희망자

0개의 댓글