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

코틀린 스터디 - 5장. 람다로 프로그래밍

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

코틀린 스터디 - 5장. 람다로 프로그래밍

다루는 내용

람다란 기본적으로 다른 함수에 넘길 수 있는 작은 코드 조각을 뜻한다.

  • 람다 식과 멤버 참조
  • 함수형 스타일로 컬렉션 다루기
  • 시퀀스: 지연 커렉션 연산
  • 자바 함수형 인터페이스를 코틀린에서 사용
  • 수신 객체 지정 람다 사용

5.1 람다식과 멤버 참조

5.1.1 람다 소개: 코드 블록을 함수 인자로 넘기기

람다 식을 사용하면 함수를 선언할 필요가 없고 코드 블록을 직접 함수의 인자로 전달할 수 있다.

자바의 경우

// 무명 내부클래스로 리스너 구현하기
button.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View view) {
        /* 클릭 시 수행할 동작 */
    }
});

코틀린의 경우

// 람다로 리스너 구현하기
button.setOnClickListener { /* 클릭 시 수행할 동작 */ }

5.1.2 람다와 컬렉션

사람의 이름과 나이를 저장하는 Person 클래스

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

Person으로 이뤄진 리스트에서 가장 연장자를 찾고 싶을 경우

직접 검색

fun findTheOldest(people: List<Person>) {
    var maxAge = 0
    var theOldest: Person? = null

    for (person in people) {
        if (person.age > maxAge) {
            maxAge = person.age
            theOldest = person
        }
    }
    println(theOldest)
}

>>> val people = listOf(Person("Alice", 29), Person("Bob", 31)))
>>> findTheOldest(people)
Person(name=Bob, age=31)

람다를 사용

people.maxBy({ p: Person -> p.age })

>>> val people = listOf(Person("Alice", 29), Person("Bob", 31)))
>>> println(people.maxBy { it.age }) // it는 collection의 원소, age는 멤버변수
Person(name=Bob, age=31)

멤버 참조를 사용

people.maxBy(Person::age)

5.1.3 람다 식의 문법

  • 람다 식은 항상 중괄호로 둘러싸여있다.
  • '->' 가 인자 목록과 람다 본문을 구분한다.
  • 람다 식은 변수에 저장할 수 있다.
  • >>> val sum = { x: Int, y: Int -> x + y } >>> println(sum(1, 2)) 3
  • 람다 식을 직접 호출해도 된다.
  • >>> { println(42) }() 42
  • run을 사용하여 람다의 본문을 실행할 수 있다.
  • >>> run { println(42) } 42

실행 시점에 코틀린 람다 호출에는 아무 부가 비용이 들지 않으며,
프로그램의 기본 구성요소와 비슷한 성능을 낸다. (8.2절 보충설명)

연장자 찾기 예제 풀어쓰기

>>> val people = listOf(Person("Alice", 29), Person("Bob", 31)))
>>> println(people.maxBy { it.age }) // it는 collection의 원소, age는 멤버변수
Person(name=Bob, age=31)

// 풀어쓸 경우
people.maxBy({ p: Person -> p.age })
  • 코틀린에는 함수 호출 시 맨 뒤에 있는 인자가 람다 식이라면 그 람다를 괄호 밖으로 빼낼 수 있다는 문법 관습이 있다.
  • // 람다 식을 뺀 경우 people.maxBy() { p: Person -> p.age }
  • 람다가 어떤 함수의 유일한 인자이고 괄호 뒤에 람다를 썼다면 호출 시 빈 괄호를 없애도 된다.인자가 여럿 있는 경우에는 람다를 밖으로 빼낼 수도 있고 넣을 수도 있다.
    둘 이상의 람다를 인자로 받는 함수라고 해도 인자 목록의 맨 마지막 람다만 밖으로 뺄 수 있다.
  • // 괄호를 제거 people.maxBy { p: Person -> p.age }
  • 람다의 파라미터 타입 제거하기
  • people.maxBy { p: Person -> p.age } // 파라미터 타입을 명시 people.maxBy { p -> p.age } // 파라미터 타입을 생략 (컴파일러가 추론)
  • 람다의 파라미터가 하나뿐이고 그 타입을 컴파일러가 추론할 수 있는 경우 it을 바로 쓸 수 있다.it을 사용하는 관습은 코드를 아주 간단하게 만들어주지만 남용해선 안된다.
    람다 안에 람다가 중첩되는 경우 각 람다의 파라미터를 명시해야한다.
  • people.maxBy { it.age } // "it"은 디폴트 파라미터 이름
  • 람다를 변수에 저장할 때는 파라미터의 타입을 추론할 문맥이 존재하지 않는다.
  • >>> val getAge = { p: Person -> p.age } >>> people.maxBy(getAge)
  • 본문이 여러 줄로 이루어진 경우 본문의 마지막에 있는 식이 람다의 결과 값이 된다.
  • val sum = { x: Int, y: Int -> println("Computing the sum of $x and $y...") x + y }

5.1.4 현재 영역에 있는 변수에 접근

람다를 함수 안에서 정의하면 함수의 파라미터뿐 아니라 람다 정의의 앞에 선언된 로컬 변수까지 람다에서 사용할 수 있다.

fun printMessageWithPrefix(messages: Collection<String>, prefix: String) {
    messages.forEach {          // 각 원소에 수행할 작업을 람다로 받는다.
        println("$prefix $it")     // 람다 안에서 prefix 파라미터를 사용한다.
    }
}

>>> val errors = listOf("403 Forbidden", "404 Not Found")
>>> printMessagesWithPrefix(errors, "Error:")
Error: 403 Forbidden
Error: 404 Not Found

람다 안에서는 파이널 변수가 아닌 변수에 접근할 수 있다. (자바와의 차이)

fun printProblemCounts(responses: Collection<String>) {
    var clientErrors = 0

    responses.forEach {
        if (it.startWith("4")) {
            clientErrors++
        }
    }

    println("$clientErrors client errors")
}

>>> val responses = listOf("200 OK", "418 I'm a teapot", "500 Internal Server Error")
>>> printProblemCounts(responses)
1 client errors

예제의 prefix, clientErrors와 같이 람다 안에서 사용하는 외부 변수를 람다가 포획(capture)한 변수라고 부른다.
어떤 함수가 자신의 로컬 변수를 포획한 람다를 반환하거나 다른 변수에 저장한다면 로컬 변수의 생명주기와 함수의 생명주기가 달라질 수 있다.
포획한 변수가 있는 람다를 저장해서 함수가 끝난 뒤에 실행해도 람다의 본문 코드는 여전히 포획한 변수를 읽거나 쓸 수 있다.
파이널 변수를 포획한 경우 람다 코드를 변수 값과 함께 저장하며, 파이널이 아닌 변수를 포획한 경우 변수를 특별한 래퍼로 감싸 람다 코드와 저장한다.

// 변경 가능한 변수 포획하기: 자세한 구현 방법
class Ref<T>(var value: T)
>>> val counter = Ref(0)
>>> val inc = { counter.value++ }

var counter = 0
val inc = { counter++ }

5.1.5 멤버 참조

넘기려는 코드가 이미 함수로 선언된 경우는 멤버 참조를 사용한다.
:: 를 사용하는 식을 멤버 참조라고 부른다.

// Person - 클래스, age - 멤버
val getAge = Person::age
val getAge = { person : Person -> person.age }

최상위에 선언된 함수나 프로퍼티를 참조할 수도 있다.

fun salute() = println("Salute!")
>>> run(::salute)    // 최상위 함수를 참조한다.
Salute!

생성자 참조를 사용하면 클래스 생성 작업을 연기하거나 저장해둘 수 있다.

data class Person(val name: String, val age: Int)
>>> val createPerson = ::Person.   // "Person"의 인스턴스를 만드는 동작을 값으로 저장
>>> val p = createPerson("Alice", 29)
>>> println(p)
Person(name=Alice, age=29)

확장 함수도 멤버 함수와 똑같은 방식으로 참조할 수 있다.

fun Person.isAdult() = age >= 21
val predicate = Person::isAdult

바운드 멤버 참조 (코틀린 1.1+)

>>> val p = Person("Dmitry", 34)
>>> val personsAgeFunction = Person::age
>>> println(personsAgeFunction(p))
34

>>> val dmitrysAgeFunction = p::age
>>> println(dmitrysAgeFunction())
34

5.2 컬렉션 함수형 API

5.2.1 filter, map

>>> var list = listOf(1, 2, 3, 4)
>>> println(list.filter { it % 2 == 0 })   // 짝수 filter
[2, 4]

>>> println(list.map { it * it })   // 제곱 map
[1, 4, 9, 16]

// 주의할점
people.filter { it.age == people.maxBy(Person::age)!!.age }  // maxBy 100번 연산

val maxAge = people.maxBy(Person::age)!!.age // maxBy 1번 연산
people.filter { it.age == maxAge }

5.2.2 all, any, count, find

>>> var list = listOf(1, 2, 3, 4)
>>> println(list.all { it % 2 == 0 })   // 모든 원소가 짝수인지
false

>>> println(list.any { it % 2 == 0 })   // 짝수인 원소가 하나라도 있는지
true

>>> println(list.count { it % 2 == 0 })   // 짝수인 원소의 개수
2

>>> println(list.find { it % 2 == 0 })   // 짝수인 원소를 하나 반환
2

5.2.3 groupBy

결과 타입은 Map

>>> val people = listOf(Person("Alice", 31), Person("Bob", 29), Person("Carol", 31))
>>> println(people.groupBy { it.age })
{29=[Person("Bob", 29)], 31=[Person("Alice", 31), Person("Carol", 31)]}

5.2.4 flatMap

class Book(val title: String, val authors: List<String>)
books.flatMap { it.authors }.toSet()    // books 컬렉션에 있는 책을 쓴 모든 저자의 집합

5.3 지연 계산(lazy) 컬렉션 연산

앞서 설명한 컬렉션 함수를 사용할 경우 결과를 즉시 생성한다.
이는 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 말이다.
시퀀스를 사용할 경우 중간 임시 컬렉션을 사용하지 않고 컬렉션 연산을 연쇄할 수 있다. (자바의 경우 stream)

// 아래와 같은 경우 map, filter의 중간 결과로 2개의 컬렉션이 추가로 생성된다.
people.map(Person::name).filter { it.startsWith("A") }

// 시퀀스를 사용할 경우 중간 처리 결과를 따로 저장하지 않는다.
people.asSequence().map(Person::name).filter { it.startsWith("A") }.toList()

5.3.1 시퀀스 연산 실행: 중간 연산과 최종 연산

  • 시퀀스의 연산은 중간 연산과 최종 연산으로 나뉜다.
    중간 연산은 다른 시퀀스를 반환하며 항상 지연 계산된다.
    최종 연산을 호출해야만 연기되었던 모든 중간 연산이 수행된다.
  • 즉시 계산과 지연 계산의 차이점
  • listOf(1, 2, 3, 4).asSequence().map { it * it }.find { it > 3 })

 

순서에 따른 연산 횟수 차이

val people = listOf(Person("Alice", 29), Person("Bob", 31), Person("Charles", 31), Person("Dan", 21))

왼쪽   - people.asSequence().map(Person::name).filter { it.name.length < 4 }.toList())
오른쪽 - people.asSequence().filter { it.name.length < 4 }.map(Person::name).toList())

 

5.3.2 시퀀스 만들기

val naturalNumbers = generateSequence(0) {it + 1}
val numbersTo100 = naturalNumbers.takeWhile { it <= 100}
println(numbersTo100.sum())
5050

5.4 자바 함수형 인터페이스 활용

코틀린 람다를 자바 API에 사용해도 아무런 문제가 없다.

자바의 경우

// 무명 내부클래스로 리스너 구현하기
button.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View view) {
        ...
    }
});

코틀린의 경우

// 람다로 리스너 구현하기
button.setOnClickListener { view -> ... }

위와 같은 코드가 동작하는 이유는 OnClickListener에 추상 메소드가 단 하나만 있기 때문이다.
그런 인터페이스를 함수형 인터페이스 또는 SAM 인터페이스라고 한다.
SAM - Single Abstract Method (= Runnable, Callable 인터페이스)

5.4.1 자바 메소드에 람다를 인자로 전달

/* 자바 */
void postponeComputation(int delay, Runnable computation);
// 컴파일러가 자동으로 람다를 Runnable을 구현한 무명 클래스의 인스턴스로 변환
postponeComputation(1000) { println(42) }

// 명시적으로 Runnable을 구현한 무명 객체를 생성
postponeComputation(1000, object : Runnable {
    override fun run() {
        println(42)
    }
})

람다와 무명 객체에는 차이가 있다.
위와 같이 객체를 명시적으로 선언하는 경우 메소드를 호출할 때 마다 새로운 객체가 생성된다.

// 명시적인 객체를 생성하면서 람다와 동일하게 작동하는 코드
val runnable = Runnable { println(42) } // SAM 생성자
fun handleComputation() {
    postponeComputation(1000, runnable)
}

람다가 주변 영역의 변수를 포획한다면 매 호출마다 같은 인스턴스를 사용할 수 없다.
그런 경우 컴파일러는 매번 주변 영역의 변수를 포획한 새로운 인스턴스를 생성해준다.

fun handleComputation(id: String) {
    postponeComputation(1000) { println(id) }  // handleComputation을 호출할 때마다 새로 Runnable 인스턴스 생성
}

람다의 자세한 구현
코틀린 1.0에서 인라인(inline) 되지 않은 모든 람다 식은 무명 클래스로 컴파일된다.
inline으로 표시된 코틀린 함수에게 람다를 넘기면 무명 클래스도 만들어지지 않는다.
이에 대해서는 8.2절에서 설명한다.

5.4.2 SAM 생성자: 람다를 함수형 인터페이스로 명시적으로 변경

SAM 생성자는 람다를 함수형 인터페이스의 인스턴스로 변환할 수 있게 컴파일러가 자동으로 생성한 함수다.
컴파일러가 자동으로 람다를 함수형 인터페이스 무명 클래스로 바꾸지 못하는 경우 SAM 생성자를 사용할 수 있다.

// 반환형이 함수형 인터페이스의 인스턴스라면 람다를 반환할 수 없다. 따라서 람다를 SAM 생성자로 감싸서 반환해야한다.
fun createAllDoneRunnable(): Runnable {
    return Runnable { println("All done!") }
}

5.5 수신 객체 지정 람다: with와 apply

수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있게 하는 것이다.
그런 람다를 수신 객체 지정 람다라고 부른다. (with, apply는 수신 객체 지정 람다의 한 종류)

5.5.1 with 함수

// A~Z 까지 append 하는 함수
fun alphabet(): String {
    val result = StringBuilder()
    for (letter in 'A'..'Z') {
        result.append(letter)
    }
    result.append("\nNow I know the alphabet!")
    return result.toString()
}

>>> println(alphabet())
ABCDEFGHIJKLMNOPQRSTUVWXYZ
Now I know the alphabet!

wtih로 리팩토링

fun alphabet(): String {
    val result = StringBuilder()
    return with(result) {
        for (letter in 'A'..'Z') {
            append(letter)
        }
        append("\nNow I know the alphabet!")
        toString()
    }
}

with가 반환하는 값은 람다 코드를 실행한 결과이며, 그 결과는 람다 식의 본문에 있는 마지막 식의 값이다.
그러나 때로는 람다의 결과 대신 수신 객체가 필요한 경우도 있다.

5.5.2 apply 함수

apply와 with는 거의 같으며, 유일한 차이는 apply는 항상 자신에게 전달된 객체를 반환한다는 점이다.

fun alphabet() = StringBuilder().apply {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet!")
}.toString()

 

반응형

댓글