Developing Myself Everyday

클래스 위임(delegation)


클래스 위임은 다른 클래스에 자기 클래스가 할 일을 다른 클래스에 맡겨 처리하는 것을 말합니다. 보통 하나의 클래스에는 하나의 책임을 부여해서 설계하는 방식을 사용합니다. 하지만 위임은 2개의 클래스가 동일한 책임을 가지고 나눠서 처리합니다.

 

이는 구현 상속에 대한 좋은 대안으로 입증되었으며 코틀린에서는 by를 사용하여 쉽게 구성할 수 있습니다.

 

클래스 위임을 위해서는 위탁자 클래스(delegator), 수탁자 클래스(delegate), 두 클래스가 상속하는 인터페이스로 구성합니다.

 

인터페이스

동일한 일을 나눠서 처리하라면 일단 두 클래스 사이의 공통된 인터페이스가 필요합니다.

interface Base {
    fun print()
}

 

수탁자 클래스

수탁자 클래스는 실제 기능을 대신 처리하는 클래스입니다. 

class BaseImpl(val x: Int) : Base {
    override fun print() { print(x) }
}

 

위임자 클래스

위임자 클래스는 수탁자 클래스의 인스턴스를 포함하고 해당 인스턴스를 사용해 기능을 위임합니다. 인터페이스 by 이후에 수탁자 객체를 지정하면 위임이 구성됩니다.

class Derived(b: Base) : Base by b

fun main() {
    val b = BaseImpl(10)
    Derived(b).print()
}

 

 

위임의 내부 동작

이런 코드가 어떻게 동작하는지 알아보겠습니다. 위의 코드들은 아래와 같은 Java 코드를 생성합니다.

public interface Base {
    void print();
}
public final class BaseImpl implements Base {
    private final int x;

    public void print() {
        int var1 = this.x;
        System.out.print(var1);
    }
    // ...
}
public final class Derived implements Base {
    // $FF: synthetic field
    private final Base $$delegate_0;

    public Derived(@NotNull Base baseImpl) {
        Intrinsics.checkParameterIsNotNull(baseImpl, "baseImpl");
        super();
        this.$$delegate_0 = baseImpl;
    }

    public void print() {
        this.$$delegate_0.print();
    }
}

위의 코드로 알 수 있는 것은 `$$delegate_0`가 Base 타입의 원래 객체를 가리키며, `print()`도 정적 메서드로 생성되어 `$$delegate_0``print()`를 호출할 수 있도록 생성된다는 것입니다.

 

이런 과정을 통해 Base에 대한 명시적 참조를 생략하고, `print()` 메서드를 호출하는 것이 가능해집니다.

 

 

위탁자 클래스에서 메서드 선언

인터페이스에서 파생되지않은 다른 메서드와 속성을 선언한다면 어떻게 될까요??

interface Base {
    fun print()
}

class BaseImpl(val x: Int) : Base {
    override fun print() { print(x) }

    private var y : Int = 10
    fun printY() { print(y) }
}

위임 과정에서 Base 인터페이스에 존재하지 않는 `printY()` 메서드는 위임시에 구현되지 않습니다.

 

 

 

다양한 상황에서의 클래스 위임


여러 클래스에서 위임

하나의 인터페이스를 여러 클래스에서 위임 처리할 수 있습니다.

 

아래의 코드는 2개의 수탁자 클래스가 하나의 인터페이스를 상속한 코드입니다.

interface Vehicle {
    fun go(): String
}

class CarImpl(val where: String) : Vehicle {
    override fun go() = "is going to $where"
}

class AirplaneImpl(val where: String) : Vehicle {
    override fun go() = "is flying to $where"
}

class CarOrAirplane(
    val model: String,
    impl: Vehicle
) : Vehicle by impl {
    fun tellMeYourTrip() {
        println("$model ${go()}")
    }
}

 

 

이제 객체를 만들어 수탁자 역할을 하는 객체를 전달하고 메서드를 호출하면 전달된 수탁자 클래스의 메서드가 실행됩니다. 아래의 예시를 보면 이해가 됩니다.

 

fun main(args: Array<String>) {
    val myAirbus330
          = CarOrAirplane("Lamborghini", CarImpl("Seoul"))
    val myBoeing337
          = CarOrAirplane("Boeing 337", AirplaneImpl("Seoul"))
    
    myAirbus330.tellMeYourTrip() // Lamborghini is going to Seoul
    myBoeing337.tellMeYourTrip() // Boeing 337 is flying to Seoul
}

 

 

상속 관계에서 위임

아래와 같이 상속 관계를 만들어 보겠습니다.

open class CarImpl(open val where: String): Vehicle {
    override fun go() = "is going to $where"
}
class AirplaneImpl(override val where: String): CarImpl(where) {
    override fun go() = "is flying to $where"
}

 

`AirplaneImpl` 클래스를 호출하는 방법은 여러 클래스에서 하나의 인터페이스를 위임한 방식과 크게 다르지 않습니다.

fun main(args: Array<String>) {
    val myBoeing337
            = CarOrAirplane("Boeing 337", AirplaneImpl("Seoul"))

    myBoeing337.tellMeYourTrip() // Boeing 337 is flying to Seoul
}

 

 

인터페이스에 일반 메서드 사용

인터페이스에 일반 메서드를 사용하는 것은 다르지 않습니다. 구현이 강제되지 않는 것 뿐입니다.

interface Vehicle {
    fun go(): String
    fun display() = "안녕하세영"
}
class CarImpl(val where: String): Vehicle {
    override fun go() = "is going to $where"
    override fun display(): String = "자동차"
}
class AirplaneImpl(val where: String): Vehicle {
    override fun go() = "is flying to $where"
    override fun display(): String = "비행기"
}
class CarOrAirplane(
    val model: String,
    impl: Vehicle
): Vehicle by impl {
    fun tellMeYourTrip() {
        println("$model ${go()}")
        println(display())
    }
}
fun main(args: Array<String>) {
    val myAirbus330
            = CarOrAirplane("Lamborghini", CarImpl("Seoul"))
    val myBoeing337
            = CarOrAirplane("Boeing 337", AirplaneImpl("Seoul"))

    myAirbus330.tellMeYourTrip()
    myBoeing337.tellMeYourTrip()
}


--------------출력--------------
Lamborghini is going to Seoul
자동차
Boeing 337 is flying to Seoul
비행기

 

 

내장된 Set 인터페이스를 위임 처리

코틀린의 컬렉션인 List, Set, Map은 인터페이스입니다. 그렇다면 이를 위임처리할 수도 있을 것입니다. 아래는 Set을 이용해 위임 처리를 한 예를 보여줍니다.

class CustomSet<T>(
    private val innerSet: MutableSet<T> = mutableSetOf()
) : MutableSet<T> by innerSet {

    var elementsAdded: Int = 0
        private set

    override fun add(element: T): Boolean {
        elementsAdded++
        return innerSet.add(element)
    }

    override fun addAll(elements: Collection<T>): Boolean {
        elementsAdded += elements.size
        return innerSet.addAll(elements)
    }

    fun display() = innerSet
}

fun main() {
    val counterList = CustomSet<String>()
    counterList.addAll(listOf("A", "B", "C", "D", "E"))
    println(counterList.elementsAdded) // 5
    println(counterList.display()) // [A, B, C, D, E]
}

 

 

클래스 위임을 사용해야 하는 이유

클래스 위임은 인스턴스에 대한 참조없이 구현된 메서드를 사용하는 쉬운 방법이라고 이해할 수 있습니다. 클래스 위임을 사용하면 캡슐화와 다형성을 구현할 수 있습니다.

 

 

 

 

 

속성(프로퍼티) 위임


프로퍼티에도 by로 위임을 처리할 수 있습니다. 코틀린에서는 내부적으로 위임을 처리할 수 있는 notNull, vetoable, observable 메서드를 제공합니다.

 

notNull

notNull은 객체 생성 시점이 아닌 나중에 초기화 되는 읽기/쓰기 프로퍼리를 가진 프로퍼티 대리자를 반환합니다. 초기 값이 할당되기 전에 프로퍼티를 읽으려고 하면 예외가 발생합니다. 

 

지연초기화는 lateinit 예약어를 사용해서 처리할 수 있습니다.

lateinit var str : String
// lateinit var num: Int <- error

다만 정수와 실수와 같은 기본 데이터 타입은 일반적인 프로퍼티가 아니라 값 자체이기 때문에 객체가 아닙니다. 그렇기 때문에 lateinit 예약어를 사용해서 지연초기화를 할 수 없습니다.

 

 

이러한 상황에서 Delegates object 내의 notNull 메서드를 사용할 수 있습니다.

var num: Int by Delegates.notNull<Int>()

 

 

그렇다면 우리가 위에서 배운 내용으로라면 Delegates object 내의 notNull 메서드는 인터페이스를 상속받은 수탁자 클래스여야 합니다.

public object Delegates {
    public fun <T : Any> notNull(): ReadWriteProperty<Any?, T> = NotNullVar()
    ...   
}

 

 

notNull 메서드는 `NotNullVar` 메서드를 반환하고 있는데 이는 Delegates.kt 파일 내에서 ReadWriteProperty 인터페이스를 반환하는 메서드입니다.

private class NotNullVar<T : Any>() : ReadWriteProperty<Any?, T> {
    private var value: T? = null

    public override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        return value ?: throw IllegalStateException("Property ${property.name} should be initialized before get.")
    }

    public override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        this.value = value
    }

    public override fun toString(): String =
        "NotNullProperty(${if (value != null) "value=$value" else "value not initialized yet"})"
}

이렇게 되면 프로퍼티에 대한 get() / set()을 대리자 getValue() / setValue() 메서드에 위임합니다. 만약 프로퍼티 num의 값을 읽으려고 하면 NotNullVargetValue() 메서드를 실행하게 되는 것입니다.

 

 

observable

Delegates object 내의 observable 메서드를 사용하면 프로퍼티 위임을 처리할 때 해당 프로퍼티가 변경되는 상태를 관찰할 수 있습니다. 

 

observable 메서드는 초기값과 값이 변경될 때 호출할 콜백 함수를 받아서 ReadWriteProperty 인터페이스를 반환합니다.

public inline fun <T> observable(initialValue: T, crossinline onChange: (property: KProperty<*>, oldValue: T, newValue: T) -> Unit):
        ReadWriteProperty<Any?, T> =
    object : ObservableProperty<T>(initialValue) {
        override fun afterChange(property: KProperty<*>, oldValue: T, newValue: T) = onChange(property, oldValue, newValue)
    }

 

아래와 같이 프로퍼티가 변경될 때 println을 출력할 수 있습니다.

var max: Int by Delegates.observable<Int>(0) { _, oldValue, newValue ->
    println("변경전: $oldValue 변경후: $newValue")
}

 

 

vetoable

Delegates object 내의 vetoable 메서드를 사용하면 프로퍼티의 값이 변경되기 전에 특정 조건을 검사하거나 변경을 거부할 수 있는 프로퍼티를 생성할 수 있습니다.

 

 vetoable 메서드는 observable 메서드와 거의 동일하지만 콜백 함수의 반환값이 Boolean입니다.

public inline fun <T> vetoable(initialValue: T, crossinline onChange: (property: KProperty<*>, oldValue: T, newValue: T) -> Boolean):
        ReadWriteProperty<Any?, T> =
    object : ObservableProperty<T>(initialValue) {
        override fun beforeChange(property: KProperty<*>, oldValue: T, newValue: T): Boolean = onChange(property, oldValue, newValue)
    }

 

아래와 같이 람다의 마지막에 조건을 넣어주면 아래의 경우에는 짝수일 때만 값을 변경하게 됩니다.

var max: Int by Delegates.vetoable<Int>(0) { _, oldValue, newValue ->
    println("변경전: $oldValue 변경후: $newValue")
    newValue % 2 == 0
}

 

 

 

lazy


지금까지는 프로퍼티와 지역변수가 var 여야만 할 수 있던 것들을 배웠습니다. val로 프로퍼티를 정의할 때는 프로퍼티 위임을 lazy로 처리해서 지연 처리를 할 수 있습니다.

 

lazy는 람다를 받아 Lazy<T>의 인스턴스를 반환하는 함수입니다. lazy()에 전달된 람다를 실행하고 그 결과를 기억합니다. 이후에 get()을 호출하면 단순히 기억된 결과가 반환됩니다.

public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

 

아래와 같이 지연 처리를 할 수 있습니다.

val max: Int by lazy { 0 }

 

 

lazy는 아래와 같은 3가지 모드가 있습니다.

  • LazyThreadSafetyMode.SYNCHRONIZED
  • LazyThreadSafetyMode.PUBLICATION
  • LazyThreadSafetyMode.NONE

 

각각의 모드에 따라 초기화를 다르게 수행하게 됩니다.

public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
    when (mode) {
        LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
        LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
        LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
    }

 

 

LazyThreadSafetyMode.SYNCHRONIZED

SYNCHRONIZED는 Default 모드입니다. 이 모드는 반환된 인스턴스 자체를 동기화 용도로 사용합니다. 

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)
}

위의 코드를 간단하게 설명하자면 오직 하나의 스레드에서만 접근이 가능하게 설정합니다. 이는 여러 스레드에서 인스턴스를 각각 생성해 한 스레드에서 변경한 어떤 메모리값이 다른 스레드에서 제대로 읽어지지 않을 때 발생하는 memory visibility 문제를 해결합니다.

 

 

LazyThreadSafetyMode.PUBLICATION

PUBLICATION 모드는 여러 스레드에서 호출될 때, 다른 스레드에 의해 이미 초기화된 값이 있다면 그 값을 반환합니다.

private class SafePublicationLazyImpl<out T>(initializer: () -> T) : Lazy<T>, Serializable {
    @Volatile private var initializer: (() -> T)? = initializer
    @Volatile private var _value: Any? = UNINITIALIZED_VALUE
    // this final field is required to enable safe initialization of the constructed instance
    private val final: Any = UNINITIALIZED_VALUE

    override val value: T
        get() {
            val value = _value
            if (value !== UNINITIALIZED_VALUE) {
                @Suppress("UNCHECKED_CAST")
                return value as T
            }

            val initializerValue = initializer
            // if we see null in initializer here, it means that the value is already set by another thread
            if (initializerValue != null) {
                val newValue = initializerValue()
                if (valueUpdater.compareAndSet(this, UNINITIALIZED_VALUE, newValue)) {
                    initializer = null
                    return newValue
                }
            }
            @Suppress("UNCHECKED_CAST")
            return _value as T
        }

    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)

    companion object {
        private val valueUpdater = java.util.concurrent.atomic.AtomicReferenceFieldUpdater.newUpdater(
            SafePublicationLazyImpl::class.java,
            Any::class.java,
            "_value"
        )
    }
}

 

 

LazyThreadSafetyMode.NONE

NONE

모드는 단순하게 초기화를 진행해 주고 있습니다. 그렇기에 단일 스레드 환경이 보장되는 경우에 사용해야 합니다.

internal class UnsafeLazyImpl<out T>(initializer: () -> T) : Lazy<T>, Serializable {
    private var initializer: (() -> T)? = initializer
    private var _value: Any? = UNINITIALIZED_VALUE

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

    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)
}

 

 

 

Reference

 

Kotlin의 클래스 위임은 어떻게 동작하는가

If you wanna read English version, please see here.

medium.com

 

[Kotlin] Lazy Property 톺아보기 (1)

코틀린을 사용하시는 분들은 흔히 사용하는 lazy property에 대해서 톺아보겠습니다. 먼저, lazy property는 아래와 같이 흔하게 사용됩니다. 하지만, 이렇게 사용하면 lazy property에 있는 세 가지 모드

velog.io

 

profile

Developing Myself Everyday

@배준형

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!