Kotlin

[Kotlin] 코틀린의 연산자 관례 정리, 어떤 클래스든 연산시켜보자!

노소래 2023. 1. 8. 00:00

코틀린에서 숫자가 아닌 객체에 대해서도 + - * / 등의 연산을 하는 경우를 보았을 것이다.

또한 컬렉션도 배열도 아닌 타입이 [] 로 프로퍼티에 접근한다든지 하는 것도 보았을 것이다.

 

예를들면 컴포즈에서 사용하는 Offset 클래스이다. 

(본 글이 어떤 Offset 이 어떤 클래스인지 논하는 글은 아니므로 Offset 은 x, y 라는 프로퍼티를 가지고 있고 있는 클래스라는 것에 집중한다.)

아래와 같이 Offset 끼리 연산해서 새로운 Offset 을 만드는 예시를 살펴보자 

Offset(1f, 2f) + Offset(2f, 3f)

위 연산이 가능한 이유는 Offset 클래스가 plus 라는 이름의 특별한 메서드를 정의했기 때문이다. 

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

처음에 이런 것들이 뭔지 궁금했고, 검색을 통해 이해는 했지만 Kotlin in Action 을 읽으면서 큰 틀에서 정리된 것 같아 글을 써보려고 한다.

아래 세 가지에 해당한다면 글을 읽었을 때 유익할 것 같다.

  • 커스텀 클래스에 숫자관련 연산자 사용이 가능한 원리가 무엇인지 궁금하거나,
  • 내가 만든 클래스도 그런 연산이 가능하게 하고 싶거나,
  • 다른 연산자도 똑같이 적용할 수 있는 게 있는지  궁금하거나

코틀린에서의 관례

코틀린에서 관례 (Convention) 라고 부르는 것은 다음을 의미한다.

"어떤 언어 기능과 미리 정해진 이름의 함수를 연결해주는 기법"

 

다시 더하기 예를 들어 어떤 클래스 안에 plus 라는 이름의 메서드를 정의하면 그 클래스 인스턴스에 대해 + 연산자를 사용할 수 있는 것이다.

이렇게 언어 기능을 관례라는 것에 의존하는 이유는 자바 클래스를 코틀린 언어에 적용하기 위해서이다.

기본적으로 자바 클래스가 구현하는 인터페이스는 이미 고정되어 있고 코틀린 쪽에서 자바 클래스가 새로운 인터페이스를 구현하게 만들 수 없으니까!

하지만 관례에 의존하면 확장 함수를 사용해서 자바 기존 클래스 내부를 바꾸지 않아도

새로운 메서드를 추가해서 새로운 기능을 쉽게 부여하여 사용할 수있다.

 

물론 코틀린에서는 프로그래머가 직접 연산자를 만들어 사용할 수는 없고 언어에서 미리 정해둔 연산자만 오버로딩 할 수 있다. 

 

그리고 관례를 이용한 대표 예시가 산술연산자였던 것!

이제  여러 관례를 살펴보자.


이항 산술 연산 오버로딩

operator 키워드

연산자를 오버로딩하는 함수 앞에는 꼭 operator 키워드가 붙어야한다.

operator 키워드를 붙임으로써 어떤 함수가 관례를 따르는 함수임을 명확히 할 수 있는 것이다. 

컴파일 형태

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

이제 위 예시를 한 번 더 보면 이 함수가 + 를 사용하여 호출 되었을 때 어떤 형태인지 감이 올 것이다.

a + b 는 a.plus(b) 형태로 컴파일 된다!

다음과 같은 형태로 확장함수로 연산자를 오버로딩 할 수도 있다!

operator fun 수신객체타입.plus(파라미터): 반환타입 { ... }

이제 plus 말고 다른 이항 산술 연산 관례도 정리해보자

함수명
+ plus
* times
/ rem (1.1 미만은 div)
% mod
- minus

특징

  • 직접 정의한 함수를 통해 구현하더라도 연산자의 우선순위는 언제나 표준 숫자에 대한 연산자 우선순위와 동일하다.
    • a + b / c 있으면 b / c 먼저 계산되고 그결과를 a 와 + 하는 순서이다.
    • + - 보다 * / %  가 연산자 우선순위가 높다.
  • 연산자를 정의할 때 두 피연산자가 같은 타입일 필요는 없다.
    • operator fun Offset.times(operand: Float): Offset = Offset(x  * operand, y * operand) 과 같은 형태로 오버로딩하고
    • Offset(1f, 2f) * 3f 과 같은 형태로 호출할 수 있다.
  • 코틀린 연산자가 자동으로 교환 법칙을 지원하지 않음에 주의해야한다.
    • 3f * Offset(1f, 2f) 를 계산하고 싶으려면
    • operator fun Float.times(offset: Offset): Offset = Offset(offset.x * this, offset.y * this) 과 같은 형태의 함수도 정의해줘야한다.
    • Offset(1f, 2f) * 3f  은 Offset 객체에서 times 를 호출하도록 컴파일되는데 3f * Offset(1f, 2f) 는 Float 객체에서 times 를 호출하도록 컴파일 되어야하기 때문이다.
  • 연산하 함수의 반환 타입이 꼭 두 피연산자 중 하나와 일치해야 하진 않다. 자유다.
  • operator 함수도 일반함수와 마찬가지로 오버로딩 할 수 있다.
    • 즉, 이름은 같아도 파라미터 타입 서로 다른 연산자 함수를 여럿 만들 수 있는 것이다.
  • plusAssign, minusAssign, timesAssign ... 과 같이 ~Assign 를 함수이름을 가지고 반환타입 Unit 으로하고 연산자 오버로딩을 하면 += -= *=  ... 과 같은 복합 대입 (compound assignment) 를 사용할 수 있다. 
    • 코틀린 표준 라이브러리는 뮤터블 컬렉션에 대해 plusAssign 을 정의해서 += 연산자로 원소를 add 할 수 있게 해준다.
      • 예를들면 mutableListOf<Int>() += 12 과 같은 형태이다.  그럼 원소 12가 추가되는 것
    • 하지만 plus 와 plusAssign 을 동시에 정의하지 말자. += 연산자는 plus 또는 plusAssign 호출로 번역될 수 있기 때문이다.
public inline operator fun <T> MutableCollection<in T>.plusAssign(element: T) {
    this.add(element)
}

단항 연산자 오버로딩

단항 연산자를 오버로딩하는 방식도 이항 연산자와 같다.

단항연산자 관례 정리

+a unaryPlus
-a unaryMinus
!a not
++a, a++ inc
--a, a-- dec

특징

전위 후위 연산을 처리하기 위해 별다른 처리를 해주지 않아도 제대로 증가 연산자가 작동


비교 연산자 오버로딩

이 역시 오버로딩하는 방식은 산술 연산자와 같다.

동등성 연산자

대표 예시는 == , != 연산자를 사용할 때 equals 메서드 호출로 컴파일되는 것이다.

물론 비교처리에는 인자가 널인지 검사까지 해주므로 다른 연산과 달리 널이 될 수 있는 값에도 적용할 수 있다

따라서 최종적으로. a == b 는 a?.equals(b) ?: (b == null) 로 컴파일 될 것이다.

여기서 오버로딩하는 방식은 산술 연산자와 같다고했는데 왜 equals 는 operator 키워드를 붙이지 않고 override 를 붙이는지 의문을 가질 수 있는데, 

그 이유는 최상위 타입 Any 의 equals 에 operator 가 붙어있고 그 메서드를 오버라이드하는 메서드 앞에는 operator 를 붙이지 않아도 자동으로 상위 클래스의 operator 지정이 적용되기 때문이다.

또한 Any 에서 상속받은 equals 가 확장함수보다 우선순위가 높아 equals 를 확장함수로 정의할 수 없다.

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

참고로  같은 주소의 객체인지(원시타입인 경우 두 값이 같은지) 비교하는 연산자는 === 이다. 식별자 비교 (identity equals) 라고 부르는 이 연산자는 자바의 == 과 같이 equals 의 파라미터가 수신객체와 같은지 살펴본다.

=== 는 오버로딩할 수 없다.

순서 연산자

자바와 마찬가지로 정렬, 최대값, 최소값 등의 비교가 필요한 알고리즘을 위해 Comparable 을 구현한다.

Comparable 에 들어있는 compareTo 메서드로 기준을 정의한다.

compareTo 도 Comparable 에 operator 키워드가 붙어있다.

아래 함수를 오버라이드하여 기준을 정해주면

 <, >, <=, >= 등의 연산자를 사용할 수 있다.

Comparable 인터페이스를 구현하는 모든 자바클래스에 대해서도 사용할 수 있다.

public operator fun compareTo(other: T): Int

인덱스 연산자

인덱스 연산자는 보통 콜렉션에서 더 강력하게 쓰이고 지원한다.

원소에 접근하기 위한 인덱스 연산자 [] 는 get/set 로 컴파일된다.

get

자바에서 [] 를 인덱스로 배열 원소에 접근하는 기능을 어느 클래스에든 만들 수 있다!

List 인터페이스에 정의된 operator fun get 을 살펴보자 (E 는 원소타입)

public operator fun get(index: Int): E

파라미터 타입과 반환타입은 자유다. 그 예시를 Map 인터페이스에 정의된 get 으로 확인할 수 있다.

K 는 키이고 V 는 키에 해당하는 원소이다.

public operator fun get(key: K): V?

파라미터를 여러 개 받는 get  을 정의할 수도 있다. 

operator fun get(rowIndex: Int, colIndex: Int) 이런식으로 정의해서 

matirx[0, 2] 이런식으로 메서드를 호출 할 수 있는 것이다.

앞서 말했듯 이 경우에도 파라미터 타입과 반환타입은 자유

a[b, c] 는 a.get(b, c) 로 컴파일 된다!

set

 

set  도 마찬가지이다.  뮤터블리스크객체[1] = 원소 이런식으로 특정인덱스에 원소를 쓸 수 있다.

그럼 MutableList 인터페이스의 set 을 살펴보자

public operator fun set(index: Int, element: E): E

set 이 받는 마지막 파라미터 값은 대입문의 우항에 들어가고, 나머지 파라미터 값은 인덱스 연산자 [] 에 들어간다.

a[b, c]  = d 는 a.set(b, c, d) 로 컴파일된다!


in 관례

함수정의 방식이 같다는 것은 이제 입이 아플듯하다.

in 연산자와 대응하는 함수는 contains 이다. 

b 를 contains 를 정의한 클래스 객체라고 했을 때, a in b 는 b.contains(a) 로 컴파일된다.


rangeTo 관례

.. 구문을 사용해서 범위를 만든다.

다른 연산자에 대응하는 함수 정의하듯이

어떤 클래스이든 rangeTo 를 정의하면 사용할 수 있다.

아니면 Comparable 인터페이스를 구현하면 rangeTo 는 정의할 필요없다.

코틀린 표준 라이브러리에는 아래 정의와 같이 모든 Comparable 객체에 대해 적용가능한 rangeTo 함수가 들어있기 때문!

operator fun <T: Comparable<T>> T.rangeTo(that: T): ClosedRange<T>

 

열린범위는 끝값을 포함하지 않는 범위를 말하고 닫힌범위는 끝값을 포함하는 범위를 말한다.

1 until 10 은 1이상 9이하의 범위이고

1..10 은 1 이상 10이하의 범위이다.

위 함수의 반환타입은 ClosedRange 인 것으로 감을 잡을 수 있을 것이다.


iterator 관례

for 루프를 위한 관례이다.

for (c in "abcde") 와 같은 형태로 각각의 문자에 접근할 수 있는 이유는 아래와 같이 String 의 상위 클래스인 CharSequence 에 대한 iterator 확장함수를 제공했기 때문이다.

operator fun CharSequence.iterator(): CharIterator

component 함수

component'N' 함수는 구조 분해 선언과 관련이 있다. (N 에는 1 이상 5 이하의 숫자가 들어간다.)

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

구조 분해 선언은 함수에서 여러 값을 반환할 때 유용하다.

여러 값 들어간 데이터 클레스를 반환하여, 반환하는 값을 쉽게 풀어서 여러 변수에 넣을 수 있다는 말이다.

val (a, b) = Offset(1f, 2f)

위와 같이 변수를 선언하는 방식이다.

실제로 Offset 클래스에는 component1, component2 가 정의되어있다.

operator fun component1(): Float = x

operator fun component2(): Float = y

val (a, b) = o 는 val a = o.component1, val b = o.component2 로 컴파일된다.

data class 는 주 생성자에 들어있는 프로퍼티에 대해서는 컴파일러가 자동으로 componentN 함수를 만들어준다. (그래서 Pair, Triple 이 구조분해선언이 가능했던 것!)


마무리..  (+ 프로퍼티 위임 관례 ~ getValue, setValue)

이렇게 여러 관례에 대해 알아봤다. 

이외에도 프로퍼티 접근자 로직 재활용을 위한 위임 프로퍼티 관례와 관련있는 getValue 와 setValue 메서드가 있다.

위임 프로퍼티까지 공략하면 너무 길어질 것 같아 이만 끊고 다음 글에서 위임 프로퍼티에 대한 글을 쓰며 getValue 와 setValue 도 정리하려고한다.