안드로이드 개발자 노트
[코틀린 완벽 가이드] 11장 : 도메인 특화 언어 본문
1. 연산자 오버로딩
연산자 오버로딩은 +, -, *, / 등 코틀린 내장 연산자에 대해 새로운 의미를 부여할 수 있게 해주는 언어 기능이다.
1. 단항 연산
단항 연산은 피연산자가 한 개인 연산으로 오버로딩할 수 있는 단항 연산자로는 전위+나 -,! 연산자가 있다. 컴파일러는 이런 연산자를 적절한 함수 호출로 펼쳐준다.
식 | 의미 |
+e | e.unaryPlus() |
-e | e.unaryMinus() |
!e | e.not() |
예를 들어 색을 표현하는 이넘 클래스가 있다고 생각해보면,
enum class Color {
BLACK, RED, GREEN, BLUE, YELLOW, CYAN, MAGENTA, WHITE;
operator fun not() = when (this) {
BLACK -> WHITE
RED -> CYAN
GREEN -> MAGENTA
BLUE -> YELLOW
CYAN -> RED
MAGENTA -> GREEN
WHITE -> BLACK
else -> ""
}
}
fun main() {
println(!Color.RED) // CYAN
}
not()을 오버로딩하여 보색관계를 ! 연산자로 표현할 수 있게 된다.
연산자 함수를 확장 함수로 정의함으로써 임의의 타입에 대한 연산자를 오버로딩할 수 있다.
위 함수를 정의하면 ! 연산자 파라미터가 하나뿐인 술어에 적용할 수 있다.
operator fun <T> ((T) -> Boolean).not(): (T) -> Boolean = { !this(it) }
fun isShort(s: String) = s.length <= 4
fun String.isUpperCase() = all { it.isUpperCase() }
fun main() {
val data = listOf("abc", "abcde", "ABCDE", "aBcD", "ab")
println(data.count(::isShort)) // 3
println(data.count(!::isShort)) // 2
println(data.count(String:: isUpperCase)) // 1
println(data.count(!String:: isUpperCase)) // 4
}
2. 증가와 감소
증가(++)와 감소(--) 연산자도 inc()와 dec()로 오버로딩할 수 있다. inc() / dec()가 사용되는 방법은 전위 연산자 형태로 쓰이느냐 후위 연산자 형태로 쓰이느냐에 따라 달라진다.
다음은 후위 연산자 형태일 때 실제 다음과 같은 코드로 동작한다.
enum class RainbowColor {
RED, GREEN, YELLOW, ORANGE, BLUE, INDIGO, VIOLET;
operator fun inc() = values[(ordinal + 1) % values.size]
operator fun dec() = values[(ordinal + values.size - 1) % values.size]
companion object {
private val values = enumValues<RainbowColor>()
}
}
fun main() {
// color++
var color = RainbowColor.INDIGO
val _oldColor = color
color = color.inc()
println(_oldColor) // INDIGO
}
전위 연산자 형태일 때는 다음과 같다.
fun main() {
// ++color
var color = RainbowColor.INDIGO
color = color.inc()
println(color) // VIOLET
}
3. 이항 연산
코틀린에서는 대부분의 이항연산자를 오버로딩할 수 있다.단항 연산과의 차이로 대부분 이항 연산자 함수는 왼쪽 피연산자를 수신 객체로, 오른쪽 피연산자를 인자로 받는다는 점이다.
식 | 의미 |
a + b | a.plus(b) |
a - b | a.minus(b) |
a * b | a.times(b) |
a / b | a.div(b) |
a % b | a.rem(b) ( deprecated mod() ) |
a .. b | a.rangeTo(b) |
a in b | b.contains(a) |
a !in b | !b.contains(a) |
예를 들어 기본 산술 연산을 지원하는 간단한 유리수(rational number) 시스템이 있다.
class Rational private constructor(
val sign: Int,
val num: Int,
val den: Int
) {
operator fun unaryMinus() = Rational(-sign, num, den)
operator fun plus(r: Rational): Rational {
val gcd = gcd(den, r.den)
val newDen = den/gcd*r.den
val newNum = newDen/den*num*sign + newDen/r.den*r.num*r.sign
val newSign = newNum.sign
return Rational(newSign, abs(newNum), newDen)
}
operator fun minus(r: Rational) = this + (-r)
operator fun times(r: Rational): Rational {
return of(sign*r.sign*num*r.num, den*r.den)
}
operator fun div(r: Rational): Rational {
return of(sign*r.sign*num*r.den, den*r.num)
}
override fun toString(): String {
return "${sign*num}" + if (den != 1) "/$den" else ""
}
companion object {
private fun Int.sign() = when {
this > 0 -> 1
this < 0 -> -1
else -> 0
}
private tailrec fun gcd(a: Int, b: Int): Int {
return if (b == 0) a else gcd(b, a % b)
}
fun of(num: Int, den: Int = 1): Rational {
if (den == 0) throw ArithmeticException("Denominator is zero")
val sign = num.sign() * den.sign()
val numAbs = abs(num)
val denAbs = abs(den)
val gcd = gcd(numAbs, denAbs)
return Rational(sign, numAbs/gcd, denAbs/gcd)
}
}
}
여기서 연산자 오버로딩을 사용하면 Rational 인스턴스의 산술 연산을 편하게 사용할 수 있다.
fun r(num: Int, den: Int = 1) = Rational.of(num, den)
fun main() {
// 1/2 - 1/3
println(r(1, 2) - r(1, 3)) // 1/6
// 2/3 + (1/3)/2
println(r(2, 3) + r(1, 3)/r(2)) // 5/6
// 3/4 * 8/9 / (2/3)
println(r(3, 4)*r(8, 9)/r(2, 3)) // 1
// (1/10)*2 - 2/6
println(r(1, 10)*r(2) - r(2, 6)) // -2/15
}
일부 연산자를 오버로딩하여 Rational 객체와 Int를 혼용하여 사용할 수 있다.
operator fun Rational.plus(n: Int) = this + Rational.of(n)
operator fun Int.plus(r: Rational) = r + this
operator fun Rational.minus(n: Int) = this - Rational.of(n)
operator fun Int.minus(r: Rational) = Rational.of(this) - r
fun main() {
// -1/3 + 2
println(r(-1, 3) + 2) // 5/3
// 1 - (3/4)*(1/2)
println(1 - r(1, 4)* r(1, 2)) // 7/8
}
..연산자도 rangeTo() 함수를 오버로딩하여 두 유리수 사이의 구간을 표현할 수 있다.
class RationalRange(val from: Rational, val to: Rational) {
override fun toString(): String = "[$from, $to]"
}
operator fun Rational.rangeTo(r: Rational) = RationalRange(this, r)
fun main() {
println(r(1, 4)..r(1)) // [1/4, 1]
}
in / !in 연산은 contains() 연산자 함수에 의해 표현된다. 이때 다른 이항 연산과 인자 방향이 반대라는 것에 유의해야 한다.
RationalRange 클래스를 주어진 수가 구간 안에 들어있는지 검사할 수 있게 변경해보면,
private fun Rational.isLessOrEqual(r: Rational): Boolean {
return sign*num*r.den <= r.sign*r.num*den
}
class RationalRange(val from: Rational, val to: Rational) {
override fun toString(): String = "[$from, $to]"
operator fun contains(r: Rational): Boolean {
return from.isLessOrEqual(r) && r.isLessOrEqual(to)
}
operator fun contains(n: Int) = contains(r(n))
}
fun main() {
// 1/2 in [1/4, 1]
println(r(1,2) in r(1, 4)..r(1)) // true
// 1 not in [5/4, 7/4]
println(1 !in r(5, 4)..r(7, 4)) // true
}
<, > 처럼 비교와 관련된 연산자도 오버로딩이 가능하다. 이 연산자들은 모두 compareTo() 함수로 구현되며 주어진 피연산자 타입에 대한 모든 비교 연산을 구현한다.
앞 예제에서 isLessOrEqual()을 없애고 compareTo() 함수를 오버로딩하여 구현해보면,
operator fun Rational.compareTo(r: Rational): Int {
val left = sign * num * r.den
val right = r.sign * r.num * den
return when {
left < right -> -1
left > right -> 1
else -> 0
}
}
operator fun Rational.compareTo(n: Int) = compareTo(r(n))
operator fun Int.compareTo(r: Rational) = -r.compareTo(this)
class RationalRange(val from: Rational, val to: Rational) {
override fun toString(): String = "[$from, $to]"
operator fun contains(r: Rational) = r >= from && r <= to
operator fun contains(n: Int) = contains(r(n))
}
fun main() {
println(-1 > r(1, 3)) //false
println(r(3/4) <= r(7/8)) // true
}
마지막으로 ==나 !=와 같은 동등성 관련 연산은 컴파일러가 equals() 함수를 호출해준다. 여기서 equals() 함수가 Any 클래스에서 정의된 기반 구현을 상속하기 때문에 operator 변경자를 붙이지 않아도 된다. 같은 이유로 equals() 함수는 확장 함수로 정의하면 안되고 멤버 함수로 정의해야 한다.
코틀린은 &&와 ||를 오버로딩할 수 없다. 이들은 Boolean 값에 대해서만 지원되는 내장 연산이며 참조 동등성 연산자인 ===나 !==도 마찬가지로 오버로딩할 수 없다.
4. 중위 연산
코틀린에는 to와 같은 중위 연산이 있다.
val pair1 = 1 to 2 // 중위 호출
val pair2 = 1.to(2) // 일반적인 호출
중위 연산 함수는 infix 변경자를 사용하며 이항 연산자와 마찬가지로 infix를 붙일 수 있는 함수는 파라미터가 하나인 멤버나 확장 함수여야 한다.
예를 들어 표준 to 함수 구현은 다음과 같다.
public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
논리곱(and)과 논리합(or)을 이 함수처럼 바꿔 정의해보면,
infix fun <T> ((T) -> Boolean).and(other: (T) -> Boolean): (T) -> Boolean {
return { this(it) && other(it) }
}
infix fun <T> ((T) -> Boolean).or(other: (T) -> Boolean): (T) -> Boolean {
return { this(it) || other(it) }
}
이제 함수 리터럴을 좀 더 간결하게 조합해 새로운 술어를 만들어낼 수 있다.
operator fun <T> ((T) -> Boolean).not(): (T) -> Boolean = { !this(it) }
fun isShort(s: String) = s.length <= 4
fun String.isUpperCase() = all { it.isUpperCase() }
fun main() {
val data = listOf("abc", "abcde", "ABCDE", "aBcD", "ab")
println(data.count(::isShort and String::isUpperCase)) // 0
println(data.count(::isShort or String::isUpperCase)) // 4
println(data.count(!::isShort or String:: isUpperCase)) // 2
println(data.count(!(::isShort and String:: isUpperCase))) // 5
}
내장 ||와 && 연산자 중 && 연산자가 우선순위가 더 높지만, 이렇게 정의한 모든 중위 연산은 우선순위가 같다.
!::isShort or String::isEmpty and String::isUpperCase // 이 식은
(!::isShort or String::isEmpty) and String::isUpperCase // 이 식과 같다.
5. 대입
또 다른 이항 연산 그룹으로 +=와 같은 복합 대입 연산이 있다.
7장에서 가변 컬렉션과 불변 컬렉션에 따라 동작이 달라진다고 설명했는데 정리하자면,
가변 변수에 불변 컬렉션일 경우 새로운 컬렉션 객체를 생성해 변수 값을 바꿔준다.
var numbers = listOf(1, 2, 3)
numbers += 4
println(numbers) // [1, 2, 3, 4]
불변 변수에 가변 컬렉션인 경우 컬렉션에 새로운 원소룰 추가해준다.
val numbers = mutableListOf(1, 2, 3)
numbers += 4
println(numbers) // [1, 2, 3, 4]
임의의 타입에 대해 이 두 가지 관습을 적용할 수 있으며 정해진 관습에 따라 연산자에 대응하는 함수를 정의해야 한다.
복합 대입 연산자를 해석하는 방식은 다음과 같다.
- 커스텀 복합 대입 함수가 있으면 그 함수를 사용함: +=의 경우 plusAssign()이 있는지, -=의 경우 minusAssign()이 있는지 등에 따라 복합 대입문을 대응하는 복합 대입 함수로 변환해 컴파일한다.
- plusAssign() 등의 복합 대입 연산자 함수가 없는 경우 복합 대입문을 산술 연산자와 대입을 사용한 연산으로 해석함: a+=b의 경우 plus()가 있으면 a=a.plus(b)로 복합 대입문을 해석한다.
- 왼쪽 피연산자가 불변인 경우 변수에 새 값을 대입할 수 없으므로 해석할 수 없다.
대입 연산자 관습
식 | 의미 | 의미 |
a += b | a = a.plus(b) | a.plusAssign(b) |
a -= b | a = a.minus(b) | a.minusAssign(b) |
a *= b | a = a.times(b) | a.timesAssign(b) |
a /= b | a = a.div(b) | a.divAssign(b) |
a %= b | a = a.rem(b) | a.remAssign(b) |
위에서 Rational 객체로 예로, 복합 대입 연산을 사용했을 때 왼쪽 피연산자에 대응하는 연산자가 있지만 Rational에 적용할 수 있는 plusAssign() 등의 복합 대입 연산자 함수 정의가 없으므로 단순한 연산식으로 변경된다.
var r = r(1, 2) // 1/2
// r = r + r(1, 3)으로 변환해 컴파일한다.
r += r(1, 3) // 1/2 + 1/3
println(r) // 5/6
커스텀 대입 함수가 있는 경우에는 plus()가 있든 없든 복합 대입문이 이 커스텀 대입 함수 호출로 바뀐다. 9장의 TreeNode 클래스로 예를 들면,
class TreeNode<T>(val data: T) {
private val _children = arrayListOf<TreeNode<T>>()
var parent: TreeNode<T>? = null
private set
operator fun plusAssign(data: T) {
val node = TreeNode(data)
_children += node
node.parent = this
}
operator fun minusAssign(data: T) {
val index = _children.indexOfFirst { it.data == data }
if (index < 0) return
val node = _children.removeAt(index)
node.parent = null
}
// --의 동작을 확인하기 위한 단순 이항 연산자 함수
operator fun minus(data: T): TreeNode<T> = TODO("Comming Soon")
override fun toString() = _children.joinToString(prefix = "$data {", postfix = "}")
}
fun main() {
val tree = TreeNode("root")
tree += "child 1" // plus()가 없어도 복합 대입 연산이 적용됨
tree += "child 2"
println(tree) // root {child 1 {}, child 2 {}}
tree -= "child 2"
println(tree) // root {child 1 {}} // minus()가있지만 복합 대입 연산이 적용됨
}
커스텀 대입 함수의 반환 타입은 Unit이어야 한다는 점에 유의해야 한다.
6. 호출과 인덱스로 원소 찾기
5장에서 나온 invoke() 함수 호출을 사용하면 값을 함수처럼 호출식에서 사용할 수 있다.
val lessThan: (Int, Int) -> Boolean = { a, b -> a < b }
println(lessThan.invoke(1,2)) // true
println(lessThan(1,2)) // true
함수 타입의 값은 자동으로 invoke() 멤버가 생긴다. 하지만 원한다면 임의의 타입에 대해 invoke() 를 정의할 수 있다.
operator fun <K, V> Map<K,V>.invoke(key: K) = get(key)
이렇게 정의하면 키를 넣으면 값이 나오는 함수처럼 사용할 수 있다.
val map = mapOf("I" to 1, "V" to 5, "X" to 10)
println(map("V")) // 5
println(map.invoke("L")) // null
invoke() 함수를 동반 객체에 넣어서 동반 객체 팩토리로 만들 수도 있다. 예를 들어 Rational 함수를 확장해 팩토리를 보충할 수 있다.
operator fun Rational.Companion.invoke(num: Int, den: Int = 1) = of(num, den)
이제 클래스 이름을 참조해 Rational 인스턴스를 만들 수 있다.
val r = Rational(1, 2)
이 코드는 마치 직접적인 생성자 호출처럼 보이지만 사실은 다음과 같은 호출 경로를 거친다.
invoke() -> of() -> Rational의 비공개 생성자
문자열, 배열, 리스트 등의 여러 객체에 대해 인덱스 연산자 [ ]를 적용할 수 있는 것도 이 덕분이다.
인덱스 연산자 호출은 인덱스 연산자 식이 값으로 쓰이느냐 대입의 왼쪽에 있느냐에 따라 달라지는데, 전자의 경우 컴파일러는 인덱스 연산자를 get() 함수 호출로 변환하고 인자에 인덱스를 넣어준다.
val array = arrayOf(1, 2, 3)
println(array[0]) // array.get(0)
인덱스 식이 대입 연산의 왼쪽에 있으면 컴파일러는 이를 set() 함수 호출로 변환한다.
val array = arrayOf(1, 2, 3)
array[0] = 10 // array.set(0, 10)
TreeNode 클래스에 자식에 접근할 수 있는 get()과 set() 연산자를 추가한 예제를 보면 다음과 같다.
class TreeNode<T>(var data: T) {
private val _children = arrayListOf<TreeNode<T>>()
var parent: TreeNode<T>? = null
private set
operator fun plusAssign(data: T) {
val node = TreeNode(data)
_children += node
node.parent = this
}
operator fun minusAssign(data: T) {
val index = _children.indexOfFirst { it.data == data }
if (index < 0) return
val node = _children.removeAt(index)
node.parent = null
}
operator fun get(index: Int) = _children[index]
operator fun set(index: Int, node: TreeNode<T>) {
node.parent?._children?.remove(node)
node.parent = this
_children[index].parent = null
_children[index] = node
}
}
fun main() {
val root = TreeNode("Root")
root += "Child 1"
root += "Child 2"
println(root[1].data) // Child 2
root[0] = TreeNode("Child 3")
println(root[0].data) // Child 3
}
7. 구조 분해
구조 분해 선언은 데이터 클래스 인스턴스로부터 한 번에 여러 프로퍼티를 읽어서 여러 가지 변수에 대입해준다.
data class Person(val firstName: String,
val familyName: String,
val age: Int)
val (firstName, familyName, age) = Person("John", "Doe", 25) // 구조 분해
파라미터가 없는 componentN()이라는 이름의 컴포넌트 함수를 멤버 함수나 확장 함수로 정의하여 연산자 오버로딩을 사용하면 임의의 타입에 대해 구조 분해를 제공할 수 있다.
operator fun RationalRange.component1() = from
operator fun RationalRange.component2() = to
fun main() {
val (from, to) = r(1, 3)..r(1, 2)
println(from) // 1/3
println(to) // 1/2
}
코틀린 표준 라이브러리에는 몇 가지 확장 컴포넌트 함수가 들어있다. 예를 들어 이런 함수로 맵 원소 타입(키/값 쌍)을 구조 분해하거나 리스트나 배열에서 몇 가지 원소를 추출할 수 있다.
val map = mapOf("I" to 1, "V" to 5, "X" to 10)
for ((key, value) in map) {
println("$key = $value")
}
val numbers = listOf(10, 20, 30, 40, 50)
val (a, b, c) = numbers
println("$a, $b, $c") // 10, 20, 30
8. 이터레이션
문자열이나 범위, 컬렉션 등의 객체에 for 루프를 적용할 수 있는건 iterator() 함수를 가지고 있기 때문이다.
원하는 타입에 대해 iterator() 함수를 멤버나 확장으로 정의하면 for루프를 사용할 수 있다.
operator fun <T>TreeNode<T>.iterator() = _children.iterator()
fun main() {
val content = TreeNode("Title").apply {
addChild("Topic 1").apply {
addChild("Topic 1.1")
addChild("Topic 1.2")
}
addChild("Topic 2")
addChild("Topic 3")
}
for (item in content) {
println(item.data)
/*
Topic 1
Topic 2
Topic 3
*/
}
}
2. 위임 프로퍼티
1. 표준 위임들
4장에서는 지연 계산 프로퍼티를 표현하는 위임을 살펴보았다.
val text by lazy { File("data.txt").readText() } // 지연 계산 프로퍼티
lazy() 함수는 다중 스레드 환경에서 지연 계산 프로퍼티의 동작을 제어하기 위해 세 가지 다른 버전을 갖고 있는데, LazyThreadSafetyMode 이넘 상수를 통해 세 가지 기본 구현 중 하나를 선택할 수 있다.
- SYNCHRONIZED: 디폴트값이며 프로퍼티 접근을 동기화한다. 따라서 한 번에 한 스레드만 프로퍼티 값을 초기화할 수 있다.
- PUBLICATION: 초기화 함수가 여러 번 호출될 수 있지만 가장 처음 도착하는 결과가 프로퍼티 값이 되도록 프로퍼티 접근을 동기화한다.
- NONE: 프로퍼티 접근을 동기화하지 않는다. 이 방식을 선택하면 다중 스레드 환경에서 프로퍼티의 올바른 동작을 보장할수 없다.
초기화 함수가 예외를 던지면 프로퍼티가 초기화되지 않으며 이 경우 프로퍼티에 다시 접근하려 시도하면 다시 초기화 함수가 호출된다.
kotlin.properties.Delegates의 멤버를 통해 몇 가지 표준 위임을 사용할 수 있다.
notNull() 함수는 프로퍼티 초기화를 미루면서 널이 아닌 프로퍼티를 정의할 수 있게 해준다.
import kotlin.properties.Delegates.notNull
var text: String by notNull()
fun readText() {
text = readLine()!!
}
fun main() {
readText()
println(text)
}
notNull()은 기본적으로 lateinit과 같으며 보통은 lateinit쪽이 간결하고 성능도 좋아 notNull()보단 lateinit을 사용하는 편이 낫다.
observable() 함수를 사용하면 프로퍼티 값이 변경될 때 통지를 받을 수 있다. observable()은 초깃값과 람다를 인자로 받으며 프로퍼티 값이 바뀔 때마다 람다가 호출된다. 람다에서 사용하지 않을 파라미터는 _(밑줄)로 받는다.
import kotlin.properties.Delegates.observable
class Person(name: String, val age: Int) {
var name: String by observable(name) { _, old, new ->
println("Name chaged: $old to $new")
}
}
fun main() {
val person = Person("John", 25)
person.name = "Harry" // Name chaged: John to Harry
person.name = "Vincent" // Name chaged: Harry to Vincent
person.name = "Vincent" // Name chaged: Vincent to Vincent
}
새 값과 이전 값이 같더라도 통지가 온다. 필요하다면 두 값이 같은지 람다에서 직접 검사하면 된다.
vetoable() 함수도 비슷한 위임을 만든다. 이 함수는 초깃값과 Boolean을 반환하는 람다를 인자로 받으며 프로퍼티 값을 변경하려고 시도할 때마다 값을 변경하기 직전에 이 람다가 호출된다. 람다가 true를 반환하면 실제 값 변경이 일어나고 false를 반환하면 값이 바뀌지 않는다.
import kotlin.properties.Delegates.vetoable
var password: String by vetoable("password") { _, old, new ->
if (new.length < 8) {
println("Password should be at least 8 characters long")
false
}else {
println("Password is Ok")
true
}
}
fun main() {
password = "pAsSwOrD" // Password is Ok
println(password) // pAsSwOrD
password = "qwerty" // Password should be at least 8 characters long
println(password) // pAsSwOrD
}
observable()과 vetoable()이 제공하는 변경 전과 변경 후 통지를 함께 조합하고 싶다면 ObservableProperty를 상속해서 beforeChange()와 afterChange() 함수를 오버라이드하면 된다.
코틀린 표준 라이브러리는 맵에 프로퍼티 값을 설정하고 읽어올 수 있는 위임 기능도 제공한다. 프로퍼티 이름을 키로 사용한 map 인스턴스를 위임 객체로 사용하면 이런 기능을 활용할 수 있다.
class CarItem(data: Map<String, Any?>) {
val title: String by data
val price: Double by data
val quantity: Int by data
}
fun main() {
// map 형태로 데이터를 저장할 수 있다.
// 프로퍼티 이름을 키값으로 쓴다.
val item = CarItem(mapOf(
"title" to "Laptop",
"price" to 999.9,
"quantity" to 1
))
println(item.title) // Laptop
println(item.price) // 999.9
println(item.quantity) // 1
}
프로퍼티에 접근할 때는 맵에서 가져온 후 프로퍼티의 타입으로 값을 캐스팅한다. 맵에 저장된 값이 원하는 타입의 값이 아니면 프로퍼티 접근이 캐스트 예외와 함께 끝난다.
맵을 위임 객체로 사용할 수 있으므로, MutableMap을 통해 가변 프로퍼티를 정의할 수도 있다.
class CarItem(data: MutableMap<String, Any?>) {
val title: String by data
val price: Double by data
val quantity: Int by data
}
fun main() {
val item = CarItem(mutableMapOf(
"title" to "Laptop",
"price" to 999.9,
"quantity" to 1
))
println(item.title) // Laptop
println(item.price) // 999.9
println(item.quantity) // 1
}
2. 커스텀 위임 만들기
커스텀 위임을 만들려면 특별한 연산자 함수(들)을 정의하는 타입이 필요하다. 이 함수들은 프로퍼티 값을 읽고 쓰는 방법을 구현하며 읽기 함수는 getValue여야 하고 다음 두 가지 파라미터를 받는다.
- receiver: 수신 객체 값이 들어있고, 위임된 프로퍼치의 수신 객체와 같은 타입이거나 상위 타입이어야 한다.
- property: 프로퍼티 선언을 표현하는 리플렉션이 들어있다. KProperty<*>이거나 상위 타입이어야 한다.
예를 들어 프로퍼티 값과 수신 객체를 연관시켜 기억하는 일종의 캐시 역할을 하는 위임을 만들어보면 다음과 같다.
import kotlin.reflect.KProperty
class CachedProperty<in R, out T: Any>(val initializer: R.() -> T) {
private val cachedValues = HashMap<R, T>()
operator fun getValue(receiver: R, property: KProperty<*>): T {
return cachedValues.getOrPut(receiver) { receiver.initializer() }
}
}
fun <R, T : Any> cached(initializer: R.() -> T) = CachedProperty(initializer)
class Person(val firstName: String, val familyName: String)
val Person.fullName: String by cached { "$firstName $familyName" }
fun main() {
val johnDoe = Person("John", "Doe")
val harrySmith = Person("Harry", "Smith")
// johnDoe에 저장된 수신 객체에 최초 접근, 값을 계산해 캐시에 담음
println(johnDoe.fullName) // John Doe
// harrySmith에 저장된 수신 객체에 최초 접근, 값을 계산해 캐시에 담음
println(harrySmith.fullName) // Harry Smith
// johnDoe에 저장된 수신 객체에 재접근, 캐시에서 값을 읽음
println(johnDoe.fullName) // John Doe
// harrySmith 저장된 수신 객체에 재접근, 캐시에서 값을 읽음
println(harrySmith.fullName) // Harry Smith
}
읽기 전용 커스텀 위임을 정의하고 싶다면 kotlin.properties 패키지의 ReadOnlyProperty 인터페이스를 사용하면 된다.
interface ReadOnlyProperty<in R, out T> {
operator fun getValue(thisRef: R, property: KProperty<*>): T
}
가변(var) 프로퍼티의 경우, getValue() 외에도 프로퍼티에 값을 저장할 때 호출될 setValue() 함수를 정의해야 한다. 이 함수의 반환 타입은 Unit이며 세 가지 프로퍼티를 받는다.
- receiver: getValue()와 동일하다.
- property: getValue()와 동일하다.
- newValue: 프로퍼티에 저장할 새 값이다. 프로퍼티 자체와 같은 타입 또는 상위 타입이어야 한다.
다음 예제는 lateinit 프로퍼티의 final 버전인 위임 클래스를 정의한다. 이 프로퍼티는 초기화를 단 한번만 허용한다.
class FinalLateinitProperty<in R, T: Any> {
private lateinit var value: T
operator fun getValue(receiver: R, property: KProperty<*>): T {
return value
}
operator fun setValue(receiver: R,
property: KProperty<*>,
newValue: T) {
if (this::value.isInitialized) throw IllegalStateException(
"Property ${property.name} is already initialized"
)
value = newValue
}
}
fun <R, T: Any> finalLateInit() = FinalLateinitProperty<R, T>()
var message: String by finalLateInit()
fun main() {
message = "Hello"
println(message) // Hello
message = "Bye" // Exception: Property message is already initialized
}
코틀린 표준 라이브러리에는 ReadOnlyProperty 외에도 값을 변경할 수 있는 위임을 표현하는 인터페이스가 들어있다. 이 인터페이스의 이름은 ReadWriteProperty이다.
public interface ReadWriteProperty<in R, T> {
operator fun getValue(thisRef: R, property: KProperty<*>) : T
operator fun setValue(thisRef: R, property: KProperty<*>, value: T)
}
getValue() / setValue() 함수를 확장 함수로 정의할 수도 있다. 확장 함수를 사용하면 원하는 객체를 언제든지 일종의 위임 객체로 바꿀 수 있다.
inline operator fun <V, V1: V> Map<in String, V>.getValue(
thisRef: R,
property: KProperty<*>): V1 { }
provideDelegate() 함수를 통해 위임 인스턴스화를 제어할 수 있다. 기본적으로 by 키워드 뒤에 오는 식을 통해 위임 인스턴스를 정의하거나 일종의 위임 팩토리 역할을 하는 중간 인스턴스를 provideDelegate()를 통해 넘길 수도 있다. getValue()와 비슷하게 이 함수는 프로퍼티 수신 객체와 리플렉션 객체를 파라미터로 받고, 프로퍼티 값 대신 실제 위임 객체를 돌려준다.
다음 예제는 위임을 제공하는 위임 객체를 도입함으로써, 위임을 생성하기 전에 대상 프로퍼티를 검증해서 이런 기능을 구현한다.
@Target(AnnotationTarget.PROPERTY)
annotation class NoCache
class CachedPropertyProvider<in R, out T: Any>(
val initializer: R.() -> T
) {
operator fun provideDelegate(
receiver: R?,
property: KProperty<*>
): CachedProperty<R, T> {
if (property.annotations.any { it is NoCache }) {
throw java.lang.IllegalStateException("${property.name} forbids caching")
}
return CachedProperty(initializer)
}
}
class CachedProperty<in R, out T: Any>(val initializer: R.() -> T) {
private val cachedValues = HashMap<R, T>()
operator fun getValue(receiver: R, property: KProperty<*>): T {
return cachedValues.getOrPut(receiver) { receiver.initializer() }
}
}
fun <R, T: Any> cached(initializer: R.() -> T) = CachedPropertyProvider(initializer)
class Person(val firstName: String, val familyName: String)
@NoCache val Person.fullName: String by cached {
if (this != null) "$firstName $familyName" else ""
}
fun main() {
val johnDoe = Person("John", "Doe")
println(johnDoe.fullName) // Exception: IllegalStateException: fullName forbids caching
}
3. 위임 표현
런타임에 위임은 별도의 필드에 저장되며 프로퍼티 자체에 대해서는 접근자가 자동으로 생성된다. 이 접근자는 위임에 있는 적절한 메서드를 호출한다.
예를 들어 다음 코드는
class Person(val firstName: String, val familyName: String) {
var age: Int by finalLateInit()
}
다음과 같은 효과를 나타내는 코드로 변환된다.
class Person(val firstName: String, val familyName: String) {
private val `age$delegate` = finalLateInit<Person, Int>()
var age: Int
get() = `age$delegate`.getValue(this, this::age)
set(value) {
`age$delegate`.setValue(this, this::age, value)
}
}
리플렉션 API를 사용하면 getDelegate() 멤버를 통해 얻을 수 있는 프로퍼티 객체를 이용해 위임값에 접근할 수 있으며 시그니처는 수신 객체의 수에 따라 달라진다.
import kotlin.reflect.jvm.isAccessible
class Person(val firstName: String, val familyName: String) {
val fullName by lazy { "$firstName $familyName" }
}
fun main() {
val person = Person("John", "Doe")
// KProperty0: 모든 수신 객체가 엮여 있음
println(
person::fullName
// 위임 인스턴스가 저장된 비공개 필드에 접근하려면 isAccessible = true를 사용
.apply { isAccessible = true }
.getDelegate()!!::class.qualifiedName
) // kotlin.SynchronizedLazyImpl
// KProperty1: 수신 객체가 엮여 있지 않아서 수신 객체를 따로 지정해야 함
println(
Person::fullName
.apply { isAccessible = true }
.getDelegate(person)!!::class.qualifiedName
) // kotlin.SynchronizedLazyImpl
}
확장으로 정의된 프로퍼티의 경우 모든 수신 가능 객체에 대해 위임이 공유되며, getExtensionDelegate() 함수를 사용하여 특정 수신 객체를 지정하지 않아도 위임을 얻을 수 있다.
class Person(val firstName: String, val familyName: String)
val Person.fullName: String by cached { "$firstName $familyName" }
fun main() {
println(
Person::fullName
.apply { isAccessible = true }
.getExtensionDelegate()!!::class.qualifiedName
) // CachedProperty
}
3. 고차 함수와 DSL
DSL(Domain-Specific Language)은 도메인 특화 언어로 풀이되며 말 그대로 특정 영역에 대한 연산 및 작업을 간결하게 기술 할 수 있는 언어다. 흔히 쓰는 SQL, 정규식이 바로 대표적인 DSL 이라고 할 수 있다.
1. 중위 함수를 사용해 플루언트 DSL 만들기
중위 함수를 사용하는 플루언트(fluent) API를 만드는 방법을 알아보자. 컬렉션 데이터에 대한 질의를 사용할 수 있는 SQL과 비슷한 문법을 사용하는 DSL을 정의할 것이다.
val nums = listOf(2, 8, 9, 1, 3, 6, 5)
val query = from(nums) where { it > 3 } select { it * 2 } orderBy { it }
기본적으로 질의는 다음 요소로 구성된다.
- from 절은 대상 컬렉션을 지정한다.
- 선택적으로 where 절이 온다. 걸러낼 조건을 기술한다.
- 선택적으로 select 절이 온다. 원래 데이터를 출력값으로 매핑해준다.
- select절이 있는 경우, orderBy 절을 추가할 수 있다. 결과의 순서를 정할 때 사용할 키를 지정한다.
질의 API를 만들기 위해 먼저 원소의 시퀀스를 돌려주는 공통 인터페이스를 정의해보면,
interface ResultSet<out T> {
val items: Sequence<T>
}
그리고 질의 구성 요소를 표현하는 클래스를 정의해보면 다음과 같이 할 수 있다.
class From<out T>(private val source: Iterator<T>) : ResultSet<T> {
override val items: Sequence<T>
get() = source.asSequence()
}
class Where<out T>(
private val from: ResultSet<T>,
private val condition: (T) -> Boolean
) : ResultSet<T> {
override val items: Sequence<T>
get() = from.items.filter(condition)
}
class Select<out T, out U>(
private val from: ResultSet<T>,
private val output: (T) -> U
) : ResultSet<U> {
override val items: Sequence<U>
get() = from.items.map(output)
}
class OrderBy<out T, in K : Comparable<K>>(
private val select: ResultSet<T>,
private val orderKey: (T) -> K
) : ResultSet<T> {
override val items: Sequence<T>
get() = select.items.sortedBy(orderKey)
}
구성 요소가 준비됐으므로, 이들을 엮어줄 중위 연산자 함수를 정의할 수 있다.
// from 뒤에 where이 올 수 있음
infix fun <T> From<T>.where(condition: (T) -> Boolean) =
Where(this, condition)
// from이나 where 뒤에 select가 올 수 있음
infix fun <T, U> From<T>.select(output: (T) -> U) =
Select(this, output)
infix fun <T, U> Where<T>.select(output: (T) -> U) =
Select(this, output)
// select 뒤에 orderBy가 올 수 있음
infix fun <T, K : Comparable<K>> Select<*, T>.orderBy(
orderKey: (T) -> K
) = OrderBy(this, orderKey)
// 이 함수는 질의를 시작한다.
fun <T>from(source: Iterable<T>) = From(source)
이제 원래 예제는 다음과 같다.
fun main() {
val nums = listOf(2, 8, 9, 1, 3, 6, 5)
val query = from(nums) where { it > 3 } select { it * 2 } orderBy { it }
println(query.items.toList()) // [10, 12, 16, 18]
}
'Kotlin > 코틀린 완벽 가이드' 카테고리의 다른 글
[코틀린 완벽 가이드] 13장 : 동시성 (0) | 2022.12.07 |
---|---|
[코틀린 완벽 가이드] 10장 : 애너테이션과 리플렉션 (0) | 2022.11.15 |
[코틀린 완벽 가이드] 9장 : 제네릭스 (0) | 2022.11.07 |