본문 바로가기
kotlin

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

by 권성호 2023. 3. 5.

5.1 람다 식과 멤버 참조

5.1.2 람다와 컬랙션

람다가 없을 때

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

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

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

fun main(args: Array<String>) {
    val peoples = listOf(Person("soungho", 14), Person("sejin", 50))
    findTheOldest(peoples)
}

 

람다를 사용

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

fun main(args: Array<String>) {
    val peoples = listOf(Person("soungho", 14), Person("sejin", 50))
    println(peoples.maxByOrNull {it.age})
}
  • 람다를 적극적으로 활용한 코드는 일반적으로 더 짧고 이해하기 쉬우며, 실수할 여지도 낮다.
  • 코틀린은 간결한 코드 작성을 위한 람다 함수를 많이 지원하니 적극적으로 사용해 보자

5.1.3 람다식의 문법

peoples.maxByOrNull {it.age}의 변천사

fun main(args: Array<String>) {
    val peoples = listOf(Person("soungho", 14), Person("sejin", 50))
    println(peoples.maxByOrNull({p:Person -> p.age}))   // <- 가장 넓게 풀어진 형태
    println(peoples.maxByOrNull() {p:Person -> p.age})  // <- 가장 마지막에 람다 인자가 올 경우 메소드 밖으로 이동 가능
    println(peoples.maxByOrNull{p:Person -> p.age})     // <- 람다가 유일한 인자인 메소드는 소괄호 생략 가능
    println(peoples.maxByOrNull{p -> p.age})            // <- 람다의 인자가 추론 가능하면 생략 가능
    println(peoples.maxByOrNull{it.age})                // <- it 으로 대치 가능
}
  • it 사용 시 주의사항
    • it을 사용하는 관습은 코드를 간결하게 한다. 하지만 이를 남용하면 안 된다.
    • 특히, 람다 안에 람다가 중첩되는 경우에는 각 람다 파라미터를 명시하는 것이 좋다. 
    • 중첩된 경우 it을 사용하면 이 it 이 어떤 것을 가리키는지 명시적이지 않기 때문이다.
    • 문맥상 람다 파라미터의 의미나 타입을 쉽게 알 수 있는 경우에만 it을 사용하자

5.1.4 현재 영역에 있는 변수에 접근(클로저)

  • 자바의 경우 무명 내부 클래스를 정의할 때, 메서드의 로컬 변수를 무명 내부 클래스 내부에서 사용할 수 있었다.
    • 단, 람다 밖의 변수는 final 이여야 한다는 제약이 있었다.
    • 즉, 람다 밖에 선언된 변수를 람다 안에서 참조는 가능하지만 변경은 허용하지 않았다.
  • 코틀린에 람다에서는, final 이 아니어도 람다 내부에서 람다 밖의 지역변수를 참조하고 심지어 값을 바꿀 수도 있게 되었다. -> 클로저 개념 도입
    • 클로저: 함수를 쓸모 있는 1급 시민으로 만드는데 필수 요소
  • 이로 인해 어떤 함수가 자신의 로컬 변수를 포획한 람다를 반환하거나 다른 변수에 저장한다면 로컬 변수의 생명주기와 함수의 생명주기가 달라질 수 있다.
fun lamba1(): () -> Unit {
    var x = 3
    return {
        x = 7               // <- 람다 밖에 있는 x 변수를 포획
        println("x is $x")  // => 이로인해 lamba1 메소드 밖에서도 람다 호출이 가능!
                            // => lamba1 밖에서도 x 변수는 살아있을 수 있음에 주의해야 한다
    }
}

fun main(args: Array<String>) {
    val lam = lamba1()
    lam()
}
  • 어떻게 이걸 코틀린에서 가능하게 했을까?
    • 기본적으로 final 지역변수는 람다 내부에서 접근 가능하다고 했다.
    • final 이 아닌 변수를 내부적으로 wrapper class를 만들어서 감싸고 이를 final로 만들어 람다 내부에서 접근할 수 있도록 트릭을 사용했다.
    • final wrapper class는 변경이 불가하지만 그 속에 실제 value는 final 이 아니라 변경이 가능하고 코틀린 내부적으로 이걸 사용해 값을 변경시키는 것을 통해 클로저를 구현했다.

5.2 컬렉션 함수형 API

  • 코틀린만의 특징적인 함수형 API는 없다.
  • 모두 C#, 그루비, 스칼라 등의 함수형 패러다임이 있는 언어에서 일반적으로 사용되는 함수형 API와 거의 동일한 내용이다.
  • 함수형 API는 간결하지만 그만큼 축약된 형태이기 때문에 간혹 의도와 다른 비효율적인 코드가 나올 수 있어서 주의가 필요하다.
  • 필요할 때 찾아서 쓰자..

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

  • 배경
    • 지금까지 위에서 언급한 map이나 filter와 같은 API는 결과 컬렉션을 즉시(eagerly) 생성한다.
    • 이는 컬렉션 관련 함수를 연쇄하면 매 단계마다 임시 컬렉션이 생성됨을 의미한다.
    • 컬렉션의 원소가 적을 때는 별 차이가 없지만, 백만 개 정도로 원소가 많을 때는 매우 비효율적일 수 있다.
  • 시퀀스를 사용하면 이러한 비효율을 줄일 수 있다.
data class Person(val name: String, val age: Int)

fun main(args: Array<String>) {
    val peoples = listOf(Person("soungho", 14), Person("sejin", 50))

    peoples.map(Person::name).filter{ it.startsWith("A") }

    peoples.asSequence()    // <- 시퀸스 생성
        .map(Person::name)  // <- 시퀀스도 컬렉션 API 와 동일한 API 제공
        .filter { it.startsWith("A") }
        .toList()
}
  • 시퀀스를 사용하면 중간 결과를 저장하는 컬렉션이 생성되지 않아서 원소가 많은 경우 성능이 눈에 띄게 좋아진다.
  • 시퀀스가 내부적으로 어떻게 동작하기에, 이런 최적화가 가능한 걸까??? -> 지연계산 방법 사용
    • 코틀린 지연 계산 시퀀스는 Sequence 인터페이스로부터 시작한다.
    • 이 인터페이스는 단지 한 번에 하나씩 열거될 수 있는 원소의 시퀀스를 표현한다.(-> 내부에 iterator() 메서드 하나뿐임)
    • Sequence의
    • 아래에서 더 자세히 다룬다.

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

  • 시퀀스의 연산은 중간 연산과 최종 연산으로 나뉜다.
  • 중간 연산
    • 또 다른 시퀀스를 반환하는 연산
    • 그 시퀀스는 원래의 시퀀스의 원소를 변환하지는 않고 변환하는 방법만 알 고 있다.
    • 중간 연산은 항상 최종 연산까지 지연된다.
  • 최종 연산
    • 최초 컬렉션에 대해 변환을 적용한 시퀀스로부터, 일련의 계산을 수행해 얻은 컬렉션이나 원소, 숫자 또는 객체를 반환
  • 즉시 계산과 지연 계산의 개념적 차이

  • 코틀린의 시퀀스는 자바의 스트림 API와 동작 방식이 같다.

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

  • 코틀린은 람다를 잘 지원하지만, 코틀린으로 개발하면서 자바 API를 다뤄야 하는 경우도 많다.
  • 자바는 엄밀하게 말하면 함수 타입이 존재하지 않는다. 함수형 인터페이스만 존재할 뿐이다.
    • 함수형 인터페이스란, 하나의 인터페이스에 하나의 메서드만 정의된 형태를 말한다.(단일 추상 메서드)
  • 함수형 인터페이스를 인자로 받는 메서드를 호출할 때, 자바 8 이전까지는 익명 내부 클래스를, 자바 8부터는 람다를 넘길 수 있으나 람다는 내부적으로 함수형 인터페이스를 구현한 익명 클래스로 변환되어 동작했다.
  • 코틀린은 이와 달리, 명확하게 함수 타입이 존재한다.
  • 그리고 함수형 인터페이스를 인자로 받는 자바 API를 호출할 때 함수타입(람다)을 넘겨줘도 코틀린 컴파일러에 함수형 인터페이스에 대한 구현체로 변환되어 호환성이 유지된다.
  • 단, 코틀린 코드에서 만들어진 함수형 인터페이스를 인자로 받는 메서드는 람다로 호출할 수 없다.
    • 코틀린 함수를 작성할 때, 함수형 인터페이스가 아니라 명확하게 함수 타입으로 인자를 선언해야 한다.
interface TestRunnable{
    fun run()
}

fun test1(runnable: TestRunnable) {
    runnable.run()
}

fun test2(runnable: Runnable) {
    runnable.run()
}

fun main() {
    test1 { println("hello") }  // <- Type mismatch. Required: TestRunnable Found: () → Unit
    test2 { println("hello") }
}
  • 아래에서 함수형 인터페이스를 인자로 받는 자바 메서드에 코틀린 람다를 넘기면 어떻게 동작하는지 알아보자

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

public class Test {
    public static void postPhoneComputation(int delay, Runnable runnable){
        runnable.run();
    }
}
fun main() {
    /***
     * case 1: 
     */
    Test.postPhoneComputation(1000) { println("hello")}	// <- 포획 변수가 없음(외부 조건에 상관없이 항상 동일하게 동작

    /***
     * case 2:
     */
    Test.postPhoneComputation(1000, object : Runnable {
        override fun run() {
            println("hello")
        }
    })


    /***
     * case 3:
     */
    val someVal = 3
    Test.postPhoneComputation(1000) { println(someVal)}	// <- 포획 변수가 있음(외부 조건에 따라 달라져야함)
}

case1: 자바 함수형 인터페이스 인자에 포획 변수 없이 람다를 넘기는 경우

  • 컴파일러에 의해 내부적으로 람다를 본문으로 하는 인터페이스의 구현체를 넘겨줌
  • 외부 환경에 상관없이 람다의 동작이 항상 동일
  • 구현체를 함수 호출 때마다 만들지 않고 하나 만들어두고 재사용하는 방식(컴파일러 수준 최적화)

case2: 자바 함수형 인터페이스 인자에 포획 변수 없이 함수형 인터페이스를 구현한 객체 식을 넘기는 경우

  • 위에서 언급한 컴파일러 최적화 없이 항상 구현체를 생성해서 인자로 전달
  • 문법적인 간결함이나, 성능 두 가지 다 객체식보단 람다를 넘기는 게 효과적이다

case3: 자바 함수형 인터페이스 인자에 포획 변수가 있는 람다를 넘기는 경우

  • 외부 환경에 의해 람다 동작이 달라질 수 있음
  • 따라서, 함수 호출 시 마다 구현체 생성해서 전달됨

지금까지 코틀린 람다 <-> (함수형 인터페이스의 구현체) <-> 자바 함수형 인터페이스 사이의 자동 변환되는 경우에 대해 알아봤다.

이제 코틀린에서 이 변환을 명시적으로 해 줘야 하는 경우에 대해 알아보자

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

함수형 인터페이스를 리턴하는 경우 람다를 직접 반환할 수 없고 반환하고 싶은 람다를 SAM 생성자로 감싸줘야 한다.

fun createAllDoneRunnable1(): Runnable {
    return { println("All done")}   // Type mismatch. Required: Runnable Found: () → Unit
                                    // 일반 람다는 코틀린 컴파일러가 해석 불가
}

fun createAllDoneRunnable2(): Runnable {
    return Runnable{ println("All done")}   // <- SAM 생성자 호출: 마치 익명 내부 클래스를 생성하는것과 유사
                                            // <- 함수형 인터페이스의 유일한 추상 메소드의 본문에 사용할 람다만을 인자로 받아서
                                            // <- 그 함수형 인터페이스를 구현한 인스턴스를 반환
}

fun createAllDoneRunnable3(): Runnable {
    return object : Runnable{
        override fun run() {
            println("All done")
        }
    }   // <- SAM 생성자 대신 객체 식을 사용해 함수형 인터페이스의 구현체를 반환할 수도 있다.
        // <- 요 방법보단 SAM 생성자 사용을 추천한다(문법적으로 더 간결하기 때문)
}

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

수신 객체 지정 람다

  • 람다 안에서 미리 정해둔 수신 객체의 메서드를 사용가능
  • 일반 람다와 수신 객체 지정 람다의 관계는 일만 함수화 확장함수의 관계와 유사
@kotlin.internal.InlineOnly
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return receiver.block()
}
@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}
  • 둘 다 T 타입으로 수신 객체의 타입을 제네릭하게 받고 있음
  • 메서드는 T 타입으로 되어있지만 메서드 호출부에 있는 수신 객체와 컴파일 타임에 바인딩되어 수신객체 타입에 해당하는 모든 프로퍼티와 메서드를 참조 가능
  • with 은 전달된 람다의 결과를 반환, apply 는 전달된 수신 객체를 반환

댓글