Kotlin, 어렵지 않게 사용하기 (6) - lambda 1

2022. 9. 28. 23:44Spring/Kotlin

kotlin의 람다 사용 방식과 용례를 Java 코드와 비교하며 학습하는 것이 해당 포스팅의 목표입니다.

🔗 Kotlin 시리즈 모아보기

 

안녕하세요. 이번 포스팅에서는 Kotlin의 람다와 관련된 기본 문법을 학습합니다.

 


Lambda expression

: 다른 함수에 넘길 수 있는 작은 코드 조각

코틀린에서는 자바 8과 마찬가지로 람다를 쓸 수 있다.

 

 

바람직한 람다의 사용으로 코드의 관리와 가독성을 키울 수 있다. 

코틀린에서 람다를 활용하여 코드를 깔끔하게 관리할 수 있는데 아래와 같이 사용하는 것이 대표적이다.

 

✔️ 람다로 작성한 라이브러리 함수로 중복 코드 제거

: 코틀린 표준 라이브러리는 람다를 아주 많이 사용하는데, 컬렉션이 대표적이다.

✔️ 코틀린 람다를 자바 라이브러리와 함께 사용
✔️ 수신 객체 지정 람다 lambda with receiver

: 수신 객체 지정 람다는 특별한 람다로, 람다 선언을 둘러싸고 있는 환경과는 다른 상황에서 람다 본문을 실행할 수 있다.

 

위의 세가지 내용을 살펴보도록 한다.

 

 

VS 무명 내부 클래스

자바의 무명 내부 클래스를 사용하면 코드를 함수에 넘기거나 변수에 저장할 수 있지만 상당히 번거롭다.

 

예를 들어, 아래 OnClickListener는 onClick이라는 메소드가 구현해야 한다.

무명 내부 클래스를 선언으로 코드가 복잡해지는 것을 확인할 수 있다.

 

/* 자바 */ 
button.setOnClickListener(new OnClickListener () { 

    @Override 
    public void onClick (View view) { 
        /* 클릭 시 수행할 동작 */ 
    }
});

 

람다 식을 사용하면 함수를 선언할 필요가 없고,

함수의 인자를 코드 블록에서 바로 사용하여 코드를 깔끔하게 관리할 수 있다.

 

button.setOnClickListener { /* 클릭 시 수행할 동작 */ }

 

이 코틀린 코드는 앞에서 살펴본 자바 무명 내부 클래스와 같은 역할을 하지만 훨씬 더 간결하고 읽기 쉽다.

 

 

 

Collection

코드에서 중복을 제거하는 것은 코드를 개선하는 방법 중 하나다.

 

Collection을 사용한 대부분의 작업은 대부분 일반적인 패턴을 가진다.

사용 시마다 구현하지 않으려면 일반적인 패턴은 반복되기 때문에 라이브러리 내에 정의해야 한다.

 

람다가 없다면 컬렉션을 편리하게 처리할 수 있는 좋은 라이브러리를 제공하기 힘들다.

 

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

fun findTheOldest(people: List) { 
    var maxAge = 0					// 가장 많은 나이 저장
    var theOldest: Person? = null	// 가장 연장자인 사람 저장
    for (person in people) { 
        if (person.age > maxAge) {	// 현재까지 발견한 최연장자보다 더 나이가 많은 사람을 찾으면 최댓값 변경
            maxAge = person.age
            theOldest = person
        }
    }
    println(theOldest) 
}

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

 

findTheOldest 메소드는 Person 리스트의 요소 중 가장 연장자를 찾는다.

해당 함수 내의 루프는 긴 로직을 포함하기 때문에 작성하다 실수를 저지르기 쉽다.
가령, 비교 연산자를 잘못 사용하면 최댓값 대신 최솟값을 찾게 된다

 

코틀린에서는 라이브러리 함수를 사용해서 아래와 같이 한 줄로 적을 수 있다.

 

val people = listOf(Person("Alice", 29), Person ("Bob", 31))
people.maxByOrNull({ p: Person -> p.age }) // Person(name=Bob, age=31)

 

maxByOrNull 는 모든 컬렉션에서 사용할 수 있으며, 가장 큰 원소를 찾기 위해 비교할 값을 리턴하는 함수를 인자로 받는다.

 

중괄호로 둘러싸인 코드 { it.age } 가 바로 비교에 사용할 값을 돌려주는 함수다.

이 코드는 컬렉션의 원소를 인자로 받아서 비교에 사용할 값을 반환한다.

it이 그 인자를 가리킨다.

 

위에서 함수가 반환하는 값은 Person 객체의 age 필드에 저장된 나이 정보다.

이런 식으로 단지 함수나 프로퍼티를 반환하는 역할을 수행하는 람다는 멤버 참조로 대치할 수 있다

 

people.maxBy(Person::age)

 

자바에서 메소드 참조 연산자인 이중 콜론과 비슷하게 코틀린에서도 사용할 수 있다.

Java에서 마치 Person.getAge()Person::getAge로 작성하는 것처럼 코틀린에서도 멤버 참조를 할 수 있다.

 

 

 

 Syntax

람다의 문법을 보기 전, 특징을 정리하면 아래와 같다.

 

✔️ 변수의 일반적인 값처럼 여기저기 전달할 수 있는 동작의 모음

✔️ 람다를 변수에 저장 가능

: 하지만 함수에 인자로 넘기면서 바로 람다를 정의하는 경우가 대부분

✔️ 항상 중괄호로 둘러싸여 있음

✔️ 인자 목록 주변에 괄호가 없음

: 화살표(->)가 인자 목록과 람다 본문을 구분

 

 

람다 사용을 살펴보기 전, 사용될 컬렉션은 아래와 같이 정의한다.

 

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

 

지금부터 람다를 간단하게 하나씩 변환해보자.

 

 

📌 STEP 1. 정식적 람다 정의

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

 

📌 STEP 2. 함수의 맨 뒤 파라미터가 람다  👉🏻  람다를 괄호 밖으로 빼낼 수 있음

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

 

 

📌 STEP 3. 함수의 유일한 인자  👉🏻  괄호 뒤에 빈 괄호 생략

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

 

📌 STEP 4. 컴파일러가 파라미터 타입 추론 가능  👉🏻  타입 생략

people.maxByOrNull { p -> p.age }

 

📌 STEP 5. 람다의 파라미터가 단 하나 + 컴파일러가  그 타입을 추론 가능  👉🏻 자동 생성된 파라미터인 it 사용

people.maxByOrNull { it.age }

 

it은 아래에서 자세히 확인해보도록 하자.

 

📌 참고 | 람다 객체와 같이 넘기기

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

 

변수 이름 뒤에 괄호를 놓고 그 안에 필요한 인자를 넣어서 람다를 호출할 수 있다.

 

run

run { printin (42) } // 3

 

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

run은 인자로 받은 람다를 실행해 주는 라이브러리 함수다.

 

 

 

it

위의 코드 중 it으로 요소를 직접 접근하는 코드를 확인했다.

 

people.maxByOrNull { it.age }

 

it은 코틀린이 컴파일할 때 자동으로 생성하는 변수를 사용한다.

실제로 자바로 디컴파일한 로직을 확인해보면 Iterable의 타겟을 담는 it을 확인할 수 있다.

 

Iterable $this$maxByOrNull$iv = (Iterable)LambdaExpressionKt.getPeople();
int $i$f$maxByOrNull = false;
Iterator iterator$iv = $this$maxByOrNull$iv.iterator();
if (iterator$iv.hasNext()) {
   Object maxElem$iv = iterator$iv.next();
   if (iterator$iv.hasNext()) {
      Person it = (Person)maxElem$iv;
      int var6 = false;
      int maxValue$iv = it.getAge();

      do {
         Object e$iv = iterator$iv.next();
         Person it = (Person)e$iv;
         int var8 = false;
         int v$iv = it.getAge();
         if (maxValue$iv < v$iv) {
            maxValue$iv = v$iv;
         }
      } while(iterator$iv.hasNext());
   }
}

 

위의 코드를 아래의 코드와 비교하면 더욱 이해하기 쉬울 것이다.

 

people.maxByOrNull { p -> p.age }

 

위의 코드에서는 people의 한 요소를 꺼낼 때 p 라는 변수에 담는 다는 의미이다.

 

...
if (iterator$iv.hasNext()) {
   Person p = (Person)maxElem$iv;
...

 

it은 이러한 요소 명을 정의하지 않고, 코틀린이 미리 정의된 방식인 it을 사용하게 하여 변수 명 짓는 수고로움을 덜어준다.

 

 

Caution

it을 사용하는 관습은 코드를 아주 간단하게 만들어주지만 남용하면 안된다.

모호하거나 여러 람다를 중첩할 때는 파라미터 명을 명시하는 것이 좋다.

 

람다가 중첩되는 경우 파라미터를 명시하지 않으면 각각의 it이 가리키는 파라미터가 어떤 람다에 속했는지 파악하기 어렵다.

또, 문맥에서 람다 파라미터의 의미나 타입을 쉽게 알 수 없는 경우에도 파라미터를 명시적으로 선언하면 도움이 된다.

 

 

 

Accessing variables in Scope

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

이런 기능을 보여주기 위해 forEach 표준 함수를 사용해보자.

대표적으로 forEach는 가장 기본적인 컬렉션 조작 함수 중 하나로, 람다를 통해 컬렉션의 모든 원소를 호출한다. 

forEach는 일반적인 for 루프보다 훨씬 간결하지만 그렇다고 다른 장점이 많지는 않다. 

 

아래는 모든 메시지에 특정 접두사를 붙여 출력한다.

 

fun printMessagesWithPrefix(messages: Collection<String>, prefix: String) {
    messages.forEach { println("$prefix $it") } // 람다 내에서 함수의 파라미터 사용
}

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

 

자바와 다른 점 중 중요한 한 가지는 코틀린 람다 안에서는 파이널 변수가 아닌 변수에 접근할 수 있다는 점이다. 

또한 람다 안에서 바깥의 변수를 변경해도 된다. 

 

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

 

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

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

 

자바와 달리 코틀린에서는 람다 내에서 람다 밖 함수에 있는 파이널이 아닌 변수에 접근할 수 있고, 변경할 수도 있다.

이렇게 람다 안에서 사용하는 외부 변수는 람다 내에 포획되었다고captured 표현한다.

 

 

Capturing a mutable variable

Java에서는 파이널 변수만 포획할 수 있는데,

종종 Java 개발자들은 필요한 경우 트릭으로 변경 가능한 변수를 선언하곤 한다.

 

트릭은 바로 변경 가능한 변수를 저장하는 원소가 단 하나뿐인 배열을 선언하거나,

변경 가능한 변수를 필드로 하는 클래스를 선언하는 것이다.

안에 들어있는 원소는 변경 가능 할지라도 배열이나 클래스의 인스턴스에 대한 참조를 final로 만들면 포획이 가능하다.

 

이런 트릭을 코틀린으로 작성하면 다음과 같다.

 

class Ref<T> (var value: T)	// 변수를 변경 가능하도록 포획하기 위한 클래스

val counter = Ref(0)
val inc = { counter. value++ }

 

공식적으로는 변경 불가능한 변수를 포획했지만 그 변수가 가리키는 객체의 필드 값을 바꿀 수 있다.
실제 코드에서는 이런 래퍼를 만들지 않고, 변수를 직접 바꾼다.

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

 

 

실제로, 바로 위의 코드의 내부 동작 방식이 바로 첫 번째 코드이다.

 

람다가 파이널 변수val를 포획하면 자바와 마찬가지로 그 변수의 값이 복사된다.

하지만 람다가 변경 가능한 변수var를 포획하면 변수를 Ref 클래스 인스턴스에 넣는다.

그 Ref 인스 턴스에 대한 참조를 파이널로 만들면 쉽게 람다로 포획할 수 있고, 람다 안에서는 Ref 인스턴스 의 필드를 변경할 수 있다.

 

 

Lifecycle

기본적으로 함수 안에 정의된 로컬 변수의 생명주기는 함수가 반환되면 끝난다.

하지만 함수가 자신의 로컬 변수를 포획한 람다를 반환하거나,

다른 변수에 저장한다면 로컬 변수의 생명주기와 함수의 생명주기가 달라질 수 있다.

 

한 가지 꼭 알아둬야 할 함정이 있다. 

람다를 이벤트 핸들러나 다른 비동기적으로 실행 되는 코드로 활용하는 경우,

함수 호출이 끝난 다음에 로컬 변수가 변경될 수도 있다.

 

예를 들어 다음 코드는 버튼 클릭 횟수를 제대로 셀 수 없다.

 

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

 

이 함수는 항상 0을 반환한다.

 

onClick 핸들러는 호출될 때마다 clicks의 값을 증가시키지만 그 값의 변경을 관찰할 수는 없다.

핸들러는 tryToCountButtonClicksclicks를 반환한 다음에 호출되기 때문이다.

 

이 함수를 제대로 구현하려면 클릭 횟수를 세는 카운터 변수를 함수의 내부가 아니라 클래스의 프로퍼티나 전역 프로퍼티 등의 위치로 빼내서 나중에 변수 변화를 살펴볼 수 있게 해야 한다.

 

 

Member References

람다를 사용해 코드 블록을 다른 함수에게 인자로 넘기는 방법을 살펴봤다. 

하지만 넘기려는 코드가 이미 함수로 선언된 경우는 어떻게 해야 할까?

물론 그 함수를 호출하는 람다를 만들면 되지만 이는 중복이다.

코틀린에서는 자바 8과 마찬가지로 함수를 값으로 바꿀 수 있다. 이때 이중 콜론 :: 을 사용한다.

val getAge = Person::age

// val getAge = { person: Person -> person.age } 와 동일


::를 사용하는 식을 멤버 참조member reference라고 부른다.
멤버 참조는 프로퍼티나 메소드를 단 하나만 호출하는 함수 값을 만들어준다. 

::는 클래스 이름과 여러분이 참조하려는 멤버프로퍼티나 메소드 이름 사이에 위치한다.

 

Classname::( property | method )

 

참조 대상이 함수인지 프로퍼티인지와는 관계없이 멤버 참조 뒤에는 괄호를 넣으면 안 된다.

멤버 참조는 그 멤버를 호출하는 람다와 같은 타입이다.

따라서 다음 예처럼 그 둘을 자유롭게 바꿔 쓸 수 있다.

 

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

 

최상위에 선언된(그리고 다른 클래스의 멤버가 아닌) 함수나 프로퍼티를 참조할 수도 있다.

 

fun salute() = printin("Salute!")

run (::salute) // "Salute!"

 

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

::salute라는 멤버 참조를 run 라이브러리 함수에 넘긴다trun은 인자로 받은 람다를 호출한다.

람다가 인자가 여럿인 다른 함수한테 작업을 위임하는 경우 람다를 정의하지 않고 직접 위임 함수에 대한 참조를 제공하면 편리하다.

 

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

val createPerson = ::Person		// person 생성자를 동적인 값으로 저장
val p = createPerson ("Alice", 29)
printin(p) //Person(name=Alice, age=29)

 

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

 

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

 

isAdult는 Person 클래스의 멤버가 아니고 확장 함수다. 그렇지만 isAdult를 호출할 때 person.isAdult() 로 인스턴스 멤버 호출 구문을 쓸 수 있는 것처럼 Person:: isAdult로 멤버 참조 구문을 사용해 이 확장 함수에 대한 참조를 얻을 수 있다.

 

 

 

 

다음 포스팅은 Kotlin에서의 Lambda 표현이 활발하게

사용되고 있는 collection 등의 API를 살펴보도록 하겠습니다.