[Kotlin]7장. 연산자 오버로딩과 기타 관례

sonnie
lucky-sonnie
Published in
16 min readJan 16, 2021

어떤 클래스 안에 plus 라는 이름의 특별한 메소드를 정의하면 그 클래스의 인스턴스에 대해 + 연산자를 사용할 수 있다. 이런 식으로 어떤 언어 기능과 미리 정해진 이름의 함수를 연결해주는 기법을 코틀린에서는 관례(Convention)라고 부른다.

언어 기능을 타입에 의존하는 자바와 달리 코틀린은 (함수 이름을 통한) 관례에 의존한다. 이런 관례를 채택한 이유는 기존 자바 클래스를 코틀린 언어에 적용하기 위함이다.

7.1 산술 연산자 오버로딩

7.1.1 이항 산술 연산 오버로딩

data class Point(val x: Int, val y: Int){
operator fun plus(other: Point)
= Point(x + other.x, y+other.y)
}

fun main(){
val p1 = Point(10, 20)
val p2 = Point(30, 40)
println(p1 + p2) // +로 계산하면 "plus"함수가 호출된다.
}

연산자를 오버로딩하는 함수 앞에는 꼭 operator 가 있어야 한다. p1+p2a.plus(b) 함수 호출로 컴파일 된다. 아래 코드처럼 연산자를 확장함수로 정의할 수도 있다.

operator fun Point.plus(other: Point)
= Point(x + other.x, y+other.y)
a*b -> times
a/b -> div
a%b -> mod (1.1 부터 rem)
a+b -> plus
a-b -> minus

직접 정의한 함수를 통해 구현하더라도 연산자 우선순위는 언제나 표준 숫자 타입에 대한 연산자 우선 순위와 같다. ( a+b*c → *, + 순으로 연산됨)

연산자를 정의할 때 연산자 함수의 두 파라미터가 같은 타입일 필요는 없다.

operator fun Point.times(scale: Double) 
= Point((x*scale).toInt(), (y * scale).toInt())
>>> println(p1 * 1.5)
Point(x=15, y=30)

코틀린 연산자가 자동으로 교환법칙(a op b == b op a)을 지원하지는 않음에 유의하라. 1.5 * p 로도 연산이 가능하게 하고 싶다면 operator fun Double.times(p: Point): Point 를 더 정의해야 한다.

결과 타입이 다른 피연산자 타입과 다른 연산자 정의도 할 수 있다.

operator fun Char.times(count: Int) = toString().repeat(count)>>> println('a' * 3)
aaa

7.1.2 복합 대입 연산자 오버로딩

코틀린은 + 연산자 뿐 아니라 += plusAssign, -= minusAssign등 복합 대입 연산자도 지원한다.

point += Point(3,4) 는 두 점의 좌표 성분을 각각 더한 성분으로 새로운 Point 객체를 반환한다.

val list = arrayListOf(1,2)
list += 3 // list를 변경
val newList = list + listOf(4,5) // 새로운 리스트를 반환

7.1.3 단항 연산자 오버로딩

operator fun Point.unaryMinus() = Point(-x, -y)+a : unaryPlus
-a : unaryMinus
!a : not
++a, a++ : inc
--a, a-- : dec

7.2 비교 연산자 오버로딩

코틀린에서는 == 비교 연산자를 직접 사용할 수 있어 비교 코드가 equals나 compareTo를 사용한 코드보다 더 직관적이다.

7.2.1 동등성 연산자 : equals

!= 연산자를 사용하는 식도 equals 호출로 컴파일된다.

(a == b) → (a?.equals(b) ?: (b == null))
// a가 널이라면 b도 널인 경우에만 결과가 true다.

아래 equals 함수에는 override 가 붙어있다. 다른 연산자 오버로딩 관례와 달리 equals는 Any에 정의된 메소드이므로 override가 필요하다. 또한 Any에서 상속받은 equals가 확장 함수보다 우선순위가 높기 때문에 equals를 확장 함수로 정의할 수는 없다.

data 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
}
}

7.2.2 순서 연산자: compareTo

코틀린도 자바와 같은 Comparable 인터페이스를 지원한다. 코틀린은 Comparable 인터페이스 안에 있는 compareTo 메소드를 호출하는 관례를 제공한다. 따라서 <, >, ≥, ≤ 는 compareTo로 호출된다.

(a >= b) → (a.compareTo(b) >= 0)

compareTo 메소드 구현하기

class Person(val firstName: String, val lastName: String): Comparable<Person>{
override fun compareTo(other: Person): Int {
// 인자로 받은 함수를 차례로 호출하면서 값을 비교한다.
return compareValuesBy(this, other,
Person::firstName, Person::lastName)
}
}

compareValuesBy 는 첫 번째 비교 함수에 두 객체를 넘겨서 두 객체가 같지 않다는 결과(0이 아닌 값)가 나오면 그 결과 값을 즉시 반환하고, 아니면 계속 비교한다. 모든 함수가 0을 반환하면 0을 반환한다.

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

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

val value = map[key] 같은 연산자를 사용해 변경 가능 맵에 키/값 쌍을 넣거나 이미 맵에 들어있는 키/ 값 연관 관계를 변경할 수 있다. mutableMap[key]=newValue

위의 코드들이 어떻게 동작하는지 살펴본다.

operator fun Point.get(index: Int): Int{
return when(index){
0 -> x
1 -> y
else -> throw IndexOutOfBoundsException("invalid coordinate $index")
}
}
>>>println(p1[1])
20

x[a,b] → x.get(a,b) 로 변환된다. get 메소드의 파라미터로 int가 아닌 타입도 사용할 수 있다.

data class MutablePoint(var x: Int, var y: Int)
operator fun MutablePoint.set(index: Int, value: Int){
when(index){
0 -> x = value
1 -> y = value
else -> throw IndexOutOfBoundsException("invalid coordinate $index")
}
}

7.3.2 in 관례

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
}

(a in c) → c.contains(a)

7.3.3 rangeTo 관례

start..end → (start.rangeTo(end))

코틀린 표준 라이브러리에는 모든 Comparable 객체에 대해 적용 가능한 rangeTo함수가 들어있다.

7.3.4 for 루프를 위한 interator 관례

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

7.4 구조 분해 선언과 component 함수

val p = Point(10,20)
val (x, y) = p

구조 분해 선언은 일반 변수 선언과 비슷해보인다. 다만 =의 좌변에 변수를 괄호로 묶었다는 점이 다르다.

val (a, b) = p → val a = p.component1() / val b — p.component2()

data 클래스의 주 생성자에 들어있는 프로퍼티에 대해서는 컴파일러가 자동으로 componentN 함수를 만들어준다.

class Point(val x: Int, val y: Int){
operator fun component1() = x
operator fun component2() = y
}

컬렉션에 대해 구조 분해 선언 사용하기

data class NameComponents(val name: String, val extension: String)
fun splitFilename(fullName: String): NameComponents {
val (name, extension) = fullName.split('.', limit=2)
return NameComponents(name, extension)
}

표준 라이브러리의 Pair, Triple 클래스를 사용하면 함수에서 여러 값을 더 간단하게 반환할 수 있다.

7.4.1 구조 분해 선언과 루프

fun printEntries(map: Map<String, String>){
for((key, value) in map){
println("$key -> $value")
}
}

7.5 프로퍼티 접근자 로직 재활용 : 위임 프로퍼티

위임 프로퍼티를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 거보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다. (ex. 프로퍼티는 위임을 사용해 자신의 값을 필드가 아니라 DB 테이블이나 브라우저 세션, 맵 등에 저장할 수 있다. ) 위임은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체가 그 작업을 처리하게 맡기는 디자인 패턴을 말한다. 이 때 작업을 처리하는 도우미 객체를 위임 객체라고 부른다.

7.5.1 위임 프로퍼티(Delegated Property) 소개

class Foo{
var p: Type by Delegate()
}

p 프로퍼티는 접근자 로직을 다른 객체에게 위임한다. 여기서는 Delegate 클래스의 인스턴스를 위임 객체로 사용한다. by 뒤에 있는 식을 계산해서 위임에 쓰일 객체를 얻는다. 프로퍼티 위임 객체가 따라야 하는 관례를 따르는 모든 객체를 위임에 사용할 수 있다.

다음과 같이 컴파일러는 숨겨진 도우미 프로퍼티를 만들고 그 프로퍼티를 위임 객체의 인스턴스로 초기화한다. p 프로퍼티는 바로 그 위임 객체에게 자신의 작업을 위임한다.

class Foo{
private val delegate = Delegate() // 컴파일러가 생성한 도우미 프로퍼티
var p: Type
set(value: Type) = delegate.setValue(..., value)
get() = delegate.getValue(...)
}

프로퍼티 위임 관례를 따르는 Delegate 클래스는 getValue와 setValue 메소드를 제공해야 한다.

class Delegate{
operator fun getValue(...){...} // 게터를 구현하는 로직
operator fun
setValue(..., value: Type){...} // 세터를 구현하는 로직
}

class Foo{
var p: Type by Delegate()
}
>>> val foo = Foo()
>>> val oldValue = foo.p // delegate.getValue(...)
>>> foo.p = newValue // delegate.setValue(..., newValue)

코틀린 라이브러리는 프로퍼티 위임을 사용해 프로퍼티 초기화를 지연시켜줄 수 있다.

7.5.2 위임 프로퍼티 사용: by lazy()를 사용한 프로퍼티 초기화 지연

class Email
fun loadEmails(person: Korean): List<Email>{
println("${person.name}의 이메일을 가져옴")
return listOf()
}

class Korean(val name: String){
private var _emails: List<Email>? = null
val
emails: List<Email>
get() {
if (_emails == null){
_emails = loadEmails(this)
}
return _emails!! // 저장해 둔 데이터가 있으면 그 데이터를 반환한다.
}
}

_emails 은 값을 저장하고, null이 가능하다. 하지만 emails_emails 에 대한 읽기 연산을 제공하며 null 이 될 수 없다. 이런 기법을 뒷받침하는 프로퍼티(backing property)라고 한다.

위임 프로퍼티는 데이터를 저장할 때 쓰이는 뒷받침하는 프로퍼티와 값이 오직 한 번만 초기화됨을 보장하는 게터 로직을 함께 캡슐화해준다.

class Korean(val name: String){
val emails by lazy { loadEmails(this) }
}

lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue 메소드가 들어있는 객체를 반환한다. 따라서 lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다. lazy 함수의 인자는 값을 초기화할 때 호출할 람다다. lazy 함수는 기본적으로 스레드 안전하다. 하지만 필요에 따라 동기화에 사용할 락을 lazy 함수에 전달할 수도 있고, 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy 함수가 동기화를 하지 못하게 막을 수도 있다.

7.5.3 위임 프로퍼티 구현

ObservableProperty를 프로퍼티 위임에 사용할 수 있게 바꾼 것이다.

class ObservableProperty(
var propValue: Int,
val changeSupport: PropertyChangeSupport
){
operator fun getValue(p: Korean, prop: KProperty<*>): Int = propValue
operator fun setValue(p: Korean, prop: KProperty<*>, newValue: Int){
val oldValue = propValue
propValue = newValue
changeSupport.firePropertyChange(prop.name, oldValue, newValue)
}
}
  • 코틀린 관례에 사용하는 다른 함수와 마찬가지로 getValue와 setValue함수에도 operator 변경자가 붙는다.
  • getValue와 setValue는 프로퍼티가 포함된 객체와 프로퍼티를 표현하는 객체를 파라미터로 받는다. 코틀린은 KProperty 타입의 객체를 사용해 프로퍼티를 표현한다.
  • KProperty 인자를 통해 프로퍼티 이름을 전달받으므로 주 생성자에서는 name 프로퍼티를 없앤다.
class Korean(
val name: String, age: Int, salary: Int
): PropertyChangeAware(){
val age: Int by ObservableProperty(age, changeSupport)
val salary: Int by ObservableProperty(salary, changeSupport)
}

by 키워드를 사용해 위임 객체를 지정하면 이전 예제에서 직접 코드를 짜야 했던 여러 작업을 코틀린 컴파일러가 자동으로 처리해준다. 코틀린은 위임 객체를 감춰진 프로퍼티에 저장하고, 주객체의 프로퍼티를 읽거나 쓸 때마다 위임 객체의 getValue와 setValue를 호출해준다.

7.5.4 위임 프로퍼티 컴파일 규칙

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)
}

프로퍼티 값이 저장될 장소를 바꿀 수도 있고(맵, 데이터베이스 테이스, 사용자 세션의 쿠키 등) 프로퍼티를 읽거나 쓸 때 벌어질 일을 변경할 수도 있다. (값 검증, 변경 통지 등)

7.5.5 프로퍼티 값을 맵에 저장

자신의 프로퍼티를 동적으로 정의할 수 있는 객체를 만들 때 위임 프로퍼티를 활용하는 경우가 자주 있다. 그런 객체를 확장 가능한 객체라고 부르기도 한다.

class Korean {
private val _attributes = hashMapOf<String, String>()
fun setAttribute(attrName: String, value: String){
_attributes[attrName] = value
}

val name: String by _attributes // 위임 프로퍼티로 맵 사용
}

7.5.6 프레임워크에서 위임 프로퍼티 활용

object Users: IdTable(){ // 이 객체는 데이터베이스 테이블에 해당
val
name = varchar("name", length=50).index()
val age = integer("age")
}

// 각 User 인스턴스는 테이블에 들어있는 구체적인 엔티티에 해당한다.
class
User(id: EntityID): Entity(id){
var
name: String by Users.name // 사용자 이름은 db name 컬럼에 있다.
var
age: Int by Users.age
}

Users 객체는 DB 테이블을 표현한다. DB 전체에 단 하나만 존재하는 테이블을 표현하므로 Users를 싱글턴 객체로 선언했다. 객체의 프로퍼티는 테이블 칼럼을 표현한다.

User의 상위 클래스인 Entity 클래스는 DB 칼럼을 엔티티의 속성 값으로 연결해주는 매핑이 있다. 각 User의 프로퍼티 중에는 DB에서 가져온 name과 age가 있다.

이 프레임워크(사실 hibernate )를 사용하면 User의 프로퍼티에 접근할 때 자동으로 Entity 클래스에 정의된 DB 매핑으로부터 필요한 값을 가져오므로 편리하다.

출처: 코틀린 인 액션

--

--