리시버를 가진 람다 식

리시버를 가진 람다 식 (Lambda Expressions with Receiver)

이 장에서는 또 다른 종류의 함수인 람다 식에서 리시버를 쓰는 법과, 이를 통해 도메인 특화 언어(DSL)를 만들 수 있다는 걸 배워 볼게요.

출처: Kotlin 공식문서

본문

리시버를 가진 람다 식 (Lambda expressions with receiver)

초급 투어에서 람다 식을 쓰는 방법을 배웠어요. 람다 식도 리시버를 가질 수 있어요. 이 경우 람다 식은 리시버의 멤버 함수나 프로퍼티를 매번 리시버를 명시하지 않고 바로 접근할 수 있어요. 이런 추가 참조가 없어지면 코드가 더 읽기 쉽고 유지보수하기 쉬워져요.

TIP: 리시버를 가진 람다 식은 '리시버를 가진 함수 리터럴(function literals with receiver)'이라고도 불러요.

리시버를 가진 람다 식의 문법은 함수 타입을 정의할 때 달라져요. 먼저 확장하고 싶은 리시버를 쓰고, 다음에 .을 붙인 뒤 함수 타입 정의의 나머지를 완성하면 돼요. 예를 들어:

MutableList<Int>.() -> Unit

이 함수 타입은:

  • MutableList<Int>를 리시버로 가져요.
  • 괄호 () 안에 함수 파라미터가 없어요.
  • 반환 값이 없어요: Unit.

캔버스에 도형을 그리는 이 예시를 살펴볼게요:

class Canvas {
    fun drawCircle() = println("🟠 Drawing a circle")
    fun drawSquare() = println("🟥 Drawing a square")
}

// Lambda expression with receiver definition
fun render(block: Canvas.() -> Unit): Canvas {
    val canvas = Canvas()
    // Use the lambda expression with receiver
    canvas.block()
    return canvas
}

fun main() {
    render {
        drawCircle()
        // 🟠 Drawing a circle
        drawSquare()
        // 🟥 Drawing a square
    }
}

이 예시에서:

  • Canvas 클래스는 원이나 사각형을 그리는 것을 흉내 내는 함수 두 개를 가져요.
  • render() 함수는 block 파라미터를 받고 Canvas 클래스의 인스턴스를 돌려줘요.
  • block 파라미터는 리시버를 가진 람다 식으로, 여기서 Canvas 클래스가 리시버예요.
  • render() 함수는 Canvas 클래스의 인스턴스를 만들고, canvas 인스턴스를 리시버로 삼아 block() 람다 식을 호출해요.
  • main() 함수는 block 파라미터에 전달되는 람다 식과 함께 render() 함수를 호출해요.
  • drawCircle()drawSquare() 함수가 리시버를 가진 람다 식 안에서 호출되므로, 마치 Canvas 클래스 안에 있는 것처럼 직접 호출돼요.

리시버를 가진 람다 식은 **도메인 특화 언어(DSL)**를 만들고 싶을 때 특히 유용해요. 리시버를 따로 참조하지 않고도 리시버의 멤버 함수와 프로퍼티에 접근할 수 있으니, 코드가 더 간결해지거든요.

이걸 보여 주기 위해, 메뉴에 항목을 구성하는 예시를 살펴볼게요. 먼저 MenuItem 클래스와, 메뉴에 항목을 추가하는 item() 함수와 모든 메뉴 항목의 리스트 items를 가진 Menu 클래스를 만들어 볼게요:

class MenuItem(val name: String)

class Menu(val name: String) {
    val items = mutableListOf<MenuItem>()

    fun item(name: String) {
        items.add(MenuItem(name))
    }
}

시작점으로, 메뉴를 만드는 menu() 함수에 함수 파라미터(init)로 전달되는 리시버를 가진 람다 식을 사용해 볼게요:

fun menu(name: String, init: Menu.() -> Unit): Menu {
    // Creates an instance of the Menu class
    val menu = Menu(name)
    // Calls the lambda expression with receiver init() on the class instance
    menu.init()
    return menu
}

이제 DSL로 메뉴를 구성하고, 메뉴 구조를 콘솔에 출력하는 printMenu() 함수를 만들 수 있어요:

class MenuItem(val name: String)

class Menu(val name: String) {
    val items = mutableListOf<MenuItem>()

    fun item(name: String) {
        items.add(MenuItem(name))
    }
}

fun menu(name: String, init: Menu.() -> Unit): Menu {
    val menu = Menu(name)
    menu.init()
    return menu
}

fun printMenu(menu: Menu) {
    println("Menu: ${menu.name}")
    menu.items.forEach { println("  Item: ${it.name}") }
}

// Use the DSL
fun main() {
    // Create the menu
    val mainMenu = menu("Main Menu") {
        // Add items to the menu
        item("Home")
        item("Settings")
        item("Exit")
    }

    // Print the menu
    printMenu(mainMenu)
    // Menu: Main Menu
    //   Item: Home
    //   Item: Settings
    //   Item: Exit
}

보시다시피 리시버를 가진 람다 식을 쓰면 메뉴를 만드는 데 필요한 코드가 훨씬 간단해져요. 람다 식은 초기 설정과 생성뿐 아니라 구성(configuration)에도 유용해요. API, UI 프레임워크, 설정 빌더에서 DSL을 만들 때 흔히 사용되며, 간결한 코드를 만들어 내복적인 코드 구조와 로직에 더 쉽게 집중할 수 있게 해 줘요.

Kotlin 생태계에는 표준 라이브러리의 buildList()buildString() 함수처럼 이 디자인 패턴을 쓰는 예시가 많이 있어요.

TIP: 리시버를 가진 람다 식은 Kotlin의 타입 안전 빌더(type-safe builders)와 결합해, 런타임이 아니라 컴파일 타임에 타입 문제를 잡아내는 DSL을 만들 수 있어요. 자세한 내용은 타입 안전 빌더(Type-safe builders) 문서를 참고하세요.

연습 (Practice)

연습 1

리시버를 가진 람다 식을 받는 fetchData() 함수가 있어요. 코드의 출력이 Data received - Processed가 되도록 람다 식을 append() 함수를 쓰도록 수정해 보세요.

fun fetchData(callback: StringBuilder.() -> Unit) {
    val builder = StringBuilder("Data received")
    builder.callback()
}

fun main() {
    fetchData {
        // Write your code here
        // Data received - Processed
    }
}

연습 2

Button 클래스와 ButtonEvent, Position 데이터 클래스가 있어요. Button 클래스의 onEvent() 멤버 함수를 트리거해서 더블 클릭 이벤트를 발생시키는 코드를 작성해 보세요. 코드는 "Double click!"을 출력해야 해요.

class Button {
    fun onEvent(action: ButtonEvent.() -> Unit) {
        // Simulate a double-click event (not a right-click)
        val event = ButtonEvent(isRightClick = false, amount = 2, position = Position(100, 200))
        event.action() // Trigger the event callback
    }
}

data class ButtonEvent(
    val isRightClick: Boolean,
    val amount: Int,
    val position: Position
)

data class Position(
    val x: Int,
    val y: Int
)

fun main() {
    val button = Button()

    button.onEvent {
        // Write your code here
        // Double click!
    }
}

연습 3

모든 요소가 1씩 증가한 정수 리스트의 복사본을 만드는 함수를 작성해 보세요. List<Int>incremented 함수로 확장하는 주어진 함수 뼈대를 사용하세요.

fun List<Int>.incremented(): List<Int> {
    val originalList = this
    return buildList {
        // Write your code here
    }
}

fun main() {
    val originalList = listOf(1, 2, 3)
    val newList = originalList.incremented()
    println(newList)
    // [2, 3, 4]
}

더 알아보기