오버로딩과 고차 함수

코틀린 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)
}

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

Expression
Function name

a * b

times

a / b

div

a % b

mod

a + b

plus

a - b

minus

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

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

복합 대입 연산자 오버로딩

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

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

단항 연산자 오버로딩

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

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

Expression
Function name

+a

unaryPlus

-a

unaryMinus

!a

not

++a, a++

inc

--a, a--

dec


비교 연산자 오버로딩

동등성 연산자: equals

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

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

순서 연산자: compareTo

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

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

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


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

in 관례

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

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

rangeTo 관례

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

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

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

for 루프를 위한 iterator 관례

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

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


구조 분해 선언과 component 함수

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

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

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

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

구조 분해 선언과 루프

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

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

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

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

  • 구조 분해 선언


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

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

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

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

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

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

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


고차 함수 정의

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

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

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

함수 타입

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

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

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

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

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

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

인자로 받은 함수 호출

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

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

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

함수를 함수에서 반환

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

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

람다를 활용한 중복 제거

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

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


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

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

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

인라이닝이 작동하는 방식

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

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

컬렉션 연산 인라이닝

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

filter, map은 인라인 함수

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • 람다를 인자로 받는 함수

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

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

  • 고차 함수

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

  • 성능이 중요한 경우

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

  • 작은 함수

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

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

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

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

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

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


고차 함수 안에서 흐름 제어

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Last updated