본문 바로가기
language/Kotilin In Action

4주차(9~10장)

by abstract.jiin 2025. 9. 1.

9장 연산자 오버로딩과 다른 관례(convention)

관례(Convention)란?

특정 함수 이름과 언어 기능을 연결하는 약속

9.1 산술 연산자 오버로딩

기본 개념

연산자를 오버로딩하는 함수 앞에는 반드시 operator가 있어야 함
→ 실수로 관례에서 사용하는 함수 이름을 사용하는 경우를 막아줌

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

val p1 = Point(10, 20)
val p2 = Point(30, 40)
val p3 = p1 + p2  *// Point(40, 60) - plus 함수 호출됨!*

지원되는 산술 연산자들

plus, times, divide 등: 이항 산술 연산

class Vector(val x: Int, val y: Int) {
    operator fun plus(other: Vector) = Vector(x + other.x, y + other.y)   *// +*
    operator fun minus(other: Vector) = Vector(x - other.x, y - other.y)  *// -*
    operator fun times(scale: Int) = Vector(x * scale, y * scale)         *// **
    operator fun div(scale: Int) = Vector(x / scale, y / scale)           *// /*
    operator fun mod(scale: Int) = Vector(x % scale, y % scale)           *// %*
}

val v = Vector(10, 20)
println(v * 3)    *// Vector(30, 60)*
println(v + Vector(1, 2))  *// Vector(11, 22)*

다른 타입과의 연산

*// [두 피연산자의 타입이 다른 연산사 정의 ] 
// 포인트 * 배율  (Int * Double)*
operator fun Point.times(scale: Double): Point {
    return Point((x * scale).toInt(), (y * scale).toInt())
}

println(Point(10, 20) * 1.5)  *// Point(15, 30)
// 자동 교환법칙 ( p*1.5 = 1.5*p) 적용 안됨 주의, 추가 구현 필요
// operator fun Double.times(p: Point): Point*

*// [결과 타입이 피연산자 타입과 다른 연산자 정의 ] 
// 문자 * 횟수 = 문자열 (Char * Int = String)*
operator fun Char.times(count: Int): String {
    return toString().repeat(count)
}

println('A' * 5)               *// "AAAAA"*

비트 연산자

shl: 왼쪽 시프트(자바 <<)
shr: 오른쪽 시프트(부호 비트 유지, 자바 >>)
ushr: 오른쪽 시프트(0으로 부호 비트 설정, 자바 >>>)
and: 비트 곱(자바 &)
or: 비트 합(자바 I)
xor: 비트 배타 합(자바 ^)
inv: 비트 반전(자바 ~)

fun main() {
  println(0x0F and 0xF0)// 0
  println(0x0F or 0xF0) // 255
  println(0xl shl 4)    // 16 

복합 대입 연산자 (+=, -=, =, /=)

var point = Point(1, 2)
point += Point(3, 4)  *// point = point + Point(3, 4)와 동일*
println(point)  *// Point(4, 6)

// 또는 직접 구현*
operator fun Point.plusAssign(other: Point) {
    *// 원본 객체 수정 (mutable한 경우)*
}
  • 추가..상황 설명+= 연산자가 동작하는 두 가지 방법
      *// a += b가 이렇게 변환됨*
      a = a.plus(b)  *// 새로운 객체를 만들어서 대입
    
      // 예시*
      val list1 = listOf(1, 2, 3)        *// 읽기 전용 리스트*
      val list2 = list1 + 4               *// 새로운 리스트 [1, 2, 3, 4] 생성*
    방법 2: plusAssign 함수 사용 (기존 객체 수정)표준 라이브러리의 구현
      operator fun <T> MutableCollection<T>.plusAssign(element: T) {
          this.add(element)  *// 기존 컬렉션에 원소 추가*
      }
    실제 동작 비교어떤 방식이 선택될까?
    1. plusAssign이 있으면 → plusAssign 사용 (기존 객체 수정)
    2. plusAssign이 없으면 → plus 사용 (새 객체 생성)
    3. 둘 다 있으면컴파일 오류val confused = Confused()
    4. ```kotlin
      class Confused {
      operator fun plus(other: Confused) = Confused() // plus 정의
      operator fun plusAssign(other: Confused) = Unit // plusAssign도 정의
      }
    // confused += Confused() // 컴파일 오류 - 어떤 걸 써야 할지 모르겠음plusAssign 방식 (기존 객체 수정)다른 복합 대입 연산자들활용 예시
      class TextBuilder {
          private val content = StringBuilder()
    
          operator fun plusAssign(text: String) {
              content.append(text)
          }
    
          override fun toString() = content.toString()
      }
    
      val builder = TextBuilder()
      builder += "Hello "
      builder += "World!"
      println(builder)  *// "Hello World!"*
    커스텀 컬렉션정리
    1. += 연산자plusAssign 또는 plus 함수로 처리됨
    2. 가변 컬렉션에서는 plusAssign이 우선 → 기존 객체 수정
    3. 읽기 전용 컬렉션에서는 plus 사용 → 새 객체 생성
    4. 메모리 효율성: plusAssign > plus
    5. 둘 다 정의하면 컴파일 오류 → 하나만 정의할 것
    6. ```kotlin
    // 권장: 목적에 맞게 하나만 정의
    class ImmutablePoint(val x: Int, val y: Int) {}
      operator fun plusAssign(other: MutablePoint) {
          x += other.x
          y += other.y
      }
    }
  • 코틀린은 **불변 객체는 새로 만들고, 가변 객체는 수정**하는 방식으로 각각의 특성에 맞게 `+=` 연산자를 처리 함
  • class MutablePoint(var x: Int, var y: Int) {
  • operator fun plus(other: ImmutablePoint) = ImmutablePoint(x + other.x, y + other.y)
  • class NumberContainer { private val numbers = mutableListOf<Int>() operator fun plusAssign(number: Int) { if (number > 0) { *// 양수만 추가* numbers.add(number) } } operator fun minusAssign(number: Int) { numbers.removeAll { it == number } } fun getNumbers() = numbers.toList() } val container = NumberContainer() container += 5 *// 추가됨* container += -3 *// 추가 안 됨 (양수가 아님)* container += 10 *// 추가됨* container -= 5 *// 제거됨* println(container.getNumbers()) *// [10]*
  • StringBuilder 스타일
  • *// 모든 복합 대입 연산자들* operator fun <T> MutableCollection<T>.plusAssign(element: T) { add(element) } *// +=* operator fun <T> MutableCollection<T>.minusAssign(element: T) { remove(element) } *// -=* operator fun <T> MutableList<T>.timesAssign(n: Int) { */* 반복 추가 */* } *// *=// 사용 예시* val numbers = mutableListOf(1, 2, 3) numbers += 4 *// [1, 2, 3, 4]* numbers -= 2 *// [1, 3, 4]* val chars = mutableListOf('A') chars *= 3 *// ['A', 'A', 'A'] (만약 구현되어 있다면)*
  • fun plusAssignExample() { val list = mutableListOf(1, 2, 3) *// 기존 리스트를 수정 → 메모리 효율적* list += 4 *// 기존 리스트에 4 추가* list += 5 *// 기존 리스트에 5 추가* list += 6 *// 기존 리스트에 6 추가* println(list) *// [1, 2, 3, 4, 5, 6]* }
  • ## **메모리 효율성 비교** ### **plus 방식 (새 객체 생성)** ```kotlin fun plusExample() { var list = listOf(1, 2, 3) *// 매번 새로운 리스트 생성 → 메모리 사용량 증가* list += 4 *// 새 리스트 [1, 2, 3, 4] 생성* list += 5 *// 새 리스트 [1, 2, 3, 4, 5] 생성* list += 6 *// 새 리스트 [1, 2, 3, 4, 5, 6] 생성* println(list) *// [1, 2, 3, 4, 5, 6]* }
  • 우선순위 규칙
  • fun main() { *// 1. 읽기 전용 컬렉션 + plus 함수* var readOnlyList = listOf(1, 2, 3) readOnlyList += 4 *// readOnlyList = readOnlyList.plus(4)와 동일* println(readOnlyList) *// [1, 2, 3, 4] - 새로운 리스트가 생성됨* *// 2. 가변 컬렉션 + plusAssign 함수* val mutableList = mutableListOf(1, 2, 3) mutableList += 4 *// mutableList.plusAssign(4)와 동일* println(mutableList) *// [1, 2, 3, 4] - 기존 리스트가 수정됨* }
  • MutableCollection에 정의된 plusAssign
  • *// a += b가 이렇게 변환됨* a.plusAssign(b) *// 기존 객체를 직접 수정 // 예시* val mutableList = mutableListOf(1, 2, 3) *// 가변 리스트* mutableList += 4 *// 기존 리스트에 4 추가 (새 객체 생성 안 함)*
  • 방법 1: plus 함수 사용 (새 객체 생성)
  • fun main() { val numbers = mutableListOf<Int>() *// 빈 가변 리스트 생성* numbers += 42 *// 42를 리스트에 추가* println(numbers[0]) *// 42 출력* }
  • += 연산자의 두 가지 처리 방식

단항 연산자

operator fun Point.unaryMinus() = Point(-x, -y)      *// -p*
operator fun Point.unaryPlus() = Point(+x, +y)       *// +p*
operator fun Point.not() = Point(!x, !y)             *// !p (boolean이라면)// 증감 연산자*
operator fun BigDecimal.inc() = this + BigDecimal.ONE
operator fun BigDecimal.dec() = this - BigDecimal.ONE

var bd = BigDecimal.ZERO
println(bd++)  *// 0 (출력 후 증가)*
println(++bd)  *// 2 (증가 후 출력)*
  • 추가 ..
      *// 좋은 예: 직관적이고 예측 가능*
      data class Money(val amount: Int) {
          operator fun unaryMinus() = Money(-amount)  *// 음수 금액 (부채)*
      }
    
      *// 나쁜 예: 의미가 불분명*
      data class Person(val name: String) {
          operator fun unaryMinus() = Person(name.reversed())  *// 의미 불분명*
      }
    2. inc/dec는 새 객체 반환3. 컴파일러가 전위/후위 자동 처리
  • *// inc() 함수 하나만 정의하면* operator fun Counter.inc() = Counter(count + 1) *// 컴파일러가 자동으로 처리* val counter = Counter(5) val a = counter++ *// 후위: 이전 값 반환 후 증가* val b = ++counter *// 전위: 증가 후 새 값 반환*
  • *// 불변 객체에서 inc/dec는 항상 새 객체 반환* operator fun Point.inc() = Point(x + 1, y + 1) val p1 = Point(1, 2) val p2 = ++p1 *// p1과 p2는 다른 객체!*
  • 1. 의미있게 사용하기

9.2 비교 연산자

동등성 연산자 (==, !=)

기본 원리

a == b  // 이 코드가...
        ↓
a?.equals(b) ?: (b == null)  // 이렇게 변환

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

!= 연산자도 ****equals 메서드 호출로 컴파일

// != 연산자는 자동으로 equals의 반대로 처리됨
a != b  // 이 코드가...
        ↓
!(a == b)  // 이렇게 변환됨

// 따라서 != 를 별도로 구현할 필요 없음
fun main() {
    val p1 = Point(1, 2)
    val p2 = Point(3, 4)

    println(p1 == p2)  // false
    println(p1 != p2)  // true (자동으로 == 결과를 반전)
}

== 연산자의 null 처리

fun main() {
    val a: String? = "hello"
    val b: String? = "hello"
    val c: String? = null
    val d: String? = null

    println(a == b)     // true  (둘 다 "hello")
    println(a == c)     // false ("hello" != null)
    println(c == d)     // true  (null == null)
    println(c == null)  // true  (null == null)
}

// a == b 가 실행될 때:
// 1. a가 null인가?
//    - null이면: b == null을 반환
//    - null이 아니면: a.equals(b) 호출

// 실제 구현 (의사코드)
fun safeEquals(a: Any?, b: Any?): Boolean {
    return when {
        a == null -> b == null     // a가 null이면 b도 null인지 확인
        else -> a.equals(b)        // a가 null이 아니면 equals 호출
    }
}
class Person(val name: String, val age: Int) {
    override fun equals(other: Any?): Boolean {
        if (other !is Person) return false
        return name == other.name && age == other.age
    }

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

*// a == b는 a?.equals(b) ?: (b == null)로 변환됨*
val p1 = Person("김철수", 25)
val p2 = Person("김철수", 25)
println(p1 == p2)  *// true*

참조 동등성 (===) vs 구조적 동등성 (==)

fun main() {
    val str1 = "hello"
    val str2 = "hello" 
    val str3 = String("hello".toCharArray())  *// 새 객체 생성*

    *// 구조적 동등성 (==) - 내용 비교*
    println(str1 == str2)   *// true  (내용이 같음)*
    println(str1 == str3)   *// true  (내용이 같음)*

    *// 참조 동등성 (===) - 객체 동일성 비교*
    println(str1 === str2)  *// true  (문자열 풀에서 같은 객체)*
    println(str1 === str3)  *// false (다른 객체)*

    *// === 연산자는 오버로딩할 수 없음!*
}

동등성 연산자 (==, !=) 관련 정리

  • == 연산자는 null 안전한 equals 호출
  • === 연산자는 참조 동등성 (오버로딩 불가)
    • 오버로딩 불가 하다는 것이 뭘까?
    • class Point(val x: Int, val y: Int) { *// 가능 - 다른 연산자들은 오버로딩 가능* operator fun plus(other: Point) = Point(x + other.x, y + other.y) operator fun equals(other: Any?): Boolean = *// ...* *// 불가능 - === 연산자는 오버로딩 불가 // operator fun identityEquals(other: Any?): Boolean = // 컴파일 에러* } *// === 연산자는 언제나 JVM 레벨에서 참조 비교만 함* val p1 = Point(1, 2) val p2 = Point(1, 2) val p3 = p1 println(p1 === p2) *// 항상 false (다른 객체)* println(p1 === p3) *// 항상 true (같은 객체) // 이 동작을 바꿀 수 없음!*
  • != 연산자는 자동으로 == 결과 반전
  • equals 오버라이드 시 operator 키워드 불필요/ override 필요
    • 다른 연산자 오버로딩 관례와 달리 equals는 Any에 정의된 메서드

순서 비교 연산자 (<, >, <=, >=)

자바 vs 코틀린의 비교 연산

  • 자바: Comparable 인터페이스의 compareTo() 메서드를 명시적으로 호출해야 함
  • *// 자바에서는 이렇게 써야 함* element1.compareTo(element2)
  • 코틀린: 비교 연산자(<, >, <=, >=)를 사용하면 자동으로 compareTo() 호출로 컴파일됨
  • *// 코틀린에서는 이렇게 쓸 수 있음* p1 < p2 *// 실제로는 p1.compareTo(p2) < 0 으로 컴파일됨*
class Person(
    val firstName: String,
    val lastName: String
) : Comparable<Person> {

    override fun compareTo(other: Person): Int {
        // compareValuesBy를 사용한 간편한 구현
        return compareValuesBy(this, other,
            Person::lastName,   // 먼저 성으로 비교
            Person::firstName   // 성이 같으면 이름으로 비교
        )
    }
}

fun main() {
    val p1 = Person("Alice", "Smith")
    val p2 = Person("Bob", "Johnson")
    println(p1 < p2)  // false (Smith > Johnson이므로, 사전순 J가 S보다 앞)
}
// 간편하지만 느린 방법
return compareValuesBy(this, other, Person::lastName, Person::firstName)

// 복잡하지만 빠른 방법
override fun compareTo(other: Person): Int {
    val lastNameComparison = lastName.compareTo(other.lastName)
    if (lastNameComparison != 0) return lastNameComparison
    return firstName.compareTo(other.firstName)
}

9.3 컬렉션과 범위 관례

자주 사용하는 두 가지 핵심 연산:

  1. 인덱스 접근: a[b] (원소 읽기/쓰기)
  2. 멤버십 검사: in 연산자 (원소가 포함되어 있는지 확인)

인덱스 접근 (get, set)

get 관례: x[a, b]x.get(a, b)

set 관례: x[a, b] = cx.set(a, b, c)

kotlinclass StringTable {
    private val data = mutableMapOf<Pair<Int, Int>, String>()
    //Pair(행, 열)를 키, 문자열을 값으로 저장 : Pair(0, 1) → "Hello"

    operator fun get(row: Int, col: Int): String? {
        return data[Pair(row, col)] // 맵에서 키로 찾아서 반환
    }

    operator fun set(row: Int, col: Int, value: String) {
        data[Pair(row, col)] = value // 맵에 키-값 쌍으로 저장
    }
}

fun main() {
    val table = StringTable()
    // 1단계: (0,1) 위치에 "Hello" 저장
    table[0, 1] = "Hello"
    // 내부적으로: data[Pair(0, 1)] = "Hello"

    // 2단계: (0,1) 위치의 값을 읽어오기
    println(table[0, 1])
    // 내부적으로: data[Pair(0, 1)] 조회 → "Hello" 반환

}
     col 0    col 1    col 2
row 0   ?     "Hello"    ?
row 1   ?        ?       ?
row 2   ?        ?       ?
table[0, 1]  // 사용자가 이렇게 쓰면
↓
table.get(0, 1)  // 컴파일러가 이렇게 변환
↓
data[Pair(0, 1)]  // 실제로는 맵에서 키로 검색

일반적인 방법:

table.setValue(0, 1, "Hello")
val value = table.getValue(0, 1)

operator를 사용한 방법:

table[0, 1] = "Hello"  
val value = table[0, 1]

멤버십 검사 (in)

in 관례: a in bb.contains(a)

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

operator fun Rectangle.contains(point: Point): Boolean {
    return point.x in upperLeft.x..<lowerRight.x &&
           point.y in upperLeft.y..<lowerRight.y
}

val rect = Rectangle(Point(10, 20), Point(50, 50))
println(Point(20, 30) in rect)  *// true*

범위 생성 (rangeTo, rangeUntil)

범위 관례: a..ba.rangeTo(b), a..<ba.rangeUntil(b)

start..end  →  start.rangeTo(end)      //마지막 숫자 포함
start..<end →  start.rangeUntil(end)   //마지막 숫자 포함하지 않음

범위 연산자는 우선순위가 낮으므로 괄호 사용 권장


// 좋은 예 - 괄호 사용
println(0..(n + 1))        // 명확함
(0..n).forEach { print(it) }  // 범위의 메서드 호출시 괄호 필수

// 나쁜 예
println(0..n + 1)          // 혼동 가능
0..n.forEach { }           // 컴파일 에러!
*// 날짜 범위 만들기*
import java.time.LocalDate

val today = LocalDate.now()
val vacation = today..today.plusDays(10)  *// rangeTo 호출*
val weekdaysOnly = today..<today.plusDays(5)  *// rangeUntil 호출*

println(today.plusDays(3) in vacation)  *// true*

이터레이션 (iterator)

iterator 관례: for (x in obj)obj.iterator()를 통한 반복

기본 변환

for (x in list) { ... }
↓
val iterator = list.iterator()
while (iterator.hasNext()) {
    val x = iterator.next()
    ...
}

문자열 이터레이션: 코틀린이 제공하는 확장 함수

operator fun CharSequence.iterator(): CharIterator

fun main() {
    for (c in "abc") {
        println(c)  // a, b, c 출력
    }
}

날짜 범위 이터레이션: 날짜 범위 반복

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

        override fun hasNext() = current <= endInclusive

        override fun next(): LocalDate {
            val result = current
            current = current.plusDays(1)
            return result
        }
    }
}

*// 사용*
val dateRange = LocalDate.of(2024, 1, 1)..LocalDate.of(2024, 1, 3)
for (date in dateRange) {
    println(date)  *// 2024-01-01, 2024-01-02, 2024-01-03*
}

9.4 구조 분해 선언

구조 분해 선언: 복합적인 값을 분해해서 여러 변수에 한꺼번에 초기화하는 기능

기본 사용법

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

val point = Point(10, 20)
val (x, y) = point  *// component1(), component2() 호출*
println("x: $x, y: $y")  *// x: 10, y: 20

// 직접 구현할 경우*
class Person(val name: String, val age: Int) {
    operator fun component1() = name
    operator fun component2() = age
}

val person = Person("김철수", 25)
val (name, age) = person

데이터 클래스의 componentN 함수 자동 생성

data class Person(
    val name: String,     *// component1()*
    val age: Int,         *// component2()*
    val city: String      *// component3()*
)
// 컴파일러가 내부적으로 이런 함수들을 자동 생성
class Person(val name: String, val age: Int, val city: String) {

    operator fun component1(): String = name
    operator fun component2(): Int = age  
    operator fun component3(): String = city

    // 기타 데이터 클래스 함수들도 자동 생성
    // equals(), hashCode(), toString(), copy()
}

일반 클래스 - 수동 구현 필요

class Point(val x: Int, val y: Int) {
    *// 직접 작성해야 함*
    operator fun component1() = x
    operator fun component2() = y
}

fun main() {
    val p = Point(10, 20)
    val (x, y) = p  *// 동작함*
}

데이터 클래스 - 자동 생성

data class Point(val x: Int, val y: Int)
*// componentN 함수들이 자동으로 생성됨!*

fun main() {
    val p = Point(10, 20)
    val (x, y) = p  *// 바로 동작함*
}
  • 주의 ) 프로퍼티 개수 제한 list, array → 5개
// 코틀린 표준 라이브러리는 component5()까지만 제공
val list = listOf(1, 2, 3, 4, 5, 6)
val (a, b, c, d, e) = list  // 가능
// val (a, b, c, d, e, f) = list  // component6() 없음

함수에서 여러 값 반환

data class Result(val success: Boolean, val message: String, val data: Any?)

fun processData(): Result {
    return Result(true, "성공", listOf(1, 2, 3))
}

val (success, message, data) = processData()
if (success) {
    println("처리 성공: $message")
}
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])
}

fun main() {
    val (name, ext) = splitFilename("example.kt")
    println(name)  // example
    println(ext)   // kt
}

9.4.1 반복문에서 구조 분해

val map = mapOf("Alice" to 25, "Bob" to 30)

for ((name, age) in map) {
    println("$name: $age세")
}

*// 람다에서도 사용 가능*
map.forEach { (name, age) ->
    println("$name: $age세")
}

9.4.2 문자로 일부 값 무시하기

data class User(val id: Long, val name: String, val email: String, val city: String)

val user = User(1, "김철수", "kim@email.com", "서울")
val (id, name, _, _) = user  *// email과 city는 무시*

위치 기반의 한계: 순서에 의존하므로 리팩터링 시 주의 필요

9.5 위임 프로퍼티(Delegated Properties)

  • 무거운 작업을 나중에 하고 싶을 때 → lazy
  • 값 변경을 감지하고 싶을 때 → observable
  • 설정값을 맵으로 관리하고 싶을 때 → map

9.5.1 기본 문법과 내부 동작

기본문법/컴파일러가 생성하는 코드

class Foo {
    var p: Type by Delegate()
}

*// ↓ 컴파일러가 실제로 생성하는 코드*
class Foo {
    private val delegate = Delegate()  *// 위임 객체*

    var p: Type
        get() = delegate.getValue(this, ::p)
        set(value) = delegate.setValue(this, ::p, value)
}

위임 객체가 구현해야 하는 관례

class Delegate {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): Type {
        *// getter 로직*
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Type) {
        *// setter 로직*  
    }
}

9.5.2 지연 초기화: by lazy()

문제 상황 - 수동 구현

class Person(val name: String) {
    private var _emails: List<Email>? = null  *// 뒷받침하는 프로퍼티*

    val emails: List<Email>
        get() {
            if (_emails == null) {
                _emails = loadEmails(this)  *// 비용이 많이 드는 작업*
            }
            return _emails!!
        }
}

문제점:

  • 코드가 복잡함
  • 스레드 안전하지 않음
  • 여러 지연 프로퍼티가 있으면 중복 코드 발생

해결책 - by lazy() 사용

class Person(val name: String) {
    val emails by lazy { loadEmails(this) }  *// 간단하고 스레드 안전!*
}

장점:

  • 스레드 안전: 기본적으로 동기화 보장
  • 한 번만 초기화: 최초 접근 시에만 람다 실행
  • 간결함: 보일러플레이트 코드 제거

9.5.3 위임 프로퍼티 직접 구현

1단계: 수동 구현 (옵저버 패턴)

class Person(val name: String, age: Int, salary: Int): Observable() {
    var age: Int = age
        set(newValue) {
            val oldValue = field
            field = newValue
            notifyObservers("age", oldValue, newValue)  *// 중복 코드!*
        }

    var salary: Int = salary
        set(newValue) {
            val oldValue = field
            field = newValue
            notifyObservers("salary", oldValue, newValue)  *// 중복 코드!*
        }
}

2단계: 도우미 클래스 추출

class ObservableProperty(
    val propName: String,
    var propValue: Int,
    val observable: Observable
) {
    fun getValue(): Int = propValue
    fun setValue(newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        observable.notifyObservers(propName, oldValue, newValue)
    }
}

3단계: 위임 프로퍼티용 관례 구현

class ObservableProperty(var propValue: Int, val observable: Observable) {
    operator fun getValue(thisRef: Any?, prop: KProperty<*>): Int = propValue

    operator fun setValue(thisRef: Any?, prop: KProperty<*>, newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        observable.notifyObservers(prop.name, oldValue, newValue)  *// 프로퍼티 이름을 자동으로 가져옴*
    }
}

4단계: 위임 프로퍼티 사용

class Person(val name: String, age: Int, salary: Int) : Observable() {
    var age by ObservableProperty(age, this)
    var salary by ObservableProperty(salary, this)
}

5단계: 표준 라이브러리 활용

class Person(val name: String, age: Int, salary: Int) : Observable() {
    private val onChange = { property: KProperty<*>, oldValue: Any?, newValue: Any? ->
        notifyObservers(property.name, oldValue, newValue)
    }

    var age by Delegates.observable(age, onChange)
    var salary by Delegates.observable(salary, onChange)
}

9.5.5 맵에 위임하여 동적 속성 접근

수동 구현

class Person {
    private val _attributes = mutableMapOf<String, String>()

    fun setAttribute(attrName: String, value: String) {
        _attributes[attrName] = value
    }

    var name: String
        get() = _attributes["name"]!!
        set(value) { _attributes["name"] = value }
}

위임 프로퍼티 사용

class Person {
    private val _attributes = mutableMapOf<String, String>()

    fun setAttribute(attrName: String, value: String) {
        _attributes[attrName] = value
    }

    var name: String by _attributes  *// 맵을 위임 객체로 사용*
}

동작 원리:

  • 표준 라이브러리가 MapMutableMap에 대해 getValue/setValue 확장 함수 제공
  • 프로퍼티 이름이 자동으로 맵의 키로 사용됨

9.5.6 프레임워크에서의 활용 (데이터베이스 ORM)

*// 테이블 정의*
object Users : IdTable() {
    val name = varchar("name", length = 50).index()
    val age = integer("age")
}

*// 엔티티 클래스*
class User(id: EntityID) : Entity(id) {
    var name: String by Users.name  *// 데이터베이스 칼럼에 위임*
    var age: Int by Users.age
}

*// 프레임워크가 제공하는 위임 구현*
operator fun <T> Column<T>.getValue(o: Entity, desc: KProperty<*>): T {
    *// 데이터베이스에서 값 읽기*
}

operator fun <T> Column<T>.setValue(o: Entity, desc: KProperty<*>, value: T) {
    *// 데이터베이스에 값 쓰기, dirty 상태 관리*
}

사용 예:

val user = User(someId)
user.age += 1  *// 자동으로 데이터베이스 업데이트*

10장 고차 함수: 람다를 파라미터와 반환값으로 사용

10.1 고차 함수

고차 함수 : 고차 함수는 람다나 함수 참조를 인자로 넘길 수 있거나, 람다나 함수 참조를 반환하는 함수
코틀린에서는 람다나 함수 참조를 사용해 함수를 값으로 표현할 수 있음

// 예시: filter는 술어 함수를 인자로 받는 고차 함수
list.filter { it > 0 }

10.1.1 함수 타입은 람다의 파라미터 타입과 반환 타입을 지정한다

함수 타입 선언 문법

// 기본 형태: (파라미터 타입들) -> 반환 타입

// 컴파일러가 타입을 알아서 추론
// 람다에서 타입 명시
val sum = { x: Int, y: Int -> x + y }
val action = { println(42) }

// 명시적으로 함수 타입 적기
// 변수 타입 명시 (람다에서는 생략)
val sum: (Int, Int) -> Int = { x, y -> x + y }
val action: () -> Unit = { println(42) }

예시

// 두 개의 Int를 받아서 Int를 반환
val calculator: (Int, Int) -> Int = { x, y -> x + y }

// 아무것도 받지 않고 Unit(아무것도) 반환  
val printer: () -> Unit = { println("안녕!") }

// String 하나 받아서 Boolean 반환
val checker: (String) -> Boolean = { it.length > 5 }

// 여러 파라미터
val formatter: (String, Int, Boolean) -> String = { name, age, isStudent ->
    "$name님은 ${age}살이고 학생여부: $isStudent"
}

널 가능한 함수 타입

// 함수가 널을 반환할 수 있음
var canReturnNull: (Int, Int) -> Int? = { x, y -> null }

// 함수 타입 자체가 널일 수 있음 (괄호 주의!)
var funOrNull: ((Int, Int) -> Int)? = null

10.1.2 인자로 전달 받은 함수 호출

파라미터 이름 지정

"어떤 계산 방법"을 받아서 (operation 파라미터) 결과 계산해서 리턴하는 함수

fun twoAndThree(operation: (operandA: Int, operandB: Int) -> Int) {
    val result = operation(2, 3)
    println("The result is $result")
}

fun main() {
    // 원하는 다른 이름 사용해도 OK! operandA 아니고 a 해도 됨

    twoAndThree { a, b -> a + b }    // 출력: The result is 5
    twoAndThree { a, b -> a * b }    // 출력: The result is 6
    twoAndThree { a, b -> a - b }    // 출력: The result is -1
    twoAndThree { a, b -> a / b }    // 출력: The result is 0 (정수 나눗셈)

    // 더 복잡한 계산
    twoAndThree { a, b -> a * a + b * b }
    // 출력: The result is 13 (2²+3² = 4+9 = 13)
}

filter 함수 구현 예시

fun String.filter(predicate: (Char) -> Boolean): String
//    ↑              ↑           ↑        ↑         ↑
//  수신객체        파라미터명   입력타입  출력타입   반환타입
fun String.filter(predicate: (Char) -> Boolean): String {
    return buildString {
        for (char in this@filter) {        // 문자열의 각 문자 확인
            if (predicate(char)) {          // 조건 함수 호출
                append(char)                // 조건에 맞으면 추가
            }
        }
    }
}

fun main() {
    println("able".filter { it in 'a'..'z' })  // "able"
}

10.1.3 자바에서 코틀린 함수 타입 사용

코틀린 함수 타입은 내부적으로 FunctionN 인터페이스로 구현

// 코틀린 선언
fun processTheAnswer(f: (Int) -> Int) {
    println(f(42))
}

// 자바 호출
processTheAnswer(number -> number + 1);  // 43

문제와 해결방법

수신 객체 명시: strings.forEach가 아니라 CollectionsKt.forEach(strings, ...)
Unit 반환: Unit.INSTANCE를 명시적으로 반환해야 함

import kotlin.collections.CollectionsKt;
import kotlin.Unit;

public class JavaExample {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        strings.add("Hello");
        strings.add("World");

        // 코틀린의 forEach를 자바에서 사용
        CollectionsKt.forEach(strings, s -> {
            System.out.println(s);
            return Unit.INSTANCE;  // Unit을 명시적으로 반환해야 함
        });
    }
}

함수 타입의 내부 구조

FunctionN 인터페이스

코틀린 함수 타입은 내부적으로 FunctionN 인터페이스로 구현

*// 코틀린 함수 타입*
val myFunction: (Int) -> String = { "Number: $it" }

*// 실제로는 이런 인터페이스의 구현체*
interface Function1<P1, R> {
    operator fun invoke(p1: P1): R
}

파라미터 개수별 인터페이스

*// 파라미터 없음*
val func0: () -> String                    *// Function0<String>// 파라미터 1개*  
val func1: (Int) -> String                 *// Function1<Int, String>// 파라미터 2개*
val func2: (Int, String) -> Boolean        *// Function2<Int, String, Boolean>// 파라미터 3개*
val func3: (Int, String, Boolean) -> Unit  *// Function3<Int, String, Boolean, Unit>*

실제 동작 과정

함수 호출의 내부

fun processTheAnswer(f: (Int) -> Int) {
    println(f(42))  *// 실제로는 f.invoke(42)*
}

*// 컴파일러가 내부적으로 변환하는 코드*
fun processTheAnswer(f: Function1<Int, Int>) {
    println(f.invoke(42))
}

10.1.4 함수 타입의 파라미터에 대해 기본값을 지정할 수 있고, 널이 될 수도 있다

기본값이 있는 함수 타입 파라미터

// 문법
parameter: (ParamType) -> ReturnType = { 기본 람다 }

// 예시
transform: (T) -> String = { it.toString() }
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))
    }
    result.append(postfix)
    return result.toString()
}

// 사용법
val letters = listOf("Alpha", "Beta")
println(letters.joinToString())  // Alpha, Beta
println(letters.joinToString { it.lowercase() })  // alpha, beta

널 가능한 함수 타입 파라미터

// 선언
parameter: ((ParamType) -> ReturnType)? = null

// 안전한 호출
parameter?.invoke(argument) ?: 기본값
  • transform?.invoke(element) → transform이 null이 아니면 호출
  • ?: element.toString() → transform이 null이면 기본 toString() 사용
fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = "",
    transform: ((T) -> String)? = null
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        val str = transform?.invoke(element) ?: element.toString()
        result.append(str)
    }
    result.append(postfix)
    return result.toString()
}

10.1.5 함수를 함수에서 반환하기

상황에 맞는 도구를 골라주는 함수

fun 함수이름(파라미터): (입력타입) -> 출력타입 {
    return { 입력 -> 처리로직 }
}
enum class Delivery { STANDARD, EXPEDITED }
class Order(val itemCount: Int)

fun getShippingCostCalculator(delivery: Delivery): (Order) -> Double {
    if (delivery == Delivery.EXPEDITED) {
        // 빠른 배송: 기본 6달러 + 아이템당 2.1달러
        return { order -> 6 + 2.1 * order.itemCount }
    }
        // 일반 배송: 아이템당 1.2달러
    return { order -> 1.2 * order.itemCount }
}

fun main() {
    val calculator = getShippingCostCalculator(Delivery.EXPEDITED)
    println("Shipping costs ${calculator(Order(3))}")  // Shipping costs 12.3
}
  • getShippingCostCalculator() → 배송 방법에 맞는 계산 함수 반환
  • 반환된 함수를 calculator 변수에 저장
  • calculator(Order(3)) → 실제 계산 실행

활용 - UI 필터링

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 {
    // 이름이나 성이 prefix로 시작하는지 확인하는 함수
        val startsWithPrefix = { p: Person ->
            p.firstName.startsWith(prefix) || p.lastName.startsWith(prefix)
        }

        if (!onlyWithPhoneNumber) {
            return startsWithPrefix // 전화번호 조건 없음
        }

       // 전화번호도 있어야 함
        return { startsWithPrefix(it) && it.phoneNumber != null }
    }
}
//사용
fun main() {
    val contacts = listOf(
        Person("Dmitry", "Demerov", "123-4567"),
        Person("Svetlana", "Isakova", null)
    )

    val contactListFilters = ContactListFilters()
    contactListFilters.prefix = "Dm"
    contactListFilters.onlyWithPhoneNumber = true

    // getPredicate()가 반환한 함수를 filter에 전달
    val filtered = contacts.filter(contactListFilters.getPredicate())
    println(filtered)


    // [Person(firstName=Dmitry, lastName=Demerov, phoneNumber=123-4567)]
}

10.1.6 람다로 중복 제거하기

일반적인 중복 제거

data class SiteVisit(val path: String, val duration: Double, val os: OS)
enum class OS { WINDOWS, LINUX, MAC, IOS, ANDROID }

// Before: 하드코딩된 필터
val averageWindowsDuration = log
    .filter { it.os == OS.WINDOWS }
    .map(SiteVisit::duration)
    .average()

// After: 파라미터화
fun List<SiteVisit>.averageDurationFor(os: OS) =
    filter { it.os == os }.map(SiteVisit::duration).average()

고차 함수를 사용한 더 유연한 해결책

fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
    filter(predicate).map(SiteVisit::duration).average()

fun main() {
    // 복잡한 조건도 처리 가능
    println(log.averageDurationFor { it.os in setOf(OS.ANDROID, OS.IOS) })
    println(log.averageDurationFor { it.os == OS.IOS && it.path == "/signup" })
}

10.2 인라인 함수를 사용해 람다의 부가 비용 없애기

문제: 람다 비용이 클 수도 있다

// 겉보기에는 간단해 보이지만...
people.filter { it.age < 30 }

// 실제로는 이렇게 컴파일됨
people.filter(new Function1<Person, Boolean>() {
    public Boolean invoke(Person it) {
        return it.age < 30;
    }
});
  • 람다마다 새로운 클래스 생성
  • 객체 생성 비용 발생
  • 메모리 사용량 증가

→ 해결책 :inline 키워드

10.2.1 인라이닝이 작동하는 방식

inline 키워드를 사용하면 함수 호출을 함수 본문으로 대체해 성능을 향상시킵니다.

// 인라인 함수 정의
inline fun <T> synchronized(lock: Lock, action: () -> T): T {
    lock.lock()
    try {
        return action()
    } finally {
        lock.unlock()
    }
}

//사용
fun foo(l: Lock) {
    println("Before sync")
    synchronized(l) {
        println("Action")
    }
    println("After sync")
}

// 컴파일 후 실제로는 다음과 같음:
fun foo(l: Lock) {
    println("Before sync")
      // synchronized 함수가 여기에 복사됨
    l.lock()
    try {
        println("Action") // 람다 내용도 여기에 복사됨
    } finally {
        l.unlock()
    }
    println("After sync")
}

장점:

함수 호출 없음 , 객체 생성 없음, 성능은 직접 작성한 코드와 동일

10.2.2 인라인 함수의 제약

안 되는 경우: 람다를 저장하는 경우

// 불가능: 람다를 변수에 저장
class FunctionStorage {
    var myStoredFunction: ((Int) -> Unit)? = null

    inline fun storeFunction(f: (Int) -> Unit) {
        myStoredFunction = f  //  컴파일 에러
        // 람다를 저장하면 인라이닝 불가능
    }
}

// 가능: noinline으로 특정 파라미터만 제외
inline fun foo(
    inlined: () -> Unit,           // 인라이닝됨
    noinline notInlined: () -> Unit // 인라이닝 안됨
) {
    inlined()        // 인라인됨
    notInlined()     // 일반 함수 호출
}

10.2.3 컬렉션 연산 인라이닝

filter는 인라인 함수임

data class Person(val name: String, val age: Int)
val people = listOf(Person("Alice", 29), Person("Bob", 31))

// 인라인 함수들이므로 성능 걱정 없음
println(people.filter { it.age < 30 })

// filter와 map을 연쇄 사용
println(people.filter { it.age > 30 }.map(Person::name))
// 하지만 중간 리스트가 생성됨

// 큰 데이터의 경우 시퀀스 고려
people.asSequence()
    .filter { it.age > 30 }
    .map(Person::name)
    .toList()

10.2.4 언제 함수를 인라인으로 선언할지 결정

인라인 사용 권장 사항

  • 사용하기 좋은 경우: 람다를 인자로 받는 작은 함수
  • 피해야 할 경우: 큰 함수 (코드 중복 발생)

인라인의 장점

  1. 함수 호출 오버헤드 제거
  2. 람다 객체 생성 비용 제거
  3. 비로컬 return 가능
*// 람다를 받는 함수 - 효과 큼!*
inline fun repeat(times: Int, action: (Int) -> Unit) {
    for (i in 0 until times) {
        action(i)
    }
}
*// 일반 함수 - JVM이 알아서 최적화해줌*
fun add(a: Int, b: Int): Int = a + b

10.2.5 withLock, use, useLines로 자원 관리를 위해 인라인된 람다 사용

withLock 사용

val lock = ReentrantLock()
lock.withLock {
    // 보호되는 코드
}

// withLock 구현
fun <T> Lock.withLock(action: () -> T): T {
    lock()
    try {
        return action()
    } finally {
        unlock()
    }
}

use 함수 활용 : 자동 리소스 해제 / useline : 더 간편한 파일 처리

fun readFirstLineFromFile(fileName: String): String {
    BufferedReader(FileReader(fileName)).use { br ->
        return br.readLine() //자동으로 파일 닫기
    }
}

// 더 간결한 방법
fun readFirstLineFromFile(fileName: String): String {
    return Path(fileName).useLines { it.first() } // 첫 번째 줄 반환
}

10.3 람다에서 반환: 고차 함수에서 흐름 제어

람다에서 return을 사용할 때 어떤 일이 일어나는지 알아보는 챕터

10.3.1 람다 안의 return 문: 람다를 둘러싼 함수에서 반환

비로컬 return (Non-local return) ,forEach도 일반 루프와 똑같이 동작

  • 비로컬 return: 람다를 넘어서서 바깥 함수까지 종료
  • 로컬 return: 람다 종료하고 바깥 함수는 계속 실행
//일반 루프
fun lookForAlice(people: List<Person>) {
    for (person in people) {
        if (person.name == "Alice") {
            println("Found!")
            return  // lookForAlice 함수를 종료
        }
    }
    println("Alice is not found")  // 출력 X
}

//forEach
fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") {
            println("Found!")
            return  // lookForAlice 함수를 종료 
            // return이 forEach만 끝내는 건지, 아니면 findPerson 함수 전체를 끝내는 건지?
            }
    }
    println("Alice is not found") // 출력 X 
}
//람다 안의 return은 람다를 감싸고 있는 함수를 종료

자바 람다의 제약 사항

// 자바 람다는 "값을 반환하는 식"이지 "코드 블록"이 아님
List<String> names = people.stream()
    .map(person -> person.getName())  // 값 반환은 OK
    .collect(toList());

// 하지만 제어 흐름(return/break/continue)은 불가능
people.forEach(person -> {
    if (condition) {
        return; // 안됨
        break;  // 안됨
        continue; // 안됨
    }
});

public void findPerson() {
    for (Person person : people) {
        if (person.getName().equals("Alice")) {
            System.out.println("찾음");
            return; // 이건 됨
        }
    }
    System.out.println("못 찾음");
}

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

로컬 return (레이블 사용)

fun lookForAlice(people: List<Person>) {
    people.forEach label@{
        if (it.name != "Alice") return@label  // 람다만 종료
        print("Found Alice!")
    }
}

// 또는 함수 이름을 레이블로 사용
fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name != "Alice") return@forEach
        print("Found Alice!")
    }
}

10.3.3 익명 함수: 기본적으로 로컬 return

익명 함수 = 이름이 없는 함수

// 일반 함수 (이름 있음)
fun add(a: Int, b: Int): Int {
    return a + b
}

// 익명 함수 (이름 없음)
fun(a: Int, b: Int): Int {
    return a + b
}

람다 vs 익명함수

val numbers = listOf(1, 2, 3, 4, 5)

// 1. 람다 
numbers.filter { it > 3 } 

// 2. 익명 함수 
numbers.filter(fun(x) = x > 3)

// 3. 일반 함수
fun isGreaterThan3(x: Int) = x > 3
numbers.filter(::isGreaterThan3)

// [4,5]
fun processNumbers() {
    numbers.forEach(fun(it) {
        if (it == 3) {
            println("3 건너뜀!")
            return  // 이 익명 함수만 종료
        }
        println("숫자: $it")
    })
    println("처리 완료")  // 항상 실행됨
}
//복잡한 조건들... 레이블 많이 필요
fun process() {
    items.forEach label1@{ item ->
        if (item.type == "A") {
            return@label1
        }

        item.subItems.forEach label2@{ subItem ->
            if (subItem.isInvalid()) {
                return@label2
            }
            // 처리...
        }
    }
}

//익명 함수로 깔끔하게!
kotlinfun process() {
    items.forEach(fun(item) {
        if (item.type == "A") {
            return  // 간단
        }

        item.subItems.forEach(fun(subItem) {
            if (subItem.isInvalid()) {
                return  // 레이블 필요 없음
            }
            // 처리...
        })
    })
}

결론

전체 종료: return (비로컬 return) , 여기서 끝

건너뛰기: return@label 또는 익명 함수 , 이번만 패스

복잡한 로직: 익명 함수가 깔끔 , (익명함수 : 안전한 return 구역)

'language > Kotilin In Action' 카테고리의 다른 글

6주차 (13장)  (2) 2025.09.01
5주차(11~12장)  (1) 2025.09.01
3주차 (6~8장)  (1) 2025.09.01
2주차 (4~5장)  (1) 2025.09.01
1주차 (1~3장)  (0) 2025.09.01