코딩을 하다보면 프로퍼티를 선언하는 동시에 초기화를 할 수 없거나, 초기 생성 비용이 비싼 인스턴스를 만들어야 하는 경우가 있다. 코틀린으로는 이런 경우 보통 lateinit이나 lazy를 사용해 지연초기화를 하게 된다. lateinit는 초기화 타이밍을 놓치는 실수의 여지가 존재하기 때문에 개인적으로는 lazy 사용을 선호한다.

 

lazy() -> T 타입 람다식을 인자로 받아서, delegated properties를 사용하는 Lazy<T> 타입 인스턴스를 반환하는 함수다. by lazy { ... }의 연산 결과는 캐싱된다.

fun main() {
    val repeatCount = 2
    val lazyTest by lazy {
        println("두근두근 하는 중")
        Thread.sleep(1000L)
        "두근".repeat(repeatCount)
    }

    println(lazyTest)
    println(lazyTest)
    println(lazyTest)
}

lazyTest의 get()이 처음으로 호출됐을 때 초기화 블록에 정의된 코드를 실행한다. 이후 동일한 변수를 재사용하면 초기화문 실행을 반복하지 않고 처음에 계산해뒀던 결과를 재활용한다. 예제를 실행해보면 '두근두근 하는 중' 출력 후 1초 간 대기하고, 연산 결과인 '두근두근'이 저장되어 두번째 println() 호출부터는 1초 대기 없이 바로 결과가 출력된다.

두근두근 하는 중
두근두근
두근두근
두근두근

만약 lazy로 선언한 변수를 프로그램 실행 도중 다시 초기화하고 싶다면 어떻게 해야할까?

'왜 굳이 그런 짓을...?' 싶겠지만 궁금해서 한번 찾아봤다. 아래와 같이 Lazy를 상속받아 값을 가변으로 들고있는 클래스를 만들 수 있다. 구현 방식은 UnsafeLazyImpl이랑 비슷한데, 캐싱된 값을 비우는 reset()이라는 함수를 추가했다.

class MutableLazy<T>(private val initializer: () -> T) : Lazy<T> {
    private var cached: T? = null

    override val value: T
        get() {
            if (!isInitialized()) {
                cached = initializer()
            }
            @Suppress("UNCHECKED_CAST")
            return cached as T
        }

    override fun isInitialized(): Boolean = cached != null

    fun reset() {
        cached = null
    }
}

동기화 처리가 필요할 경우 AtomicReference를 사용하면 된다.

class SynchronizedMutableLazy<T>(private val initializer: () -> T) : Lazy<T> {
    private val cached: AtomicReference<Lazy<T>> = AtomicReference()

    override val value: T
        get() {
            if (!isInitialized()) {
                cached.set(lazy(initializer))
            }
            return cached.get().value
        }

    override fun isInitialized(): Boolean = cached.get() != null
    
    fun reset() {
        cached.set(lazy(initializer))
    }
}

람다식 외부에 선언된 상태값을 사용해 지연초기화 하도록 예제를 작성해봤다.

fun main() {
    var repeatCount = 1
    val lazyTest = MutableLazy {
        println("$repeatCount 번째 연산 진행 중~")
        "두근".repeat(repeatCount)
    }
    val value by lazyTest

    println(value)
    println(value)

    repeatCount++
    lazyTest.reset()
    println(value)
}

실행 결과는 다음과 같다. reset()을 호출한 이후 value를 구하는 연산이 다시 실행되었다.

1 번째 연산 진행 중~
두근
두근
2 번째 연산 진행 중~
두근두근

코틀린에서 가변 lazy가 유용하다면 처음부터 제공을 했을거다. 안만들어 둔 것에는 그만한 이유가 있으리라 생각된다. 개인적인 생각으로는,

  1. 변수를 불변으로 만든 의미가 없어진다. 처음엔 val로 선언해놓고 뒤에가서 reset() 같은걸 호출하고 있으니 보는 사람이 당황스럽다.
  2. lazy 초기화를 다시 진행해야 하는 상황이 존재한다면, 그 이유는 클로저 바깥에서의 상태값이 변화했기 때문일거다. 연산에 필요한 상태값이 변화하면 그에따라 lazy의 계산 결과도 달라질 수 있다. 그러면 lazy가 어떤 타이밍에 호출되더라도 값이 동일함을 보장할 수 없게되고, 이로인해 개발자가 예측하지 못한 시점에서 사이드 이펙트가 발생할 수 있다.

결론 : 시도는 해봤으나 쓸 일은 없을거 같다.

Reference

kotlin-lazy-properties-and-values-reset-a-resettable-lazy-delegate