[Kotlin In Action] 7. 연산자 오버로딩과 기타 관례
Convention(컨벤션/관례)
: Kotlin에서 어떤 언어 기능과 미리 정해진 이름의 함수를 연결해주는 기법
예) 어떤 클래스 안에 plus
라는 이름의 메서드를 정의하면, 그 클래스 인스턴스에 대해 +
연산자를 사용할 수 있다.
기존 자바 클래스에 대해 확장 함수를 구현하면서 관례에 따라 이름을 붙이면 기존 자바 코드를 바꾸지 않아도 새로운 기능을 쉽게 부여할 수 있다.
#1. 산술 연산자 오버로딩
이항 산술 연산자 오버로딩
data class Point(val x: Int, val y: Int) {
operator fun plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
>>> val p1 = Point(10, 20)
>>> val p2 = Point(30, 40)
>>> println(p1 + p2) // plus 함수가 호출된다.
Point(x=40, y=60
연산자를 오버로딩하는 함수 앞에는 꼭 operator
가 있어야 한다.
operator
가 없는데 관례에서 사용하는 함수 이름을 쓴다면 “operator modifier is required…” 오류가 발생한다.
외부 함수의 클래스에 대한 연산자를 정의할 때는 관례를 따르는 이름의 확장 함수로 구현하는 게 일반적인 패턴이다.
operator fun Point.plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
오버로딩 가능한 이항 산술 연산자
식 | 함수 이름 |
---|---|
a * b | times |
a / b | div |
a % b | rem |
a + b | plus |
a - b | minus |
연산자 우선순위는 표준 숫자 타입에 대한 연산자 우선순위와 같다.
자바를 코틀린에서 호출하는 경우, 함수 이름이 코틀린의 관례에 맞아 떨어지기만 하면 항상 연산자 식을 사용해 그 함수를 호출할 수 있다.
자바에서는 따로 operator 표시를 할 수 없으므로 이름과 파라미터 개수만 맞으면 된다.
연산자를 정의할 때 두 파라미터가 같은 타입일 필요는 없다.
operator fun Point.times(scale: Double): Point {
return Point((x * scale).toInt(), (y * scale).toInt())
}
// >>> val p = Point(10, 20)
// >>> println(p * 1.5)
// Point(x=15, y=30)
코틀린 연산자가 자동으로 교환 법칙(a op b == b op a 인 성질)을 지원하지는 않는다.
-> 사용자가 p * 1.5
외에 1.5 * p
라고도 쓸 수 있어야 한다면, 대응하는 연산자 함수인 operator fun Double.times(p: Point): Point
함수도 정의해야 한다.
연산자 함수의 반환 타입 역시 두 파라미터와 다른 타입으로 구현 가능하다.
operator fun Char.times(count: Int): String {
return toString().repeat(count)
}
// >>> println('a' * 3)
// aaa
일반 함수와 마찬가지로 연산자 함수도 오버로딩 가능하다. -> 이름은 같지만 파라미터 타입이 서로 다른 연산자 함수를 여럿 만들 수 있다.
복합 대입 연산자 오버로딩
+=
, -=
같은 연산자를 복합 대입 연산자라 부른다.
반환 타입이 Unit
인 plusAssign
함수를 정의하면 코틀린은 +=
연산자에 그 함수를 사용한다. (minusAssign
, timesAssign
등도 마찬가지)
이론적으로는 +=
를 plus
와 plusAssign
양쪽으로 컴파일할 수 있다.
어떤 클래스가 두 함수를 모두 정의하고, 둘 다 +=
에 사용 가능한 경우, 컴파일 에러가 발생한다. 변수를 val
로 바꿔서 plusAssign
적용이 불가능하게 만들어 해결할 수도 있지만, plus
와 plusAssign
을 동시에 정의하지 말고 일관성있게 설계하는 것이 좋다.
컬렉션 접근 방법
+
와-
는 항상 새로운 컬렉션을 반환한다.+=
와-=
는 항상 변경 가능한 컬렉션에 작용하여, 메모리에 있는 객체 상태를 변화시킨다.- 읽기 전용 컬렉션에서
+=
와-=
는 변경을 적용한 복사본을 반환한다. ->var
로 선언한 변수가 가리키는 읽기 전용 컬렉션에만+=
/-=
를 적용할 수 있다.
단항 연산자 오버로딩
예) --
, ++
operator fun Point.unaryMinus(): Point { //파라미터가 없다.
return Point(-x, -y)
}
//>>> val p = Point(10, 20)
//>>> println(-p)
//Point(x=-10, y=-20)
단항 산술 연산자
식 | 함수 이름 |
---|---|
+a | unaryPlus |
-a | unaryMinus |
!a | not |
++a, a++ | inc |
--a, a-- | dec |
#2. 비교 연산자 오버로딩
동등성 연산자: equals
코틀린에서는 ==
/ !=
연산자 호출을 equals
메서드 호출로 컴파일한다.
==
/ !=
는 내부에서 인자가 null인지 검사하므로 다른 연산과 달리 nullable 값에도 적용할 수 있다.
a == b
비교를 처리할 때 a가 null이 아닌 경우에만 a.equals(b)
를 호출한다. a가 null이면 b가 null인 경우에만 true가 된다.
data class
는 컴파일러가 자동으로 equals
를 생성해주며, 일반 클래스 또는 data class의 equals
를 직접 구현할 수도 있다.
식별자 비교는 ===
연산자를 사용하며, 자바의 ==
연산과 같이 서로 같은 객체를 가리키는지(primitive type은 두 값이 같은지) 비교한다. ===
는 오버로딩할 수 없다.
equals
는 다른 연산자 오버로딩 관례와 달리 operator가 아닌 override가 붙어 있다. equals
는 Any
에 정의된 메서드이므로 override가 필요하며, Any의 equals에는 operator가 붙어있다.
-> 메서드를 오버라이드하는 하위 클래스의 메서드 앞에는 operator를 붙이지 않아도, 자동으로 상위 클래스의 operator 지정이 적용된다.
Any에서 상속받은 equals가 확장 함수보다 우선순위가 높기 때문에 equals는 확장 함수로 정의할 수 없다.
순서 연산자: compareTo
자바에서 값을 비교해야 하는 경우 Comparable 인터페이스를 구현해야 한다.
코틀린도 Comparable 인터페이스를 지원하며, 비교 연산자(<
, >
, <=
, >=
)를 compareTo
호출로 컴파일하는 관례를 제공한다.
class Person(
val firstName: String, val lastName: String
) : Comparable<Person> {
override fun compareTo(other: Person): Int {
return compareValuesBy(this, other,
Person::lastName, Person::firstName)
}
}
코틀린 표준 라이브러리에서 제공하는 compareValuesBy
함수를 이용하면 compareTo
를 쉽고 간결하게 정의할 수 있다. compareValuesBy는 두 객체와 여러 비교 함수를 인자로 받는다. 첫 번째 비교 함수에 두 객체를 넘겨 같지 않으면 그 결과를 즉시 반환하고, 같으면(0) 두 번째 비교 함수로 비교한다. 이런 식으로 모든 함수가 0을 반환하면 0을 반환한다. 각 비교 함수는 람다_프로퍼티 참조_메서드 참조가 될 수 있다.
- 성능 관점) 필드를 직접 비교하면 코드는 복잡해지지만 비교 속도는 훨씬 빨라진다.
#3. 컬렉션과 범위에 대해 쓸 수 있는 관례
인덱스로 원소에 접근: get / set
인덱스 연산자를 사용해 원소를 읽는 연산은 get
연산자 메서드로 변환되고, 원소를 쓰는 연산은 set
연산자 메서드로 변환된다.
메서드의 파라미터는 Int가 아닌 타입도 사용할 수 있다.
in 관례
in
은 객체가 컬렉션에 들어있는지 검사하는 연산자이고, contains
와 대응된다.
rangeTo 관례
범위를 만들 때 사용하는 ..
구문은 rangeTo
함수를 간략하게 표현하는 방법이다.
어떤 클래스가 Comparable 인터페이스를 구현한다면 rangeTo를 별도 정의없이 사용할 수 있다.
for loop를 위한 iterator 관례
for (x in list) {..}
와 같은 코드는 list.iterator()
를 호출해서 hasNext
와 next
호출을 반복하는 식으로 변환된다.
iterator
연산자 함수를 오버로딩하면 해당 클래스의 범위 객체를 for 루프에 사용할 수 있다.
#4. 구조 분해 선언과 component 함수
구조 분해를 사용하면 복합적인 값을 분해해서 여러 변수를 한꺼번에 초기화할 수 있다.
val p = Point(10, 20)
val (x, y) = p // x와 y를 한 번에 초기화한다.
// x = 10, y = 20
구조 분해 선언은 함수에서 여러 값을 반환할 때 유용하다.
구조 분해 선언과 루프
변수 선언이 들어갈 수 있는 곳이면 구조 분해 선언을 사용할 수 있다. 루프 안에서도 구조 분해 선언을 사용할 수 있으며, 특히 맵의 원소에 대해 이터레이션할 때 유용하다.
fun printEntries(map: Map<String, String>) {
for ((key, value) in map) { //루프 변수에 구조 분해 선언
println("$key -> $value")
}
}
//>>> val map = mapOf("Oracle" to "Java", "JetBrains" to "Kotlin")
//-> 맵 원소를 만들 때도 to를 사용하면 유용하다.
#5. 프로퍼티 접근자 로직 재활용: 위임 프로퍼티
위임 프로퍼티를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다. 예) 프로퍼티는 위임을 통해 자신의 값을 필드가 아닌 데이터베이스 테이블이나 맵 등에 저장할 수 있다.
위임 프로퍼티 소개
class Foo {
var p: Type by Delegate()
}
-> p
프로퍼티는 접근자 로직을 Delegate
객체에게 위임한다.
class Foo {
private val delegate = Delegate() //컴파일러가 생성한 도우미 프로퍼티
var p: Type
set(value: Type) = delegate.setValue(..., value)
get() = delegate.getValue(...)
}
p
프로퍼티를 위해 컴파일러가 생성한 접근자는 delegate
의 getValue / setValue를 호출한다.
프로퍼티 위임 관례에 따르는 Delegate 클래스는 getValue와 setValue 메서드를 제공해야 한다. (setValue는 변경 가능한 프로퍼티만 사용) getValue와 setValue는 멤버 메서드, 확장 함수 모두 가능하다.
class Delegate {
operator fun getValue(...) { ... }
operator fun setValue(..., value: Type) { ... }
}
class Foo {
var p: Type by Delegate()
}
위임 프로퍼티 사용: by lazy()를 사용한 프로퍼티 초기화 지연
지연 초기화(lazy initialization)는 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요할 때 초기화하는 패턴이다. 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티에 사용할 수 있다.
class Person(val name: String) {
val emails by lazy { loadEmails(this) }
}
lazy
함수는 getValue 메서드가 들어있는 객체를 반환한다.
lazy
함수는 기본적으로 thread-safe하지만, 필요에 따라 동기화에 사용할 lock을 lazy 함수에 전달할 수도 있고 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy
함수가 동기화를 하지 못하게 막을 수도 있다.
위임 프로퍼티 컴파일 규칙
class C {
var prop: Type by MyDelegate()
}
val c = C()
컴파일러는 MyDelegate 클래스의 인스턴스를 감춰진 프로퍼티에 저장하며, 그 감춰진 프로퍼티를 <delegate>
라는 이름으로 부른다. 또한 프로퍼티를 표현하기 위해 KProperty
타입의 객체를 사용하며, 이 객체를 <property>
라고 부른다.
컴파일러가 생성한 코드는 다음과 같다.
class C {
private val <delegate> = MyDelegate()
var prop: Type
get() = <delegate>.getValue(this, <property>)
set(value: Type) = <delegate>.setValue(this, <property>, value)
}
컴파일러는 모든 프로퍼티 접근자 안에 아래와 같이 getValue / setValue 호출 코드를 생성해준다.
프로퍼티 값이 저장될 장소를 바꿀 수도 있고, 프로퍼티를 읽거나 쓸 때 실행될 동작을 변경할 수도 있다.
Comments