11-1-1. 도메인 특화 언어 : 연산자 오버로딩(1~5)
11장
11-1-1. 도메인 특화 언어 : 연산자 오버로딩(1~5)
11-1-2. 도메인 특화 언어 : 연산자 오버로딩(6~8)
11-1-1. 도메인 특화 언어 : 연산자 오버로딩(1~5)
연산자 오버로딩은 + - * / 등 코틀린 내장 연산자에 대해
새로운 의미를 부여할 수 있게 해주는 언어 기능이다.
당장 떠오르는건 같은 + 연산자를 사용하더라도,
Number 타입의 경우 덧셈 연산을 하지만 문자 타입의 경우 문자를 합치는 연산을 한다.
이게 가능한 이유가 +가 오버로딩돼 있어 다양한 구현을 제공하기 때문이라고 한다.
이러한 기능들을 사용하기 위한 연산자 오버로딩에 대해 좀 더 자세히 알아보자.
1) 단항 연산
오버로딩이 가능한 단항 연산자로는 전위 + - ! 연산자가 있다.
컴파일러는 이러한 연산자를 함수 호출로 표현하는 방법을 제공한다.
식 | 의미 |
+e | e.unaryPlus() |
-e | e.unaryMinus() |
!e | e.not() |
이런 함수를 인자 식의 타입에 대해 멤버 함수나 확장 함수로 정의할 수 있다.
이를 이용해 not() 컨벤션을 보색 관계를 ! 연산자를 표현할 수 있다.
enum class Color{
Black, RED, GREEN, BLUE, YELLOW, CYAN, MAGENTA, WHITE;
operator fun not() = when (this){
Black -> WHITE
RED -> CYAN
GREEN -> MAGENTA
BLUE -> YELLOW
YELLOW -> BLUE
CYAN -> RED
MAGENTA -> GREEN
WHITE -> Black
}
}
fun main() {
val black = Color.Black
println(!black) // WHITE
val red = Color.RED
println(!red) // CYAN
}
연산자 함수를 확장 함수로 정의하면 임의의 타입에 대한 연산자를 오버로딩하는것도 가능하다
operator fun <T> ((T) -> Boolean).not(): (T) -> Boolean = {!this(it)}
fun isShort(s: String) = s.length <= 4
fun String.isUpperCase() = all { it.isUpperCase() }
fun main() {
val data = listOf("abc", "ABCDE", "ababc", "BdAc", "xyz")
println(data.count(::isShort)) // 3
// abc, ababc, xyz
println(data.count(!::isShort)) // 2
// ABCDE, BdAc
println(data.count(String:: isUpperCase)) // 1
// ABCDE
println(data.count(!String:: isUpperCase)) //4
// abc, ababc, BdAc, xyz
}
2) 증가와 감소
증가(++)와 감소(--)연산자도 피연산자 타입에 대한 파라미터가 없는 inc(), dec() 함수로 오버로딩할 수 있다.
이 함수의 반환 타입은 증가 아후 값과 증가 이전 값이 같은 타입이어야 한다.
enum class RainbowColor{
RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET;
operator fun inc() = values()[(ordinal + 1) % values().size]
operator fun dec() = values()[(ordinal + values().size - 1) % values().size]
}
fun main() {
var color = RainbowColor.INDIGO
println(color++) // INDIGO
println(color) // VIOLET
}
3) 이항 연산
이상 연산자 역시 오버로딩할 수 있다. 마찬가지로 정해진 이름의 연산자 함수를 정의하면 된다.
식 | 의미 |
a + b | a.plus(b) |
a - b | a.minus(b) |
a * b | a.times(b) |
a / b | a.div(b) |
a % b | a.rem(b) |
a .. b | a.rangeTo(b) |
a in b | b.contains(a) |
a !in b | !b.contains(a) |
a < b | a.compareTo(b) < 0 |
a <= b | a.compareTo(b) <= 0 |
a > b | a.compareTo(b) > 0 |
a >= b | a.compareTo(b) >= 0 |
% 연산자의 이름은 mod() 연산자였지만 rem() 연산자로 대체되었고, mod()는 사용 불가능하다.
개인적으로 비교 연산을 할 때 함수가 compareTo 연산 이후 0과 비교하는게 신기했다.
이항 연산자중 동등성 관련 연산도 있다. ==나 !=를 사용하면 컴파일러는 equals() 함수를 호출한다.
여기서 equals() 구현이 Any 클래스에 정의된 기반 구현을 상속하므로
명시적인 operator 변경자를 붙이지 않아도 된다.
또한 같은 이유로 equals()를 항상 멤버 함수로 정의해야 한다.
코틀린에서 && 나 || 연산자는 오버로딩할 수 없다.
이들은 Boolean값에 대해서만 지원되는 내장 연산이다.
참조 동등성 연산자인 ===나 !==도 마찬가지로 오버로딩할 수 없는 내장 연산이다.
4) 중위 연산
중위 연산(infix function)에 대해서 앞장에서 공부한 적이 있다.
중위 연산은 변수 두 개 사이에 정의된 infix 변경자를 이용한 연산을 하는것으로 기억한다.
val pair1 = 1 to 2 // 중위 호출
val pair2 = 1.to(2) // 일반적인 호출
표준 to 함수 구현은 아래와 같다.
infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
이를 이용해 논리곱, 논리합을 표현하는 중위 연산을 정의할 수 있다.
앞에서 작성했던 함수를 가져와서 비교해보자면,
operator fun <T> ((T) -> Boolean).not(): (T) -> Boolean = {!this(it)}
fun isShort(s: String) = s.length <= 4
fun String.isUpperCase() = all { it.isUpperCase() }
fun main() {
val data = listOf("abc", "ABCDE", "ababc", "BdAc", "xyz")
println(data.count(::isShort)) // 3
// abc, ababc, xyz
println(data.count(!::isShort)) // 2
// ABCDE, BdAc
println(data.count(String:: isUpperCase)) // 1
// ABCDE
println(data.count(!String:: isUpperCase)) //4
// abc, ababc, BdAc, xyz
}
위 함수를 중위 연산 정의를 통해 좀 더 간결하게 만들 수 있다.
infix fun <T> ((T) -> Boolean).and(
other: (T) -> Boolean
): (T) -> Boolean{
return { this(it) && other(it) }
}
infix fun <T> ((T) -> Boolean).or(
other: (T) -> Boolean
): (T) -> Boolean{
return { this(it) || other(it) }
}
operator fun <T> ((T) -> Boolean).not(): (T) -> Boolean = {!this(it)}
fun String.isUpperCase() = all { it.isUpperCase() }
fun isShort(s: String) = s.length <= 4
fun main() {
val data = listOf("abc", "ABCDE", "ababc", "BdAc", "xyz")
println(data.count(::isShort and String::isUpperCase)) // infix fun .and 사용
// 0
println(data.count(::isShort or String::isUpperCase)) // infix fun .or 사용
// 4 >> abc, ABCDE, BdAc, xyz
println(data.count(!::isShort and String::isUpperCase)) // infix fun .and 사용
// 1 >> ABCDE
println(data.count(!(::isShort and String::isUpperCase))) // infix fun .or 사용
// 5 >> abc, ABCDE, ababc, BdAc, xyz
}
5) 대입
다른 이항 연산으로 +=와 같은 복합 대입 연산이 있다.
가변 컬렉션과 불변 컬렉션에 따라 복합 대입 연산의 동작이 달라진다.
+=를 불변 컬렉션 타입의 변수에 적용하면 새로운 컬렉션 객체가 생기고
이 객체를 변수에 대입해 변수 값이 바뀐다. 그래서 이 경우 변수가 가변이어야 한다.
var numbers = listOf(1, 2, 3)
numbers += 4
println(numbers) // [1, 2, 3, 4]
말이 어려워서 혼자 이것저것 해봤더니, 불변 컬렉션(immutable collection)의 경우(위 코드에서 list)
+=을 var 타입에 변수에 대입하면, 컬렉션은 불변 컬렉션이므로
기존 값 + 추가한 값을 가진 새로운 컬렉션을 만든 뒤 numbers에 대입한다.
(즉 numbers가 가리키고 있는 변수의 주소가 바뀐다)
이 때 numbers는 val 타입의 변수인 경우 변경이 불가능하므로, var 타입의 변수여야만 한다.
val numbers1 = listOf(1, 2, 3)
numbers1 += 4 // ERROR : Val cannot be reassigned
반대로 가변 컬렉션의 경우 += 연산을 컬렉션에 적용하면, 컬렉션의 내용은 바뀌지만
객체 자체의 정체성(가리키고 있는 주소)는 바뀌지 않는다.
var numbers1 = listOf(1, 2, 3)
println(numbers1) // ref : @875
numbers1 += 4
println(numbers1) // ref : @890
// numbers1이 가리키는 주소가 바뀌는것을 확인
val numbers2 = mutableListOf(1, 2, 4)
println(numbers2) // ref : @ 893
numbers2 += 5
println(numbers2) // ref : @ 893
// numbers2이 가리키는 주소가 동일한 것을 확인
임의의 타입에 대해 위 두 가지 관습(convention)을 적용할 수 있다.
+= 같은 연산자를 복합 대입 연산자(augmented assignment operator)라고 부르며
정해진 관습에 따라 연산자에 대응하는 함수표를 작성해야 한다.
식 | 의미 | 의미 |
a += b | a = a.plus(b) | a.plusAssign(b) |
a -= b | a = a.minus(b) | a.minusAssign(b) |
a *= b | a = a.times(b) | a.timesAssign(b) |
a /= b | a = a.div(b) | a.divAssign(b) |
a %= b | a = a.rem(b) | a.remAssign(b) |
복합 대입 연산자를 해석하는 방법은 다음과 같다.
● 커스텀 복합 대입 함수가 있다면 그 함수를 사용
○ 복합 대입 함수의 존재 여부에 따라 복합 대입문을 대응하는 복합 함수로 변환해 컴파일
○ (+=의 경우 plusAssign()이 있는지, -=의 경우 minusAssign()이 있는지 등)
● 복합 대입 연산자 함수가 없는 경우(plusAssign() 등) 복합 대입문을
이행 연산자 + 대입을 사용한 연산으로 해석
○ a + b의 경우 plus()가 있으면 a = a.plus(b)로 해석
● 복합 대입 연산자의 왼쪽 피연산자가 불변인 경우 변수에 새 값을 대입할 수 없으므로
a = a.plus(b)와 같이 일반 대입문과 이항 연산을 활용한 방식으로 해석이 불가능
다음글