관리 메뉴

나만을 위한 블로그

[Kotlin in Action] 7장. 기타 관례 본문

책/Kotlin in Action

[Kotlin in Action] 7장. 기타 관례

참깨빵위에참깨빵 2023. 10. 8. 18:31
728x90
반응형
컬렉션, 범위에 대해 쓸 수 있는 관례

 

인덱스로 원소에 접근 : get, set

 

a[b]처럼 인덱스 연산자(=대괄호)를 써서 변경 가능한 맵에 키밸류 쌍을 넣거나 이미 들어있는 키밸류 쌍의 연관 관계를 바꿀 수 있다.

코틀린에선 인덱스 연산자도 관례를 따른다. 인덱스 연산자로 원소를 읽는 연산은 get 연산자 메서드로 변환되고, 쓰는 연산은 set 연산자 메서드로 변환된다. Map, MutableMap 인터페이스엔 이미 두 메서드가 들어있다.

 

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

operator fun Point.get(index: Int): Int {
    return when (index) {
        0 -> x
        1 -> y
        else -> throw IndexOutOfBoundsException("유효하지 않은 인덱스 : $index")
    }
}

fun main() {
    val p = Point(10, 20)
    println(p[1])
}

// >> 20

 

get()을 만들고 operator 키워드를 붙이면 된다. 그 후 p[1]은 p가 Point 타입일 때 get()으로 변환된다.

get()을 정의할 때, 인자로 Int가 아닌 타입도 쓸 수 있다. 맵 인덱스 연산의 경우 get()의 파라미터 타입은 맵의 키 타입과 같은 임의의 타입이 될 수 있다. 여러 파라미터를 쓰는 get()을 정의할 수 있다.

인덱스에 해당하는 컬렉션 원소를 쓰려면 set()을 정의하면 된다. Point는 불변 클래스기 때문에 set이 의미 없어서 변경 가능한 점을 표현하는 다른 클래스를 만든다.

 

data class MutablePoint(var x: Int, var y: Int)

operator fun MutablePoint.set(index: Int, value: Int) {
    when (index) {
        0 -> x = value
        1 -> y = value
        else -> throw IndexOutOfBoundsException("유효하지 않은 인덱스 : $index")
    }
}

fun main() {
    val p = MutablePoint(10, 20)
    p[1] = 42
    println(p)
}

// >> MutablePoint(x=10, y=42)

 

in 관례

 

컬렉션이 지원하는 연산자 중 in이 있다. in은 객체가 컬렉션에 들어있는지 검사하는 연산자다. in 연산자와 대응하는 함수는 contains다.

아래는 어떤 Point 객체가 사각형 영역에 들어가는지 판단하는 예시다.

 

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

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

fun main() {
    val rect = Rectangle(Point(10, 20), Point(50, 50))
    println(Point(20, 30) in rect)
    println(Point(5, 5) in rect)
}

// >> true
// >> false

 

in의 우항에 있는 객체는 contains()의 수신 객체가 되고 in의 좌항에 있는 객체는 contains()에 인자로 전달된다.

열린 범위는 끝 값을 포함하지 않는 범위를 말한다. "10..20"으로 일반적인 닫힌 범위를 만들면 10 이상 20 이하인 범위가 생긴다. "10 until 20"은 10 이상 19 이하인 범위다.

참고로 코틀린 1.9.0 이상을 사용할 경우 인텔리제이에서 until을 아래와 같이 바꿀 수 있다는 노란 줄을 표시한다.

 

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

 

코틀린 1.9.0부터 "..<" 키워드는 until과 같은 동작을 한다. until보다 좀 더 직관적으로 보여서 더 좋긴 한데, 1.9.0 버전에서 적용된 변경사항이기 때문에 참고한다.

 

rangeTo 관례

 

범위를 만들려면 ".." 구문을 써야 한다. ".." 연산자는 rangeTo 함수를 간략하게 표현하는 방법이다.

rangeTo 함수는 범위를 리턴한다. 이 연산자는 아무 클래스에나 정의할 수 있다. 하지만 어떤 클래스가 Comparable 인터페이스를 구현하면 rangeTo를 정의할 필요가 없다.

아래는 LocalDate 클래스를 써서 날짜 범위를 만드는 예시다.

 

fun main() {
    val now = LocalDate.now()
    val vacation = now .. now.plusDays(10) // 오늘부터 시작하는 10일짜리 범위
    println(now.plusWeeks(1) in vacation)
}

// >> true

 

"now .. now.plusDays(10)"은 컴파일러가 "now.rangeTo(now.plusDays(10))"으로 바꾼다. rangeTo 함수는 LocalDate의 멤버는 아니고 Comparable에 대한 확장 함수다. 이 연산자는 다른 연산자보다 우선순위가 낮아서 괄호로 인자를 감싸면 혼동을 피할 수 있다.

 

fun main() {
    val n = 9
    println(0 .. (n + 1))
}

// >> 0..10

 

for 루프를 위한 iterator 관례

 

코틀린의 for 루프는 범위 검사와 같은 in 연산자를 쓰지만 이 때 in의 의미는 다르다.

 

for (x in list) {
    // ...
}

 

이 코드는 list.iterator()를 호출해서 이터레이터를 얻은 다음 자바처럼 이터레이터에 대해 hasNext(), next() 호출을 반복하는 식으로 변환된다.

코틀린에선 이것 또한 관례기 때문에 iterator 메서드를 확장 함수로 정의할 수 있다. 또한 클래스 안에 직접 iterator 메서드를 구현할 수도 있다.

 

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

        // compareTo 관례를 써서 날짜 비교
        override fun hasNext(): Boolean = current <= endInclusive

        override fun next(): LocalDate = current.apply {
            current = plusDays(1) // 현재 날짜를 1일 뒤로 변경
        }

    }

fun main() {
    val newYear = LocalDate.ofYearDay(2024, 1)
    val daysOff = newYear.minusDays(1) .. newYear
    for (dayOff in daysOff) {
        println(dayOff)
    }
}

// >> 2023-12-31
// >> 2024-01-01

 

구조 분해 선언, component 함수

 

구조 분해를 쓰면 복합적인 값을 분해해서 여러 다른 변수를 한꺼번에 초기화할 수 있다. 아래는 구조 분해 선언 사용법이다.

 

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

fun main() {
    val p = Point(10, 20)
    val (x, y) = p
    println(x)
    println(y)
}

// >> 10
// >> 20

 

내부에서 구조 분해 선언은 다시 관례를 사용한다. 구조 분해 선언의 각 변수를 초기화하기 위해 componentN이란 함수를 호출한다. N은 구조 분해 선언의 변수 위치에 따라 붙는 번호다. x는 component1, y는 component2라는 이름으로 컴파일된다.

data class의 주 생성자 내부 프로퍼티는 컴파일러가 자동으로 componentN()을 만들어 준다.

 

구조 분해 선언은 함수에서 여러 값을 반환할 때 유용하다. 여러 값을 한꺼번에 리턴해야 하는 함수가 있다면 리턴해야 하는 모든 값이 들어갈 data class를 정의하고 함수의 리턴타입을 그 data class로 바꾼다. 구조 분해 선언 구문을 쓰면 이런 함수가 리턴하는 값을 쉽게 풀어서 여러 변수에 넣을 수 있다. 아래는 파일명을 이름, 확장자로 나누는 함수 작성 예시다.

 

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, 확장자 : $ext")
}

// >> 이름 : example, 확장자 : kt

 

배열, 컬렉션에도 componentN()이 있어서 이 코드를 더 개선할 수 있다. 크기가 정해진 컬렉션을 다룰 경우 구조 분해가 유용하다.

아래 예시에서 split()은 원소 2개로 이뤄진 리스트를 리턴한다.

 

fun splitFileName(fullName: String): NameComponents {
    val (name, extension) = fullName.split(".", limit = 2)
    return NameComponents(name, extension)
}

 

componentN()을 무한히 선언할 수는 없어서 이런 구문을 무한하게 쓸 순 없다. 그래도 컬렉션에 대한 구조 분해는 유용하다.

표준 라이브러리의 Pair, Triple 클래스를 쓰면 함수에서 여러 값을 더 간단하게 리턴할 수 있다. 두 클래스는 안에 담긴 원소의 의미를 말해주지 않아서 경우에 따라 가독성은 떨어지지만 직접 클래스를 작성할 필요가 없어 코드는 단순해진다.

 

구조 분해 선언과 루프

 

함수 본문 안의 선언문 뿐 아니라 변수 선언이 들어갈 수 있는 곳이면 구조 분해 선언을 쓸 수 있다. 루프 안에서도 구조 분해 선언을 쓸 수 있다.

 

fun printEntries(map: Map<String, String>) {
    for ((key, value) in map) {
        println("$key -> $value")
    }
}

fun main() {
    val map = mapOf("김" to "철수", "이" to "영희")
    printEntries(map)
}

// >> 김 -> 철수
// >> 이 -> 영희

 

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

 

위임 프로퍼티를 쓰면 backing field에 값을 단순히 저장하는 것보다 더 복잡하게 작동하는 프로퍼티를 쉽게 구현할 수 있다. 또한 이 과정에서 접근자 로직을 매번 재구현할 필요도 없다. 이런 특성의 기반에는 위임이 있다. 위임은 객체가 직접 작업을 수행하지 않고 다른 헬퍼 객체가 그 작업을 처리하게 맡기는 디자인 패턴을 말한다. 이 때 작업하는 헬퍼 객체를 위임 객체라고 부른다.

위임 프로퍼티의 일반적인 문법은 아래와 같다.

 

class Foo {
    var p: Type by Delegate() // 이 클래스는 아직 선언하지 않았다
}

 

p 프로퍼티는 접근자 로직을 다른 객체에 위임한다. 여기선 Delegate 클래스의 인스턴스를 위임 객체로 쓴다. by 뒤의 식을 계산해서 위임에 쓰일 객체를 얻는다. Delegate 클래스를 단순화하면 아래와 같다.

 

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

class Delegate {
    // 게터 구현 로직
    operator fun getValue(foo: Foo, property: KProperty<*>): Type {
        // ...
    }
    
    // 세터 구현 로직
    operator fun setValue(foo: Foo, property: KProperty<*>, type: Type) {
        // ...
    }
}

 

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

 

지연 초기화는 객체 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요할 경우 초기화할 때 흔히 사용하는 패턴이다. 초기화 과정에 자원을 많이 사용하거나 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티에 대해 지연 초기화 패턴을 쓸 수 있다.

예를 들어 person 클래스가 자신이 작성한 이메일의 목록을 제공한다고 가정한다. 이메일은 DB에 들어있고 불러오려면 시간이 오래 걸린다. 그래서 이메일 프로퍼티의 값을 최초로 사용할 때 단 1번만 이메일을 DB에서 가져오려고 할 경우, 이메일을 가져온 loadEmails()를 확인한다.

 

class Email { /* ... */ }

fun loadEmails(person: Person): List<Email> {
    println("${person.name}의 이메일 가져옴")
    return listOf(Email(/* ... */))
}

 

아래 Person 클래스는 이메일을 불러오기 전에는 null을 저장하고 불러온 다음에는 이메일 리스트를 저장하는 _email 프로퍼티로 지연 초기화를 구현하는 예시다.

 

class Email { /* ... */ }

class Person(val name: String) {
    private var _emails: List<Email>? = null
    val emails: List<Email>
        get() {
            if (_emails == null) {
                // 최초 접근 시 이메일을 얻음
                _emails = loadEmails(this)
            }

            return _emails!! // 저장해 둔 데이터가 있으면 그걸 반환
        }
}

fun loadEmails(person: Person): List<Email> {
    println("${person.name}의 이메일 가져옴")
    return listOf(Email(/* ... */))
}

fun main() {
    val p = Person("김철수")
    p.emails
}

// >> 김철수의 이메일 가져옴

 

여기선 backing property 기법을 쓴다. _emails라는 프로퍼티는 값을 저장하고, emails는 _emails 프로퍼티의 읽기 연산을 제공한다.

_emails는 널이 될 수 있지만 emails는 널이 될 수 없는 타입이므로 프로퍼티를 2개 써야 한다.

하지만 이런 코드를 만드는 건 성가시다. 지연 초기화해야 하는 프로퍼티가 많아지면 코드가 어떻게 되는가? 그리고 쓰레드 세이프하지 않아서 언제나 제대로 작동한다고 말할 수도 없다.

위임 프로퍼티를 쓰면 이 코드가 더 간단해진다. 위임 프로퍼티는 backing property와 값이 1번만 초기화됨을 보장하는 게터 로직을 같이 캡슐화한다.

 

class Person(val name: String) {
    val emails by lazy { loadEmails(this) }
}

 

lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue()가 들어 있는 객체를 리턴한다. 따라서 lazy를 by 키워드와 같이 써서 위임 프로퍼티를 만들 수 있다. lazy 함수의 인자는 값을 초기화할 때 사용할 람다고, lazy 함수는 기본적으로 쓰레드 세이프하다.

 

위임 프로퍼티 구현

 

어떤 객체의 프로퍼티가 바뀔 때마다 리스너에게 변경 통지를 보내려 한다고 가정한다. 아래 클래스를 만든다.

 

open class PropertyChangeAware {
    protected val changeSupport = PropertyChangeSupport(this)
    
    fun addPropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.addPropertyChangeListener(listener)
    }
    
    fun removePropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.removePropertyChangeListener(listener)
    }
}

 

이 클래스는 리스너 목록을 관리하고 PropertyChangeEvent가 들어오면 리스트의 모든 리스너에게 이벤트를 통지한다. 리스너 지원이 필요한 클래스는 이 클래스를 상속해서 changeSupport에 접근할 수 있다.

이제 새로 Person 클래스를 작성한다. Person 클래스는 나이나 급여가 바뀌면 리스너에게 통지한다.

 

class Person(
    val name: String,
    age: Int,
    salary: Int
): PropertyChangeAware() {
    var age: Int = age
        set(newValue) {
            val oldValue = field // backing field(여기선 age)에 접근할 때 field 식별자 사용
            field = newValue
            changeSupport.firePropertyChange( // 프로퍼티 변경을 리스너에 통지
                "age",
                oldValue,
                newValue
            )
        }

    var salary: Int = salary
        set(newValue) {
            val oldValue = field
            field = newValue
            changeSupport.firePropertyChange(
                "salary",
                oldValue,
                newValue
            )
        }
}

fun main() {
    val p = Person("김철수", 34, 1000)
    p.addPropertyChangeListener { event ->
        println("프로퍼티 ${event.propertyName}가 변경됨 - from ${event.oldValue} to ${event.newValue}")
    }
    p.age = 35
    p.salary = 1500
}

// >> 프로퍼티 age가 변경됨 - from 34 to 35
// >> 프로퍼티 salary가 변경됨 - from 1000 to 1500

 

세터 코드에 중복이 많다. 이제 프로퍼티의 값을 저장하고 필요하면 통지를 보내주는 클래스를 추출한다.

 

open class PropertyChangeAware {
    protected val changeSupport = PropertyChangeSupport(this)

    fun addPropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.addPropertyChangeListener(listener)
    }

    fun removePropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.removePropertyChangeListener(listener)
    }
}

class ObservableProperty(
    val propName: String, var propValue: Int,
    val changeSupport: PropertyChangeSupport
) {
    fun getValue(): Int = propValue
    fun setValue(newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        changeSupport.firePropertyChange(propName, oldValue, newValue)
    }
}

class Person(
    val name: String,
    age: Int,
    salary: Int
): PropertyChangeAware() {
    val _age = ObservableProperty("age", age, changeSupport)
    var age: Int
        get() = _age.getValue()
        set(value) {
            _age.setValue(value)
        }

    val _salary = ObservableProperty("salary", salary, changeSupport)
    var salary: Int
        get() = _salary.getValue()
        set(value) {
            _salary.setValue(value)
        }
}

fun main() {
    val p = Person("이영희", 27, 1000)
    p.addPropertyChangeListener { event ->
        println("프로퍼티 ${event.propertyName}가 변경됨 - from ${event.oldValue} to ${event.newValue}")
    }
    p.age = 28
    p.salary = 1300
}

// >> 프로퍼티 age가 변경됨 - from 27 to 28
// >> 프로퍼티 salary가 변경됨 - from 1000 to 1300

 

하지만 위임 프로퍼티를 쓰기 전에 ObservableProperty의 두 메서드 시그니처를 코틀린 관례에 맞게 고쳐야 한다.

 

class ObservableProperty(
    val propName: String, var propValue: Int,
    val changeSupport: PropertyChangeSupport
) {
    operator fun getValue(p: Person, prop: KProperty<*>): Int = propValue
    operator fun setValue(p: Person, prop: KProperty<*>, newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        changeSupport.firePropertyChange(prop.name, oldValue, newValue)
    }
}

 

이제 Person에서 위임 프로퍼티를 사용한다.

 

class ObservableProperty(
    var propValue: Int,
    val changeSupport: PropertyChangeSupport
) {
    operator fun getValue(p: Person, prop: KProperty<*>): Int = propValue
    operator fun setValue(p: Person, prop: KProperty<*>, newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        changeSupport.firePropertyChange(prop.name, oldValue, newValue)
    }
}

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

 

by로 위임 객체를 지정하면 이전 예시에서 직접 코드를 짜야 했던 여러 작업을 코틀린 컴파일러가 자동으로 처리한다.

관찰 가능한 프로퍼티 로직을 직접 작성하지 않고 코틀린 표준 라이브러리로 써도 된다. 이미 ObservableProperty와 비슷한 클래스가 있다. 하지만 표준 라이브러리의 클래스는 PropertyChangeSupport와 연결돼 있진 않다.

따라서 프로퍼티 값의 변경을 통지할 때 PropertyChangeSupport를 쓰는 법을 알려주는 람다를 그 표준 라이브러리 클래스에 넘겨야 한다.

 

class Person(
    val name: String,
    age: Int,
    salary: Int
): PropertyChangeAware() {
    private val observer = {
        prop: KProperty<*>, oldValue: Int, newValue: Int ->
        changeSupport.firePropertyChange(prop.name, oldValue, newValue)
    }
    var age: Int by Delegates.observable(age, observer)
    var salary: Int by Delegates.observable(salary, observer)
}

 

by의 오른쪽에 있는 식이 꼭 새 인스턴스를 만들 필요는 없다. 함수 호출, 다른 프로퍼티, 다른 식 등이 올 수도 있다. 다만 우항에 있는 식을 계산한 결과인 객체는 컴파일러가 호출 가능한 올바른 타입의 getValue, setValue를 반드시 제공해야 한다.

예제 단순화를 위해 Int 타입의 프로퍼티 위임만 확인했지만 프로퍼티 위임 매커니즘은 모든 타입에 사용할 수 있다.

반응형
Comments