[Kotlin] 비교 연산자 오버로딩
Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.
코틀린에서는 산술 연산자와 마찬가지로 원시 타입 값뿐 아니라 모든 객체에 대해 비교 연산을 수행할 수 있다.
- 자바에서는 객체 비교 시 `equals` 나 `compareTo` 를 호출해야 함
- 코틀린에서는 `==` 비교 연산자를 오버로딩하여 사용 가능
이번에는 비교 연산자를 지원하는 convention(관례) 를 살펴보자.
동등성 연산자: equals
코틀린은 `==` 연산자 호출을 `equals` 메서드 호출로 컴파일한다.
이는 다른 산술 연산자 오버로딩처럼 convention 을 적용한 것이다.
`!=` 연산자를 사용하는 식도 `equals` 호출로 컴파일된다. (비교 결과를 뒤집은 값을 결과 값으로)
`==` 와 `!=` 는 내부에서 인자가 null 인지 검사하므로 다른 연산과 달리 nullable 값에도 적용할 수 있다.
- `a == b` → a 가 null 이 아니면 `a.equals(b)` 를 호출.
- a 가 null 이면 b 도 null 일 때만 true 리턴
`equals` 메서드 구현하기
class Point(val x: Int, val y: Int) {
override fun equals(other: Any?): Boolean {
if (other === this) return true // 최적화: 파라미터가 this 와 같은 객체인지 확인
if (other !is Point) return false // 파라미터 타입 검사
return other.x == x && other.y == y // Point 로 smart cast 해서 x 와 y 프로퍼티에 접근
}
}
assert(Point(10, 20) == Point(10, 20))
assert(Point(10, 20) != Point(5, 5))
assert(null == Point(1, 2))
- 식별자 비교 연산자(`===`)를 사용해 `equals` 의 파라미터가 수신 객체와 같은지 살펴본다.
- 식별자 비교 연산자는 자바 `==` 연산자와 같다.
- `equals` 를 구현할 때는 `===` 를 사용해서 자기 자신과의 비교를 최적화하는 경우가 많다.
- `===` 는 오버로딩할 수 없다.
이미 `Any` 의 `equals` 에는 `operator` 가 붙어 있다.
public open class Any {
public open operator fun equals(other: Any?): Boolean
// ...
}
`Any` 에 이미 정의된 메서드를 override 하는 것이므로 따로 operator 를 붙이지 않아도 된다.
또한 Any 에서 상속받은 `equals` 가 확장 함수보다 우선순위가 높기 때문에 `equals` 를 확장 함수로 정의할 수 없다.
`!=` 호출은 `equals` 메서드 호출로 바뀌며, 컴파일러는 `equals` 의 리턴 값을 반전시켜서 돌려준다.
우리가 따로 `!=` 을 처리하기 위해 해야 할 일은 없다.
순서 연산자: compareTo
자바에서 비교를 수행하는 클래스는 `Comparable` 인터페이스를 구현해야 한다.
자바는 `>` 혹은 `<` 연산자로는 원시 타입의 값만 비교할 수 있다.
다른 모든 타입은 `element1.compareTo(element2)` 처럼 명시적으로 써야 한다.
`Comparable` 에 들어있는 `compareTo` 메서드는 한 객체와 다른 객체의 크기를 비교해 정수로 나타내준다. (이 메서드와 관련된 설명)
public interface Comparable<T> { public int compareTo(T o); }
코틀린도 똑같은 `Comparable` 인터페이스를 지원한다.
코틀린은 `Comparable` 인터페이스 안에 있는 `compareTo` 메서드를 호출하는 convention 을 제공한다.
public interface Comparable<in T> {
public operator fun compareTo(other: T): Int
}
따라서 비교 연산자 `<`,`>`,`<=`,`>=` 는 `compareTo` 호출로 컴파일된다.
`Comparable` 인터페이스의 `compareTo` 에 `operator` 가 이미 붙어 있다.
`compareTo` 는 `Int` 를 리턴한다.
`compareTo` 메서드를 `Person` 에 구현하기
class Person(val firstName: String, val lastName: String) : Comparable<Person> {
override fun compareTo(other: Person): Int =
compareValuesBy(this, other, Person::lastName, Person::firstName) // 인자로 받은 함수를 차레로 호출하면서 값 비교
}
val p1 = Person("Alice", "Smith")
val p2 = Person("Bob", "Johnson")
assertFalse { p1 < p2 }
여기서 정의한 `Person` 객체의 `Comparable` 인터페이스는 코틀린 뿐 아니라 자바 쪽에서도 사용할 수 있다.
`compareValuesBy` 함수는 두 객체와 여러 비교 함수를 인자로 받는다.
첫 번째 비교 함수에 두 객체를 넘겨서 두 객체가 같지 않다는 결과가 나오면 값을 즉시 리턴하고, 두 객체가 같다는 결과가 나오면, 두 번째 비교 함수를 통해 두 객체를 비교하는 식으로 모두 비교한다.
각 비교함수는 람다나 프로퍼티/메서드 참조일 수 있다.
필드를 직접 비교하면 코드는 더 복잡해지지만, 비교 속도는 훨씬 더 빨라진다.
처음에는 성능에 신경 쓰지 말고 이해하기 쉽고, 간결하게 코딩하자.
나중에 성능이 문제가 되면 성능을 개선하자.
`Comparable` 인터페이스를 구현하는 모든 자바 클래스를 코틀린에서는 간결한 연산자 구문으로 비교할 수 있다.
즉, 비교 연산자를 자바 클래스에 대해 사용하기 위해 특별히 확장 메서드를 만들 필요는 없다.
compareTo 규약을 지켜라
`compareTo` 는 아래와 같이 동작해야 한다.
- 비대칭적 동작
- `a >= b` 이고, `b >= a` 라면, `a == b` 여야 한다.
비교와 동등성 비교에 어떠한 관계가 있어야하며, 서로 일관성이 있어야 한다.
- `a >= b` 이고, `b >= a` 라면, `a == b` 여야 한다.
- 연속적 동작
- `a >= b` 이고 `b >= c` 라면, `a >= c` 여야 한다.
마찬가지로 `a > b` 이고, `b > c` 라면 `a > c` 여야 한다.
이러한 동작을 하지 못하면, 원소 정렬이 무한 loop 에 빠질 수 있다.
- `a >= b` 이고 `b >= c` 라면, `a >= c` 여야 한다.
- 코넥스적 동작(connex relation)
- 두 원소는 어떤 확실한 관계를 가져야 한다.
즉, `a >= b` 또는 `b >= a` 중, 적어도 하나는 항상 true 여야 한다.
두 원소 사이에 관계가 없으면, 퀵 정렬과 삽입 정렬 등의 정렬 알고리즘을 사용할 수 없다.
대신 위상 정렬(topological sort) 와 같은 정렬 알고리즘만 사용할 수 있다.
- 두 원소는 어떤 확실한 관계를 가져야 한다.
compareTo 를 따로 정의하지 않아도 되는 경우
코틀리에서는 일반적으로 어떤 프로퍼티 하나를 기반으로 순서를 지정하는 것으로 충분하다.
`sortedBy`: `surname` 프로퍼티를 기반으로 정렬하는 예
data class User(val name: String, val surname: String)
val names = listOf(User("Alice", "Smith"), User("Bob", "Johnson"))
val sorted = names.sortedBy { it.surname }
assert(sorted == listOf(User("Bob", "Johnson"), User("Alice", "Smith")))
그렇다면 여러 프로퍼티를 기반으로 정렬해야 한다면 어떻게 할까?
그럴 때는 `sortedWith` 함수를 사용하면 된다.
`compareBy` 를 활용해서 comparator(비교기)를 만들어서 사용한다.
`sortedWith`: `surname` 으로 정렬하고, 같다면 name 까지 비교해서 정렬하는 예
val names = listOf(User("Alice", "Smith"), User("Bob", "Smith"))
val sorted = names.sortedWith(compareBy({ it.surname }, { it.name }))
assert(sorted == listOf(User("Alice", "Smith"), User("Bob", "Smith")))
위와 달리 `User` 가 `Comparable<User>` 를 구현하는 형태로 만들 수도 있다.
특정 프로퍼티를 기반으로 정렬하게 하면 된다.
예를 들어 문자열은 알파벳과 숫자 등의 순서가 있다.
그래서 내부적으로 `Comparable<String>` 을 구현하고 있다.
일반적으로 텍스트는 알파벳과 숫자 순서로 정렬해야 하는 경우가 많아서 유용하다.
하지만 단점도 있다.
예를 들어 직관적이지 않은 부등호 기호로 두 문자열을 작성하는 코드는 이해하는 데 약간 시간이 걸린다.
그래서 Effective kotlin 에서는 아래처럼 하지 말라고 권고한다.
// 이렇게 하지 말라고 권고함
println("Kotlin" > "Java") // true
측정 단위, 날짜, 시간 등의 객체는 자연스러운 순서를 갖는다.
만약 객체가 자연스러운 순서인지 확실하지 않다면 Comparable 을 바로 구현하지 말고 comparator(비교기) 를 사용하자.
이를 자주 사용한다면, 클래스에 동반 객체로 만들어 두는 것이 좋다.
companion object 를 사용하여 comparator 만들어두기
data class User(val name: String, val surname: String) {
// ...
companion object {
val DISPLAY_ORDER = compareBy(User::surname, User::name)
}
}
val names = listOf(User("Alice", "Smith"), User("Bob", "Smith"))
val sorted = names.sortedWith(User.DISPLAY_ORDER)
assert(sorted == listOf(User("Alice", "Smith"), User("Bob", "Smith")))
compareTo 구현하기
`compareTo` 를 구현할 때 유용한 최상위 함수가 있다.
- `compareValues`: 두 값을 단순하게 비교하기만 하는 경우
class User1(val name: String, val surname: String) : Comparable<User> {
override fun compareTo(other: User): Int = compareValues(surname, other.surname)
}
- `compareValuesBy`: 더 많은 값을 비교하거나 selector(선택기)를 활용해서 비교하고 싶은 경우
class User2(val name: String, val surname: String) : Comparable<User2> {
override fun compareTo(other: User2): Int = compareValuesBy(this, other, { it.surname }, { it.name })
}
이 `compareValuesBy` 함수는 위에서도 사용했다.
`compareValuesBy` 함수는 사실 내부적으로 `compareValues` 를 for 문 을 돌면서 여러 번 실행하는 식으로 구현되어 있다.
`compareTo` 함수는 아래와 같은 값을 리턴해야 한다.
- 0: 수신 객체(receiver)와 `other` 가 같은 경우
- 양수: 수신 객체가 `other` 보다 큰 경우
- 음수: 수신 객체가 `other` 보다 작은 경우
이를 구현한 뒤에는 이 함수가 비대칭적 동작, 연속적 동작, 코넥스적 동작을 하는지 확인하자!