오버로딩과 고차 함수

코틀린 Step02 - 오버로딩과 고차 함수

📒 연산자 오버로딩과 기타 관례 요약
  • 표준 수학 연산자 오버로딩

data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point {
        return Point(x + other.x, y + other.y)
    }
}

@Test
fun `test operator overloading`() {
    val p1 = Point(10, 20)
    val p2 = Point(30, 40)
    val result = p1 + p2
    assertEquals(Point(40, 60), result)
}
  • 비교 연산자는 equalscompareTo 메소드로 변환

    class Person(val name: String, val age: Int) : Comparable<Person> {
    override fun compareTo(other: Person): Int {
        return age - other.age
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Person) return false
        return name == other.name && age == other.age
    }

    override fun hashCode(): Int {
        return name.hashCode() * 31 + age
    }
}

@Test
fun `test comparison operators`() {
    val p1 = Person("Alice", 30)
    val p2 = Person("Bob", 25)
    val p3 = Person("Alice", 30)

    assertTrue(p1 > p2)
    assertFalse(p1 == p2)
    assertTrue(p1 == p3)
}
  • get, set, contains 함수를 정의하면 그 클래스의 인스턴스에 대해 []in 연산 사용 가능

    • 해당 객체는 코틀린 컬렉션 객체와 유사

class CustomCollection {
    private val items = mutableListOf<String>()

    operator fun get(index: Int): String {
        return items[index]
    }

    operator fun set(index: Int, value: String) {
        items[index] = value
    }

    operator fun contains(value: String): Boolean {
        return items.contains(value)
    }

    fun add(value: String) {
        items.add(value)
    }
}

@Test
fun `test get, set, contains`() {
    val collection = CustomCollection()
    collection.add("Hello")
    collection.add("World")
    assertEquals("Hello", collection[0]) // get()

    collection[1] = "Kotlin" // set()
    assertEquals("Kotlin", collection[1]) // get()
    assertTrue("Kotlin" in collection) // contains()
}
  • rangeTo, iterator 함수를 정의하면 범위를 만들거나 컬렉션과 배열의 원소를 이터레이션 가능

class CustomRange(val start: Int, val end: Int) {
    operator fun rangeTo(other: CustomRange): IntRange {
        return start..other.end
    }

    operator fun iterator(): Iterator<Int> {
        return (start..end).iterator()
    }
}

@Test
fun `test rangeTo and iterator`() {
    val range = CustomRange(1, 5)
    val result = mutableListOf<Int>()
    for (i in range) { // iterator()
        result.add(i)
    }
    assertEquals(listOf(1, 2, 3, 4, 5), result)

    val range2 = CustomRange(6, 10)
    val combinedRange = range..range2 // rangeTo()
    assertEquals((1..10), combinedRange)
}
  • 구조 분해 선언을 통해 한 객체의 상태를 분해해서 여러 변수에 대입 가능

    • 함수가 여러 값을 한꺼번에 반환해야 하는 경우 유용

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

@Test
fun `test destructuring declaration`() {
    val person = Person("Alice", 30)
    val (name, age) = person
    assertEquals("Alice", name)
    assertEquals(30, age)
}
  • 위임 프로퍼티를 통해 프로퍼티 값을 저장, 초기화, 읽거나 변경할 때 사용하는 로직을 재활용 가능

    • 위임 프로퍼티는 프레임워크를 만들 때 아주 유용

    • Delegates.observable 함수를 사용하면 프로퍼티 변경 관찰자를 쉽게 추가 가능

class User {
    var name: String by Delegates.observable("<no name>") { _, old, new ->
        println("Name changed from $old to $new")
    }
}

@Test
fun `test delegated property`() {
    val user = User()
    assertEquals("<no name>", user.name)

    user.name = "Alice"
    assertEquals("Alice", user.name)

    user.name = "Bob"
    assertEquals("Bob", user.name)
}
  • 표준 라이브러리 함수인 lazy를 통해 지연 초기화 프로퍼티를 쉽게 구현

class LazyInitialization {
    val lazyValue: String by lazy {
        println("Computed!")
        "Hello"
    }
}

@Test
fun `test lazy property`() {
    val instance = LazyInitialization()
    assertEquals("Hello", instance.lazyValue) // 최초로 접근 시 초기화
}
📕 고차 함수 요약
  • 함수 타입을 사용해 함수에 대한 참조를 담는 변수, 파라미터, 반환 값을 만들 수 있다.

// 함수 타입을 사용한 변수
val add: (Int, Int) -> Int = { a, b -> a + b }

// 함수 타입을 파라미터로 받는 함수
fun operate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}

// 함수 타입을 반환 값으로 사용하는 함수
fun getOperation(type: String): (Int, Int) -> Int {
    return when (type) {
        "add" -> { a, b -> a + b }
        "multiply" -> { a, b -> a * b }
        else -> { _, _ -> 0 }
    }
}

@Test
fun `test function type as variable`() {
    assertEquals(5, add(2, 3))
}

@Test
fun `test function type as parameter`() {
    val result = operate(2, 3, add)
    assertEquals(5, result)
}

@Test
fun `test function type as return value`() {
    val addOperation = getOperation("add")
    val multiplyOperation = getOperation("multiply")
    assertEquals(5, addOperation(2, 3))
    assertEquals(6, multiplyOperation(2, 3))
}
  • 고차 함수는 다른 함수를 인자로 받거나 함수를 반환한다.

// 함수를 인자로 받는 고차 함수
fun applyOperation(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}

// 함수를 반환하는 고차 함수
fun getOperation(type: String): (Int, Int) -> Int {
    return when (type) {
        "add" -> { a, b -> a + b }
        "subtract" -> { a, b -> a - b }
        else -> { _, _ -> 0 }
    }
}

@Test
fun `test applyOperation with add`() {
    val result = applyOperation(3, 4) { a, b -> a + b }
    assertEquals(7, result)
}

@Test
fun `test applyOperation with subtract`() {
    val result = applyOperation(10, 4) { a, b -> a - b }
    assertEquals(6, result)
}

@Test
fun `test getOperation with add`() {
    val addOperation = getOperation("add")
    val result = addOperation(3, 4)
    assertEquals(7, result)
}

@Test
fun `test getOperation with subtract`() {
    val subtractOperation = getOperation("subtract")
    val result = subtractOperation(10, 4)
    assertEquals(6, result)
}
  • 인라인 함수를 컴파일할 때 컴파일러는 그 함수의 본문과 그 함수에게 전달된 람다의 본문을 컴파일한 바이트 코드를 모든 함수 호출 지점에 삽입해준다.

inline fun performOperation(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}

@Test
fun `test performOperation with add`() {
    val result = performOperation(3, 4) { x, y -> x + y }
    assertEquals(7, result)
}

@Test
fun `test performOperation with multiply`() {
    val result = performOperation(3, 4) { x, y -> x * y }
    assertEquals(12, result)
}
  • 고차 함수를 사용하면 컴포넌트를 이루는 각 부분의 코드를 더 잘 재사용할 수 있다.

    • 또 고차 함수를 활용해 강력한 제네릭 라이브러리를 만들 수 있다.

// 고차 함수: 두 리스트를 합치는 함수
fun <T> combineLists(list1: List<T>, list2: List<T>, combine: (T, T) -> T): List<T> {
    return list1.zip(list2, combine)
}

// 고차 함수: 리스트의 모든 요소에 연산을 적용하는 함수
fun <T, R> mapList(list: List<T>, transform: (T) -> R): List<R> {
    return list.map(transform)
}

@Test
fun `test combineLists with addition`() {
    val list1 = listOf(1, 2, 3)
    val list2 = listOf(4, 5, 6)
    val result = combineLists(list1, list2) { a, b -> a + b }
    assertEquals(listOf(5, 7, 9), result)
}

@Test
fun `test combineLists with string concatenation`() {
    val list1 = listOf("a", "b", "c")
    val list2 = listOf("d", "e", "f")
    val result = combineLists(list1, list2) { a, b -> a + b }
    assertEquals(listOf("ad", "be", "cf"), result)
}

@Test
fun `test mapList with square`() {
    val list = listOf(1, 2, 3, 4)
    val result = mapList(list) { it * it }
    assertEquals(listOf(1, 4, 9, 16), result)
}

@Test
fun `test mapList with string length`() {
    val list = listOf("apple", "banana", "cherry")
    val result = mapList(list) { it.length }
    assertEquals(listOf(5, 6, 6), result)
}
  • 인라인 함수에서는 람다 안에 있는 return 문이 바깥쪽 함수를 반환시키는 non-local return을 사용할 수 있다.

inline fun performOperationWithNonLocalReturn(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}

@Test
fun `test non-local return in inline function`() {
    val result1 = performOperationWithNonLocalReturn(3, 4) { x, y ->
        if (x > y) return@performOperationWithNonLocalReturn x
        x + y
    }
    assertEquals(7, result1)

    val result2 = performOperationWithNonLocalReturn(3, 1) { x, y ->
        if (x > y) return@performOperationWithNonLocalReturn x
        x + y
    }
    assertEquals(3, result2)
}
  • 무명 함수는 람다 식을 대신할 수 있으며 return 식을 처리하는 규칙이 일반 람다 식과는 다르다.

    • 본문 여러 곳에서 return 해야 하는 코드 블록을 만들어야 한다면 람다 대신 무명 함수를 쓸 수 있다.

fun performOperationWithAnonymousFunction(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}

@Test
fun `test anonymous function with multiple returns`() {
    val result = performOperationWithAnonymousFunction(3, 4, fun(x, y): Int {
        if (x > y) return x
        return x + y
    })
    assertEquals(7, result)

    val result2 = performOperationWithAnonymousFunction(5, 2, fun(x, y): Int {
        if (x > y) return x
        return x + y
    })
    assertEquals(5, result2)
}

산술 연산자 오버로딩

코틀린에서 관례를 사용하는 가장 단순한 예는 산술 연산자

자바에서는 원시 타입에 대해서만 산술 연산자 사용 가능하고, String에 대해 + 연산자를 사용 가능

이항 산술 연산 오버로딩

operator 변경자를 추가해 plus 함수를 선언하고 나면 + 기호로 두 Point 객체를 더할 수 있음

// case 1. 연산자를 자체 함수로 정의하기
data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point {
        return Point(x + other.x, y + other.y)
    }
}

// case 2. 연산자를 확장 함수로 정의하기
data class Point2(val x: Int, val y: Int)

operator fun Point2.plus(other: Point2): Point2 {
    return Point2(x + other.x, y + other.y)
}

@Test
fun `이항 산술 연산 오버로딩`() {
    val p1 = Point(10, 20)
    val p2 = Point(30, 40)
    assertEquals(Point(40, 60), p1 + p2)

    val p3 = Point2(10, 20)
    val p4 = Point2(30, 40)
    assertEquals(Point2(40, 60), p3 + p4)
}

ℹ️ 오버로딩 가능한 이항 산술 연산자

연산자를 정의할 때 두 피연산자가(연산자 함수의 두 파라미터)가 같은 타입일 필요는 없다.

또는 연산자 함수의 반환 타입이 꼭 두 피연산자 중 하나와 일치해야만 하는 것도 아니다.

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

// case 1. 두 피연산자가 다른 연산자 정의
// Point, Double 타입의 피연산자를 받아 Point 반환
operator fun Point.times(scale: Double): Point {
    return Point((x * scale).toInt(), (y * scale).toInt())
}

// case 2. 반환 타입이 피연산자와 다른 연산자 정의
// Char, Int 타입의 피연산자를 받아 String 반환
operator fun Char.times(count: Int): String {
    return toString().repeat(count)
}

@Test
fun `연산자 정의`() {
    val point = Point(2, 3)
    val scaledPoint = point * 2.5
    assertEquals(Point(5, 7), scaledPoint)

    val result = 'a' * 3
    assertEquals("aaa", result)
}

복합 대입 연산자 오버로딩

+=, -= 등의 연산자는 복합 대입(compound assignment) 연산자라 불림

operator fun Point.plus(other: Point): Point {
    return Point(x + other.x, y + other.y)
}

@Test
fun `복합 대입 연산자`() {
    var point = Point(1, 2)
    point += Point(3, 4)
    assertEquals(Point(4, 6), point)
}

코틀린 표준 라이브러리는 변경 가능한 컬렉션에 대해 plusAssign을 정의

@Test
fun `test plusAssign operator`() {
    val mutableList = mutableListOf(1, 2, 3)
    mutableList += 4
    assertEquals(listOf(1, 2, 3, 4), mutableList)
}

단항 연산자 오버로딩

단항 연산자 오버로딩하는 절차도 이항 연산자와 동일

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

operator fun Point.unaryMinus(): Point {
    return Point(-x, -y)
}

@Test
fun `단항 연산자`() {
    val p = Point(10, 20)
    assertEquals(Point(-10, -20), -p)
}

ℹ️ 오버로딩할 수 있는 단항 산술 연산자


비교 연산자 오버로딩

동등성 연산자: equals

코틀린이 == 연산자 호출을 equals 메소드 호출로 컴파일

연산자를 사용하는 식도 equals 호출로 컴파일

class Point(val x: Int, val y: Int) {
    override fun equals(obj: Any?): Boolean {
        if (obj === this) return true
        if (obj !is Point) return false
        return obj.x == x && obj.y == y
    }
}

@Test
fun `동등성 연산자`() {
    assertTrue(Point(10, 20) == Point(10, 20))
    assertTrue(Point(10, 20) != Point(5, 5))
    assertFalse(null == Point(1, 2))
}

순서 연산자: compareTo

자바에서 정렬, 최댓값, 최솟값 등 값을 비교해야 하는 알고리즘에 사용할 클래스는 Comparable

인터페이스 구현이 필요하듯, 코틀린도 같은 Comparable 인터페이스를 지원

 @Test
fun `순서 연산자`() {
    class Person(
        val firstName: String, val lastName: String
    ) : Comparable<Person> {
        override fun compareTo(other: Person): Int {
            return compareValuesBy(
                this, other,
                Person::lastName, Person::firstName
            )
        }

        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (other !is Person) return false
            return firstName == other.firstName && lastName == other.lastName
        }

        override fun hashCode(): Int {
            return firstName.hashCode() * 31 + lastName.hashCode()
        }
    }

    val person1 = Person("John", "Doe")
    val person2 = Person("Jane", "Doe")
    val person3 = Person("John", "Smith")

    // 같은 성, 다른 이름
    assertTrue(person1 > person2)
    assertFalse(person1 < person2)

    // 다른 성
    assertTrue(person1 < person3)
    assertFalse(person1 > person3)

    // 같은 성, 같은 이름 (equals, hashCode 오버라이드 필요)
    val person4 = Person("John", "Doe")
    assertTrue(person1 == person4)
}

코틀린 표준 라이브러리의 compareValuesBy 함수를 사용해 compareTo를 쉽고 간결하게 정의할 수 있다.

@Test
fun `compareValuesBy compareTo`() {
    assertTrue("abc" < "bac")
}

컬렉션과 범위에 대해 쓸 수 있는 관례

in 관례

In은 객체가 컬렉션에 들어있는지 검사.

in 연산자와 대응하는 함수는 contains

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

data class Rectangle(val upperLeft: Point, val lowerRight: Point)

operator fun Rectangle.contains(p: Point): Boolean {
    // 20 in (10 <= true < 50) && 30 in (20 <= true < 50)
    return p.x in upperLeft.x until lowerRight.x &&
            p.y in upperLeft.y until lowerRight.y
}

@Test
fun `in 관례`() {
    val rect = Rectangle(Point(10, 20), Point(50, 50))
    assertTrue(Point(20, 30) in rect)
    assertFalse(Point(5, 5) in rect)
}

rangeTo 관례

범위를 만들려면 .. 구문을 사용

예를 들어 1..10은 1부터 10까지 모든 수가 들어있는 범위를 가리킨다.

https://livebook.manning.com/book/kotlin-in-action/chapter-7/65

@Test
fun `rangeTo 관례`() {
    val n = 9
    assertEquals(IntRange(0, 10), 0..(n + 1))
    (0..n).forEach { print(it) } // 0123456789
}

for 루프를 위한 iterator 관례

코틀린에서는 iterator 메소드를 확장 함수로 정의 가능

이런 성질로 인해 일반 자바 문자열에 대한 for 루프가 가능

operator fun ClosedRange<LocalDate>.iterator(): Iterator<LocalDate> =
    object : Iterator<LocalDate> {
        var current = start

        override fun hasNext() =
            current <= endInclusive

        override fun next() = current.apply {
            current = plusDays(1)
        }
    }

@Test
fun `for 루프를 위한 iterator 관례`() {
    val newYear = LocalDate.ofYearDay(2024, 1)
    val daysOff = newYear.minusDays(1)..newYear
    /**
     * 2023-12-31
     * 2024-01-01
     */
    for (dayOff in daysOff) { println(dayOff) }
}

구조 분해 선언과 component 함수

구조 분해를 사용하면 복합적인 값을 분해해서 여러 다른 변수를 한꺼번에 초기화 가능

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

@Test
fun `구조 분해 선언`() {
    val p = Point(10, 20)
    val (x, y) = p
    assertEquals(10, x)
    assertEquals(20, y)
}

구조 분해 선언은 함수에서 여러 값을 반환할 때 유용

  • 👎🏻 여러 값을 한꺼번에 반환해야 하는 함수가 있다면 반환해야 하는 모든 값이 들어갈 데이터 클래스를 정의하고 함수의 반환 타입을 그 데이터 클래스로 바꿔주어야 한다.

  • 👍🏼 구조 분해 선언 구문을 사용하면 이런 함수가 반환하는 값을 쉽게 풀어서 여러 변수에 넣을 수 있다.

data class NameComponents(val name: String,
                          val extension: String)

fun splitFilename(fullName: String): NameComponents {
    val result = fullName.split('.', limit = 2)
    return NameComponents(result[0], result[1]) // 함수에서 데이터 클래스의 인스턴스를 반환
}

@Test
fun `component 함수`() {
    val (name, ext) = splitFilename("example.kt") // 구조 분해 선언 구문을 사용해 데이터 클래스를 해체
    assertEquals("example", name)
    assertEquals("kt", ext)
}

구조 분해 선언과 루프

함수 본문 내의 선언문 뿐 아니라,

변수 선언이 들어갈 수 있는 장소라면 어디든 구조 분해 선언을 사용 가능

 fun printEntries(map: Map<String, String>) {
    // 구조 분해 선언
    for ((key, value) in map) {
        println("$key -> $value")
    }
}

@Test
fun `구조 분해 선언과 루프`() {
    /**
     * Oracle -> Java
     * JetBrains -> Kotlin
     */
    val map = mapOf("Oracle" to "Java", "JetBrains" to "Kotlin")
    printEntries(map)
}

두 가지 코틀린 관례를 활용한 예시

  • 객체를 이터페이션하는 관례

  • 구조 분해 선언

@Test
fun `코틀린 관례`() {
    val map = mapOf(1 to "one", 2 to "two", 3 to "three")
    val keys = mutableListOf<Int>()
    val values = mutableListOf<String>()

    for (entry in map.entries) {
        // 코틀린 라이브러리는 Map.Entry에 대한 확장 함수로 component1, component2 제공
        val key = entry.component1()
        val value = entry.component2()
        keys.add(key)
        values.add(value)
    }

    assertEquals(listOf(1, 2, 3), keys)
    assertEquals(listOf("one", "two", "three"), values)
}

프로퍼티 접근자 로직 재활용: 위임 프로퍼티

위임 프로퍼티(delegated property)를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다

더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현 가능하다.

또한, 그 과정에서 접근자 로직을 매번 재구현할 필요도 없다.

위임은 객체가 직접 작업을 수행하지 않고, 다른 도우미 객체가 그 작업을 처리하게 맡기는 디자인 패턴을 말한다.

  • 이때 작업을 처리하는 도우미 객체를 위임 객체라고 부른다.

위임 프로퍼티 사용: by lazy()를 사용한 프로퍼티 초기화 지연

// AS-IS
@Test
fun `**AS-IS by lazy**`() {
    data class Email(val address: String)

    class Person(val name: String) {
        // 데이터를 저장하고 emails의 위임 객체 역활을 하는 _emails 프로퍼티
        private var _emails: List<Email>? = null

        val emails: List<Email>
            get() {
                if (_emails == null) {
                    _emails = loadEmails(this) // 최초 접근 시 이메일 가져오기
                }
                return _emails!! // 저장해 둔 데이터가 있으면 그 데이터를 반환
            }

        private fun loadEmails(person: Person): List<Email> {
            println("Loading emails for ${person.name}")
            return listOf(
                Email("alice@example.com"),
                Email("alice.work@example.com")
            )
        }
    }

    val p = Person("Alice")
    assertEquals(listOf(
        Email("alice@example.com"),
        Email("alice.work@example.com")
    ), p.emails)  // 최초로 emails를 읽을 때 단 한번만 이메일을 가져온다.
    assertEquals(listOf(
        Email("alice@example.com"),
        Email("alice.work@example.com")
    ), p.emails)
}

// TO-BE
/* 
 * 지연 초기화해야 하는 프로퍼티가 많아지면 코드가 복잡해지고, 
 * 스레드 안전하지 않아서 언제나 제대로 작동한다고 말할 수 없다.
 * 이 경우 위임 프로퍼티를 사용하면 훨씬 더 간편해진다.
 */
@Test
fun `by lazy`() {
    data class Email(val address: String)

    class Person(val name: String) {
		    // lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue 메소드가 들어있는 객체를 반환
		    // 따라서 lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다.
        val emails by lazy { loadEmails(this) }

        private fun loadEmails(person: Person): List<Email> {
            println("Loading emails for ${person.name}")
            return listOf(
                Email("alice@example.com"),
                Email("alice.work@example.com")
            )
        }
    }

    val p = Person("Alice")
    val emails1 = p.emails // 최초로 emails를 읽을 때 단 한번만 이메일을 가져온다.
    val emails2 = p.emails

    assertEquals(emails1, emails2)
    assertEquals(listOf(
        Email("alice@example.com"),
        Email("alice.work@example.com")
    ), emails1)
}

고차 함수 정의

고차 함수는 다른 함수를 인자로 받거나 함수를 반환하는 함수

코틀린에서는 람다나 함수 참조를 사용해 함수를 값으로 표현 가능

따라서 고차 함수는 람다나 함수 참조를 인자로 넘길 수 있거나, 람다나 함수 참조를 반환하는 함수

함수 타입

함수 타입을 정의하려면 함수 파라미터의 타입을 괄호 안에 넣고,

그 뒤에 화살표(→)를 추가한 다음, 함수의 반환 타입을 지정

https://livebook.manning.com/book/kotlin-in-action/chapter-8

Unit 타입은 의미 있는 값을 반환하지 않는 함수 반환 타입에 쓰는 특별한 타입

  • 그냥 함수를 정의한다면 함수의 파라미터 목록 뒤에 오는 Unit 반환 타입 지정을 생략해도 되지만,

  • ⚠️ 함수 타입을 선언할 때는 반환 타입을 반드시 명시해야 하므로 Unit을 필수로 명시

인자로 받은 함수 호출

인자로 받은 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 동일

fun twoAndThree(operation: (Int, Int) -> Int): Int { // 함수 타입의 인자를 받는 함수
    val result = operation(2, 3)
    return result
}

@Test
fun `인자로 받은 함수 호출`() {
    assertEquals(5, twoAndThree { a, b -> a + b })
    assertEquals(6, twoAndThree { a, b -> a * b })
}

디폴트 값을 지정한 함수 타입 파라미터나 널이 될 수 있는 함수 타입 파라미터

파라미터를 함수 타입으로 선언할 때도 디폴트 값을 정할 수 있다.

fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = "",
    transform: (T) -> String = { it.toString() } // 함수 타입 파라미터를 선언하면서 람다를 디폴트 값으로 지정
): String {
    val result = StringBuilder(prefix)

    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(transform(element)) // transform 파라미터로 받은 함수를 호출
    }

    result.append(postfix)
    return result.toString()
}

@Test
fun `파라미터를 함수 타입으로 선언할 때도 디폴트 값 설정 가능`() {
    val letters = listOf("Alpha", "Beta")

    assertEquals("Alpha, Beta", letters.joinToString()) // 디폴트 변환 함수 사용
    assertEquals("alpha, beta", letters.joinToString { it.lowercase() }) // 람다를 인자로 전달
    assertEquals("alpha! beta! ",
        letters.joinToString(separator = "! ", postfix = "! ", transform = { it.lowercase() })
    ) // 이름 붙은 인자 구문을 사용해 람다를 포함하는 여러 인자를 전달
}

함수를 함수에서 반환

다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정

함수를 반환하려면 return 식에 람다나 멤버 참조나 함수 타입의 값을 계산하는 식 등을 넣으면 된다.

data class Person(
    val firstName: String,
    val lastName: String,
    val phoneNumber: String?
)

class ContactListFilters {
    var prefix: String = ""
    var onlyWithPhoneNumber: Boolean = false

    fun getPredicate(): (Person) -> Boolean { // 함수를 반환하는 함수를 정의한다.
        val startsWithPrefix = { p: Person ->
            p.firstName.startsWith(prefix) || p.lastName.startsWith(prefix)
        }
        if (!onlyWithPhoneNumber) {
            return startsWithPrefix // 함수 타입의 변수 반환
        }
        return {  // 람다 반환
            startsWithPrefix(it)
                    && it.phoneNumber != null
        }
    }
}

@Test
fun `함수를 함수에서 반환`() {
    val contacts = listOf(
        Person("Dmitry", "Jemerov", "123-4567"),
        Person("Svetlana", "Isakova", null)
    )
    val contactListFilters = ContactListFilters()
    with(contactListFilters) {
        prefix = "Dm"
        onlyWithPhoneNumber = true
    }
    assertEquals(
        listOf(Person("Dmitry", "Jemerov", "123-4567")),
        contacts.filter(contactListFilters.getPredicate()) // 반환된 람다 적용
    )

    with(contactListFilters) {
        prefix = "Sv"
        onlyWithPhoneNumber = false
    }
    assertEquals(
        listOf(Person("Svetlana", "Isakova", null)),
        contacts.filter(contactListFilters.getPredicate()) // 반환된 함수 타입 변수 적용
    )
}

람다를 활용한 중복 제거

함수 타입과 람다 식은 재활용하기 좋은 코드를 만들 때 쓸 수 있는 훌륭한 도구

웹 사이트 방문 기록을 분석하는 예시

@Test
    fun `람다를 활용한 중복 제거`() {
    /**
     * Case 01. 중복이 발생하는 코드
     */
    val averageWindowsDuration = log
        .filter { it.os == OS.WINDOWS }.map(SiteVisit::duration).average()

    val averageMacDuration = log
        .filter { it.os == OS.MAC }.map(SiteVisit::duration).average()

    assertEquals(23.0, averageWindowsDuration)
    assertEquals(22.0, averageMacDuration)

    /**
     * Case 02. 함수를 사용하여 일반 함수를 통해 중복을 줄이기
     */
    fun List<SiteVisit>.averageDurationFor(os: OS) =
        filter { it.os == os }.map(SiteVisit::duration).average()

    assertEquals(23.0, log.averageDurationFor(OS.WINDOWS))
    assertEquals(22.0, log.averageDurationFor(OS.MAC))

    /**
     * Case 02. 고차 함수를 이용하여 함수를 확장
     * > 특정 OS의 평균 방문 시간을 구하고 싶다거나, 특정 페이지 평균 방문 시간을 구하고 싶을 경우
     */
    fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
        filter(predicate).map(SiteVisit::duration).average()

    // 모바일 디바이스(IOS, 안드로이드)의 평균 방문 시간
    assertEquals(12.15, log.averageDurationFor { it.os in setOf(OS.ANDROID, OS.IOS) })
    // IOS 사용자의 /signup 페이지 평균 방문 시간
    assertEquals(8.0, log.averageDurationFor { it.os == OS.IOS && it.path == "/signup" })
}

인라인 함수: 람다의 부가 비용 없애기

inline 변경자를 함수에 붙이면 컴파일러는 그 함수를 호출하는 모든 문장을

함수 본문에 해당하는 바이트 코드로 바꿔준다.

인라이닝이 작동하는 방식

특정 함수를 inline 선언하면 그 함수의 본문이 인라인

  • 함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신함수 본문을 번역한 바이트 코드로 컴파일

@Test
fun `test inline function`() {
    /*
     * inline 변경자를 사용한 함수 호출 시 컴파일러가 해당 함수 호출을 실제 함수 본문으로 대체
     * 함수 호출의 오버헤드를 줄이고 성능을 향상
     */
    val addition = inlineFunction { a, b -> a + b }
    assertEquals(5, addition)
    // 컴파일러는 val addition = { a: Int, b: Int -> a + b }(2, 3) 로 변환
    assertEquals(5, { a: Int, b: Int -> a + b }(2, 3))

    val multiplication = inlineFunction { a, b -> a * b }
    assertEquals(6, multiplication)
    // 컴파일러는 val multiplication = { a: Int, b: Int -> a * b }(2, 3) 로 변환
    assertEquals(6, { a: Int, b: Int -> a * b }(2, 3))
}

컬렉션 연산 인라이닝

코틀린 표준 라이브러리의 컬렉션 함수는 대부분 람다를 인자로 받는다.

filter, map은 인라인 함수

  • 두 함수의 본문은 인라이닝되며, 추가 객체나 클래스 생성은 없다.

  • 하지만 이 코드는 리스트를 걸러낸 결과를 저장하는 중간 리스트를 만든다.

    • 처리할 원소가 많아지면 중간 리스트를 사용하는 부가 비용도 걱정할 만큼 커진다.

  • asSequence를 통해 리스트 대신 시퀀스를 사용하면 중간 리스트로 인한 부가 비용은 줄어든다.

    • 각 중간 시퀀스는 람다를 필드에 저장하는 객체로 표현되며,

    • 최종 연산은 중간 시퀀스에 있는 여러 람다를 연쇄 호출

@Test
fun `test filter and map with list`() {
    val list = listOf(1, 2, 3, 4, 5)
    val result = list.filter { it % 2 == 0 }.map { it * 2 }
    assertEquals(listOf(4, 8), result)
}

@Test
fun `test filter and map with sequence`() {
    val list = listOf(1, 2, 3, 4, 5)
    val result = list.asSequence().filter { it % 2 == 0 }.map { it * 2 }.toList()
    assertEquals(listOf(4, 8), result)
}

함수를 인라인으로 선언해야 하는 경우

⚠️ inline 키워드의 이점을 배우고 나면 코드를 더 빠르게 만들기 위해 여기저기에서 inline을 사용하고 싶어질 것이다. 하지만❗️ 이는 좋은 생각이 아니다.

  • inline 키워드를 사용해도 람다를 인자로 받는 함수만 성능이 좋아질 가능성이 높다.

1️⃣ 일반 함수 호출의 경우 JVM은 이미 강력하게 인라이닝을 지원한다.

  • JVM은 코드 실행을 분석해서 가장 이익이 되는 방향으로 호출을 인라이닝한다.

  • 이런 과정은 바이트 코드를 실제 기계어 코드로 번역하는 과정(JIT)에서 일어난다.

  • 이런 JVM의 최적화를 활용한다면 바이트 코드에서는 각 함수 구현이 정확히 한 번만 있으면 되고, 그 함수를 호출하는 부분에서 따로 함수 코드를 중복할 필요가 없다.

2️⃣ 인라인 함수는 바이트 코드에서 각 함수 호출 지점을 함수 본문으로 대치하기 때문에 코드 중복이 생긴다.

  • 게다가 함수를 직접 호출하면 스택 트레이스가 더 깔끔해진다.

3️⃣ inline 변경자를 함수에 붙일 때는 코드 크기에 주의를 기울여야 한다.

  • 인라이닝하는 함수가 큰 경우 함수의 본문에 해당하는 바이트 코드를 모든 호출 지점에 복사해 넣고 나면 바이트코드가 전체적으로 아주 커질 수 있다.

🎬 그러면 함수를 인라인으로 언제 선언할까 ❓

  • 람다를 인자로 받는 함수

    • 람다를 인자로 받는 함수는 인라인으로 선언하면 성능이 향상될 수 있다.

    • 함수 호출의 오버헤드를 줄이고, 람다를 인라인으로 대체하여 추가 객체 생성을 피할 수 있다.

  • 고차 함수

    • 고차 함수(다른 함수를 인자로 받거나 반환하는 함수)는 인라인으로 선언하면 호출 비용을 줄일 수 있다.

  • 성능이 중요한 경우

    • 함수 호출이 빈번하고, 성능이 중요한 경우 호출 오버헤드를 줄일 수 있다.

  • 작은 함수

    • 함수 본문이 작고, 여러 번 호출되는 경우 코드 중복이 크게 증가하지 않으면서 성능을 향상

자원 관리를 위해 인라인된 람다 사용

자바의 try-with-resource 문은 코틀린에서 제공하지 않는다.

대신 같은 기능을 제공하는 use 라는 함수가 코틀린 표준 라이브러리 안에 있다.

use 함수는 닫을 수 있는(closeable) 자원에 대한 확장 함수며, 람다를 인자로 받는다.

  • use는 람다를 호출한 다음에 자원을 닫아준다.

fun readFirstLineFromFile(path: String): String {
    BufferedReader(FileReader(path)).use { br ->
        return br.readLine()
    }
}

고차 함수 안에서 흐름 제어

람다 안의 return문: 람다를 둘러싼 함수로부터 반환

람다 안에서 return을 사용하면 람다로부터만 반환되는 게 아니라 그 람다를 호출하는 함수가 실행을 끝내고 반환된다.

자신을 둘러싸고 있는 블록보다 더 바깥에 있는 다른 블록을 반환하게 만드는 return 문을 non-local return이라 부른다.

  • 이렇게 return이 바깥쪽 함수를 반환시킬 수 있는 때는 람다를 인자로 받는 함수가 인라인 함수인 경우뿐

  • forEach는 인라인 함수이므로 람다 본문과 함께 인라이닝

  • 따라서 return 식이 바깥쪽 함수(lookForAlice)를 반환시키도록 쉽게 컴파일

@Test
fun `람다 안의 return문`() {
    data class Person(val name: String, val age: Int)

    fun lookForAlice(people: List<Person>): String {
        for (person in people) {
            if (person.name == "Alice") {
                return "Found!"
            }
        }
        return "Alice is not found"
    }

    fun lookForAliceForEach(people: List<Person>): String {
        people.forEach {
            if (it.name == "Alice") {
                return "Found!"
            }
        }
        return "Alice is not found"
    }

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

    assertEquals("Found!", lookForAlice(people))
    assertEquals("Found!", lookForAliceForEach(people))

    val people2 = listOf(Person("Bob", 31))
    assertEquals("Alice is not found", lookForAlice(people2))
    assertEquals("Alice is not found", lookForAliceForEach(people2))
}

람다로부터 반환: 레이블을 사용한 return

람다 식에서도 local return을 사용할 수 있다.

람다 안에서 local return은 for루프의 break와 비슷한 역할을 한다.

  • 단, local returnnon-local return을 구분하기 위해 레이블(label)을 사용해야 한다.

@Test
fun `람다로부터 반환`() {
    data class Person(val name: String, val age: Int)

    fun lookForAlice(people: List<Person>): String {
        people.forEach label@{
            // 람다를 호출하는 함수가 끝나지 않고 람다로부터 반환
            if (it.name == "Alice") return@label
        }

        return "Alice might be somewhere"
   }

    val people = listOf(Person("Alice", 29), Person("Bob", 31))
    assertEquals("Alice might be somewhere", lookForAlice(people))

    val people2 = listOf(Person("Bob", 31))
    assertEquals("Alice might be somewhere", lookForAlice(people2))
}

✅ 람다에 레이블을 붙여서 사용하는 대신 람다를 인자로 받는 인라인 함수의 이름을 return 뒤에 레이블로 사용해도 된다.

fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") return@forEach
    }
    println("Alice might be somewhere")
}

무명 함수: 기본적으로 로컬 return

무명 함수는 코드 블록을 함수에 넘길 때 사용할 수 있는 다른 방법

@Test
fun `무명 함수`() {
    data class Person(val name: String, val age: Int)

    fun lookForAlice(people: List<Person>) {
        people.forEach(fun (person) {
            if (person.name == "Alice") return
            println("${person.name} is not Alice")
        })
    }

    val people = listOf(Person("Alice", 29), Person("Bob", 31))
    lookForAlice(people) // Bob is not Alice
}

무명 함수는 일반 함수와 비슷해 보이지만, 차이는 함수 이름이나 파라미터 타입을 생략할 수 있다는 점 뿐

  • 무명 함수 안에서 레이블이 붙지 않은 return 식은 무명 함수 자체를 반환시킬 뿐 무명 함수를 둘러싼 다른 함수를 반환시키지 않는다.

    • return에 적용되는 규칙은 단순히 fun 키워드를 사용해 정의된 가장 안쪽 함수를 반환시킨다는 점

  • 람다 식의 구현 방법이나 람다 식을 인라인 함수에 넘길 때 어떻게 본문이 인라이닝 되는지 등의 규칙을 무명 함수에도 모두 적용 가능

Last updated