lateinit
Kotlin은 Java와 다르게 프로퍼티라는 개념이 있고, 주 생성자를 통해 프로퍼티를 선언하며, 프로퍼티는 인스턴스 생성 시점 당시에 초기화가 되어야 한다. Java의 경우에는 Null Safety를 언어 자체에서 지원하지 않기 때문에, 초기화되지 않은 멤버변수는 null로 초기화가 되지만, Kotlin의 경우 언어 자체적으로 Null Safety를 지원하기에 설령 null이라고 하더라도 프로퍼티 자체는 무조건 값을 초기화 해야 한다.
하지만 인스턴스 생성 시점에 값을 초기화하고 싶지 않아야 하는 경우도 있을 것이다. 예를 들어, 휴대전화 번호로 가입을 할 수 있고, 전화번호 인증 이후 비밀번호를 무조건 입력해야 하고, 요구사항 상 비밀번호가 없는 회원은 존재할 수 없다고 해보자.
class Account(
val phone: String,
) {
val password: String
}
따라서 위와 같이 처음에 계정을 생성하기 위해 주 생성자에는 휴대전화 번호만 입력하도록 했다. 하지만 위 코드는 "Property must be initialized or be abstract"라며 컴파일 에러를 낸다. `password`는 주 생성자에서 값을 초기화하지 않음으로 위와 같은 에러가 발생하는 것이다.
이를 해결하기 위해서 `password` 프로퍼티를 null이 가능한 타입으로 하여 null로 우선 초기화 한 후, 추후에 값을 변경해주는 방식으로 설계할 수 있을 것이다.
class Account(
val phone: String,
) {
var password: String? = null
}
fun main() {
// User Create New Account ...
val newAccount = Account("01012341234")
// User Authenticate Phone ...
// User Input Password ...
val inputPassword = "xxxx"
newAccount.password = inputPassword
}
하지만 위와 같은 설계는 문제가 있다. 요구사항 상 계정은 비밀번호가 무조건 있어야 한다. 비밀번호가 없는 계정은 없다. 그런데 `password` 프로퍼티는 null이 가능한 타입으로 선언되어 있다. 이는 코드를 읽는 사람으로 하여금 비밀번호가 null이 가능한 것으로 착각할 수도 있다. 그리고 추후에 `Account` 객체를 사용할 때에도, 비밀번호가 무조건 있음에도 불구하고 not-null assertion을 매번 사용해야 한다.
그래서 null이 불가능한 타입으로 선언을 하고, 기본 값을 넣어주는 방식을 사용할 수도 있을 것이다.
var password: String = "default value"
하지만 이 방법도 좋은 방법은 아니다. 실수로 이 값을 변경하지 않는 불상사가 일어날 경우, 회원은 default value라고 비밀번호를 입력해야 로그인이 가능할 것이다.
이렇게 인스턴스의 생성 시점과 프로퍼티의 초기화 시점을 다르게 가져가고 싶은 경우에는 `lateinit` 제한자를 사용할 수 있다.
class Account(
val phone: String,
) {
lateinit var password: String
}
위와 같이 코드를 작성하고 바이트 코드를 Java 코드로 디컴파일 해보자.
public final class Account {
public String password;
@NotNull
private final String phone;
@NotNull
public final String getPassword() {
String var10000 = this.password;
if (var10000 == null) {
Intrinsics.throwUninitializedPropertyAccessException("password");
}
return var10000;
}
...
}
보면 `getPassword()` 메서드에서 `password`가 null인지 확인하고 리턴하는 것을 볼 수 있다. 만약 null인 경우 `UninitializedPropertyAccessException`을 던지게 된다.
`lateinit`을 원시 타입(Primitive Type)에 사용할 수 없는 이유도 이 때문이다. 기본적으로 원시 타입은 null이 불가능하기에, null 체크 후 값을 리턴하는 `lateinit`은 사용이 불가능하다.
by lazy
이번엔 상황을 다르게 설정해보자. 계정의 비밀번호가 나중에 초기화되어야 하는 것은 동일한데, 번호를 가지고 안전한 비밀번호를 자동으로 생성해주는 특정 툴(API라던지.. 프로그램이라던지..)을 이용해서 자동으로 생성해야 한다고 해보자. 그리고 이 작업은 5초가량 오래 걸리는 작업이라고 가정해보자. 이런 상황에서 회원의 비밀번호를 사용하지 않을 수 있기 때문에, 인스턴스 초기화 시점에는 비밀번호를 따로 초기화 하지 않고, 사용 시점에 초기화 하여 최적화를 하려고 하려고 한다.
class Account(
val phone: String,
) {
val password: String
get() { return useTool(phone) }
companion object {
fun useTool(phone: String): String {
Thread.sleep(5000)
return phone.substring(1..5)
}
}
}
위와 같이 custom getter를 구현해서 사용 시점에 비밀번호의 값을 초기화할 수 있을 것이다. 하지만 이 방법은 비밀번호를 사용하려고 할 때마다 5초가 넘게 걸리는 연산을 수행하게 된다. 따라서 비밀번호를 최초로 사용하는 시점에 한번만 이 작업을 수행하고 싶다고 해보자.
private var _password: String? = null
val password: String
get() {
if (_password == null) {
this._password = useTool(phone)
}
return _password!!
}
그럼 위와 같이 Backing Property를 사용해서 해결할 수도 있다. 실제 비밀번호 값을 담아두는 Backing Property를 null이 가능한 var로 선언한 다음, 실제 비밀번호 프로퍼티에 접근할 경우 이 Backing Property에서 값을 가져오는 것이다. 만약 null이라면 한번도 호출된 적이 없기 때문에, 툴을 이용해서 비밀번호를 초기화 하고 이후에 추가로 비밀번호를 가져오려고 할 때는 Backing Property가 null이 아니기 때문에 툴을 이용하는 부분은 생략된다.
하지만 위와 같은 코드를 일일이 작성하는 것은 번거롭다. 코틀린은 이러한 코드를 줄일 수 있도록 `by` 라는 키워드와 함께 `lazy()` 함수를 제공한다.
val password: String by lazy { useTool(phone) }
이렇게 코드 한줄로 Backing Property를 사용했을 때와 동일한 기능을 구현할 수 있다.
public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
`lazy()`는 코틀린이 제공하는 함수로, `initializer`라는 함수파라미터를 하나 받는 함수다. 따라서 람다로 `String` 값을 리턴하는 함수를 작성할 수 있었던 것이다. `lazy()`를 사용하면 프로퍼티의 getter를 최초로 실행될 때 한번만 실행되고 Thread Safe 하게 작동한다.
by 키워드
우선 `by` 라는 키워드는 getter와 setter를 위임해주는 역할을 수행한다.
`by` 키워드가 사용된, 즉 getter 혹은 setter를 위임하는 프로퍼티를 "위임 프로퍼티"라고 하고, `by` 키워드의 뒤에 명시된 객체, 즉 실제 getter 혹은 setter를 수행하는 객체를 "위임 객체"라고 한다.
기본적으로 `by` 키워드를 사용해서 getter와 setter를 위임하려면 무조건 `getValue()`, `setValue()` 함수가 객체 내에 구현되어 있어야 한다. 그리고 이 함수들은 모두 다음과 같은 조건을 만족해야 한다.
1. operator fun 으로 선언해야 한다.
2. 파라미터로 thisRef: R, property: KProperty<*>, value: T(setter만)를 선언해야 한다.
즉, 아래와 같은 함수가 무조건 구현이 되어있어야 한다.
operator fun getValue(thisRef: R, property: KProperty<*>): T { ... }
operator fun setValue(thisRef: R, property: KProperty<*>, value: T) { ... }
`thisRef`의 경우에는 위임 프로퍼티를 가지고 있는 클래스의 인스턴스가 들어오며, `property`에는 위임 프로퍼티를 리플렉션 API를 사용하여 얻는 `KProperty` 타입이 들어오게 된다. 추가로 `setValue()`의 경우에는 변경해야 하는 값을 `value` 파라미터로 받는다.
그리고 연산자를 오버로딩할 때 사용하는 `operator` 제어자도 붙여줘야 하는데, 이는 코틀린 컨벤션을 따른 것으로 `by`를 통해 위임하기 위해 `getValue()`, `setValue()` 함수를 내부적으로 연산자 처리를 하기 때문이다. `get()`, `set()`, `invoke()` 등을 연산자 오버로딩하는 것과 동일한 원리가 아닌가 싶다.
lazy 함수
`by` 키워드를 통해 프로퍼티를 위임하는 조건에 대해서 알아보았다. 그렇다면 이제 `lazy()`를 해부해보자. `by` 뒤에는 `getValue()`, `setValue()`가 구현된 위임 객체가 와야 한다는 사실을 알았으니, 얼추 `lazy()` 함수 역시 `getValue()`, `setValue()`가 구현된 위임 객체를 반환할 것임을 짐작할 수 있다.
public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
`lazy()` 함수는 `Lazy<T>` 구현체를 반환 타입으로 가지고 있고, 실제 구현체는 `SynchronizedLazyImpl`이 반환된다. 한번 따라가보자.
private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
// final field is required to enable safe publication of constructed instance
private val lock = lock ?: this
override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}
return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}
override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE
override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."
private fun writeReplace(): Any = InitializedLazyImpl(value)
}
보면 프로퍼티로 `initialzer`를 가지고 있고, 이는 `lazy()` 함수의 함수파라미터(람다로 작성한)가 초기화 된다. 그리고 `_value`라는 Backing Property와 `value`라는 프로퍼티를 오버라이딩하고 있다. `value`의 getter는 custom getter로 구현이 되어 있는데, Thread Safe하면서 성능도 고려가 되어있다.
우선 `_v1`에서는 `UNINITIALIZED_VALUE` 가 아닌지 확인한다. 만약 아니라면, 그대로 값을 캐스팅해서 리턴한다. 만약 맞다면 아직 초기화가 이뤄진 적이 없는 경우이다. 이럴 경우 `synchronized` 블록을 통해서 Thread Safe하게 값을 초기화하게 된다. 만약 getter가 호출될 때마다 `synchronized` 블록을 통해서 Thread Safe하게 값을 가져왔다면, 그만큼 성능이 좋지 않았을 것이다. 아무튼 `synchronized` 블록으로 다시 넘어가보면, 만약 `_v2`가 `UNINITIALIZED_VALUE`가 아니라면 동시에 초기화가 된 경우이기에 그대로 반환한다. 만약 맞다면 `initializer` 함수를 호출하여 값을 초기화하게 된다.
자 여기까지 코드를 살펴봤으면 한 가지 의문이 들어야 한다. by 키워드가 위임 프로퍼티로 위임하는 조건을 되새겨보면, `getValue()`, `setValue()`가 구현이 되어있어야 한다. 하지만 `SynchronizedLazyImpl`에는 이 함수가 없다. 이는 `Lazy` 인터페이스가 작성되어 있는 Lazy.kt 로 가보면 확장함수로 선언이 되어있다.
public inline operator fun <T> Lazy<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value
따라서 `Lazy`의 구현체는 모두 해당 함수를 가지고 있게 되는 셈이다. 보면 value의 getter를 호출하고 있기에, 결국 실제 `getValue()`가 호출되면 위에서 살펴보았던 custom getter가 호출이 되는 것이다.
위임 인터페이스
코틀린에서는 위임 객체의 클래스를 사용자가 쉽게 구현할 수 있도록 인터페이스를 제공한다.
public fun interface ReadOnlyProperty<in T, out V> {
public operator fun getValue(thisRef: T, property: KProperty<*>): V
}
public interface ReadWriteProperty<in T, V> : ReadOnlyProperty<T, V> {
public override operator fun getValue(thisRef: T, property: KProperty<*>): V
public operator fun setValue(thisRef: T, property: KProperty<*>, value: V)
}
만약 커스텀하게 위임 객체를 만들어야 할 경우 위 인터페이스를 구현한 클래스를 구현하여 만들 수 있을 것이다.
추가로, 위임 객체 제공자 인터페이스도 코틀린 1.4에서 제공을 하고 있다.
public fun interface PropertyDelegateProvider<in T, out D> {
public operator fun provideDelegate(thisRef: T, property: KProperty<*>): D
}
말 그대로 위임 객체를 제공하는 역할을 하는데, 이 역시 일종의 관례로 `by` 키워드 뒤에 `operator` `fun` `provideDelegate()`가 구현된 가진 객체를 명시한다면 작동한다.
위임 객체를 제공한다는 말은 인자로 주어진 인스턴스나 프로퍼티 정보드를 보고 여러 위임 객체 중 적합한 것을 제공하는 역할로 사용할 수가 있다. 예를 들어, 프로퍼티의 이름을 검증하여 특정 위임 객체를 조건에 따라 제공하도록 하고 싶다고 해보자.
class CommonStringPropertyDelegate: ReadOnlyProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
// ... some initiation logic
}
}
우선 위와 같이 `ReadOnlyProperty` 인터페이스를 구현하여 문자열 전용 위임 클래스를 구현했다. 그리고 허용된 이름을 가진 프로퍼티만 위의 위임 클래스를 사용할 수 있다고 해보자.
class CommonStringPropertyDelegate: ReadOnlyProperty<Any, String> {
override fun getValue(thisRef: Any, property: KProperty<*>): String {
if (property.name == "name") {
// ... some initiation logic
}
if (property.name == "password") {
// ... some initiation logic
}
...
throw IllegalStateException("프로퍼티의 이름이 허용되지 않았습니다.")
}
}
위와 같이 위임 클래스 내에 조건을 명시할 수도 있을 것이다. 하지만 이는, 허용된 이름이 점점 많아지면 계속해서 분기문을 추가해야 한다.
이럴 경우에 `PropertyDelegateProvider`를 적극적으로 활용할 수 있다.
class PasswordDelegateProvider: PropertyDelegateProvider<Any, CommonStringPropertyDelegate> {
override fun provideDelegate(thisRef: Any, property: KProperty<*>): CommonStringPropertyDelegate {
if (property.name != "password") throw IllegalStateException("password 라는 이름만 사용 가능합니다.")
return CommonStringPropertyDelegate()
}
}
위와 같이 허용된 이름이 추가될 때마다 `PropertyDelegateProvider` 구현체를 만든 다음, 프로퍼티를 검증한 후 `CommonStringPropertyDelegate` 인스턴스를 반환해주는 것이다.
val password: String by PasswordDelegateProvider()
그리고 실제 위임은 위와 같이 `ReadOnlyProperty`의 구현체가 아니라, `PropertyDelegateProvider`의 구현체로 위임을 하면 된다.
val name: String by NameDelegateProvider()
허용된 프로퍼티 이름이 추가된다면, 위임 객체 내에 분기를 추가하는 게 아니라 `PropertyDelegateProvider`의 구현체를 추가하면 되는 것이다.
결론적으로는 위와 같은 과정을 `by` 키워드와 함께 정해진 컨벤션을 만족시키게 설계함으로 간단하게 구현할 수 있게 되는 것이다.
위임 클래스
`by` 키워드는 프로퍼티 뿐만 아니라 클래스에도 사용할 수 있다. `by` 키워드가 사용된 클래스를 위임 클래스라고 한다. 위임 클래스가 왜 필요한지 예시를 들어 이해해보자.
interface Alarm {
val mainTitle: String
val content: String
fun publish()
}
class DirectAlarm: Alarm {
override val mainTitle: String = "즉발알림"
override val content: String = "일어나세요"
override fun publish() {
println("[$mainTitle] $content")
}
}
알림 기능을 개발하기 위해 알림에 대한 인터페이스를 정의했다. 그리고 생성되면 즉시 발송되어야 하는 알림이 필요하다고 해서 이를 구현했다.
그런데 사용자가 끌 때까지 5분마다 계속 반복되어야 하는 알림을 추가해달라는 요청이 들어왔다. 제목만 반복알림으로 설정하고 나머지 성질은 즉발 알림과 동일하게 해달라고 한다. 이러한 요구사항에 맞도록 `Alarm` 인터페이스를 구현해서 `RefeatAlarm`을 구현했다.
class RepeatedAlarm: Alarm {
override val mainTitle: String = "반복알림"
override val content: String = "일어나세요"
override fun publish() {
println("[$mainTitle] $content")
}
}
근데 코드를 작성하고 보니, `DirectAlarm`과 너무 중복되는 부분이 많다. 따라서 `DirectAlarm`을 상속받아 구현하는 방법으로 수정을 했다.
open class DirectAlarm: Alarm {
override val mainTitle: String = "즉발알림"
override val content: String = "일어나세요"
override fun publish() {
println("[$mainTitle] $content")
}
}
class RepeatedAlarm: DirectAlarm() {
override val mainTitle: String = "반복알림"
}
이렇게 하니 확실히 중복은 줄었다. 하지만 `DirectAlarm`을 상속받기 위해 `open`을 통해 클래스의 상속을 허용하도록 변경해놓았다. 이는 여러 단점들을 야기할 수 있다. ("상속 보다는 합성을 사용해라" 라는 키워드로 검색을 하면 다양한 이유를 알 수 있다.) 사실 의미적으로 `RepeatedAlarm`이 `DirectAlarm`의 하위 관계라고 생각하기도 쉽지 않다. 오히려 `RepeatedAlarm`은 `DirectAlarm`의 속성을 일부 공유(사용)한다라고 보는 게 더 직관적일 것이다.
class RepeatedAlarm(
private val directAlarm: DirectAlarm,
): Alarm {
override val mainTitle: String = "반복알림"
override val content: String = directAlarm.content
override fun publish() = directAlarm.publish()
}
따라서 위와 같이 합성을 사용하는 방식으로 변경했다. 하지만 이렇게 구현하고 나니, 이전에 `Alarm`을 구현했던 방식처럼 코드가 조금 길어졌다.
이런 경우에 `by` 키워드를 통해 위임 클래스를 간단하게 만들 수 있다.
class RepeatedAlarm(
private val directAlarm: DirectAlarm,
): Alarm by directAlarm {
override val mainTitle: String = "반복알림"
}
위와 같이 합성할 객체를 프로퍼티로 선언한 후, 해당 프로퍼티에 `by` 키워드를 통해 위임시키는 것이다. 이러면 재정의하지 않은 부분에 대해서는 `by` 키워드로 위임한 프로퍼티에게 위임시키게 된다. 따라서 `RepeatedAlarm`의 고유 특성인 `mainTitle`값만 재정의를 하고, 나머지는 `directAlarm`에 위임을 간단하게 시킨 모습이다.