6장까지는 코틀린을 코틀린답게 쓰는 방법을 알아봤다. 코틀린의 특성을 알고, 코드를 이해하고, 활용할 수 있도록 말이다. 6장까지를 잘 마쳤다면 코틀린을 기존 라이브러리나 API와 함께 사용할 수 있게 되었을 것이다.
7장부터는 지금까지 익힌 코틀린을 활용해 API를 선언하고 추상화를 정의하는 방법을 배우며, 코틀린 언어의 더 깊은 특성을 다뤄본다.
7장에서는 컨벤션
에 대해 배운다. 번역본 책에서는 주로 관례
로 표현하지만 우리가 흔히 아는 그 컨벤션을 말하는 것이 맞다. 코틀린은 연산자 오버로딩과 프로퍼티 위임 등의 기법에 관례를 사용한다.
언어 기능을 타입
에 의존하는 자바와 달리 코틀린은 함수 이름을 통한 관례
에 의존한다. 코틀린에서 이런 관례를 채택한 이유는 기존 자바 클래스를 코틀린 언어에 적용하기 위함이다.
기존 자바 클래스가 구현하는 인터페이스는 이미 고정돼 있고 코틀린 쪽에서 자바 클래스가 새로운 인터페이스를 구현하게 만들 수는 없다. 반면 확장 함수를 사용하면 기존 클래스에 새로운 메서드를 추가할 수 있다.
따라서 기존 자바 클래스에 대해
확장 함수
를 구현하면서관례
에 따라 이름을 붙이면 기존 자바 코드를 바꾸지 않아도 새로운 기능을 쉽게 부여할 수 있다.
코틀린에서 관례를 사용하는 가장 단순한 예는 산술 연산자다.
자바에서는 원시 타입에 대해서만 산술 연산자를 사용할 수 있고, 추가로 String
에 대해 +
연산자를 사용할 수 있다.
그러나 다른 클래스에서도 산술 연산자가 유용한 경우가 있다. 예를 들어 BigInteger
클래스를 다룬다면 add
메서드를 명시적으로 호출하기 보다는 +
연산을 사용하는 편이 더 낫다.
코틀린에서는 그런 일이 가능하다 !
지금부터 어떻게 클래스에 대한 일반 산술 연산자를 정의할 수 있는지 살펴보자.
연산자를 오버로딩하는 함수 앞에는 꼭
operator
가 있어야 한다.
이 키워드를 붙임으로써 어떤 함수가 관례를 따르는 함수임을 명확히 할 수 있다.
data class Point(val x: Int, val y: Int) {
operator fun plus(other: Point): Point { // "plus" 라는 이름의 연산자 함수를 정의한다.
return Point(x + other.x, y + other.y) // 좌표를 성분별로 더한 새로운 점을 반환한다.
}
}
위 코드를 보면 plus
함수 앞에 operator
키워드가 붙어있는 것을 확인할 수 있다.
이렇게 operator
변경자를 추가해 plus
함수를 선언하고 나면 +
기호로 두 Point
객체를 더할 수 있다.
만약 operator
가 없는데 실수로 관례에서 사용하는 함수 이름을 쓰고 우연히 그 이름에 해당하는 기능을 사용한다면 operator modifier is required ...
오류를 통해 이름이 겹쳤다는 사실을 알고 문제를 해결할 수 있다.
또한 연산자를 멤버 함수로 만드는 대신 아래와 같이 확장 함수
로 정의할 수도 있다.
operator fun Point.plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
operator
함수도 오버로딩할 수 있다. (따라서 이름은 같지만 파라미터 타입이 서로 다른 연산자 함수를 여럿 만들 수 있다.)식 | 함수 이름 |
---|---|
a * b | times |
a / b | div |
a % b | mod |
a + b | plus |
a - b | minus |
plus
와 같은 연산자를 오버로딩하면 코틀린은 +
연산자뿐 아니라 그와 관련 있는 연산자인 +=
도 자동으로 함께 지원한다.
+=
,-=
등의 연산자는 복합 대입 연산자라 불린다.
point += Point(3, 4)
는 point = point + Point(3, 4)
라고 쓴 식과 같다. 너무 당연한 얘기를
물론 변수가 변경 가능한 경우에만 복합 대입 연산자를 사용할 수 있다.
경우에 따라 +=
연산이 객체에 대한 참조를 다른 참조로 바꾸기보다 원래 객체의 내부 상태를 변경하게 만들고 싶을 때가 있다. 변경 가능한 컬렉션에 원소를 추가하는 경우가 대표적인 예다.
반환 타입이 Unit
인 plusAssign
함수를 정의하면 코틀린은 +=
연산자에 그 함수를 사용한다. 다른 복합 대입 연산자 함수도 비슷하게 minusAssign
, timesAssign
등의 이름을 사용한다.
코틀린 표준 라이브러리는 변경 가능한 컬렉션에 대해 plusAssign
을 정의한다.
operator fun <T> MutableCollection<T>.plusAssign(element: T) {
this.add(element)
}
이론적으로는 코드에 있는 +
를 plus
와 plusAssign
양쪽으로 컴파일할 수 있다.
어떤 클래스가 이 두 함수를 모두 정의하고 둘 다 +=
에 사용 가능한 경우 컴파일러는 오류를 보고한다. 일반 연산자를 사용하면 이를 해결할 수 있다.
다른 방법으로 var
를 val
로 바꿔서 plusAssign
적용이 불가능하게 할 수도 있다. 하지만 일반적으로 새로운 클래스를 일관성 있게 설계하는 게 가장 좋다.
plus
와plusAssign
연산을 동시에 정의하지 말라.
클래스가 앞에서 본 Point
처럼 변경 불가능하다면 plus
와 같이 새로운 값을 반환하는 연산만을 추가해야 한다.
빌더와 같이 변경 가능한 클래스를 설계한다면 plusAssign
이나 그와 비슷한 연산만을 제공하라.
코틀린 표준 라이브러리는 컬렉션에 대해 두 가지 접근 방법을 함께 제공한다.
+
와 -
는 항상 새로운 컬렉션을 반환한다.+=
와 -=
는 항상 변경 가능한 컬렉션에 적용해 메모리에 있는 객체 상태를 변화시킨다.+=
와 -=
는 변경을 적용한 복사본을 반환한다. (따라서 var
로 선언한 변수가 가리키는 읽기 전용 컬렉션에만 +=
와 -=
를 적용할 수 있다.)단항 연산자를 오버로딩 하는 절차도 이항 연산자와 마찬가지다.
미리 정해진 이름의 함수를 (멤버나 확장 함수로) 선언하면서
operator
로 표시하면 된다.
operator fun Point.unaryMinus(): Point { // 단항 minus 함수는 파라미터가 없다.
return Point(-x, -y) // 좌표에서 각 성분의 음수를 취한 새 점을 반환한다.
}
단항 연산자를 오버로딩하기 위해 사용하는 함수는 인자를 취하지 않는다.
식 | 함수 이름 |
---|---|
+a | unaryPlus |
-a | unaryMinus |
!a | not |
++a, a++ | inc |
--a, a-- | dec |
코틀린에서는 산술 연산자와 마찬가지로 원시 타입 값뿐 아니라 모든 객체에 대해 비교 연산을 수행할 수 있다. equls
나 compareTo
를 호출해야 하는 자바와 달리 코틀린에서는 ==
비교 연산자를 사용할 수 있어서 비교 코드가 더 간결하며 이해하기 쉽다.
지금부터는 이렇게 비교 연산자를 지원하는 관례를 살펴보자.
4장에서 동등성에 대해 다루며 코틀린이 ==
연산자 호출을 equals
메서드 호출로 컴파일한다는 사실을 배웠다. 사실 이는 특별한 경우가 아니고 지금껏 설명한 여러 관례를 적용한 것에 불과하다.
!=
연산자를 사용하는 식도 equals
호출로 컴파일된다. 물론 당연히 비교 결과를 뒤집은 값을 결과 값으로 사용한다.
==
와 !=
는 내부에서 인자가 널인지 검사하므로 다른 연산과 달리 널이 될 수 있는 값에도 적용할 수 있다.
앞서 계속 살펴봤던 Point
클래스를 다시 생각해보자.
이 클래스의 경우 data
라는 표시가 붙어있으므로 컴파일러가 자동으로 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로 스마트 캐스트해서 x와 y 프로퍼티에 접근한다.
}
}
위 코드에서는 식별자 비교 연산자 ===
를 사용해 equals
의 파라미터가 수신 객체아 같은지 살펴본다.
식별자 비교 연산자는 자바의 ==
연산자와 같다. 따라서 ===
는 자신의 두 피연산자가 서로 같은 객체를 가리키는지 (원시 타입인 경우 두 값이 같은지) 비교한다.
equals
를 구현할 때는 ===
를 사용해 자기 자신과의 비교를 최적화하는 경우가 많다.
===
를 오버로딩할 수 없다.equals
는 Any
에 정의된 메서드이므로 override
가 필요하다.Any
의 equals
에는 operator
가 붙어있지만 그 메서드를 오버라이드하는 (하위 클래스) 메서드 앞에는 operator
변경자를 붙이지 않아도 자동으로 상위 클래스 operator
지정이 적용된다.Any
에서 상속받은 equals
가 확장 함수보다 우선 순위가 높기 때문에 equals
를 확장 함수로 정의할 수는 없다는 사실에 유의하라.자바에서 정렬이나 최댓값, 최솟값 등을 비교해야 하는 알고리즘에 사용할 클래스는 Comparable
인터페이스를 구현해야 한다. Comparable
에 들어있는 compareTo
메서드는 한 객체와 다른 객체의 크기를 비교해 정수로 나타내준다. 하지만 자바에서는 이 메서드를 짧게 호출할 수 있는 방법이 없다.
코틀린도 똑같은 Comparable
인터페이스를 지원한다. 개다가 코틀린은 이 인터페이스 안에 있는 compareTo
메서드를 호출하는 관례를 제공한다.
따라서 비교 연산자(< , >, <=, >=)는
compareTo
호출로 컴파일된다.
또한compareTo
가 반환하는 값은Int
다.
Person
클래스를 통해 compareTo
메서드를 구현하는 방법을 살펴보자.
앞서 본 equals
와 마찬가지로 compareTo
에도 operator
변경자가 붙어있으므로 하위 클래스의 오버라이딩 함수에 operator
를 붙일 필요가 없다.
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을 반환한다.
이때, 필드를 직접 비교하면 코드는 좀 더 복잡해지지만 비교 속도는 훨씬 더 빨라진다는 사실을 기억해야 한다.
코틀린에서는 인덱스 연산자도 관례를 따른다.
인덱스 연산자를 사용해 원소를 읽는 연산은
get
연산자 메서드로 변환되고, 원소를 쓰는 연산은set
연산자 메서드로 변환된다.
앞서 계속 다뤘던 Point
클래스에 이런 메서드를 추가해보자.
operator fun Point.get(index: Int): Int { // "get" 연산자 함수를 정의한다.
return when(index) { // 주어진 인덱스에 해당하는 좌표를 찾아 반환한다.
0 -> x
1 -> y
else ->
throw IndexOutOfBoundsException("Invalid coordinate $index")
}
위 코드와 같이 get
이라는 메서드를 만들고 operator
변경자를 붙이기만 하면 된다. 이후 p[1]
이라는 식은 p
가 Point
타입인 경우 위에서 정의한 get
메서드로 변환된다.
인덱스에 해당하는 컬렉션 원소를 쓰고 싶을 때는 set
이라는 이름의 함수를 정의하면 된다. 그러나 Point
클래스는 불변 클래스이므로 set
이 의미가 없다. 대신 변경 가능한 점을 표현하는 다른 클래스를 만들어서 예제로 사용해보자.
data class MutablePoint(var x: Int, var y: Int)
operator fun MutablePoint.set(index: Int, value: Int) { // "set" 이라는 연산자 함수를 정의한다.
when(index) { // 주어진 인덱스에 해당하는 좌표를 변경하여 반환한다.
0 -> x = value
1 -> y = value
else ->
throw IndexOutOfBoundsException("Invalid coordinate $index")
}
set
이 받는 마지막 파라미터 값은 대입문의 우항에 들어가고, 나머지 파라미터 값은 인덱스 연산자 [ ]
에 들어간다.
in
은 객체가 컬렉션에 들어있는지 검사한다.
이때in
연산자와 대응하는 함수는contaion
이다.
어떤 점이 사각형 영역에 들어가는지 판단할 때 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 && // 범위를 만들고 "x" 좌표가 그 범위 안에 있는지 검사한다.
p.y in upperLeft.y until lowerRight.y // "untli" 함수를 사용해 열린 범위를 만든다.
}
in
의 우항에 있는 객체는 contains
메서드의 수신 객체가 되고, in
의 좌항에 있는 객체는 contains
메서드에 인자로 전달된다.
열린 범위
는 끝 값을 포함하지 않는 범위를 말하며,until
을 이용해 구현한다.
10..20
이라는 식을 사용해 일반적인(닫힌) 범위를 만들면 10 이상 20 이하인 범위가 생긴다.10 until 20
으로 만드는 열린 범위는 10 이상 19 이하인 범위로, 20은 포함되지 않는다.일반적으로 범위를 만들려면 ..
구문을 사용해야 한다. ..
연산자는 rangeTo
함수를 간략하게 표현하는 방법이다.rangeTo
함수는 범위를 반환하며, 이 연산자를 아무 클래스에서나 정의할 수 있다.
하지만 어떤 클래스가 Comparable
인터페이스를 구현하면 rangeTo
정의할 필요가 없다. 코틀린 표준 라이브러리에는 모든 Comparable
객체에 대해 적용 가능한 rangeTo
함수가 들어있다.
operator fun <T: Comparable<T>> T.rangeTo(that: T): ClosedRange<T>
위 함수는 범위를 반환하며, 어떤 원소가 그 범위 안에 들어있는지 in
을 통해 검사할 수 있다.
rangeTo
연산자는 다른 산술 연산자보다 우선순위가 낮긴 하나 혼동을 피하기 위해 괄호로 인자를 감싸주면 더 좋다.
0..n.forEach { }
와 같은 식은 컴파일할 수 없다. 앞서 말했듯 범위 연산자는 우선 순위가 낮아서 범위의 메서드를 호출하려면 (0..n).forEach { }
와 같이 범위를 괄호로 둘러싸야 한다.
2장에서 다뤘듯이 코틀린의 for 루프는 범위 검사와 똑같이 in
연산자를 사용한다. 하지만 이때의 in
의 의미는 다르다.
for (x in list) {...}
와 같은 문장은 list.iterator()
를 호출해서 이터레이터를 얻은 다음, 자바와 마찬가지로 그 이터레이터에 대해 hasNext
와 next
호출을 반복하는 식으로 변환된다.
하지만 코틀린에서는 이 또한 관례이므로 iterator
메서드를 확장 함수로 정의할 수 있다. 이런 성질로 인해 일반 자바 문자열에 대한 for
루프가 가능하다.
코틀린 표준 라이브러리는 String
의 상위 클래스인 CharSequence
에 대한 iterator
확장 함수를 제공한다.
operator fun ClosedRange<LocalDate>.iterator(): Iterator<LocalDate> =
object : Iterator<LocalDate> {
var current = start
override fun hasNext() =
current <= endInclusive
override fun next() = current.apply {
current = plusDays(1)
}
}
여기서 범위 타입에 대한 iterator
메서드를 어떻게 정의하는지 살펴보자.
앞서 살펴본 rangeTo
라이브러리 함수는 ClosedRange
의 인스턴스를 반환한다.
위 코드에서 CloseRange<LocalDate>
에 대한 확장 함수 iterator
를 정의했기 때문에 LocalDate
의 범위 객체를 for
루프에 사용할 수 있다.
4장에서 데이터 클래스에 대해 설명할 때 데이터 클래스의 특성 중 몇 가지를 나중에 설명한다고 했다. 이제 코틀린의 관례 원리를 이해했으므로 관례를 사용한 마지막 특성인 구조 분해 선언
에 대해 알아보자.
구조 분해를 사용하면 복합적인 값을 분해해서 여러 다른 변수를 한꺼번에 초기화할 수 있다.
fun main(args: Array<String>) {
val p = Point(10, 20)
val (x, y) = p // x와 y를 선언한 다음에 p의 여러 컴포넌트로 초기화한다.
println(x)
println(y)
}
구조 분해 선언은 일반 변수 선언과 비슷해 보인다. 다만 =
의 좌변에 여러 변수를 괄호로 묶었다는 점이 다르다.
내부에서 구조 분해 선언은 다시 관례를 사용한다.
구조 분해 선언의 각 변수를 초기화하기 위해 componentsN
이라는 함수를 호출한다. 여기서 N
은 구조 분해 선언에 있는 변수 위치에 따라 붙는 번호다. data
클래스의 주 생성자에 들어있는 프로퍼티에 대해서는 컴파일러가 자동으로 componentsN
함수를 만들어준다.
구조 분해 선언은 함수에서 여러 값을 반환할 때 유용하다.
여러 값을 한꺼번에 반환해야 하는 함수가 있다면 반환해야 하는 모든 값이 들어갈 데이터 클래스를 정의하고 함수의 반환 타입을 그 데이터 클래스로 바꾼다.
구조 분해 선언 구문을 사용하면 이런 함수가 반환하는 값을 쉽게 풀어서 여러 변수에 넣을 수 있다.
data class NameComponents(val name: String, // 값을 저장하기 위한 데이터 클래스를 선언한다.
val extension: String)
fun splitFilename(fullName: String): NameComponents {
val result = fullName.split('.', limit = 2)
return NameComponents(result[0], result[1]) // 함수에서 데이터 클래스의 인스턴스를 반환한다.
}
fun main(args: Array<String>) {
val (name, ext) = splitFilename("example.kt") // 구조 분해 선언 구문을 사용해 데이터 클래스를 푼다.
println(name)
println(ext)
}
물론 무한히 componentN
을 선언할 수는 없으므로 이런 구문을 무한정 사용할 수는 없다. 그럼에도 여전히 컬렉션에 대한 구조 분해는 유용하다.
코틀린 표준 라이브러리에서는 맨 앞의 다섯 원소에 대한 componentN
을 제공한다.
표준 라이브러리의
Pair
나Triple
클래스를 사용하면 함수에서 여러 값을 더 간단하게 반환할 수 있다.
이 둘은 그 안에 담겨있는 원소의 의미를 말해주지는 않으므로 경우에 따라 가독성이 떨어질 수는 있지만, 직접 클래스를 작성할 필요가 없으므로 코드는 더 단순해진다.
함수 본문 내의 선언문뿐 아니라 변수 선언이 들어갈 수 있는 장소라면 어디든 구조 분해 선언을 사용할 수 있다.
특히 맵의 원소에 대해 이터레이션할 때 구조 분해 선언이 유용하다.
fun printEntries(map: Map<String, String>) {
for ((key, value) in map) {
println("$key -> $value")
}
}
fun main(args: Array<String>) {
val map = mapOf("Oracle" to "Java", "JetBrains" to "Kotlin")
printEntries(map)
}
위의 예제는 두 가지 코틀린 관례를 활용한다. 하나는 객체를 이터레이션하는 관례고, 다른 하나는 구조 분해 선언이다.
코틀린 표준 라이브러리에는 맵에 대한 확장 함수로 iterator
가 들어있다. 그 iterator
는 맵 원소에 대한 이터레이터를 반환한다. 따라서 자바와 달리 코틀린에서는 맵을 직접 이터레이션할 수 있다.
또한 코틀린 라이브러리는 Map.Entry
에 대한 확장 함수로 component1
과 component2
를 제공한다.
7장에서 마지막으로 다룰 내용은 코틀린이 제공하는 관례에 의존하는 특성 중에 독특하면서도 강력한 기능인 위임 프로퍼티
다.
위임 프로퍼티를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다. 또한 그 과정에서 접근자 로직을 매번 재구현할 필요도 없다.
예를 들어 프로퍼티는 위임을 사용해 자신의 값을 필드가 아니라 데이터베이스 테이블이나 브라우저 세션, 맵 등에 저장할 수 있다.
위임
은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체(위임 객체)가 그 작업을 처리하게 맡기는 디자인 패턴을 말한다.
4절에서 클래스 위임에 대해 다룰 때 이 패턴을 이미 살펴본 바 있다.
이 절에서는 그 패턴을 프로퍼티에 적용해서 접근자 기능을 도우미 객체가 수행하게 위임한다. 도우미 객체를 직접 작성할 수도 있지만 더 나은 방법은 코틀린 언어가 제공하는 기능을 활용하는 것이다.
위임 프로퍼티의 일반적인 문법은 다음과 같다.
class Foo {
var p: Type by Delegate()
}
이때 p
프로퍼티는 접근자 로직을 다른 객체에게 위임한다. 여기서는 Delegate
클래스의 인스턴스를 위임 객체로 사용한다. by
뒤에 있는 식을 계산해서 위임에 쓰일 객체를 얻는다. 프로퍼티 위임 객체가 따라야하는 관례를 따르는 모든 객체를 위임에 사용할 수 있다.
class Foo {
private val delegate = Delegate () // 컴파일러가 생성한 도우미 프로퍼티다.
var p: Type // "p" 프로퍼티를 위해 컴파일러가 생성한 접근자는 "delegate"의 getValue와 setValue 메서드를 호출한다.
set (value: Type) = delegate. setValue (..., value)
get () = delegate.getValue (...)
}
위와 같이 컴파일러는 숨겨진 도우미 프로퍼티를 만들고 그 프로퍼티를 위임 객체의 인스턴스로 초기화한다. p
프로퍼티는 바로 그 위임 객체에게 자신의 작업을 위임한다.
설명을 편하게 하기 위해 이 감춰진 프로퍼티 이름을 delegate
라고 하자.
프로퍼티 위임 관례를 따르는 Delegate
클래스는 getValue
와 setValue
(변경 가능한 프로퍼티의 경우) 메서드를 제공해야 한다. 관례를 사용하는 다른 경우와 마찬가지로 getValue
와 setValue
는 멤버 메서드이거나 확장 함수일 수 있다. 두 메서드의 파라미터를 생략하고 Delegate
클래스를 단순화하면 아래와 같다.
class Delegate {
operator fun getValue (...) { ... } // gelValue는 게터를 구현하는 로직을 담는다.
operator fun setValue (..., value: Type) { ... } // selValue 메서드는 세터를 구현하는 로직을 담는다.
}
class Foo {
var p: Type by Delegate () // "by" 키워드는 프로퍼티와 위임 객체를 연결한다.
}
foo.p
는 일반 프로퍼티처럼 쓸 수 있고, 일반 프로퍼티 같아 보인다. 하지만 실제로 p
의 게터나 세터는 Delegate
타입의 위임 프로퍼티 객체에 있는 메서드를 호출한다.
지연 초기화는 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요할 경우 초기화할 때 흔히 쓰이는 패턴이다.
초기화 과정에 자원을 많이 사용하거나 객체를 사용할 때 마다 꼭 초기화하지 않아도 되는 프로퍼티에 대해 지연 초기화 패턴을 사용할 수 있다.
class Person (val name: String) {
private var emails: List <Email>? = null
val emails: List‹Email>
get () {
if (_emails = null) {
_emails = loadEmails (this)
}
return _emails!! // 저장해 둔 데이터가 있으면 그 데이터를 반환한다.
}
}
위는 이메일을 불러오기 전에는 null
을 저장하고, 불러온 다음에는 이메일 리스트를 저장하는 _emails
프로퍼티를 추가해서 지연 초기화를 구현한 클래스를 보여준다.
여기서는 뒷받침하는 프로퍼티
라는 기법을 사용한다.
_emails
라는 프로퍼티는 값을 저장하고, 다른 프로퍼티인 emails
는 _emails
라는 프로퍼티 값에 대한 읽기 연산을 제공한다. _emails
는 널이 될 수 있는 타입인 반면 emails
는 널이 될 수 없는 타입이므로 프로퍼티를 두 개 사용해야 한다.
하지만 이런 코드를 만드는 일은 약간 성가시다.
지연 초기화해야하는 프로퍼티가 많아지면 코드가 어떻게 될까? 게다가 위 구현은 스레드 안전하지 않아서 언제나 제대로 자동한다고 말할 수도 없다. 코틀린은 이보다 더 나은 해법을 제공한다.
위임 프로퍼티를 사용하면 위 코드가 훨씬 더 간단해진다. 위임 프로퍼티는 데이터를 저장할 때 쓰이는 뒷받침하는 프로퍼티와 값이 오직 한 번만 초기화됨을 보장하는 게터 로직을 함께 캡슐화준다. 예제와 같은 경우를 위한 위임 객체를 반환하는 표준 라이브러리 함수가 바로 lazy
다.
class Person (val name: String) {
val emails by lazy { loadEmails(this) }
}
lazy
함수는 코틀린 관례에 맞는 시그니처의 getValue
메서드가 들어있는 객체를 반환한다. 따라서 lazy
를 by
키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다.
lazy
함수의 인자는 값을 초기화할 때 호출할 람다다. lazy
함수는 기본적으로 스레드 안전하다. 하지만 필요에 따라 동기화에 사용할 락을 lazy
함수에 전달할 수도 있고, 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy
함수가 동기화를 하지 못하게 막을 수도 있다.
어떤 객체의 프로퍼티가 바뀔 때마다 리스너에게 변경 통지를 보내고 싶다. 이런 기능이 유용할 때가 많다. 예를 들어 어떤 객체를 UI에 표시하는 경우 객체가 바뀌면 자동으로 UI도 바뀌어야 한다. 이 경우 코틀린에서는 위임 프로퍼티를 이용해 기능을 구현할 수 있다.
이번에는 위임 프로퍼티가 어떤 방식으로 동작하는지 정리해보자.
아래와 같은 위임 프로퍼티가 있는 클래스가 있다고 가정하자.
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) = <delegates>.setValue(this, <property>, value)
}
다시 말해 컴파일러는 모든 프로퍼티 접근자 안에 getValue
와 setValue
호출 코드를 생성해준다.
자신의 프로퍼티를 동적으로 정의할 수 있는 객체를 만들 때 위임 프로퍼티를 활용하는 경우가 자주 있다. 그런 객체를
확장 가능한 객체
라고 부르기도 한다.
예를 들어 연락처 관리 시스템에서 연락처별로 임의의 정보를 저장할 수 있게 허용하는 경우를 살펴보자. 시스템에 저장된 연락처에는 특별히 처리해야 하는 일부 필수 정보 (이름 등)가 있고, 사람마다 달라질 수 있는 추가 정보가 있다.
그린 시스템을 구현하는 방법 중에는 정보를 모두 맵에 저장하되 그 맵을 통해 처리 하는 프로퍼티를 통해 필수 정보를 제공하는 방법이 있다.
객체 프로퍼티를 저장하거나 변경하는 방법을 바꿀 수 있으면 프레임워크를 개발할 때 유용하다.