uncategorized

연산자 오버로딩과 기타 관례

산술 연산자 오버로딩

코틀린에서 어떤 언어 기능과 미리 정해진 이름의 함수를 연결해주는 기법을 코틀린에서 관례라고 부른다.
코틀린에서 관례를 사용하는 가장 단순한 예는 산술 연산자다. 기존 자바에서는 원시 타입과 문자열 정도에 산술 연산자를 쓸 수 있다. 하지만 관례를 적절히 사용하면 다른 클래스에도 산술 연산자를 쓸 수 있다.

이항 산술 연산 오버로딩

1
2
3
4
5
6
7
8
9
10
11
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(1, 2)
val p2 = Point(3, 4)

val p3 = p1 + p2
println(p3) // x = 4, y = 6

산술 연산자 +plus 라는 이름의 함수로 오버로딩할 수 있다. 이때 산술 연산 오버로딩은 함수 앞에 operator가 붙어야 한다.
연산자 오버로딩은 확장함수로도 가능하다.

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

이항 산술 연산자는 times, div, mod(rem), plus, minus 등 정해진 함수 이름을 사용해야 하며 기존 연산자의 우선순위와 같다.

연산자 함수와 자바
코틀린 연산자는 함수로 정의된다. 긴 이름을 사용하면 일반 함수로 호출할 수 있다.

코틀린 연산자가 자동으로 교환 법칙을 지원하지는 않음

1
2
3
4
5
6
7
8
9
operator fun Point.times(scale: Int) : Point {
return Point(x * scale, y * scale)
}

val p1 = Point(1, 2)
val p2 = Point(3, 4)

println(p1 * 2) // x = 2, y = 4
// p1 * 2는 컴파일 되지 않는다.

연산자 오버로딩은 다양한 매개변수를 가질 수 있다. 위의 예시도 그런 상황인데, 이때 일반적인 산술연산자 처럼 교환법칙을 지원하지는 않는다.
그리고 오버로딩된 연산자의 반환값이 반드시 두 피연산자 중 하나여야만 하는 것도 아니다.

복합 대입 연산자 오버로딩

+=, -= 같은 복합 대입 연산자는 사실 +, - 같은 연산자를 오버로딩하면 자연스럽게 지원된다.
하지만 이런 경우 새로운 객체를 만들어서 반환하게 된다. (위 예시를 보면 새로운 객체를 만들어서 반환한다.)

이를 대비해 복합 대입 연산자도 오버로딩을 지원한다.

1
2
3
4
5
6
7
8
9
10
11
12
data class Point(var x: Int, var y: Int)

operator fun Point.plusAssign(other: Point) {
this.x += other.x
this.y += other.y
}

var p1 = Point(1, 2)
var p2 = Point(3, 4)

p1+=p2
println(p1) // x = 4, y = 6

이때 주의해야 할 점은 복합 대입 연산자와 이에 필요한 이항 산술 연산자를 같이 재정의하면 컴파일러가 어떤 연산자를 사용해서 코드를 실행해야 할 지 알 수 없어서 컴파일 에러가 생긴다.
그래서 이 두가지 산술 연산자를 동시에 재정의하지 않도록 하자. 위 예시에서는 plusplusAssign을 동시에 재정의하면 +=를 할 때 컴파일 오류가 생긴다. 객체의 프로퍼티를 변경할 수 없는 경우는 plus와 같은 이항 산술 연산자만 오버로딩하고 변경 가능한 클래스를 설계한다면 plusAssign만 제공하는 방식을 고려할 수 있다.

코틀린 컬렉션에서 대응 예시

코틀린 컬렉션에서는 변경 가능한 컬렉션에는 +=-=를 통해 메모리에 있는 객체 상태를 변경시킬 수 있게 해놨다. 반면 읽기 전용 컬렉션에서는 +=-=는 변경을 적용한 복사본을 반환하도록 했다.

단항 연산자 오버로딩

단항 연산자도 크게 다르지 않다. unaryPlus, unaryMinus, not 등 정해진 이름을 통해 오버로딩한다.

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

이때 ++-- 같은 전위와 후위 증가(감소) 연산자는 연산해서 반환할 값만 정해주면 연산자를 어떻게 사용하느냐에 따라 컴파일러가 알아서 전위 혹은 후위 연산을 처리해준다.
incdec을 사용하면 된다.

1
2
3
4
5
6
operator fun Point.inc() : Point {
return Point(++x, ++y)
}

println(p2++)
println(++p2)

비교 연산자 오버로딩

equals

코틀린에서 ==가 참조 주소를 비교하는 동일성 비교가 아닌 값을 비교하는 동등성 비교로 작동한다. 이를 가능하게 하는 것도 관례의 힘이다.
대략 다음과 같은 코드가 작동하는 셈이다.

1
2
//a == b
a?.equals(b) ?: (b == null)

참고로 != 또한 ==의 결과를 반전시키면 되니 자연스럽게 지원된다.

compareTo

코틀린에서 관례를 통해 compareTo<,>, <=, >=로 해결할 수 있다.

1
2
3
4
5
6
7
data class Point(var x: Int, var y: Int) : Comparable<Point> {
override fun compareTo(other: Point): Int {
return compareValuesBy(this, other, Point::x, Point::y)
}
}

println(Point(3, 4) <= Point(4, 3)) // true

Point가 Comparable을 구현하여 compareTo를 재정의 했다. 이때 기존 인터페이스에 operator 선언이 되어 있어서 위 예시에는 생략됐다.

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

코틀린에서 관례를 통해 컬렉션의 원소에 접근해 읽거나 쓰는 연산을 함수가 아닌 연산자로 할 수 있다.

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

mutableMap[key] = newValue 처럼 코틀린에서는 맵에서 대괄호로 원소에 접근하거나 원소를 쓸 수 있다.
코틀린에서는 이를 인덱스 연산자라고 한다. Map과 MutalbeMap 인터페이스에는 두 메서드가 이미 들어있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
operator fun Point.get(index: Int) : Int {
return when(index) {
0 -> x
1 -> y
else -> throw IllegalArgumentException()
}
}

operator fun Point.set(index: Int, value: Int) {
when(index) {
0 -> this.x = value
1 -> this.y = value
else -> throw IllegalArgumentException()
}
}

val point = Point(1, 2)
println(point[0]) // 1
point[0] = 2
println(point[0]) // 2

in 관례

코틀린에서 지원하는 in 연산자는 contains 함수에 대응한다.

1
2
3
4
5
operator fun Point.contains(other: Point): Boolean {
return other.x <= x && other.y <= y
}

println(Point(1, 1) in Point(3, 3)) // true

rangeTo 관례

범위를 만드는 .. 연산자는 rangeTo 함수에 대응한다.
알아둘 점은 Comparable 인터페이스를 구현하면 rangeTo를 구현할 필요가 없다. 왜냐면 코틀린 표준 라이브러리에 Comparable 객체에 대해 적용 가능한 rangeTo 함수가 정의되어 있기 때문이다.
rangeTo는 다른 산술 연산자나 범위 메서드보다 우선순위가 낮아서 되도록 괄호로 감싸서 표현해주자.
0..(n + 1) 이렇게 가독성을 높게 하거나 (0..n).forEach {...} 이렇게 컴파일이 되도록 할 수 있다.

for 루프를 위한 iterator 관례

코틀린의 for 루프는 in 연산자를 사용한다. 하지만 contains에 대응하는 in과는 다른 연산자이다.
for (x in list) {...} 이렇게 for 루프 안에 있는 in은 iterator 함수에 대응한다.
코틀린은 확장 함수를 통한 관례를 통해 문자열을 for 루프를 돌 수 있다.

1
operator fun CharSequence.iterator(): CharIterator

특정 클래스에 적용도 가능하다.

1
2
3
4
5
6
operator fun ClosedRange<Point>.iterator(): Iterator<Point> =
object : Iterator<Point> {
var current = start
override fun hasNext(): Boolean = current <= endInclusive
override fun next(): Point = current.apply { current = Point(current.x + 1, current.y + 1) }
}

구조 분해 선언과 component 함수

구조분해 선언을 관례로 구현하면 다음과 같은 코드를 가능하게 한다.

1
2
3
4
val point = Point(1, 2)
val (x, y) = point
println(x) // 1
println(y) // 2

구조분해 선언은 내부적으로 다음과 같이 작동한다.

1
2
3
// val (x, y) = point
val x = point.component1()
val y = point.component2()

즉 componentN 함수에 대응한 관례를 만들면 된다.

1
2
operator fun Point.component1() = x
operator fun Point.component2() = y

구조 분해 선언은 Map과 for 루프에서 효과적이다.

1
for ((key, value) in map) { ... }

Map의 Entry가 확장함수로 구조 분해를 제공해서 위와 같은 코드가 가능하다.

프로퍼티 접근자 로직 재활용

위임 프로퍼티에도 코틀린의 관례가 뒷받침 된다. 위임 프로퍼티를 사용하면 값을 뒤받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다.

위임 프로퍼티 소개

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

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

프로퍼티 p는 접근자 로직을 다른 객체(Delegate)에게 위임한다.
컴파일러는 숨겨진 도우미 프로퍼티를 만들고 그 프로퍼티를 위임 객체의 인스턴스로 초기화한다.
프로퍼티 p는 자신의 작업을 위임 객체에게 전달한다.

1
2
3
4
5
6
class Foo {
private val delegate = Delegate()
var p: Type
set(value:Type) = delegate.setValue(..., value)
get() = delgate.getValue(...)
}

이때 위임 프로퍼티를 위해 생성된 위임객체는 setValue와 getValue 메서드 호출을 통해 위임한다.
위임 프로퍼티를 위한 위임 객체이 되려면 setValue와 getValue 메서드를 가져야 한다.
다양한 매개변수가 있지만 현재 기본 구조 설명을 위해 생략했다.

1
2
3
4
class Delegate {
operator fun getValue(...) {...}
operator fun setValue(..., value: Type) {...}
}

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

Person 클래스의 email을 가져오는 loadEmail 메서드가 오래걸리는 작업이라 최초 접근에만 초기화하는 경우를 생각해보자.
만약 뒷받침하는 프로퍼티 기법을 사용하면 다음과 같이 null로 초기화 해놓고 사용할 것이다.

1
2
3
4
5
6
7
8
class Person(val name: String) {
private var _emails: List<String>? = null
val emails:List<String>
get() {
if (_emails == null) _emails = loadEmail()
return emails
}
}

일단 이 방법은 코드가 복잡하고 스레드 안전하지도 않다!

위임 프로퍼티를 사용해보자.

1
2
3
class Person(val name: String) {
val emails: List<String> by lazy { loadEmail() }
}

lazy는 첫 호출 시 지연 초기화하는 함수이고 람다를 매개변수로 받는다.
lazy는 기본적으로 스레드 안전하고 필요에 따라 락을 람다에 전달할 수 있고, lazy가 동기화를 하지 못하게 할 수도 있다.

위임 프로퍼티 컴파일 규칙

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

이런 코드가 있다고 할 때 컴파일러는 Delegate 클래스의 인스턴스를 감춰진 프로퍼티에 저장하며 그 감춰진 프로퍼티를 <delegate>라는 이름으로 부른다. 컴파일러는 프로퍼티를 포현하기 위해 KProperty 타입의 객체를 사용한다. 이 객체를 <property>라는 이름으로 부른다.

1
2
3
4
5
6
class Foo {
private val <delegate> = Delegate()
var p: Type
get() = <delegate>.getValue(this, <property>)
set(value: Type) = <delegate>.setValue(this, <property>, value)
}

위임 프로퍼티의 특징을 보면 프로퍼티를 꼭 초기화 하지 않아도 된다. 즉 프로퍼티 값이 저장될 곳을 바꿀 수 있고 간단한 로직을 추가할 수 도 있다.

프로퍼티 값을 맵에 저장

프로퍼티를 동적으로 정의할 수 있는 확장 가능한 객체라고 부른다.

1
2
3
4
5
6
7
8
9
10
class Person {
private val _attributes = hashMapOf<String, String>()

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

val name: String
get() = _attributes["name"]!!
}

이런 식으로 프로퍼티를 담을 수 있는 맵을 가지고, 필수 프로퍼티를 선언하고 접근 로직을 맵을 통해 가져오도록 구현했다.
이를 위임 프로퍼티로 구현할 수 있다.

1
2
3
4
5
6
7
8
9
class Person3 {
private val _attributes = hashMapOf<String, String>()

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

val name: String by _attributes
}

by 뒤에는 위임 객체가 와야 한다. Map과 MutableMap은 기본적으로 getValue와 setValue를 제공하기 때문에 가능하다.

Share