언어의 어떤 기능과 미리 정해진 이름의 함수를 연결해주는 기법을 코틀린에서는 관례라고 부른다.
코틀린이 지원하는 여러 관례와 그 관례의 사용법을 살펴본다.
언어의 기능을 타입에 의존하는 자바와 달리 코틀린은 관례에 의존한다.
기존 자바 클래스가 구현하는 인터페이스는 이미 고정돼 있고 코틀린 쪽에서 자바 클래스가 새로운 인터페이스를 구현하게 만들 수는 없다.
확장 함수 메커니즘을 이용하면, 기존 클래스에 새로운 메서드를 추가할 수 있다.
따라서 기존 자바 클래스에 대해 확장 함수를 구현하면서 관례에 따라 이름 붙이면 기존 자바 코드를 바꾸지 않아도 새로운 기능을 쉽게 부여할 수 있다.
9.1 산술 연산자를 오버로딩해서 임의의 클래스에 대한 연산을 더 편리하게 만들기
코틀린에서 관례를 사용하는 가장 단순한 예는 산술 연산자다. 자바에서는 기본 타입에 대해서만 산술 연산자를 사용할 수 있고, 추가로 String 값에 대해 + 연산자를 사용 가능
다른 클래스에서도 산술 연산자가 유용한 경우들이 있다.
ex) BigInteger 클래스에서 add 메서드를 명시적으로 호출하기 보다는 + 연산을 사용하는 편이 더 낫다
9.1.1 plus, times, divide 등: 이항 산술 연산 오버로딩
// plus 연산자 구현하기
data classs Point(val x: Int, val y: Int){
operator fun plust(other: Point){
return Point(x + other.x, y + other.y)
}
}
fun main(){
val p1 = Point(10, 20);
val p2 = Point(30, 40);
println(p1 + p2);
// Point(x=40, y=60)
}
plus 함수 앞에 operator 키워드를 붙여야 한다.
연산자를 오버로딩하는 함수 앞에는 반드시 operator 키워드가 있어야 한다.
연산자를 멤버 함수로 만드는 대신 확장 함수로 선언할 수도 있다.
// 연산자를 확장함수로 정의하기
operator fun Point.plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
외부 함수의 클래스에 대한 연산자를 정의할 때는 관례를 따르는 이름의 확장 함수로 구현하는 것이 일반적인 패턴이다.
코틀린에서는 프로그래머가 직접 연산자를 만들어 사용할 수 없고, 언어에서 미리 정해둔 연산자만 오버로딩할 수 있다.
관례에 따르기 위해 클래스에서 정의해야 하는 이름이 연산자별로 정해져 있다.
식
함수 이름
a * b
times
a / b
div
a % b
mod
a + b
plus
a - b
minus
// 두 피연산자의 타입이 다른 연산자 정의
operator fun Point.times(scale: Doouble): Point { // 피연산자 타입: Point와 Double
return Poinit((x * scale).toInt(), (y * scale).toInt())
}
fun main() {
val p = Point(10, 20)
println(p * 1.5)
// Point(x=15, y=30)
}
코틀린 연산자가 자동으로 교환법칙을 지원하지는 않는다.
사용자가 p * 1.5외에 1.5 * p라고도 쓸 수 있어야 한다면 기존 연산자 함수에서 파라미터 타입의 순서가 바뀐 또다른 연산자 함수 operator fun Double.times(p: Point): Point를 더 정의해야 한다.
연산자 반환 타입이 꼭 두 피연산자 중 하나와 일치해야만 하는 것도 아니다.
// 결과 타입이 피연산자 타입과 다른 연산자 정의하기
operator fun Char.times(count: Int): String {
return toString().repeat(count)
}
fun main() {
println('a' * 3)
// aaa
}
일반 함수와 마찬가지로 operator 함수도 오버로딩할 수 있다.
이름은 같지만 파라미터 타입이 서로 다른 연산자 함수를 여럿 만들 수 있다.
9.1.2 연산을 적용한 다음에 그 결과를 바로 대입: 복합 대입 연산자 오버로딩
plus와 같은 연산자를 오버로딩하면 코틀린은 + 연산자 뿐만 아니라 그와 관련 있는 연산자인 +=와 같은 복합 대입 연산자도 자동으로 함께 지원한다.
fun main() {
var point = Point(1, 2) // += 연산자를 쓰려면 var로 선언돼야 한다.
point += Point(3, 4) // point = point + Point(3, 4)
println(point)
// Point(4, 6)
}
경우에 따라 += 연산이 객체에 대한 참조를 다른 참조로 바꾸기보다 원래 객체의 내부 상태를 변경하게 만들고 싶을 때가 있는데
대표적으로 변경 가능한 컬렉션에 원소를 추가하려는 경우가 있다.
fun main() {
val numbers = mutableListOf<Int>()
numbers += 42
println(numbers[0])
// 42
}
반환 타입이 Unit인 plusAssign 함수를 정의하면서 operator로 표시하면 코틀린은 += 연산자에 그 함수를 적용한다.
다른 복합 대입 연산자 함수도 minusAssign, timesAssign 등의 이름을 사용한다.
코틀린 표준 라이브러리는 변경 가능한 컬렉션에 대해 plusAssign을 정의한다.
operator fun <T> MutableCollection<T>.plussAssign(element: T) {
this.add(element)
}
이론적으로는 코드에 있는 +=를 plus와 plusAssign 양쪽으로 컴파일할 수 있지만
어떤 클래스가 이 두 함수를 모두 정의하고 둘 다 +=에 사용 가능한 경우 컴파일러는 오류를 보고한다.
plus와 plusAssign 연산을 동시에 정의하지 말라.
클래스가 변경 불가능하다면 plus와 같이 새로운 값을 반환하는 연산만을 추가해야 하고
빌더와 같이 변경 가능한 클래스를 설계한다면 plusAssign이나 그와 비슷한 연산만을 제공하도록 해야한다.
코틀린 표준 라이브러리에서 +, - 연산은 항상 새로운 컬렉션을 반환하며
+=와 -= 연산자는 항상 변경 가능한 컬렉션에 작용해 메모리에 있는 객체 상태를 변화시킨다.
또한 읽기 전용 컬렉션에서 +=와 -=는 변경을 적용한 새로운 복사본을 반환한다.
fun main() {
val list = mutableListOf(1, 2) // 변경 가능한 컬렉션 참조 list
list += 3 // list가 참조하는 객체의 상태(내용)을 변경한다.
val newList = list + listOf(4, 5) // + 는 두 리스트의 모든 원소를 포함하는 새로운
// 리스트를 반환한다.
println(list)
// [1, 2, 3]
println(newList)
// [1, 2, 3, 4, 5]
}
9.1.3 피연산자가 1개 뿐인 연산자: 단항 연산자 오버로딩
코틀린은 하나의 값에만 작용하는 단항 연산자도 제공한다.
operator fun Point.unaryMinus(): Point { // 단항 부호 반전 함수는 파라미터가 없다.
return Point(-x, -y)
}
fun main() {
val p = Point(10, 20)
println(-p)
// Point(x=-10, y =-20)
}
단항 연산자를 오버로딩하기 위해 사용하는 함수는 인자를 취하지 않는다.
식
함수 이름
+a
unaryPlus
-a
unaryMinus
!a
not
++a , a++
inc
--a, a--
dec
inc나 dec 함수를 정의해 증가/감소 연산자를 오버로딩하는 경우 컴파일러는 일반적인 값에 대한 전위와 후위 증가/감소 연산자와 같은 의미를 제공한다.
// BigDecimal 타입에 대한 전위/후위 증가 연산자 오버로딩
operator fun BigDecimal.inc() = this + BigDecimal.ONE
fun main() {
var bd = BigDecimal.ZERO
println(bd++)
// 0
println(bd)
// 1
println(++bd)
// 2
}
9.2 비교 연산자를 오버로딩해서 객체들 사이의 관계를 쉽게 검사
코틀린에서는 산술 연산자와 마찬가지로 기본 타입 값뿐 아니라 모든 객체에 대해 비교 연산(==, !=, >, < 등)을 수행할 수 있다.
equals나 compareTo를 호출해야 하는 자바와 달리, 코틀린에서는 ==비교 연산자를 직접 사용할 수 있어 비교 코드가 equals나 compareTo를 사용한 코드보다 더 간결하며 이해하기 쉽다.
9.2.1 동등성 연산자: equals
코틀린은 == 연산자 호출을 equals 메서드 호출로 컴파일한다.
!= 연산자를 사용하는 식도 equals 호출로 컴파일되는데 비교 결과를 뒤집은 값을 결괏값으로 사용하도록 컴파일된다.
== 와 !=는 내부에서 인자가 null인지 검사하므로 다른 연산과 달리 널이 될 수 있는 값에도 적용할 수 있다.
a == b => a?.equals(b) : (b == null) (equals를 호출하는 a가 널이 아닌 경우에만 equals 호출)
// equals 메서드 구현하기
class Point(val x: Int, val y: Int) {
override fun equals(obj: Any?): Boolean { // Any에 정의된 메서드를 오버라이드
if(obj === this) return true // 최적화: 파라미터가 이 식의 this와 동일한
// 객체인지 살펴본다.
if(obj !is Point) return false // 파라미터 타입 검사
return obj.x == x && obj.y == y // Point 타입으로 스마트 캐스트
}
}
equals를 구현할 때 ===를 사용해 자신과의 비교를 최적화하는 경우가 많으며 ===를 오버로딩할 수는 없다.
equals 함수에는 override가 붙어있는데, 다른 연산자 오버로딩 관례와 달리 equals는 Any에 이미 정의된 메서드이므로 operator가 아닌 override가 필요하다.
equals가 Any에 정의된 메서드이기 때문에 동등성 비교를 모든 코틀린 객체에 대해 적용할 수 있다.
Any의 equals에는 operator가 붙어있지만 그 메서드를 오버라이드하는 하위 클래스의 메서드 앞에는 operator 변경자를 붙이지 않아도 자동으로 상위 클래스의 operator 지정이 적용된다.
Any에서 상속받은 equals가 확장 함수보다 우선 순위가 높기 때문에 equals를 확장 함수로 정의할 수는 없다.
9.2.2 순서 연산자: compareTo(<, >, <=, >=)
자바에서 정렬이나 최댓값, 최솟값 등 값을 비교해야 하는 알고리즘에 사용할 클래스는 Comparable 인터페이스를 구현해야 한다.
Comparable에 들어있는 compareTo 메서드는 한 객체와 다른 객체의 크기를 비교해 정수로 나타내준다.
하지만 자바에는 이 메서드를 짧게 호출할 수 있는 방법이 없다. < 나 > 등의 연산자로는 기본 타입의 값만 비교 가능하다. 다른 모든 타입의 값에는 element1.compareTo(element2)를 명시적으로 사용해야 한다.
코틀린도 똑같은 Comparable 인터페이스를 지원한다.
코틀린은 Comparable 인터페이스 안에 있는 compareTo 메서드를 호출하는 관례를 제공
비교 연산자(<, >, <=, >=)를 사용하는 코드를 compareTo 호출로 컴파일한다.
compareTo가 반환하는 값은 Int다.
비교 연산자 컴파일 형식
a >= b => a.compareTo(b) >= 0
a <= b => a.compareTo(b) <= 0
a > b => a.compareTo(b) > 0
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::lastName, Person::firstName)
}
}
fun main() {
val p1 = Person("Alice", "Smith")
val p2 = Person("Bob", "Johnson")
println(p1 < p2) // false
}
Person 객체가 Comparable 인터페이스를 구현했기 때문에, Person 객체를 코틀린 뿐 아니라 자바 쪽의 컬렉션 정렬 메서드 등에도 사용할 수 있다.
equals와 마찬가지로 Comparable의 compareTo에도 operator 변경자가 붙어있으므로 하위 클래스에서 오버라이드 할 때 함수 앞에 operator를 붙일 필요가 없다.
equals와 마찬가지로 상속받은 compareTo가 확장 함수보다 우선 순위가 높으므로 compareTo를 확장 함수로 정의할 수는 없다.
코틀린 표준 라이브러리의 compareValuesBy 함수를 사용해 compareTo를 쉽고 간결하게 정의할 수 있다.
compareValuesBy 함수는 두 객체와 여러 비교 함수를 인자로 받는데, 첫 번째 비교 함수에 두 객체를 넘겨 두 객체가 같다는 결과(0)가 나오면 두 번째 비교 함수를 통해 두 객체를 비교한다.
compareValuesBy는 이런 식으로 두 객체의 대소를 알려주는 0이 아닌 값이 처음 나올 때까지 인자로 받은 함수를 차례로 호출해 두 값을 비교하며, 모든 함수가 0을 반환하면 0을 그대로 반환한다.
각 비교 함수는 프로퍼티/메서드 참조일 수 있다.
Comparable 인터페이스를 구현하는 모든 자바 클래스를 코틀린에서는 간결한 연산자 구문으로 비교할 수 있다. => String을 간결한 비교 연산자로 비교 가능
코틀린 컴파일러는 관례를 따르지만 자바 컴파일러는 타입을 따른다. => 자바에서 간단한 비교 연산자로 비교가 가능한 것은 기본 타입
비교 연산자를 자바 클래스에 대해 사용하기 위해 특별히 확장 메서드를 만들거나 할 필요는 없다.
fun main() {
println("abc" < "bac")
// true
}
9.3 컬렉션과 범위에 대해 쓸 수 있는 관례
컬렉션을 다룰 때 가장 많이 쓰는 연산: 인덱스를 사용해 원소를 읽거나 쓰는 연산, 어떤 값이 속해 있는지 검사하는 연산
이 모든 연산을 연산자 구문으로 사용할 수 있다.
인덱스를 사용해 원소를 설정하거나 가져오고 싶을 때는 a[b]라는 식을 사용(인덱스 접근 연산자)
in 연산자는 원소가 컬렉션이나 범위에 속하는지 검사하거나 컬렉션에 있는 원소를 이터레이션할 때 사용
9.3.1 인덱스로 원소 접근: get과 set
코틀린에서는 맵에 접근할 때 자바 배열 원소에 접근할 때처럼 각괄호([])를 사용한다.
val value = map[key]
mutableMap[key] = newValue
코틀린에서는 인덱스 접근 연산자도 관례를 따른다.
인덱스 접근 연산자를 사용해 원소를 읽는 연산은 get 연산자 메서드로, 원소를 쓰는 연산은 set 연산자 메서드로 변환된다.
Map과 MutableMap 인터페이스에는 get, set 메서드가 이미 들어있다.
직접 만든 클래스에 get, set 메서드를 추가하기
// get 관례 구현하기: 확장 함수 이용
operator fun Point.get(index: Int): Int {
return when(index) {
0 -> x
1 -> y
else ->
throw IndexOutOfBoundsException("Invalid coordinate $index")
}
}
fun main() {
val p = Point(10, 20)
println(p[1])
// 20
}
x[a] => x.get(a)
get 메서드의 파라미터로 Int가 아닌 타입도 사용할 수 있다.(맵 인덱스 연산의 경우 get의 파라미터 타입은 앱의 키 타입과 같은 임의의 타입이 될 수 있다.)
또한 여러 파라미터를 사용하는 get을 정의할 수도 있다.(오버로딩! operator 키워드: 연산자 오버로딩)
2차원 행렬이나 배열을 표현하는 클래스에 operator fun get(rowIndex: Int, colIndex: Int)를 정의하면 matrix[row, col]로 그get 메서드를 호출할 수 있다.
x[a, b] => x.get(a, b)
컬렉션 클래스가 다양한 키 타입을 지원해야 한다면 다양한 파라미터 타입에 대해 오버로딩한 get 메서드를 여럿 정의할 수도 있다.
비슷하게 인덱스에 해당하는 컬렉션 원소를 각괄호를 사용해 쓰고 변경하는, 즉 set하는 함수를 정의할 수도 있다.
Point 클래스는 불변 클래스이므로 이런 메서드르 정의하는 것이 무의미
가변 클래스에 관례를 따르는 set 구현
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 IndexOufOfBoundsException("Invalid coordinate $index")
}
}
fun main() {
val p = MutablePoint(10, 20)
p[1] = 42
println(p)
// MutablePoint(x=10, y=42)
}
대입에 인덱스 연산자를 사용하려면 set이라는 이름의 함수를 정의해야 한다.
set이 받는 마지막 파라미터 값은 대입 연산자의 오른쪽, 나머지 파라미터 값은 인덱스 연산자 안에 순서대로 들어간 값에 해당한다.
x[a, b] = c => x.set(a, b ,c)
9.3.2 어떤 객체가 컬렉션에 들어있는지 검사: in 관례
컬렉션이 지원하는 다른 연산자로는 in이 있다. in 은 객체가 컬렉션에 들어있는지 검사한다.
이 때 대응하는 함수는 contains다.
data class Rectangle(val upperLeft: Point, val lowerRight: Point)
operator fun Rectangle.contains(p: Point): Boolean {
return p.x in upperLeft.x..<lowerRight.x &&
p.y in upperLeft.y..<lowerRight.y
}
fun main() {
val rect = Rectangle(Point(10,20), Point(50,50))
println(Point(20, 30) in rect)
// true
println(Point(5, 5) in rect)
// false
}
in 오른쪽에 있는 객체는 contains 메서드의 수신 객체가 되고 in의 왼쪽에 있는 객체는 contains 메서드에 인자로 전달된다.
a in c => c.contains(a)
9.3.3 객체로부터 범위 만들기: ragneTo와 rangeUntil 관례
범위를 만들려면 .. 구문을 사용해야 한다. 1..10은 1부터 10까지의 모든 수가 들어있는 범위를 말한다.
범위 연산자 ..는 rangeTo 함수 호출을 간략하게 표현하는 방법이다.
start..end = start.rangeTo(end)
rangeTo 함수는 범위를 반환한다
직접 만든 클래스에 대해서도 .. 연산자를 정의할 수 있다.
하지만 클래스가 Comparable 인터페이스를 구현한다면 rangeTo 함수를 따로 정의할 필요가 없다.
코틀린 표준 라이브러리를 통해 비교 가능한 원소로 이뤄진 범위를 쉽게 만들 수 있다.
코틀린 표준 라이브러리에는 모든 Comparable 객체(Comparable를 구현한 모든 클래스 타입의 모든 객체)에 대해 적용 가능한 확장 함수 rangeTo가 정의되어 있다.
Ranges.kt 파일에 정의된, Comparable 인터페이스를 구현한 모든 객체에 적용할 수 있는 확장 함수 rangeTo
// Ranges.kt
public operartor fun <T: Comparable<T>> T.rangTo(that: T):ClosedRange<T> =
ComparableRange(this, that)
Comparable 인터페이스를 구현한 LocalDate에서 .. 연산자로 확장 함수 rangeTo 호출하기
import java.time.LocalTime
fun main() {
val now = LocalDate.now()
val vacation = now..now.plusDays(10)
println(now.plusWeeks(1) in vacation)
// true
}
now..now.plusDays(10)이라는 식은 컴파일러에 의해 now.rangeTo(now.plusDays(10))으로 변환된다.
rangeTo 함수는 LocalDate 클래스의 멤버는 아니며 Comparable에 대한 확장 함수를 정의함으로 인해 멤버인 것처럼 추가된 것이다.
Int, Long, Double과 같은 원시 타입의 클래스는 .. 연산자에 대해 오버로딩한(operator 선언한) rangeTo 함수를 멤버 함수로 정의해놓았다.
그런데 클래스의 멤버 함수는 클래스에 대한 확장 함수보다 우선 순위가 높기 때문에 원시 타입 객체로부터 rangeTo 함수 호출 시
Comparable 인터페이스를 구현한 모든 클래스에 대한 확장 함수 rangeTo가 아닌 원시 타입 클래스의 멤버 함수 rangeTo가 호출된다.
하지만 사실, 실제로 range 함수가 호출이 되는 것이 아니라 컴파일 타임 때 Int 타입의 range 함수에 구현된 로직이 인라이닝되고 이것이 그대로 실행되는 것이다.(Int와 같은 원시 타입의 rangeTo 함수 등은 코틀린 표준 라이브러리 개발 단계에서 특별한 처리를 하여 배포한다.)
원시 타입 클래스의 멤버 함수 rangeTo 함수의 반환 타입이 IntRange이고 IntRange는 Iterable 인터페이스를 구현하기 때문에
IntRange 타입의 객체는 코틀린 표준 라이브러리에서 정의한, Iterable 인터페이스 구현한 모든 클래스들에 대한 확장 함수 forEach를 호출할 수 있다.
여기서 잠깐! Int 타입의 rangeTo 함수는 왜 구현부가 비어있는 걸까?
그 이유는 바로 코틀린 Int 타입은 코틀린 표준 라이브러리에 속하는데 코틀린 표준 라이브러리에서 코틀린 Int 클래스의 멤버 함수들을 구현할 때 @InlineOnly 어노테이션을 사용하고 inline 선언했기 때문이다! => 배포판을 보는 일반 개발자는 실제 그 함수의 구현부를 보지 못한다.
@InlineOnly 어노테이션에 관한 이야기
코틀린에서 @InlineOnly 어노테이션은 내부 구현 목적으로 사용되는 특수한 어노테이션이며,
일반 사용자(개발자)가 사용하는 용도는 거의 없다.
주로 코틀린 표준 라이브러리에서 inline 함수의 본문만을 노출하고 외부에서 직접 호출하는 것을 방지하기 위해 사용됩니다
@InlineOnly 기본 개념
목적: 해당 함수는 반드시 inline 되어야 하며, 바이트코드 상 함수 호출로 남아있지 않도록 강제
결과: 호출하는 쪽에서 무조건 인라이닝되며, 리플렉션으로 호출하거나 참조할 수 없다.
실제로 함수 바디만 컴파일되고 함수 자체는 클래스 파일에 존재하지 않게 된다.
사용 예시 (표준 라이브러리 내부 코드 기준)
@InlineOnly
inline fun IntRange.contains(value: Int): Boolean {
return value in this
}
이런 식의 코드는 Kotlin 표준 라이브러리 내부에서만 사용되고, @InlineOnly를 붙이면 이 함수는 반드시 인라인되어야 하며, JVM 바이트코드로 컴파일될 때 함수 정의가 사라진다.
사용 시 주의점
@InlineOnly 함수는 외부에서 직접 호출하거나 참조할 수 없습니다.
이 어노테이션을 붙인 함수는 반드시 inline이어야 합니다. 그렇지 않으면 컴파일 오류가 발생합니다.
리플렉션 사용이 불가능합니다 (예: Function::class 참조 불가).
대부분의 경우 직접 사용할 필요는 없습니다. → 일반 개발자는 inline 만으로도 충분합니다.
실제와 배포 버전의 차이 (확장 개념)
Kotlin의 표준 라이브러리 코드를 GitHub 등에서 보면 @InlineOnly가 붙은 함수의 구현이 보일 수 있지만, 배포된 .class 파일이나 .kt 소스에서는 이 구현이 사라진 것처럼 보일 수 있습니다. 이유는 다음과 같습니다:
@InlineOnly는 함수 자체를 .class에 넣지 않음
인라인 되는 부분만 호출 위치에 포함되므로 .class나 디컴파일된 소스에서는 구현이 누락된 것처럼 보일 수 있음
fun main() {
val n = 9
(0..n).forEach{ print(it)}
//0123456789
}
rangeTo 연산자는 다른 산술 연산자보다 우선 순위가 낮다. 하지만 혼동을 피하기 위해 괄호로 인자를 감싸주면 더 좋다.
fun main() {
val n = 9
println(0..(n + 1))
//0..10
}
for(x in list) {...}와 같은 문장은 list.iterator()를 호출해서 이터레이터를 얻은 다음,
자바와 마찬가지로 그 이터레이터에 대해 hasNext와 next 호출을 반복하는 식으로 변환한다.
코틀린에서는 이 또한 관례이므로 iterator 메서드를 확장 함수로 원하는 대로 정의해두고 for 루프를 사용할 수 있다.
이런 성질로 인해 자바 문자열에 대한 for 루프가 가능하다.
코틀린 표준 라이브러리는 String의 상위 클래스인 CharSequence에 대한 iterator 확장 함수를 제공하고 있다.
9.4 component 함수를 사용해 구조 분해 선언 제공
구조 분해 선언 시의 관례
구조 분해를 사용하면 복합적인 값을 분해해서 여러 지역 변수를 한꺼번에 초기화할 수 있다.
fun main() {
val p = Point(10, 20)
val (x, y) = p
println(x) // 10
println(y) // 20
}
내부에서 구조 분해 선언은 관례를 사용한다.
구조 분해 선언의 각 변수를 초기화하고자 componentN이라는 함수를 호출한다.
여기서 N은 구조 분해 선언에 있는 변수 위치에 따라 붙는 번호다.
val (a, b) = p
=> val a = p.component1()
=> val b = p.component2()
data 클래스의 주 생성자에 들어있는 프로퍼티에 대해서는 컴파일러가 자동으로 componentN 함수를 만들어준다.
데이터 클래스 타입이 아닌 클래스에서 component 함수 구현
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(fulllName: String): NameComponents {
val result = fullName.split('.', limit = 2)
return NameComponents(result[0], result[1])
}
fun main() {
val (name, ext) = splitFilename("example.kt")
println(name) // example
println(ext) // kt
}
배열이나 컬렉션에도 componentN 함수가 확장 함수로 선언돼 있다.
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)
}
물론 무한히 componentN을 선언할 수는 없으므로 이런 구문을 무한정 사용할 수는 없다.
코틀린 표준 라이브러리에서는 맨 앞의 다섯 원소에 대한 componentN을 제공한다.
9.4.1 구조 분해 선언과 루프
// 구조 분해 선언을 사용해 맵 이터레이션
fun printEntries(map: Map<String, String>) {
for((key, value) in map) {
println("$key -> $value")
}
}
fun main() {
val map = mapOf("Oracle" to "Java", "JetBrains" to "Kotlin")
printEntries(map)
// Oracle -> Java
// JetBrains -> Kotlin
}
코틀린 표준 라이브러리에는 맵에 대한 확장 함수로 iterator가 들어있다.
그 iterator는 맵 항목에 대한 이터레이터를 반환한다.
자바와 달리 코틀린에서는 맵을 직접 이터레이션할 수 있다.
또한 코틀린 라이브러리는 Map.Entry에 대한 확장 함수로 component1과 component2를 제공한다.
실제 앞의 루프는 다음과 동등한 코드로 컴파일된다.
for(entry in map.etries) {
val key = entry.component1()
val value = entry.component2()
...
}
람다가 data class나 맵 같은 복합적인 값을 파라미터로 받을 때도 구조 분해 선언을 쓸 수 있다.
컴포넌트가 여럿 있는 객체에 대해 구조 분해 선언을 사용할 때는 변수 중 일부가 필요 없을 경우가 있다.
data class Person(
val firstName: String,
val lastName: String,
val age: Int,
val city: String
)
fun introducePerson(p: Person){
val (firstName, lastName, age, city) = p
println("This is $firstName, aged $age")
}
이 경우 정의된 localName과 city 지역 변수는 코드에 아무 값도 제공하지 않는다.
이들은 쓰이지 않으면서 함수의 본문에서 코드를 지저분하게 한다.
전체 객체를 구조 분해해야만 하는 것은 아니기 때문에 구조 분해 선언에서 뒤쪽의 구조 분해 선언을 제거할 수는 있다.
val (firstName, lastName, age) = p
lastName 선언을 없앨 때는 약간 다른 방식을 써야 한다.
val (firstName, age)와 같이 아예 없앨 수는 없다.
이런 경우를 처리할 수 있게 코틀린은 _ 문자를 쓸 수 있게 해준다.
lastName을 _로 바꾸고 마지막 city를 제거하면 된다.
fun introducePerson(p: Person){
val (firstName, _, age) = p
println("This is $firstName, aged $age")
}
코틀린 구조 분해의 한계와 단점
코틀린 구조 분해 선언 구현은 위치에 의한 것이다.
구조 분해의 결과가 대입될 변수의 이름은 중요하지 않다. 오직 위치, 순서에 따른다.
이로 인해 리팩터링을 하면서 데이터 클래스의 프로퍼티 순서를 변경하면 문제가 발생할 수 있다.
data class Person(
val lastName: String,
val firsttName: String,
val age: Int,
val city: String
)
val (firstName, lastName, age, cituy) = p
이런 동작은 구조 분해 선언이 작은 컨테이너 클래스나 장차 변경될 가능성이 아주 작은 클래스에 대해서만 유용하다는 것을 의미한다.
복잡한 엔티티에 대해 구조 분해 사용을 가능한 한 피해야 한다.
이 문제에 대한 잠재적인 해법은 이름 기반 구조 분해를 도입하는 것이다.
9.5 프로퍼티 접근자 로직 재활용: 위임 프로퍼티
위임 프로퍼티를 사용하면 값을, 뒷받침하는 필드에 단순히 저장하는 것하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 , 접근자 로직을 매번 재구현할 필요 없이 쉽게 구현할 수 있다.
프로퍼티는 위임을 사용해 자신의 값을 필드가 아니라 데이터베이스 테이블이나 브라우저, 세션, 맵 등에 저장할 수 있다.
이런 특성의 기반에는 위임이 있다.
위임은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체가 그 작업을 처리하도록 맡기는 디자인 패턴을 말한다.
작업을 처리하는 도우미 객체를 위임 객체라고 말한다.
클래스 위임을 통해 구현한 데코레이팅 패턴을 프로퍼티에 적용해서 접근자 기능을 도우미 객체, 위임 객체가 수행하도록 위임한다는 것이다.
9.5.1 위임 프로퍼티의 기본 문법과 내부 동작
프로퍼티에 위임을 적용하는 일반적인 문법
var p: Type by Delegate()
p 프로퍼티는 접근자 로직을 다른 객체, Delegate 타입 객체에 위임한다.
Delegate 클래스의 인스턴스를 위임 객체롤 사용한다.
by 뒤에 있는 식을 계산해 위임에 쓰일 객체를 얻는 것이다.
프로퍼티 위임 객체가 따라야 하는 모든 관례를 따르는 모든 객체를 위임에 사용할 수 있다.
class Foo {
var p: Type by Delegate()
}
컴파일러는 숨겨진 도우미 프로퍼티(private 가시성)를 만들고 그 프로퍼티를 위임 객체의 인스턴스로 초기화한다.
p 프로퍼티는 바로 그 위임 객체에게 자신의 작업을 위임한다.
감춰진 도우미 프로퍼티 이름을 delegate라고 가정했을 때 컴파일러가 프로퍼티의 위임을 변환하는 형식
class Foo {
private val delegate = Delegate()
var p: Type
get() = delegate.getValue(/*...*/)
set(value: Type) = delegate.setValue(/*...*/, value)
}
프로퍼티 위임 관례에 따라 Delegate 클래스는 getValue와 setValue 메서드를 제공해야 한다.
var로 선언된 변경 가능한 프로퍼티를 위임하는 경우에만 위임 객체에서 setValue를 호출하여 사용할 수 있다.
추가로 위임 객체는 선택적으로 provideDelegate 함수 구현을 제공할 수도 있다.
provideDelegate 함수는 위임 객체 최초 생성 시 검증 로직을 수행하거나 위임이 인스턴스화 되는 방식을 변경할 수 있다.
class Delegate {
operator fun getValue(/*...*/) {/*...*/} // 게터를 구현하는 로직
operator fun setValue(/*...*/, value: Type) {/*...*/} // 세터를 구현하는 로직
operator fun provideDelegete(/*...*/): Delegate {/*...*/} // 위임 객체 생성, 제공
}
class Foo {
var p: Type by Delegate() // by 키워드는 프로퍼티와 위임 객체를 연결한다.
}
fun main() {
val foo = Foo() // 위임 클래스 Delegate에 provideDelegete 함수가 있으면 위임 객체를
// 생성해 감춰진 도우미 프로퍼티에 대입해준다.
val oldValue = foo.p // 내부에서 delegate.getValue(...) 호출
foo.p = newValue // 내부에서 delegate.setValue(..., newValue) 호출
}
9.5.2 위임 프로퍼티 사용: by lazy()를 사용한 지연 초기화
지연 초기화는 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요할 경우 초기화할 때 흔히 쓰이는 패턴이다.
초기화 과정에 자원을 많이 사용하거나 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티에 대해 지연 초기화 패턴을 사용할 수 있다.
데이터베이스에 사람들 각자의 이메일 리스트가 저장돼있고 사람을 나타내는 Person 클래스에 이메일 목록 emails 인스턴스 변수가 선언된 상태에서
Person 클래스 타입의 객체를 생성할 때 바로 emails를 초기화하는 것이 아니라 객체가 생성된 후 Person 클래스 타입의 객체가 emails를 최초로 필요로 하여 데이터베이스에서 해당 사람의 이메일 목록을 불러올 때 단 한 번만 초기화를 해주고 싶다고 해보자.(이메일 목록을 지연 초기화를 하고 싶다는 것이다.)
우선, 이메일을 불러오는 함수 loadEmails
class Email { ... }
fun loadEmails(person: Person): List<Email> {
println("Load emails for ${person.name}")
return listOf(/*...*/)
}
일단 뒷받침하는 프로퍼티를 이용해서 지연 초기화를 구현할 수 있다.
실제로 읽히고 사용될 emails 프로퍼티와는 별개로
emails 최초 상태를 저장하고 emails 프로퍼티가 읽히고 사용되기 전에 먼저 상태를 저장해놓는 _emails 프로퍼티(뒷받침하는 프로퍼티)를 추가해서 지연 초기화를 구현한다.
_emails의 최초 상태는 null이다.
class Person(val name: String) {
private var _emails: List<Email>? = null
val emails: List<Email>
get() {
if(_emails == null){
_emails = loadEmails(this)
}
return _emails!! // 널 아님 단언: 앞에서 널 검사 통과함 보장
}
}
fun main() {
val p = Person("Alice")
p.emails
// Load emails for Alice
p.emails
}
위에 설명한 것처럼 뒷받침하는 프로퍼티라는 기법을 사용했다.
_emails라는 프로퍼티는 값을 저장하고 다른 프로퍼티인 emails는 _emails라는 프로퍼티에 대한 읽기 연산을 제공한다.
두 프로퍼티의 타입이 서로 달라서 프로퍼티를 2개 모두 사용해야 한다.
이들의 이름은 간단한 관례를 사용한다.
클래스에 같은 개념을 표현하는 프로퍼티가 2개 있을 때 비공개 프로퍼티 앞에 밑줄을 붙이며, 공개 프로퍼티에는 아무것도 붙이지 않는다.
뒷받침하는 프로퍼티 기법을 사용해서 지연 초기화를 구현하는 것에는 약간의 문제가 있다.
지연 초기화해야 하는 프로퍼티가 많아지면 코드가 어떻게 될지 생각해보자.
더군다나 지연 초기화가 원래 의도한대로 작동할 거라고 보장할 수도 없다.
현재 구현은 스레드 안전하지 않기 때문이다.
두 스레드가 동시에 Person 객체의 emails에 접근할 때 _emails 프로퍼티는 두 스레드에 대해 동기화되지 못하기 때문에 비용이 많이 드는 loadEmails 함수가 두 번 호출될 수 있다. 이것은 원래 의도한 최초로 emails에 접근할 때 단 한 번만 loadEmails 함수를 호출하여 초기화하기로 한 의도와는 맞지 않다.
이런 상태에서 무수히 많은 스레드가 동시에 하나의 Person 객체에게 접근해 emails를 읽으려고 한다고 하면, 비용이 많이 드는 loadEmails 함수가 무수히 많이 중복하여 호출될 수 있는 것이다.
위임 프로퍼티를 통한 지연 초기화는 뒷받침하는 프로퍼티를 이용한 지연 초기화 시의 코드가 길어질 우려와 스레드 동기화 문제를 해결해준다.
단, 스레드 동기화 문제를 해결을 위해선 감춰진 도우미 프로퍼티에 대입되는 위임 객체는 lazy라는 코틀린 표준 라이브러리에서 제공하는 함수를 통해 얻어내야 한다.
class Person(val name: String) {
val emails by lazy { loadEmails(this) } // 위임 프로퍼티
}
lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue 메서드가 들어있는 객체를 반환한다.
코틀린 표준 라이브러이에 lazy 함수가 반환하는 객체의 타입 Lazy< T >에 대해서 getValue가 확장 함수로 선언돼있다.
public actual fun <T> lazy(initializer:() -> T): Lazy<T>
= SynchronizedLazyImpl(initializer, lock)
@InlineOnly
public inline operator fun <T> Lazy<T>.getValue(
thisRef: Any?, property: KProperty<*>): T = value
따라서 lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다.
lazy 함수의 인자는 값을 초기화할 때 호출할 람다다.
lazy 함수 함수가 반환하는 객체는 기본적으로 스레드 안전한다.
동기화에 사용할 락을 lazy 함수에 전달할 수도 있고, 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy 함수가 동기화를 생략하게 할수도 있다.
// lock을 인자로 받는 코틀린 표준 라이브러리의 lazy 함수
public actual fun <T> lazy(lock: Any?, initializer:() -> T): Lazy<T>
= SynchronizedLazyImpl(initializer, lock)
9.5.3 위임 프로퍼티 구현
어떤 객체의 프로퍼티가 바뀔 때마다 해당 객체의 리스너(Observer)에게 객체의 프로퍼티가 어떻게 변경됐는지를 전달하여 리스너(Observer)가 이에 따라 특정 행동을 하도록 요청하고자 한다.
이런 경우를 옵저버블이라고 부른다. 코틀린이 옵저버블을 어떻게 구현할 수 있는지 살펴보자.
Observable 클래스는 Observer들의 리스트를 관리한다. notifyObservers가 호출되면 옵저버는 등록된 모든 Observer의 onChange 함수를 통해 프로퍼티의 이전 값과 새 값을 전달한다.
open class Observable {
val observers = mutableListOf<Observer>()
fun notifyObservers(propName: String, oldValue: Any?, newValue: Any?){
for(obs in observers) {
obs.onChange(propName, oldValue, newValue)
}
}
}
Observer는 onChange 메서드에 대한 구현만 제공하면 되므로 함수형 인터페이스로 선언
fun interface Observer {
fun onChange(name: String, oldValue: Any?, newValue: Any?)
}
프로퍼티 변경 알림을 위임없이 직접 구현(field 키워드를 사용, 뒷받침하는 필드에 접근하는 방법)
class Person(val name: String, age: Int, salary: Int): Observable() {
var age: Int = age
set(newValue) {
val oldValue = field
field = newValue // 뒷받침하는 필드 접근
notifyObservers(
"age", oldValue, newValue
)
}
var salary: Int = salary
set(newValue){
val oldValue = field
field = newValue
notifyObservers(
"salary", oldValue, newValue
)
}
}
fun main() {
val p = Person("Seb", 28, 1000)
p.observers += Observer {
propName, oldValue, newValue -> println(
"""
Property $propNmae changed from $oldValue to $newValue!
""".trimIndent()
)
}
p.age = 29
// Property age changed from 28 to 29!
p.salary = 1500
// Property salary changed from 1000 to 1500!
}
age또는 salary 프로퍼티의 값을 저장하고 필요에 따라 저장된 age또는 salary 프로퍼티의 값을 읽거나 age또는 salary 프로퍼티의 값을 변경하고 변경 통지를 보내는 클래스 추출
class ObservableProperty(
val propName: String,
var propValue: Int,
val observable: Observable
){
fun getValue(): Int = propValue
fun setvalue(newValue: Int){
val oldValue = propValue
propValue = newValue
notifyObservers(propName, oldValue, newValue)
}
}
ObservableProperty 클래스로 위임 패턴 구현
class Person(val name: String, age: Int, salary: Int): Observable() {
val _age = ObservableProperty("age", age, this)
var age: Int
get() = _age.getValue()
set(newValue: Int) = _age.setValue(newValue)
val _salary = ObservableProperty("salary", salary, this)
var salary: Int
get() = _salary.getValue()
set(newValue: Int) = _salary.setValue(newValue)
}
코틀린의 위임이 실제로 작동하는 방식과 비슷해졌다.
by 키워드를 이용해서 실제 위임 객체를 생성해 위임 프로퍼티로 위임 패턴을 구현해보자
우선, ObservableProperty 클래스에서 getValue 메서드와 setValue 메서드의 시그니처를 위임 관례에 맞게 수정해야 한다.
import kotlin.reflect.KProperty
class ObservableProperty(var propValue: Int, val observable: Observable) {
operator fun getValue(thisRef: Any?, prop: KProperty<*>):Int = propValue
operator fun setValue(thisRef: Any?, prop: KProperty<*>, newValue: Int){
val oldValue = propValue
propValue = newValue
observable.notifyObservers(prop.name, oldValue, newValue)
}
}
thisRef : 바로 설정하거나 읽을 프로퍼티가 들어있는 인스턴스
prop: 프로퍼티를 표현하는 객체
KProperty 인자를 통해 프로퍼티 이름을 전달받으므로 기존 ObservableProperty 클래스 주 생성자에서 name 프로퍼티를 없앤다.
by 키워드를 사용해 위임 프로퍼티 구현
class Person(val name: String, age: Int, salary: Int): Observable() {
var age by ObservableProperty(age, this) // 위임 프로퍼티
var salary by ObservableProperty(salary, this) // 위임 프로퍼티
}
by 키워드를 사용해 위임 객체를 지정하면 이전 예제에서 직접 코드를 작성해야 했던 여러 작업을 코틀린 컴파일러가 자동으로 처리해준다.
코틀린은 위임 객체를 감춰진 프로퍼티에 저장하고 주 객체의 프로퍼티를 읽거나 쓸 때마다 위임 객체의 getValue와 setValue를 호출해준다.
관찰 가능한 프로퍼티의 로직을 직접 작성하는 대신, 즉 위임 관례를 따르는 위임 클래스를 직접 구현하는 대신, 코틀린 표준 라이브러리의 Delegates.observable 함수를 이용해 ObservableProperty와 동일하게 위임 관례를 따르는 위임 클래스의 객체를 생성해줄 수도 있다.
대신 Delegates.observable 함수를 이용해 위임 객체 생성 시, setValue 함수에서 위임 객체의 상태값을 변경하는 것 외에 추가로 할 행동을 람다로 전달해줘야 한다.
import kotlin.properties.Delegates
class Person(val name: String, age: Int, salary: Int): Observable() {
private val onChange = {
property: KProperty<*>, oldValue: Any?, newValue: Any? ->
notifyObservers(property.name, oldValue, newValue)
}
var age by Delegates.observable(age, onChange)
var salary by Delegates.observable(salary, onChange)
}
다른 관례들과 마찬가지로 getValue와 setValue 함수 모두 객체 안에 정의된 메서드이거나 확장 함수일 수 있다.
9.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)
}
결국 컴파일러는 위임 프로터티의 모든 접근자 안에 위와 같은 getValue, setValue 호출 코드를 생성해준다는 것이다.
이러한 메커니즘은 단순하지만 활용성이 없다.
프로퍼티 값이 저장될 장소를 바꿀 수도 있고 프로퍼티를 읽거나 쓸 때 벌어질 일을 변경할 수도 있다.
9.5.5 맵에 위임해서 동적으로 애트리뷰트 접근
// 값을 맵에 저장하는 프로퍼티 정의
class Person {
private val _attributes = mutableMapOf<String, String>()
fun setAttribute(attrName: String, value: String) {
_attribute[attrName] = value
}
var name: String
get() = _attribute["name"]!!
set(value){
_attribute["name"] = value
}
}
fun main() {
val p = Person()
val data = mapOf("name" to "Seb", "company" to "JetBrains")
for((attrName, value) in data)
p.setAttribute(attrName, value)
println(p.name)
// Seb
p.name = "Sebastian"
println(p.name)
// Sebastian
}
// 값을 맵에 저장하는 위임 프로퍼티 구현
class Person {
private val _attributes = mutableMapOf<String, String>()
fun setAttribute(attrName: String, value: String) {
_attribute[attrName] = value
}
var name: String by _attributes
}
이런 코드가 작동하는 이유는 표준 라이브러리가 Map과 MutableMap 인터페이스에 대해 getValue와 setValue 확장 함수를 제공하기 때문이다.
9.5.6 실전 프레임워크가 위임 프로퍼티를 활용하는 방법
위임 프로퍼티를 이용하면 객체의 프로퍼티를 저장하거나 변경하는 방법을 개발자가 원하는 대로 정의하고 구현할 수 있는 구조의 프레임워크를 만들 수 있다.
User 클래스 객체의 이름과 나이를 데이터베이스로부터 읽어오고 데이터베이스에 쓸 수 있도록 위임 프로퍼티를 구현해보자
User 클래스 정의
class User(id: EntityId): Entity(id) {
val name: String
val age: Int
}
User 객체의 속성과 매핑되는 컬럼을 프로퍼티로 갖는 IdTable 클래스 타입의 객체 Users 정의
객체 식으로 정의된 싱글턴 객체이다.
object Users: IdTable() {
val name: Column<String> = varchar("name", 50).index()
val age: Column<Int> = integer("age")
}
User 클래스 name 프로퍼티의 접근자(get, set) 로직을 Users 객체 Column< String > 타입의 name 프로퍼티에 정의된 객체(varchar("name", 50).index()가 반환한 컬럼 객체)를 이용하여 구현할 수 있다.
즉, Users 객체의 Column< String > 타입의 name 프로퍼티를 User 클래스 name 프로퍼티의 접근자 로직 구현을 위한 위임 객체로 사용할 수 있다는 것이다.
마찬가지로 User 클래스 age 프로퍼티의 접근자 로직 또한 Users 객체의 이미 정의된 Column< Int > 타입의 age 프로퍼티를 위임 객체로 하여 구현할 수 있다.
그러기 위해선 우선, Column 클래스의 확장 함수로 getValue, setValue 함수를 정의하여야 하고, 이 함수는 위임 객체 관례에 따른 시그니처 요구 사항을 만족해야 한다.
operator fun <T> Column<T>.getValue(o: Entity, desc: KProperty<*>): T{
// 데이터베이스에서 컬럼 값 가져오기(Entity 타입의 o 안에 id가 들어있다. 이를 이용함)
}
operator fun <T> Column<T>.setValue(o: Entity, desc: KProperty<*>, value: T){
// 데이터베이스에서 컬럼 값 변경하기(Entity 타입의 o 안에 id가 들어있다. 이를 이용함)
}
최종적으로 by 키워드를 이용해 User 클래스의 name 프로퍼티와 age 프로퍼티를 각각 Column 타입 객체(Users.name과 Users.age)를 위임 객체로 하는 위임 프로퍼티로 만들면 된다.
class User(id: EntityId): Entity(id) {
val name: String by Users.name
val age: Int by Users.age
}