Book/Kotlin in Action

Kotlin in Action: 7.2-7.3 비교 연산자 오버로딩, 컬랙션과 범위에 대해 쓸 수 있는 관례

도라프 2023. 1. 6. 13:40

7. 2 비교 연산자 오버로딩

동등성 연산자 : equals

코틀린에서는 모든 객체에 대해 비교 연산을 수행하는 경우 equals나 compareTo를 호출해야하는 자바와 달리 "==" 비교 연산자를 직접 사용할 수 있어서 비교하는 코드가 더 간결하며 이해하기 쉽다.

 

코틀린은 "=="연산자 호출을 equals()로 컴파일하며, "!="역시 equals()를 사용하여 결과 값을 not 처리하는 식으로 동작한다.

a==b라는 코드는 실제로 내부에서 인자의 null 체크를 하므로 다른 연산자와 달리 null이 될 수 있는 값(nullable)에도 적용할 수 있다.

 

동등성 검사 ==는 equals 호출과 null 체크로 컴파일된다.

 

실제 내부 코드는 아래와 같이 구현되어 있다.

이 경우 a가 null인지 판단해서 null이 아닌 경우에만 a.equals(b)가 호출되고, 만약 a가 null이라면 b도 null인 경우에만 true가 반환된다.

 

class Point(val x: Int, val y: Int) {
   ...
   override fun equals(other: Any?): Boolean {
      return super.equals(other)
   }
   ...
}

 

equals() 함수 앞에는 override가 붙어있는 것을 볼 수가 있는데, equals()는 Any에 정의된 함수이므로 override가 필요하다.

 

아래는 Any 클래스의 구현이며, equals() 함수 앞에 operator 가 명시되어 있다.

 

package kotlin

/**
 * The root of the Kotlin class hierarchy. Every Kotlin class has [Any] as a superclass.
 */
public open class Any {
    ...
    public open operator fun equals(other: Any?): Boolean

    ...
    public open fun hashCode(): Int

    ...
    public open fun toString(): String
}

 

하위 클래스에서는 override를 해서 사용할 수 있지만, Any가 최상위 객체이며 Any를 상속받는 equals()가 확장 함수보다 우선순위가 높기 때문에 equals()는 사실상 확장 함수로 재정의해서 사용할 수가 없다는 사실에 유의해야 한다.

 

순서 연산자: compareTo

자바에서 정렬이나 최댓값, 최솟값 등 값을 비교해야 하는 알고리즘에는 Comparable 인터페이스를 사용한다.

Comparable의 compareTo 메소드를 이용해서 한 객체와 다른 객체의 크기를 비교해 정수로 나타낸다.

하지만 자바에서는 이 메소드를 짧게 호출할 수 있는 방법이 없어서 항상 object1.compareTo(object2)의 형태로 명시적으로 사용해야 한다.

 

코틀린도 똑같은 Comparable 인터페이스를 지원하며, 게다가 코틀린은 Comparable 인터페이스 안에 있는 compareTo 메소드 호출의 convention을 제공하여 비교 연산자(>,<, <=,>=)는 compareTo 호출로 컴파일 된다.

 

두 객체를 비교하는 식은 compareTo의 결과를 0과 비교하는 코드로 컴파일 된다.

 

아래의 예는 compareTo를 쉽고 간결하게 정의한 예제이다.

 

fun main(args: Array<String>) {
    println("abc" < "bac")
}

 

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

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

코틀린에서는 인덱스 연산자도 관례를 따른다.

 

인덱스 연산자를 사용해 원소를 읽는 연산은 get 연산자 메서드로 변환되고, 원소를 쓰는 연산은 set연산자 메서드로 변환된다.

 

앞서 계속 다뤘던 Point 클래스에 이런 메서드를 추가해보자.

operator fun Point.get(index: Int): Int { // "get" 연산자 함수를 정의한다.
    return when(index) { // 주어진 인덱스에 해당하는 좌표를 찾아 반환한다.
    	0 -> x
    	1 -> y
    	else ->
    		throw IndexOutOfBoundsException("Invalid coordinate $index")
}

위 코드와 같이 get 이라는 메서드를 만들고 operator 변경자를 붙이기만 하면 된다. 이후 p[1] 이라는 식은 p  Point 타입인 경우 위에서 정의한 get 메서드로 변환된다.

인덱스에 해당하는 컬렉션 원소를 쓰고 싶을 때는 set 이라는 이름의 함수를 정의하면 된다. 그러나 Point클래스는 불변 클래스이므로 set 이 의미가 없다. 대신 변경 가능한 점을 표현하는 다른 클래스를 만들어서 예제로 사용해보자.

data class MutablePoint(var x: Int, var y: Int)
operator fun MutablePoint.set(index: Int, value: Int) { // "set" 이라는 연산자 함수를 정의한다.
    when(index) { // 주어진 인덱스에 해당하는 좌표를 변경하여 반환한다.
    	0 -> x = value
        1 -> y = value
        else ->
			throw IndexOutOfBoundsException("Invalid coordinate $index")
}

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

2. in 관례

컬렉션이 지원하는 다른 연산자로는 in이 있다. in은 객체가 컬렉션에 들어있는지 검사한다. 그런 경우 in 연산자와 대응하는 함수는 contains이다.

data class Point(val x: Int, val y: Int)

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(args: Array<String>) {
    val rect = Rectangle(Point(10, 20), Point(50, 50))
    println(Point(20, 30) in rect)
    println(Point(5, 5) in rect)
}

in의 우항에 있는 객체는 contains 메서드의 수신 객체가 되고, in의 좌항에 있는 객체는 contains 메서드에 인자로 전달된다.

 

 

열린 범위 는 끝 값을 포함하지 않는 범위를 말하며, until 을 이용해 구현한다.

10..20 이라는 식을 사용해 일반적인(닫힌) 범위를 만들면 10 이상 20 이하인 범위가 생긴다.
10 until 20 으로 만드는 열린 범위는 10 이상 19 이하인 범위로, 20은 포함되지 않는다.

 

3. rangeTo 관례

일반적으로 범위를 만들려면 .. 구문을 사용해야 한다. .. 연산자는 rangeTo 함수를 간략하게 표현하는 방법이다. rangeTo 함수는 범위를 반환하며, 이 연산자를 아무 클래스에서나 정의할 수 있다.

 

하지만 어떤 클래스가 Comparable 인터페이스를 구현하면 rangeTo 정의할 필요가 없다. 코틀린 표준 라이브러리에는 모든 Comparable 객체에 대해 적용 가능한 rangeTo 함수가 들어있다.

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

위 함수는 범위를 반환하며, 어떤 원소가 그 범위 안에 들어있는지 in을 통해 검사할 수 있다.

유의할 점

  • rangeTo 연산자는 다른 산술 연산자보다 우선순위가 낮긴 하나 혼동을 피하기 위해 괄호로 인자를 감싸주면 더 좋다.
  • 0..n.forEach { } 와 같은 식은 컴파일할 수 없다. 앞서 말했듯 범위 연산자는 우선 순위가 낮아서 범위의 메서드를 호출하려면 (0..n).forEach { } 와 같이 범위를 괄호로 둘러싸야 한다.

 

4. for 루프를 위한 iterator 관례

2장에서 다뤘듯이 코틀린의 for 루프는 범위 검사와 똑같이 in 연산자를 사용한다. 하지만 이때의 in 의 의미는 다르다.

 

for (x in list) {...} 와 같은 문장은 list.iterator() 를 호출해서 이터레이터를 얻은 다음, 자바와 마찬가지로 그 이터레이터에 대해 hasNext  next 호출을 반복하는 식으로 변환된다.

 

하지만 코틀린에서는 이 또한 관례이므로 iterator 메서드를 확장 함수로 정의할 수 있다. 이런 성질로 인해 일반 자바 문자열에 대한 for 루프가 가능하다. 

코틀린 표준 라이브러리는 String 의 상위 클래스인 CharSequence 에 대한 iterator 확장 함수를 제공한다.

 

operator fun ClosedRange<LocalDate>.iterator(): Iterator<LocalDate> =
        object : Iterator<LocalDate> {
            var current = start

            override fun hasNext() =
                current <= endInclusive

            override fun next() = current.apply {
                current = plusDays(1)
            }
        }

앞서 살펴본 rangeTo 라이브러리 함수는 ClosedRange 의 인스턴스를 반환한다.
위 코드에서 CloseRange<LocalDate> 에 대한 확장 함수 iterator 를 정의했기 때문에 LocalDate 의 범위 객체를 for 루프에 사용할 수 있다.