관리 메뉴

나만을 위한 블로그

[Kotlin in Action] 5장. 람다 프로그래밍 본문

책/Kotlin in Action

[Kotlin in Action] 5장. 람다 프로그래밍

참깨빵위에참깨빵 2023. 10. 5. 00:11
728x90
반응형

람다는 기본적으로 다른 함수에 넘길 수 있는 작은 코드 조각이다. 람다를 쓰면 공통 코드 구조를 라이브러리 함수로 뽑아낼 수 있다. 람다를 자주 사용하는 경우로 컬렉션 처리를 들 수 있다.

 

람다식과 멤버 참조

 

람다와 컬렉션

 

코드 중복 제거는 프로그래밍 스타일을 개선하는 중요한 방법 중 하나다. 컬렉션을 다룰 때 수행하는 작업 대부분은 몇 가지 일반적인 패턴에 속한다.

예를 들어 사람의 이름, 나이를 저장하는 Person 클래스가 있다.

 

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

 

Person으로 이뤄진 리스트가 있고 그 중에서 가장 연장자를 찾으려면, 람다를 쓴 적이 없는 경우 루프를 통해 직접 검색을 구현할 수 있다.

 

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

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)
}

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 30)
    )
    findTheOldest(people)
}

// >> Person(name=김영희, age=30)

 

이 루프에는 상당히 많은 코드가 들어 있어 실수하기 쉽다. 코틀린에선 라이브러리 함수를 사용할 수 있다.

 

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 30)
    )
    println(people.maxBy { it.age }) // age 프로퍼티를 비교해서 값이 가장 큰 요소 찾기
}

 

모든 컬렉션에 대해 maxBy 함수를 호출할 수 있다. maxBy는 가장 큰 원소를 찾기 위해 비교에 사용할 값을 돌려주는 함수를 인자로 받는다. { it.age } 블록이 비교에 사용할 값을 돌려주는 함수다.

컬렉션의 원소가 Person이었으니 maxBy가 반환하는 값은 Person 객체의 age 필드에 저장된 나이 값이다. 이렇게 함수나 프로퍼티를 반환하는 역할을 수행하는 람다는 멤버 참조로 대치할 수 있다.

 

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 30)
    )
    println(people.maxBy(Person::age))
}

 

자바 컬렉션에 대해 수행하던 작업 대부분은 람다나 멤버 참조를 인자로 받는 라이브러리 함수를 통해 개선할 수 있다.

 

람다식의 문법

 

람다는 값처럼 이곳저곳에 전달 가능한 동작의 모음이다. 람다를 따로 선언해서 변수에 저장할 수도 있지만, 함수에 인자로 넘기면서 바로 람다를 정의하는 경우가 대부분이다.

코틀린 람다식은 항상 중괄호로 둘러싸여 있다. 인자 목록 주변에 괄호가 없다는 사실을 기억하라. 화살표(->)가 인자 목록, 람다 본문을 구별해준다.

람다가 저장된 변수는 다른 일반 함수와 마찬가지로 다룰 수 있다.

 

fun main() {
    val sum = { x: Int, y: Int -> x + y }
    println(sum(1, 2))
}

// >> 3

 

또는 람다식을 직접 호출하면 된다.

 

fun main() {
    { println(42) } ()
}

// >> 42

 

하지만 이런 구문은 읽기 어렵고 그다지 쓸모도 없다. 굳이 람다를 만들자마자 호출하는 것보다 람다 본문을 직접 실행하는 게 낫다.

이렇게 코드 일부분을 블록으로 둘러싸서 실행해야 한다면 run을 사용한다. run은 인자로 받은 람다를 실행하는 라이브러리 함수다.

 

fun main() {
    run { println(42) }
}

 

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

코틀린에는 함수 호출 시 맨 뒤의 인자가 람다식이면 그 람다를 괄호 밖으로 뺄 수 있다는 문법 관습이 있다.

 

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 30)
    )
    println(people.maxBy() { p: Person -> p.age })
}

// >> Person(name=김영희, age=30)

 

람다가 어떤 함수의 유일한 인자고, 괄호 뒤에 람다를 썼다면 호출할 때 빈 소괄호를 없애도 된다.

IDE에선 소괄호가 회색으로 표시되어 없앨 수 있다고 표시된다.

 

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 30)
    )
    println(people.maxBy { p: Person -> p.age })
}

// >> Person(name=김영희, age=30)

 

로컬 변수처럼 컴파일러는 람다 파라미터의 타입도 추론할 수 있다. 따라서 파라미터 타입을 명시할 필요가 없다. maxBy 함수의 경우 파라미터의 타입은 항상 컬렉션 원소 타입과 같다. 컴파일러는 내가 Person 타입의 객체가 든 컬렉션에 대해 maxBy를 호출한다는 걸 알고 있으므로 람다 파라미터도 Person이란 걸 이해할 수 있다.

 

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

 

자바 메서드 안에서 무명 내부 클래스를 정의할 때, 메서드의 로컬 변수를 무명 내부 클래스에서 사용할 수 있다.

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

forEach는 컬렉션의 모든 원소에 대해 람다를 호출해준다. 아래는 메시지의 목록을 받아서 모든 메시지에 같은 접두사를 붙여 출력하는 예시다.

 

fun printMessagesWithPrefix(messages: Collection<String>, prefix: String) {
    messages.forEach {
        println("$prefix $it")
    }
}

fun main() {
    val errors = listOf("403 Forbidden", "404 Not Found")
    printMessagesWithPrefix(errors, "에러 :")
}

// >> 에러 : 403 Forbidden
// >> 에러 : 404 Not Found

 

자바와 다른 점은 코틀린 람다 안에선 final 변수가 아닌 변수에 접근할 수 있다는 것이다. 또한 람다 안에서 바깥의 변수를 변경해도 된다.

아래는 전달받은 상태 코드 목록의 클라이언트, 서버의 오류 횟수를 센다.

 

fun printProblemCounts(responses: Collection<String>) {
    var clientErrors = 0
    var serverErrors = 0
    responses.forEach {
        if (it.startsWith("4")) {
            clientErrors++
        } else if (it.startsWith("5")) {
            serverErrors++
        }
    }

    println("${clientErrors}개의 클라이언트 에러, ${serverErrors}개의 서버 에러")
}

fun main() {
    val errors = listOf("200 OK", "403 Forbidden", "500 Internal Server Error")
    printProblemCounts(errors)
}

// >> 1개의 클라이언트 에러, 1개의 서버 에러

 

람다 안에서 사용하는 외부 변수를 람다가 포획한(capture) 변수라고 부른다. 기본적으로 함수 안에 정의된 로컬 변수의 생명주기는 함수가 리턴되면 끝난다. 하지만 어떤 함수가 자신의 로컬 변수를 포획한 람다를 리턴하거나 다른 변수에 저장한다면, 로컬 변수의 생명주기와 함수의 생명주기가 달라질 수 있다.

 

멤버 참조

 

넘기려는 코드가 이미 함수로 선언된 경우, 코틀린에선 자바 8과 마찬가지로 함수를 값으로 바꿀 수 있다. 이 때 이중 콜론을 사용한다.

이중 콜론을 사용하는 식을 멤버 참조(member reference)라고 부르며, 멤버 참조는 메서드를 단 하나만 호출하는 함수 값을 만들어준다. 참조 대상이 함수인지 프로퍼티인지 상관없이 멤버 참조 뒤에는 괄호를 넣으면 안 된다. 최상위에 선언됐거나 다른 클래스의 멤버가 아닌 함수, 프로퍼티를 참조할 수도 있다.

 

fun salute() = println("Salute")

fun main() {
    run(::salute)
}

// >> Salute

 

컬렉션 함수형 API

 

 

filter, map

 

filter, map은 컬렉션 활용의 기반이 되는 함수다. 대부분의 컬렉션 연산을 두 함수로 표현할 수 있다.

filter 함수는 컬렉션을 순회하면서 주어진 람다에 각 원소를 넘겨, 람다가 true를 리턴하는 원소만 모은다.

 

fun main() {
    val list = listOf(1, 2, 3, 4)
    println(list.filter { it % 2 == 0 })
}

// >> [2, 4]

 

filter 함수는 원소를 변환할 순 없다. 원소를 변환하려면 map 함수를 써야 한다.

map 함수는 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아 새 컬렉션을 만든다.

 

fun main() {
    val list = listOf(1, 2, 3, 4)
    println(list.map { it * it })
}

// >> [1, 4, 9, 16]

 

결과 리스트와 원본 리스트의 원소 개수는 같지만 각 원소는 주어진 함수에 따라 변환된 새 컬렉션이다. 위 예시를 실행하면 1x1, 2x2, 3x3, 4x4를 각각 수행한 결과가 담긴 새 컬렉션이 반환된다.

Person 목록을 활용해서 30살 이상인 사람의 이름을 출력하려면 아래처럼 작성할 수 있다.

 

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 31),
        Person("박철수", 29)
    )
    println(people.filter { it.age > 30 }.map(Person::name))
}

// >> [김영희]

 

all, any, count, find : 컬렉션에 술어 적용

 

컬렉션에 대해 자주 수행하는 연산으로 컬렉션의 모든 원소가 어떤 조건을 만족하는지 판단하는 연산이 있다. 코틀린에선 all, any가 이런 연산을 수행한다. count 함수는 조건을 만족하는 함수의 개수를 리턴하며, find 함수는 조건을 만족하는 첫 원소를 리턴한다.

사람의 나이가 27살 이하인지 판단하는 함수를 만든다.

 

val canBeInClub27 = { p: Person -> p.age <= 27 }

 

모든 원소가 이 함수를 만족하는지 확인하려면 all을 사용한다.

 

val canBeInClub27 = { p: Person -> p.age <= 27 }

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 31),
        Person("박철수", 29)
    )
    
    println(people.all(canBeInClub27))
}

// >> false

 

조건을 만족하는 원소가 하나라도 있는지 확인하려면 any를 사용한다.

 

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 31),
        Person("박철수", 29)
    )

    println(people.any(canBeInClub27))
}

// >> true

 

groupBy : 리스트를 여러 그룹으로 이뤄진 Map으로 변경

 

컬렉션의 모든 원소를 어떤 특성에 따라 여러 그룹으로 나누는 경우를 가정한다. 사람을 나이에 따라 분류할 경우, 특성을 파라미터로 전달하면 컬렉션을 자동으로 구분하는 함수가 있다면 편리할 것이다. groupBy 함수가 그런 역할을 한다.

 

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 31),
        Person("박철수", 20)
    )

    println(people.groupBy { it.age })
}

// >> {20=[Person(name=김철수, age=20), Person(name=박철수, age=20)], 31=[Person(name=김영희, age=31)]}

 

결과의 각 그룹은 리스트다. 따라서 위 예시에서 groupBy의 리턴타입은 Map<Int, List<Person>>이다. 필요하면 이 Map을 mapKeys, mapValues 등을 써서 변경할 수도 있다.

멤버 참조를 통해 문자열을 첫 글자에 따라 분류하는 예시도 확인한다.

 

fun main() {
    val list = listOf("a", "ab", "b")
    println(list.groupBy(String::first))
}

// >> {a=[a, ab], b=[b]}

 

first는 String의 멤버가 아닌 확장 함수지만 여전히 멤버 참조를 통해 first에 접근할 수 있다.

 

flatMap, flatten : 중첩된 컬렉션 내부의 원소 처리

 

Book으로 표현한 책의 정보를 저장하는 도서관이 있다고 가정한다.

 

data class Book(val title: String, val authors: List<String>)

 

책마다 저자가 한 명이거나 여럿일 수 있다. 이제 도서관에 있는 책의 저자를 모두 모은 집합을 아래처럼 가져온다고 가정한다.

 

fun main() {
    val books = listOf(
        Book("title1", listOf("a")),
        Book("title2", listOf("a", "b")),
        Book("title3", listOf("c", "d", "e")),
    )
    println(books.flatMap { it.authors }.toSet())
}

// >> [a, b, c, d, e]

 

flatMap 함수는 먼저 인자로 주어진 람다를 컬렉션의 모든 객체에 적용하고, 람다 적용 결과 얻는 여러 리스트를 한 리스트로 모은다. 아래는 문자열에 대해 이 함수를 적용한 예시다.

 

fun main() {
    val strings = listOf("abc", "def")
    println(strings.flatMap { it.toList() })
}

// >> [a, b, c, d, e, f]

 

toList()를 문자열에 적용하면 그 문자열에 속한 모든 문자로 이뤄진 리스트가 생성된다. map, toList를 같이 쓰면 문자로 이뤄진 리스트로 구성된 리스트가 생성된다.

flatMap 함수는 그 다음 단계로 리스트의 리스트에 들어있던 모든 원소로 이뤄진 단일 리스트를 리턴한다. Books 예제를 조금 수정한다.

 

fun main() {
    val books = listOf(
        Book("aaa", listOf("김철수")),
        Book("bbb", listOf("김철수", "김영희")),
        Book("ccc", listOf("박철수")),
    )
    println(books.flatMap { it.authors }.toSet())
}

// >> [김철수, 김영희, 박철수]

 

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

 

앞의 컬렉션 함수들은 결과 컬렉션을 즉시(eagerly) 생성한다. 이는 컬렉션 함수를 연쇄로 사용하면 매 단계마다 계산 중간 결과를 새 컬렉션에 임시로 담는단 뜻이다. 시퀀스를 쓰면 중간 임시 컬렉션을 쓰지 않고도 컬렉션 연산을 연쇄할 수 있다.

 

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 31),
        Person("박철수", 20)
    )
    people.map(Person::name).filter { it.startsWith("김") }
}

 

한 리스트는 filter 결과를 담고 다른 하나는 map의 결과를 담는다. 원본 리스트에 요소가 3개 정도라면 리스트가 2개 더 생겨도 큰 문제는 안 되겠지만 요소가 수백만 단위가 되면 효율이 떨어진다. 이걸 더 효율적으로 굴리려면 각 연산이 컬렉션을 직접 사용하는 대신 시퀀스를 사용하게 만들어야 한다.

 

fun main() {
    val people = listOf(
        Person("김철수", 20),
        Person("김영희", 31),
        Person("박철수", 20)
    )
    people.asSequence()
        .map(Person::name)
        .filter { it.startsWith("김") }
        .toList()
}

 

코틀린 지연 계산 시퀀스는 Sequence 인터페이스에서 시작한다. 이 인터페이스는 한 번에 하나씩 열거될 수 있는 원소의 시퀀스를 표현할 뿐이다. Sequence 안에는 iterator라는 메서드만 있다. 그 메서드를 통해 시퀀스로부터 요소 값을 얻을 수 있다.

시퀀스 요소는 필요할 때 계산된다. 따라서 중간 처리 결과를 저장하지 않고도 연산을 연쇄 적용해서 효율적으로 계산할 수 있다. 시퀀스의 요소를 차례로 순회해야 한다면 시퀀스를 직접 써도 된다. 하지만 시퀀스 원소를 인덱스를 통해 접근하는 등 다른 API 메서드가 필요하면 시퀀스를 리스트로 바꿔야 한다.

 

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

 

시퀀스에 대한 연산은 중간 연산, 최종 연산으로 나뉜다. 중간 연산은 다른 시퀀스를 리턴하고 최종 연산은 결과를 리턴한다. 중간 연산은 항상 지연 계산된다.

 

시퀀스의 경우 모든 연산은 각 원소에 대해 순차 적용된다. 즉 첫 원소가 변환되고 걸러지는 처리가 이뤄지고, 다시 2번째 원소가 처리되는 식의 처리가 모든 원소에 적용된다. 따라서 원소에 연산을 차례로 적용하다가 결과가 얻어지면, 그 이후의 원소에 대해선 변환이 이뤄지지 않을 수도 있다.

 

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

 

코틀린 라이브러리, 람다를 쓰는 건 맞지만 내가 다뤄야 하는 API 상당수는 자바로 작성된 API일 가능성이 높다. 다행인 것은 코틀림 람다를 자바 API에 써도 아무 문제 없다는 것이다. 여기선 어떻게 코틀린 람다를 자바 API에 활용할 수 있을지 확인한다.

안드로이드의 Button 클래스는 setOnClickListener()를 통해 버튼의 리스너를 설정한다. 이 때 인자의 타입은 OnClickListener다. OnClickListener 인터페이스는 onClick()만 선언된 인터페이스다. 자바 8 이전의 자바에선 setOnClickListener()에 인자로 넘기기 위해 무명 클래스의 인스턴스를 만들어야 했다. 코틀린에선 무명 클래스 인스턴스 대신 람다를 넘길 수 있다.

 

button.setOnClickListener { view -> ... }

 

이 람다에는 View 타입의 view 파라미터가 있다. 이것은 onClick()의 인자 타입과 같다. 이런 코드가 작동하는 이유는 OnClickListener에 추상 메서드 하나만 있기 때문이다. 이런 인터페이스를 함수형 인터페이스 또는 SAM 인터페이스라고 한다. SAM 인터페이스는 단일 추상 메서드라는 뜻이다.

 

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

 

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

 

fun createAllDoneRunnable(): Runnable {
    return Runnable { println("완료됨") }
}

fun main() {
    createAllDoneRunnable().run()
}

// >> 완료됨

 

위 코드에서 SAM 생성자의 이름은 사용하려는 함수형 인터페이스 이름과 같다. SAM 생성자는 그 함수형 인터페이스의 유일한 추상 메서드의 본문에 쓸 람다만 인자로 받아서 함수형 인터페이스를 구현하는 클래스의 인스턴스를 리턴한다.

람다로 만든 함수형 인터페이스 인스턴스를 변수에 저장해야 하는 때도 SAM 생성자를 쓸 수 있다. 또한 함수형 인터페이스를 요구하는 메서드 호출 시 대부분의 SAM 변환을 컴파일러가 자동 수행할 수 있지만, 가끔 오버로드한 메서드 중에서 어떤 타입의 메서드를 선택해 람다를 변환해서 넘겨야 할지 애매한 때가 있다. 이 때 명시적으로 SAM 생성자를 적용하면 오류를 피할 수 있다.

 

수신 객체 지정 람다 : with, apply

 

with 함수

 

어떤 객체의 이름을 반복하지 않고 그 객체에 대해 여러 연산을 수행할 수 있다면 좋을 것이다. 코틀린도 다른 다양한 언어들과 같이 이 기능을 제공하지만 with라는 라이브러리 함수로 제공한다.

 

fun alphabet(): String {
    val result = StringBuilder()
    for (letter in 'A' .. 'Z') {
        result.append(letter)
    }
    result.append("\n이제 난 알파벳을 깨우쳤어")

    return result.toString()
}

fun main() {
    println(alphabet())
}

// >> ABCDEFGHIJKLMNOPQRSTUVWXYZ
// >> 이제 난 알파벳을 깨우쳤어

 

위에서 result에 대해 다른 여러 메서드를 호출하며 매번 result를 반복 사용했다. 이것보다 더 긴 코드거나 result를 자주 반복해야 했다면 어땠을까?

이걸 with로 다시 작성하면 아래와 같다.

 

fun alphabet(): String {
    val result = StringBuilder()
    return with(result) {
        for (letter in 'A' .. 'Z') { // 메서드를 호출하려는 수신 객체 지정
            this.append(letter) // this를 써서 앞에서 지정한 수신 객체(StringBuilder)의 메서드 호출
        }
        append("\n이제 난 알파벳을 깨우쳤어")
        this.toString()
    }
}

 

with은 실제로 파라미터가 2개 있는 함수다. 위 코드에서 첫 파라미터는 StringBuilder고 2번째 파라미터는 람다다. 람다를 괄호 밖으로 빼내는 관례를 사용함에 따라 전체 함수 호출이 언어가 제공하는 특별 구문처럼 보인다.

with 함수는 첫 인자로 받은 객체를 2번째 인자로 받은 람다의 수신 객체로 만든다. 인자로 받은 람다 본문에선 this를 써서 그 수신 객체에 접근할 수 있다. this와 점을 쓰지 않고 프로퍼티나 메서드명만 써도 수신 객체의 멤버에 접근 가능하다. 위 코드 중 this.append()에서 this를 없애도 작동한다.

with 함수가 리턴하는 값은 람다 코드를 실행한 결과고, 이 결과는 람다식의 본문에 있는 마지막 식의 값이다.

 

apply 함수

 

apply는 with와 거의 같다. 유일한 차이는 apply는 항상 자신에게 전달된 객체를 리턴한다는 것 뿐이다.

 

fun alphabet() = StringBuilder().apply { 
    for (letter in 'A' .. 'Z') {
        append(letter)
    }
    append("\n이제 난 알파벳을 깨우쳤어")
}.toString()

 

apply는 확장 함수로 정의돼 있다. apply의 수신 객체는 받은 람다의 수신 객체가 된다. 위 함수에서 apply의 실행 결과는 StringBuilder 객체가 된다.

apply는 인스턴스를 만들면서 즉시 프로퍼티 중 일부를 초기화해야 하는 때 유용하다. 자바에선 보통 Builder 객체가 이 역할을 담당한다.

반응형
Comments