Kotlin

[Kotlin] 산술 연산자 오버로딩

sh1mj1 2024. 1. 23. 17:48

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

코틀린에서는 어떤 언어 기능과 미리 정해진 이름의 함수를 연결해주는 기법을 convention(관례)라고 한다.

언어 기능을 타입에 의존하는 자바와 달리 코틀린은 함수 이름을 통한 convention 에 의존한다.

 

자바에서는 원시 타입, `String` 에 대해서만 산술 연산자를 사용할 수 있다.

코틀린에서는 다른 클래스에서도 산술 연산자를 사용할 수 있다.

이항 산술 연산 오버로딩

`plus` 연산자 구현하기

data class Point(val x: Int, val y: Int) {
    operator fun plus(other: Point): Point { // plus 라는 이름의 연산자 함수를 정의
        return Point(x + other.x, y + other.y) // 좌표를 성분 별로 더한 새로운 점을 리턴
    }
}

val p1 = Point(10, 20)
val p2 = Point(30, 40)
assert(p1 + p2 == Point(40, 60))

 `plus` 함수 앞에 `operator` 키워드를 붙여야 한다.

`operator` 가 없는데 실수로 convention 에서 사용하는 함수의 이름을 쓰고 우연히 그 이름에 해당하는 기능을 연산자로 사용한다면, `'operator' modifier is required on 'plus' in .....'` 와 같은 컴파일 에러가 표시된다.

 

연산자를 확장 함수로 정의

operator fun Point.plus(other: Point): Point = Point(x + other.x, y + other.y)

 이 구현도 이전 구현과 같다.

외부 라이브러리의 클래스에 대한 연산자를 정의할 때는 convention을 따르는 이름의 확장함수로 구현하는 게 일반적인 패턴이다.

  • 코틀린에서는 오버로딩한 연산자를 정의, 사용하기 쉽다.
  • 직접 연산자를 만들어 사용하지는 못한다. (예를 들어 ¡™£ 라는 이름의 연산자를 만들지는 못함.)
    • 미리 정해둔 연산자만 오버로딩 가능하다.
    • 언어에서 미리 정의해야 하는 이름이 연산자별로 정해져 있음.
함수 이름
`a * b` `times`
`a / b` `div`
`a % b` `mod`(1.1 부터는 `rem`)
`a + b` `plus`
`a - b` `minus`

직접 정의한 함수를 통해 구현하더라도 연산자 우선 순위는 언제나 표준 숫자 타입에 대한 연산자 우선순위와 같다.

`*`,`/`,`%` 가 모두 우선순위가 같고 그 다음 우선순위가 `+`,`-` 이다.

연산자를 정의할 때 두 피연산자가 같은 타입이 아니어도 된다.

두 피연산자의 타입이 다른 연산자 정의하기

operator fun Point.times(scale: Double): Point = Point((x * scale).toInt(), (y * scale).toInt())

val p = Point(10, 20)
assert(p * 1.5 == Point(15, 30))

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

`p * 1.5` 가 아닌, `1.5 * p` 로 쓰고 싶다면, `Double` 이 수신 객체인 연산자 함수를 더 정의해야 한다.

연산자 함수의 리턴 타입이 꼭 두 피연산자 중 하나와 일치하지 않아도 된다.

결과 타입이 피연산자 타입과 다른 연산자 정의

operator fun Char.times(count: Int): String = toString().repeat(count)

assert('a' * 3 == "aaa")

 `Char` 을 좌항, `Int` 를 우항으로 받아서 `String` 을 리턴한다.

operator 함수도 오버로딩할 수 있다.

즉, 이름은 같지만 파라미터 타입이 서로 다른 연산자 함수도 여럿 만들 수 있다.

비트 연산자에 대해 특별한 연산자 함수를 사용하지 않는다.

코틀린에서는 표준 숫자 타입에 대해 비트 연산자를 정의하지 않는다.

물론, 커스텀 타입에도 비트 연산자를 정의할 수도 없다.

대신에 중위 연산자 표기법을 지원하는 일반 함수를 통해 비트 연산을 수행한다.

 

코틀린에서 비트 연산을 수행하는 함수 목록

  • `shl` - 왼쪽 시프트 (자바 `<<`) `0x1 shl 4` → `16` ( 이진수 10000 됨.)
  • `shr` - 오른쪽 시프트 (자바 `>>`)
  • `ushr` - 오른쪽 시프트. 0으로 사인(부호) 비트 설정. 자바 `>>>`
  • `and` - 비트 곱 (자바 `&)`
  • `or` - 비트 합(자바 `|`)
  • `xor` - 비트 배타 합(자바 `^`)
  • `inv` - 비트 반전(자바 `~`)

복합 대입 연산자 오버로딩

`+=`,`-=` 등의 연산자는 복합 대입(compound assignment) 연산자라고 부른다.

var point = Point(1, 2)
point += Point(3, 4) // point = point + Point(3, 4) 와 같다
assert(point == Point(4, 6))

 경우에 따라 `+=` 연산이 객체에 대한 참조를 다른 참조로 바꾸기보다, 원래 객체의 내부 상태를 변경하게 만들고 싶을 때가 있다.

mutable 컬렉션에 원소를 추가하는 경우가 대표적인 예이다.

val numbers = ArrayList<Int>()
numbers += 42
assert(numbers[0] == 42)

 

리턴 타입이 `Unit` 인 `plusAssign` 함수를 정의하면 코틀린은 `+=` 연산자에 그 함수를 사용한다.

다른 복합 대입 연산자도 비슷하게 `minusAssign`, `timesAssign` 의 이름이다.

kotlin stlib 이 정의한 mutable 컬렉션에 대한 `plusAssign`

@kotlin.internal.InlineOnly
public inline operator fun <T> MutableCollection<in T>.plusAssign(element: T) {
    this.add(element)
}

 

 위에서 사용한 `+=` 도 이를 사용한 것이다.

생각해보면, `+=` 는 `plus` 와 `plusAssign` 양쪽으로 컴파일 할 수 있다.

만약 두 함수의 타입이 같다면, 무엇으로 컴파일할지 알 수 없어 오류가 발생한다.

  • 일반 연산자를 사용하여 이를 해결
data class Point(var x: Int, var y: Int) 

operator fun Point.plus(i: Int): Point = Point(x + i, y + i)

operator fun Point.plusAssign(i: Int) {
    x += i + i
    y += i + i
}

// plus 의 일반 연산자를 사용하여 ambiguity 피하기
var p = Point(10, 20)
p = p.plus(1)
assert(p == Point(11,21))

// plusAssign 의 일반 연산자를 사용하여 ambiguity 피하기
val p = Point(10, 20)
p.plusAssign(1)
assert(p == Point(12,22))
  • `var` 을 `val` 로 바꾸어서 `plus` 적용이 불가능하게 하기
var p = Point(10, 20) // p 가 var 임. 변경 가능이어서 plus 와 plusAssign 둘다 호출 가능해짐.. ambiguity 발생.
p += 1 // [COMPILE ERROR] Assignment operators ambiguity

///////
////////
// p 가 val 임.. 변경 불가. 그래서 오직 plusAssign 만 호출 가능해짐.
val p = Point(10, 20)
p += 1
assert(p == Point(12, 22))

 하지만 일반적으로 새로운 클래스를 일관성 있게 설계하는 게 가장 좋다.

`plus` 와 `plusAssign` 연산을 동시에 정의하지 말자.

 

kotlin stlib 는 컬렉션에 대해 두 접근 방법을 제공한다.

  • `+` 와 `-`  는 항상 새로운 컬렉션을 리턴
  • `+=` 와 `-=` 는
    • mutable 컬렉션에 작용해 메모리에 있는 객체 상태를 변화시킴.
    • read-only 컬렉션에서는 변경을 적용한 복사본을 리턴한다
val list = arrayListOf(1, 2)
list += 3 // 리스트를 변경
assert(list == arrayListOf(1, 2, 3))

val newList = list + listOf(4, 5) // 두 리스트의 모든 원소를 포함하는 새 리스트 리턴
assert(newList == listOf(1, 2, 3, 4, 5))

단항 연산자 오버로딩

이전과 똑같이 미리 정해진 이름의 함수를 선언하면서 operator 로 표시하면된다.

단항 연산자 정의하기

operator fun Point.unaryMinus(): Point = Point(-x, -y)

val p = Point(10, 20)
assert(-p == Point(-10, -20))

 

오버로딩할 수 있는 단항 산술 연산자

  • `+a` :               `unaryPlus`
  • `-a` :                `unaryMinus`
  • `!a`.:                 `not`
  • `++a`, `a++`:  `inc`  
  • `--a`, `a--` :    `dec`
    • `inc` 나 `dec` 함수를 정의하여 증가/감소 연산자를 오버로딩하는 경우, 컴파일러는 일반적인 값에 대한 전위와 후위 증가/감소 연산자와 같은 의미를 제공한다.

증가 연산자 정의하기

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

var bd = BigDecimal.ZERO
assert(bd++ == BigDecimal.ZERO) // 후위 증가 연산자는 assert 가 실행된 다음에 값을 증가
assert(++bd == BigDecimal.TWO) // 전위 증가 연산자는  assert 가 실행되기 전에 값을 증가