Kotlin

[Kotlin] Lambda 식(람다 식) & 람다의 캡처 & Member Reference(멤버 참조)

sh1mj1 2024. 1. 11. 20:00

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

이미지 출처             https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

 

lambda expression(람다 식) 또는 lambda(람다)는 다른 함수에 넘길 수 있는 작은 코드 조각이다.

코틀린 표준 라이브러리는 람다를 아주 많이 사용하며, 특히 컬렉션 처리에서 많이 사용한다.

람다 소개

일련의 동작을 변수에 저장하거나 다른 함수에 넘겨야 하는 경우가 자주 있다.

자바에서는 익명 내부 클래스를 통해 이를 구현했다.

하지만, 이는 꽤 번거롭다.

 

함수형 프로그래밍에서는 함수를 값처럼 다룬다. 

그래서 함수를 직접 다른 함수에 전달할 수 있다.

 

자바 코드 - 버튼에 액션을 추가하는 리스너(익명 내부 클래스로 구현)

public void setListener(Button button) {
    button.addActionListener(new ActionListener() {
        @Override
        public void actionPerformed(ActionEvent e) {
            System.out.println("수행할 동작을 구현하자");
        }
    });
}

익명 내부 클래스 선언 때문에 꽤 번잡하다.

자바 8에서도 람다를 사용할 수 있다.

 

자바 - 버튼에 액션을 추가하는 리스너(람다로 구현)

public void setListener(Button button) {
    button.addActionListener(e -> System.out.println("수행할 동작을 구현하자"));
}

코틀린에서도 자바 8과 마찬가지로 람다를 사용할 수 있다.

 

코틀린 - 버튼에 액션을 추가하는 리스너(람다로 구현)

fun setListener(button: Button) = button.addActionListener { println("수행할 동작을 구현하자") }

이렇게 람다를 사용하면 익명 내부 클래스와 같은 역할를 하지만 더 간결하고 읽기 쉽다.

람다는 메서드가 하나뿐인 익명 객체를 대신하여 사용할 수 있다. (나중에 자세히 다룬다:SAM 관련)

람다와 컬렉션

컬렉션을 다룰 때 수행하는 작업의 대다수는 몇 가지 일반적인 패턴으로 정해져있다.

람다 덕분에, 컬렉션을 편하게 처리할 수 있는 좋은 라이브러리가 많다.

 

아래 `Person` 클래스를 사용하여 연장자를 찾는 로직을 구현해보자.

 

`Person` 데이터 클래스

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

 

`findTheOldest1` - 람다 없이 연장자 찾기 

fun findTheOldest1(people: List<Person>): Person? {
    var maxAge = 0
    var theOldest: Person? = null
    for (person in people) {
        if (person.age > maxAge) {
            maxAge = person.age
            theOldest = person
        }
    }
    return theOldest
}

@Test
fun testFindTheOldest1() {
    val people = listOf(Person("Alice", 29), Person("Bob", 31))
    assert(findTheOldest1(people) == Person("Bob", 31))
}

그런데 이 루프는 코드가 너무 많으며, 가독성이 떨어지고, 실수를 하기도 쉽다.

또한 이 동작은 꽤나 일반적인 패턴이다.

코틀린 라이브러리에 있는 람다 함수를 사용해보자

 

`findTheOldest2` - 람다 사용하여 연장자 찾기

fun findTheOldest2(people: List<Person>): Person = people.maxBy { it.age }

@Test
fun testFindTheOldest2() {
    val people = listOf(Person("Alice", 29), Person("Bob", 31))
    assert(findTheOldest2(people) == Person("Bob", 31))
}

이렇게 모든 컬렉션에 대해 `maxBy` 함수를 호출할 수 있다.

 

이렇게 함수나, 프로퍼티를 리턴하는 역할을 하는 람다는 멤버 참조로도 사용할 수 있다.

멤버 참조는 더 아래에서 다루겠다.

people.maxBy(Person::age)
// 아래와 같다.
people.maxBy { it.age }
여기서 `maxBy` 함수는 `people` 에 대해 `age` 가 가장 큰 값을 리턴하고 만약 모두 같다면,
가장 처음 검사한(가장 앞에 있는) 값이 리턴된다.
모두 29 살이었다면, `Person("Alice", 29)` 가 리턴될 것이다.

람다 식의 문법

이제 람다 식의 문법을 알아보자.

가장 기본적인 람다 식 문법이다.

람다를 따로 변수에 저장하는 것도 가능하다.

val sum = {x: Int, y: Int -> x + y}

@Test
fun testSum1() = assert(sum(1, 2) == 3)

기본적인 람다 식 문법에서는 중괄호 안에 파라미터와 본문이 있으면 된다고 했다.

파라미터가 아예 없는 람다 식도 있다.

즉, 아래 코드도 람다식인 것이다.

{ println(42) }()

`{ ... }` 가 `() -> Unit` 타입의 람다 식이고, `()` 를 통해 호출하고 있다.

그런데 굳이 위처럼 람다 식을 사용하면, 가독성도 나쁘고 쓸모도 없다.

 

코드의 일부분을 블록으로 둘러싸서 실행할 필요가 있다면 `run` 을 사용한다.

run { println(100) }

`run` 은 람다 본문에 있는 코드를 실행하는 것이다.

참고로 런타임에 람다 호출하는 것은 부가 비용이 들지 않으며 프로그램 기본 컴포넌트와 성능이 비슷하다.
이와 관련해선 `inline` 에 대한 이해가 필요한데 내용이 많으므로 나중에 설명한다.

람다 식 간결히 사용하기

위에서 `maxBy` 라는 함수를 사용했었다. 

가장 기본적인 람다 식의 문법으로 `maxBy` 를 처음부터 다시 호출해보자.

 

`maxBy` - 가장 기본적인 람다 식의 문법 사용

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

중괄호 안 식이 람다 식이며, 이 람다 식을 `maxBy` 함수에 넘기고 있다.

참고로 `maxBy` 의 패러미터는 `selector: (T) -> R` 이며 리턴 타입은 `T?` 이다.

 

이 코드는 더 줄일 수 있다.

코틀린에는 함수 호출 시 맨 뒤에 있는 인자(argument)가 람다 식이라면 그 람다를 괄호 밖으로 빼낼 수 있다.

이 예제 코드에서는 람다가 유일한 인자이기 때문에 마지막 인자이다.

 

`maxBy` - 람다를 괄호 밖으로 빼내기

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

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

 

`maxBy` - 빈 괄호를 없애기

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

둘 이상의 람다를 인자로 받는 함수라면, 마지막 람다만 밖으로 빼낼 수 있다.

그런 경우, 괄호를 사용하는 일반적인 문법을 사용하는 편이 더 낫다.

 

람다를 빼는 것을 보기 위해서 람다를 파라미터로 받는 코틀린 표준 라이브러리의 함수를 사용해보자

코틀린 표준 라이브러리의 `joinToString`

public fun <T> Iterable<T>.joinToString(separator: CharSequence = ", ", prefix: CharSequence = "", postfix: CharSequence = "", limit: Int = -1, truncated: CharSequence = "...", transform: ((T) -> CharSequence)? = null): String {
    return joinTo(StringBuilder(), separator, prefix, postfix, limit, truncated, transform).toString()
}

마지막 패러미터가 `transform: ((T) -> CharSequence)? = null)` 인 것을 볼  수 있다.

리스트의 원소를 `toString` 이 아닌, 다른 방식을 통해 문자열로 변환하고 싶은 경우에 이 인자를 활용한다.

 

`joinToString` 함수 사용 - 람다 식을 사용

@Test
fun testJoinToString() {
    val names = people.joinToString(separator = " ", transform = { p: Person -> p.name })
    assert(names == "Alice Bob")
}

 

`joinToString` 함수 사용 - 람다를 괄호 밖에서 전달

people.joinToString(separator = " ") { p: Person -> p.name }

다시 `maxBy` 를 사용하는 구문으로 돌아가서 코드를 더 깔끔히 해보자

 

`maxBy` - 람다 파라미터 타입 제거

people.maxBy { p -> p.age }

컴파일러는 람다 파라미터 타입도 추론할 수 있다.

(물론 항상 타입 추론이 가능한 것은 아니다. 상황에 맞게 잘 사용하면 된다.)

`maxBy` 함수의 파라미터 타입은 항상 컬렉션 원소 타입과 같다. 그래서 람다의 파라미터도 `Person` 이라는 사실을 알 수 있다.

 

`maxBy` - 파라미터 이름을 디폴트 이름인 `it` 으로 변경

people.maxBy { it.age }

람다의 파라미터가 하나뿐이고, 그 타입을 컴파일러가 추론할 수 있다면, `it` 을 바로 쓸 수 있다.

람다 파라미터 이름을 지정하지 않은 경우에만 `it` 이라는 이름이 자동으로 만들어진다.

(상황에 따라 it 을 사용하지 않는 편이 가독성이 더 좋을 때도 있다.)

 

람다 식 간결히 사용 - 람다를 변수에 저장할 때 

람다를 따로 변수에 저장하는 것도 가능하다고 했다.

람다를 변수에 저장할 때는 파라미터의 타입을 추론할 문맥이 존재하지 않는다.

그러므로 파라미터의 타입을 명시해야 한다.

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

람다가 여러 줄로 이뤄진 경우

꼭 한 줄로 이루어진 작은 람다만 있지는 않다.

바디가 여러 줄로 이루어진 람다는 바디의 맨 마지막에 있는 식이 람다의 결과 값이 된다.

@Test fun multilineLambda() {
    val sum = { x: Int, y: Int ->
        println("Computing the sum of $x and $y")
        x + y
    }
    assert(sum(1, 2) == 3)
}

현재 Scope 에 있는 변수에 접근

로컬 변수를 익명 내부 클래스에서 사용할 수 있었다.

자바에서는 읽기만 되었으며, 코틀린에서는 쓰기(수정)까지 가능했다.

람다에서도 로컬 변수를 읽고 쓸 수 있다.

자바에서는 읽기만, 코틀린에서는 읽고 쓰기가 가능하다.

 

참고로 원할환 출력 테스트를 위해 NsTest 라는 클래스를 생성해서 만들었다. (더보기)

더보기
abstract class NsTest {
    private var standardOut: PrintStream? = null
    private var captor: OutputStream? = null

    @BeforeEach
    protected fun init() {
        standardOut = System.out
        captor = ByteArrayOutputStream()
        System.setOut(PrintStream(captor))
    }

    @AfterEach
    protected fun printOutput() {
        System.setOut(standardOut)
        println(output())
    }

    protected fun output(): String = captor.toString().trim { it <= ' ' }
}​

 

람다 안에서 바깥 함수의 파라미터를 사용(읽기)

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

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

// print
/*
Error:  403 Forbidden
Error:  404 Not Found
*/

`prefix` 는 `printMessagesWithPrefix` 함수의 파라미터이다.

`forEach` 람다 안에서 함수의 `prefix` 파라미터를 사용하고 있다.

`forEach` 는 컬렉션의 모든 원소에 대해 람다를 호출해주는 함수이다.
public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
    for (element in this) action(element)
}​

람다 안에서 바깥 함수의 로컬 변수 변경(쓰기)

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 client errors, $serverErrors server errors, $responses")
}

@Test
fun testPrintProblemCounts() {
    val responses = listOf("200 OK", "418 I'm a teapot", "500 Internal Server Error")
    printProblemCounts(responses)
    assert(
        output().contains(
            "1 client errors, 1 server errors, [200 OK, 418 I'm a teapot, 500 Internal Server Error]"
        )
    )
}

코틀린 람다 안에서는 non-final(final 이 아닌) 변수에도 접근하여 수정할 수 있다.

람다가 캡처한(포획한) 변수

위 `printProblemCounts` 함수에서의 `prefix`, `clientErrors`, `serverErrors` 와 같이 람다 안에서 사용하는 외부 변수'람다가 capture(포획)한 변수'라고 부른다.

 

기본적으로 함수 안의 로컬 변수의 생명 주기는 함수가 리턴되면 끝난다.

하지만 함수 내의 람다에서 함수의 로컬 변수를 포획한다면, 로컬 변수와 람다 함수의 생명 주기가 달라질 수 있다.

포획한 변수가 있는 람다를 저장해서, 함수가 끝난 뒤에 이 람다를 실행해도 람다의 바디 코드는 여전히 포획한 변수를 읽거나 쓸 수 있다.

fun main() {
    var outerVar = 10
    val lambda: () -> Unit = {
        // 람다에서 외부 변수인 outerVar를 캡처
        println(outerVar)
    }
    lambda() // 람다 실행
    outerVar = 20 // 외부 변수 값 변경
    lambda() // 람다 다시 실행 (변경된 값 출력)
}

이 코드에서 lambda는 외부의 outerVar를 캡처한다.

처음에는 outerVar가 10으로 출력되고, 나중에 값이 변경되면 람다를 다시 실행하면 변경된 값(20)이 출력된다.

이때 람다가 외부 변수를 참조하여 값을 가져오고 변경된 값을 유지할 수 있다.

 

아래와 같은 원리로 이것이 가능하다.

  • 람다가 final 변수를 포획한 경우
    • 람다 코드를 포획한 변수 값과 함께 저장한다.
  • 람다가 non-final 변수를 포획한 경우
    • 포획한 변수를 특별한 wrapper 로 감싸서 나중에 변경하거나 읽을 수 있게 한 다음, wrapper 에 대한 참조를 람다 코드와 함께 저장한다.

변수 캡처의 구현 방법을 자세히

자바에서는 final 변수만 포획할 수 있다.

하지만 꼼수(?)를  사용해서 non-final 변수도 포획할 수 있다.

변경 가능한 변수를 저장하는 원소가 단 하나뿐인 배열을 선언하거나,  변경 가능한 변수를 필드로 하는 클래스를 선언하는 것이다.

원소는 변경 가능하더라도, 배열이나 클래스를 final 로 만들면 포획이 가능하다.

 

자바로 구현한 꼼수(?)

public class JavaCaptureNonFinal {
    static class Ref<T> {
        T value;
        Ref(T value) {
            this.value = value;
        }
    }

    public int captureNonFinal() {
        Ref<Integer> counter = new Ref<>(0);
        Runnable inc = () -> counter.value++; // 람다를 사용하여 counter 의 값을 증가시키는 Runnable 정의
        inc.run();
        return counter.value;
    }
}
@Test fun javaCaptureNonFinal() = assert(JavaCaptureNonFinal().captureNonFinal() == 1)

 

코틀린으로 구현한 꼼수(?)

class KotlinCaptureNonFinal {
    class Ref<T>(var value: T)

    fun captureNonFinal(): Int {
        val counter = Ref(0)
        val inc = { counter.value++ }
        run(inc)

        return counter.value
    }
}

@Test fun kotlinCaptureNonFinal() = assert(KotlinCaptureNonFinal().captureNonFinal() == 1)

물론 실제 코틀린 코드에서는 이런 Wrapper 를 만들지 않아도 된다.

대신 아래처럼 변수를 직접 바꾼다.

fun captureNonFinal(): Int {
    var counter = 0
    val inc = { counter++ }
    run(inc)
    return counter
}

@Test fun kotlinCaptureNonFinal2() = assert(KotlinCaptureNonFinal2().captureNonFinal() == 1)

이벤트 핸들러에서 캡처된 변수

람다를 이벤트 핸들러나 다른 비동기적으로 실행되는 코드로 활용하는 경우, 함수 호출이 종료된 후에 로컬 변수가 변경될 수 있다.

코드로 설명하는 편이 이해가 빠르니 아래 코드를 보자.

fun tryToCountButtonClicks(button: Button): Int {
    var clicks = 0
    button.onClick { clicks++ }
    return clicks
}

이 함수는 항상 0을 리턴한다.

즉, 버튼 클릭 횟수를 제대로 셀 수 없다.

 

이벤트 기반 프로그래밍에서, 이벤트 핸들러(예: `onClick`)는 해당 이벤트가 발생할 때까지 대기하고, 이벤트 발생 시에 호출된다. `tryToCountButtonClicks` 함수는 `onClick` 핸들러를 등록하고 즉시 종료된다.

즉, 위 코드는 클릭을 시키는 것이 아니다.

 

그러나 실제 버튼 클릭 이벤트가 발생하는 것은 함수 실행과는 독립적으로, 사용자가 버튼을 클릭하는 시점이다.

따라서 함수 실행이 완료된 후, 즉 함수가 반환된 후에 사용자가 버튼을 클릭하면, 그때 `onClick` 핸들러가 호출되고 `clicks` 변수가 증가한다.

벤트 기반 프로그래밍의 특성상, 이벤트 핸들러의 실행 시점은 함수 호출과는 독립적이며, 사용자 상호작용이나 다른 비동기적 이벤트에 의해 결정된다.

Member Reference(멤버 참조)

이제 마지막이다.

위 람다와 컬렉션을 설명할 때, `maxBy` 을 사용하는 코드를 보여주면서 '함수나, 프로퍼티를 리턴하는 역할을 하는 람다는 멤버 참조로도 사용할 수 있다'고 했다.

그 멤버 참조에 대해 알아보자.

 

지금까지는 람다를 사용하여 코드 블록을 다른 함수에게 인자로 넘기는 방법을 보았다.

그런데 만약 넘기려는 코드가 이미 함수로 선언되었다면????

그 함수를 호출하는 람다를 만들어도 되지만, 더 좋은 방법이 있다.

 

코틀린에서는 자바 8 처럼 함수를 값으로 바꿀 수 있다. 이 때 이중 콜론(`::`)을 사용한다.

val getAge = Person::age

이렇게 `::` 을 사용하는 식을 Member Reference(멤버 참조)라고 한다.

멤버 참조는 프로퍼티나 메서드를 단 하나만 호출하는 함수 값을 만들어준다.

`Person::age` 는 람다 식 `{ person: Person -> person.age }` 과 같다.

 

멤버 참조 시에는 참조 대상이 함수이든, 프로퍼티이든, 뒤에 괄호를 넣으면 안된다.

최상위 멤버 참조

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

fun salute() = println("Salute") // 최상위 함수로 선언

@Test fun testTopLevelFun() {
    run(::salute) // 최상위 함수를 참조한다
    assert(
        output().contains("Salute")
    )
}

이렇게 클래스 이름을 생략하고 `::` 로 바로 참조를 시작한다.

Constructor Reference(생성자 참조)

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

`::` 뒤에 클래스 이름을 넣으면 생성자 참조를 만들 수 있다.

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

@Test fun testConstructorReference() {
    val createPerson = ::Person
    // 다른 작업들 수행 .......

    val person = createPerson("sh1mj1", 25)
    assert(person == Person("sh1mj1", 25))
}

 

확장 멤버 참조

확장 함수도 멤버 함수와 똑같은 방식으로 참조할 수 있다.
fun Person.isAdult() = age >= 21 // 확장 함수

@Test fun testExtensionMemberReference() {
    val isAdult = Person::isAdult // 확장 함수 멤버 참조
    assertTrue(isAdult(Person("sh1mj1", 25)))
}

Bound Member Reference(바운드 멤버 참조)

코틀린 1.1 부터는 Bound Member Reference(바운드 멤버 참조)를 지원한다.

멤버 참조를 생성할 때 클래스 인스턴스를 함께 저장한 후, 나중에 그 인스턴스에 대해 멤버를 호출해준다.

 

바로 코드와 주석으로 보는 것이 이해가 빠를 것이다.

@Test
fun testBoundMemberReference() {
    // 기존에는 클래스의 메서드/프로퍼티에 대한 참조를 얻은 후, 참조 호출 시 인스턴스를 인자로 제공해야 했다.
    val p1 = Person("sh1mj1", 25)
    val personAge = Person::age
    assert(personAge(p1) == 25) // p 라는 Person 의 인스턴스를 인자를 제공

    // kotlin 1.1 부터 바운드 멤버 참조 지원
    val p2 = Person("sh1mj1", 25)
    val sh1mj1Age = p2::age // 클래스의 인스턴스를 가지고 멤버 참조를 만들 수 있다.
    assert(sh1mj1Age() == 25) // sh1mj1Age 는 인자가 없는 함수이다.
}

참고로 `personAge` 의 리턴 타입은  `KProperty1<Person, Int>` 이며,

`sh1mj1Age` 의 리턴 타입은  `KProperty0<Int>` 이다. 

 

'이렇게 다르구나 ' 라고만 확인하고 넘어가자.

이는 리플렉션과 관련된 것으로 나중에 따로 다룰 것이다.