[Chapter7] - 연산자 오버로딩과 기타 관계

코틀린 인 액션를 공부하며 작성한 글입니다.
혼자 공부하고 정리한 내용이며, 틀린 부분은 지적해주시면 감사드리겠습니다 😀

어떤 클래스 안에 plus라는 이름의 특별한 메소드를 정의하면, 그 클래스의 인스턴스에 대해 + 연산자를 사용할 수 있다. 이렇게 어떤 언어 기능과 미리 정해진 이름의 함수를 연결해주는 기법을 관례(convention)이라고 부른다.

1. 산술 연산자 오버로딩

자바에서는 String 값에 대해 + 혹은 += 연산을 사용할 수 있었다. 이처럼 코틀린에서도 관례를 사용하는 가장 단순한 예는 산술 연산자이다.

1-1. 이항 산술 연산과 오버로딩

아래 코드는 plus에 대한 산술 연산을 구현한 코드이다.

data class Point(val x: Int, val y: Int) {  
    operator fun plus(other: Point): Point {  
        return Point(x + other.x, y + other.y)  
    }  
}  
  
fun main() {  
    val p1 = Point(10, 20)  
    val p2 = Point(30, 40)  
    println(p1 + p2)  
}

이처럼 연산자를 오버로딩하는 함수 정의 앞에 operator를 붙여야 한다. 만약 operator를 정의하지 않을 경우 해당 연산을 사용하는 부분에서 다음과 같은 에러가 발생한다.

// 'operator' modifier is required on 'plus' in 'part1.Point'
println(p1 + p2)

연산자를 기준으로 좌측에 있는 인스턴스가 수신 객체처럼 활용되고, 우측에 있는 인스턴스가 매개변수로 활용된다.

plus 외에도 사용할 수 있는 연산은 다음과 같다.

| 식 | 함수 이름 | | —- | —- | | a * b | times | | a / b | div | | a % b | mod -> rem | | a + b | plus | | a - b | minus | 만약 a + b * c와 같은 식이 있다면, 연산자 우선 순위에 따라 *를 먼저 진행하고, +를 진행한다. 또한, 이런 연산자 오버로딩은 클래스 내부가 아닌 외부에서도 확장 함수로 정의할 수 있다.

operator fun Point.times(scale: Double): Point {  
    return Point((x * scale).toInt(), (y * scale).toInt())  
}

코틀린은 연산자가 자동으로 교환 법칙(commutativity)을 지원하지 않는다.

p * 1.5 == 1.5 * p

위 교환 법칙을 준수하려면, 같은 식에 대응하는 연산자 함수인 Double.times에 대한 확장 함수를 추가적으로 정의해야 한다.

1-2. 복합 대입 연산자 오버로딩

코틀린은 +와 같은 일반 연산자 뿐만 아니라 +=과 같은 복합 대입(compound assignment) 연산자도 지원한다. 대표적으로 List, Map, Set과 같은 컬렉션에서 사용이 가능하다.

operator fun <T> MutableCollection<T>.plusAssign(element: T) {
	this.add(element)
}

plusAssign을 사용하려면, val이 아닌 var로 정의되어 있어야 하며, 가능한 plus와 plusAssign 연산을 동시에 정의하지 않는 것이 좋다. 코틀린 컬렉션은 두 가지를 함께 제공한다. +, - 연산은 항상 새로운 컬렉션을 반환하고, +=과 -= 연산자는 항상 변경 가능한 컬렉션에 작용해 메모리에 있는 객체 상태를 변화시킨다. 만약 읽기 전용 컬렉션일 경우 변경을 적용한 복사본을 반환한다.

fun main() {  
    val list = arrayListOf(1, 2)  
    list += 3  
    val newList = list + listOf(4, 5)  
    // [1, 2, 3]
    println(list)
	// [1, 2, 3, 4, 5]  
    println(newList)  
}

1-3. 단항 연산자 오버로딩

앞서 본 연산은 모두 두 값에 대해 작용하는 이항(binary) 연산에 대한 내용이었다. 단항 연산자는 함수에 파라미터 없이 사용하는 함수이다.

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

fun main() {
	val p = Point(10, 20)
	// Point(x=-10, y=-20)
	println(-p)
}
함수 이름
+a unaryPlus
-a unaryMinus
!a not
++a, a++ inc
–a, a– dec

BigDecimal과 같은 클래스를 살펴보면 ++ 연산을 오버로딩한 것을 볼 수 있다.

operator fun BigDecimal.inc() = this + BigDecimal.ONE

2. 비교 연산자 오버로딩

equals나 compareTo를 호출해야할 경우 자바와 달리 == 비교 연산자를 직접 사용할 수 있어, 코드가 더 간결하며, 이해하기 쉬워진다.

2-1. 동등성 연산자: equals

4장에서 나온 것과 같이 코틀린에서는 == 연산을 사용하면 equals 메소드 호출로 컴파일한다. 즉, != 연산은 equals의 부정 연산을 하는 것이다. 식별자 비교(identity equals) 연산자인 ===를 사용해 파라미터가 수신 객체와 같은지 비교할 수 있다. 즉, 자바의 == 연산자와 동일한 것이다.

equals는 Any에 정의된 메소드이므로, override가 붙어있다. 해당 메소드를 살펴보면 다음과 같이 되어있다.

public open operator fun equals(other: Any?): Boolean

하지만 하위 클래스에서 해당 메소드를 오버라이드를 하면, 다음과 같이 operator를 붙이지 않는 것을 볼 수 있다.

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

상위 클래스에서 operator를 선언했을 경우 하위 클래스에서 이를 override할 때, operator 변경자를 붙이지 않아도, 자동으로 적용된다. 또한, Any에서 상속 받은 equlas가 확장 함수보다 우선순위가 높기 때문에 equals는 확장 함수로 정의할 수 없다.

2-2. 순서 연산자 : compareTo

자바에서 정렬이나 최댓값, 최솟값 등을 비교해야하는 경우 Comparable 인터페이스의 compareTo를 구현해야 한다. 자바에서는 o1.compareTo(o2)를 명시적으로 사용해야 한다. 하지만 코틀린에서는 o1 > o2와 같이 비교 연산자를 사용할 수 있다.

private class Person(  
	val firstName: String,  
	val lastName: String  
) : Comparable<Person> {  
	override fun compareTo(other: Person): Int {  
		return compareValuesBy(this, other, Person::lastName, Person::firstName)  
	}  
}  
  
fun main() {  
	val p1 = Person("Alice", "Smith")  
	val p2 = Person("Bob", "Johnson")
	// 출력 : false  
	println(p1 < p2)  
}

compareTo 메소드 또한 operator가 붙어있으므로, 오버라이딩 함수에 해당 변경자를 붙일 필요가 없다. 위 코드에서 사용한 compareValuesBy 함수를 이용해 비교에 사용할 두 객체를 넘기고, 비교할 함수를 인자로 넘겨주면 쉽고 간결하게 compareTo 함수를 정의할 수 있다. 필드를 직접 비교하는게 복잡하긴 하지만, 비교 속도는 훨신 더 빨라진다.

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

컬렉션을 다룰 때 가장 많이 쓰는 연산은 인덱스를 사용해 원소를 읽거나 쓰는 연산과 값이 속해있는지 검사하는 연산이다. 코틀린에서는 이 모든 연산을 연산자 구문으로 사용할 수 있다.

3-1. 인덱스로 원소에 접근 : get, set

자바나 코틀린 모두 배열에 접근할 때, 인덱스 연산자인 대괄호([])를 사용한다. 자바에서는 이러한 연산을 배열에서만 사용할 수 있지만, 코틀린에서는 모든 컬렉션에서 사용이 가능하다.

operator fun Point.get(index: Int): Int {  
	return when(index) {  
		0 -> x  
		1 -> y  
		else -> throw IndexOutOfBoundsException("Invalid coordinate $index")  
	}  
}

operator fun Point.set(index: Int, value: Int) {  
	when(index) {  
		0 -> x = value  
		1 -> y = value  
		else -> throw IndexOutOfBoundsException("Invalid coordinate $index")  
	}  
}

fun main() {
	val p = Point(10, 20)
	// 출력 : get = 20
	println("get = ${p[1]}")
	
	p1[0] = 20
	// 출력 : set = 20
	println("set = ${p[1]}")
}

get이라는 operator 변경자 메소드를 생성하면, 인덱스 연산자를 사용할 수 있게 된다. 반대로 set 메소드를 정의할 때에는, 인덱스와 값을 모두 받아서 구현해야 한다.

3-2. in 관례

in은 객체가 컬렉션에 들어있는지 멤버십 검사(membership test)한다. 해당 연산자와 대응하는 함수는 contains이다.

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))  
	// 출력 : true
	println(Point(20, 30) in rect)
	// 출력 : false
	println(Point(5, 5) in rect)
}

in 우항에 있는 객체는 contains 메소드의 수신 객체가 되고, 좌항에 있는 객체를 contains 메소드에 인자로 전달된다. 위 함수에서는 until을 사용해 열린 범위(끝 값을 포함하지 않음)를 사용했다. 만약 끝 값을 포함하려면 .. 연산을 사용해야 한다.

3-3. rangeTo 관례

범위를 만드려면 .. 구문을 사용해야 한다. 해당 구문은 rangeTo에 대한 함수이다.

fun main() {  
	val now = LocalDate.now()  
	val vacation = now..now.plusDays(10)
	// 출력 : true
	println(now.plusWeeks(1) in vacation)  
}

now..now.plusDays(10)은 컴파일러에 의해 now.rangeTo(now.plusDays(10))로 변환된다. 이 rangeTo 연산자는 다른 산술 연산자보다 우선 순위가 낮다. 때문에 괄호로 인자를 감싸주는게 가독성에 좋다.

val n = 9
// 출력 : 0..10
println(0..(n + 1))

3-4. for 루프를 위한 iterator 관례

for 루프에서도 똑같이 in 연산자를 사용한다. 하지만 여기서 사용하는 in의 의미는 다르다. for(x in list)와 같은 문장은 list.iterator를 호출한 뒤, hasNext와 next 호출을 반복하는 식으로 변환된다.

operator fun ClosedRange<LocalDate>.iterator(): Iterator<LocalDate> =  
	object : Iterator<LocalDate> {  
		var current = start  
		override fun hasNext(): Boolean = current <= endInclusive  
		override fun next(): LocalDate = current.apply {  
			current = plusDays(1)  
		}  
	}

fun main() {  
	val newYear = LocalDate.ofYearDay(2023, 1)  
	val daysOff = newYear.minusDays(1)..newYear  
	// 출력 : 2022-12-31
	// 출력 : 2023-01-01
	for (dayOff in daysOff) println(dayOff)  
}

4. 구조 분해 선언과 component 함수

구조 분해(destructuring declaration)를 사용하면 복합적인 값을 분해해서 여러 다른 변수를 한꺼번에 초기화할 수 있다.

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

data class의 주 생성자에 들어있는 프로퍼티에 대해서는 컴파일러가 자동으로 componentN 함수를 만들어준다. 만약 데이터 클래스가 아닐 경우 다음과 같이 정의해서 구조 분해를 정의할 수 있다.

private class Point(val x: Int, val y: Int) {  
	operator fun component1() = x  
	operator fun component2() = 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])  
}  

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

위와 같이 NameComponents를 선언하고, 함수에서 이를 반환하도록 하면, 꼭 데이터 클래스로 정의하지 않아도 함수에서 해당 클래스를 반환해 구조 분해를 사용할 수 있다. 표준 라이브러리의 Pair, Triple 클래스를 사용하면 함수에 여러 값을 더 간단하게 반환할 수 있다.

4-1. 구조 분해 선언과 루프

Map을 사용할 경우 for 루프 안에서도 구조 분해를 사용할 수 있다.

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

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

위임 프로퍼티(delegated property)를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다.

5-1. 위임 프로퍼티 소개

위임 프로퍼티의 일반적인 문법은 다음과 같다.

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

p 프로퍼티는 접근자 로직을 Delegate 객체에게 위임한다. 즉, by 뒤에 있는 식을 계산해서 위임에 쓰일 객체를 얻는 것이다.

class Foo {
	// 컴파일러가 생성한 도우미 프로퍼티
	private val delegate = Delegate()

	// 해당 프로퍼티를 위해 컴파일러가 생성한 접근자는 delegate의 getValue와 setValue 메소드를 호출한다.
	var p: Type
		set(value: Type) = delegate.setValue(..., value)
		get() = delegate.getValue(...)
}

컴파일러는 숨겨진 도우미 프로퍼티를 만들고, 그 프로퍼티를 위임 객체의 인스턴스로 초기화한다.

class Delegate {  
	operator fun getValue() {...}  
	operator fun setValue(..., value: Type) {...}  
}
class Foo {
	var p: Type by Delegate()
}

fun main() {
	val foo = Foo()
	val oldValue = foo.p
	foo.p = newValue
}

foo.p는 일반 프로퍼티처럼 사용할 수 있지만, 실제로 p의 게터 및 세터는 Delegate 타입의 위임 프로퍼티 객체에 있는 메소드를 호출한다.

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

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

fun loadEmails(person: Person): List<Email> {  
	println("${person.name}의 이메일을 가져옴")  
	return findAllEmailByPerson(person)  
}
class Person(val name: String, var age: Int) {  
	private var _emails: List<Email>? = null  
	val emails: List<Email>  
		get() {  
			if(_emails == null) {  
				_emails = loadEmails(this)  
			}  
			return _emails!!  
		}  
}

_emails라는 프로퍼티는 값을 저장하고, emails 프로퍼티는 _emails라는 프로퍼티에 대한 읽기 연산을 제공한다. 하지만 지연 초기화하는 프로퍼티가 많아질 경우 가독성이 떨어지고, 스레드 안전하지 않아 제대로 작동한다는 보장도 없다. 이럴 때 위임 프로퍼티를 사용하면 훨씬 간단해진다.

class Person(val name: String, var age: Int) {  
	val emails by lazy { loadEmails(this) }  
}

lazy 함수는 getValue 메소드가 들어있는 객체를 반환한다. 따라서 lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다. 해당 함수는 기본적으로 스레드 안전하다.

5-3. 위임 프로퍼티 구현

어떤 객체의 프로퍼티가 바뀔 때마다 리스너에게 변경 통지를 보내는 코드를 만들어보자. 보통 자바에서는 PropertyChangeSupport 혹은 PropertyChangeEvent 클래스를 사용해 처리한다. 모든 클래스에 추가하기는 어려우므로 도우미 클래스를 생성해보자.

class PropertyChangeAware {  
	protected val changeSupport = PropertyChangeSupport(this)  
	  
	fun addPropertyChangeListener(listener: PropertyChangeListener) {  
		changeSupport.addPropertyChangeListener(listener)  
	}  
	  
	fun removePropertyChangeListener(listener: PropertyChangeListener) {
		changeSupport.removePropertyChangeListener(listener)  
	}  
}
class Person(val name: String, age: Int, salary: Int) : PropertyChangeAware() {  
	var age: Int = age  
		set(newValue) {  
			val oldValue = 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("Dmitry", 34, 2000)  
	p.addPropertyChangeListener(  
		PropertyChangeListener { event ->  
			println("""  
			Property ${event.propertyName} changedfrom ${event.oldValue} to ${event.newValue}  
			""".trimIndent())  
		}  
	)  
	// 출력 : Property age changed from 34 to 35
	p.age = 35  
	// 출력 : Property age changed from 2000 to 2100
	p.salary = 2100  
}

뒷받침하는 field 키워드를 사용해 age와 salary 프로퍼티를 뒷받침하는 필드에 접근하는 방법을 보여준다. 하지만 위 코드에서 세터 코드를 보면 중복이 많은 것을 알 수 있다. 해당 프로퍼티의 값을 저장하고, 필요에 따라 통지를 보내주는 클래스를 추출해보자.

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)  
	}  
}
private 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) }  
}

옵저버를 통해 코드 양이 많이 줄었지만, 작업을 위임하는 준비 코드가 아직 상당 부분 필요한 수준이다. 이런 상황에서 위임 프로퍼티를 사용하면 준비 코드마저 없앨 수 있다.

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() {  
	val age: Int by ObservableProperty(age, changeSupport)  
	var salary: Int by ObservableProperty(salary, changeSupport)  
}

by 키워드로 위임 객체를 지정하면 직접 짜야했던 여러 작업을 코틀린 컴파일러가 자동으로 처리해준다. 코틀린 컴파일러가 만들어주는 코드는 이전에 작성했던 코드와 비슷하다.

5-4. 위임 프로퍼티 컴파일 규칙

class C {
	var prop: Type by MyDelegate()
}

val c = C()

위 클래스가 있다고 가정할 때, 컴파일러는 MyDelegate 클래스의 인스턴스를 감춰진 프로퍼티에 저장하며, 해당 프로퍼티를 <delegate>라는 이름으로 부른다. 또한, 이를 표현하기 위해 KProperty 타입의 객체를 사용하고, 이 객체를 <property>라고 부른다. 해당 코드를 컴파일하면 다음과 같은 코드가 생긴다.

class C {
	private val <delegate> = MyDelegate()
	var prop: Type
		get() = <delegate>.getValue(this, <property>)
		set(value: Type) = <delegate>.setValue(this, <property>, value)
}

5-5. 프로퍼티 값을 맵에 저장

자신의 프로퍼티를 동적으로 정의할 수 있는 객체를 만들 때, 위임 프로퍼티를 활용하는 객체를 확장 가능한 객체(expando object)라고 부르기도 한다.

private class Person {  
	private val _attributes = hashMapOf<String, String>()  
	fun setAttribute(attrName: String, value: String) {  
		_attributes[attrName] = value  
	}  
	val name: String  
		get() = _attributes["name"]!!  
}  
  
fun main() {  
	val p = Person()  
	val data = mapOf("name" to "Dmitry", "company" to "JetBrains")  
	for ((attrName, value) in data) {  
		p.setAttribute(attrName, value)  
	}  
	println(p.name)  
}

이를 위임 프로퍼티를 사용한 코드로 변환하면 다음과 같다.

private class Person2 {  
	private val _attributes = hashMapOf<String, String>()  
	fun setAttribute(attrName: String, value: String) {  
		_attributes[attrName] = value  
	}  
	val name: String by _attributes  
}

Map과 MutableMap 인터페이스가 getValue와 setValue 확장 함수를 제공하기 때문에 가능하다.

5-6. 프레임워크에서 위임 프로퍼티 활용

DB에 User라는 테이블이 있고, 그 테이블에 name이라는 문자열 타입의 칼럼과 age라는 정수 타입의 열이 있다고 가정하자.

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
}

Users 객체는 데이터 베이스 테이블을 표현하고, 싱글톤 객체로 선언했다. User의 상위 클래스인 Entity 클래스는 DB 칼럼을 엔티티의 속성(attribute) 값으로 연결해주는 매핑이 있다.

이 프레임워크를 사용하면 User의 프로퍼티에 접근할 때 자동으로 Entity 클래스에 정의된 DB 매핑으로부터 필요한 값을 가져오므로 편하다.

댓글남기기