[Kotlin] Kotlin식 문법: 클래스, 객체, 인터페이스 -7

박준규·2022년 3월 7일
0

코틀린

목록 보기
16/19

클래스 위임 사용하기

대규모 객체 지향 시스템을 설계할 때 시스템을 취약하게 만드는 문제는 보통 구현 상속에 의해 발생한다. 즉 하위 클래스가 상위 클래스의 메소드 중 일부를 오버라이드하면 하위 클래스는 상위 클래스의 세부 구현 사항에 의존하게 된다.

결국에는 시스템이 변하거나 확장됨에 따라 상위 클래스의 구현이 바뀌거나 상위 클래스에 새로운 메소드가 추가되는 데, 이 과정에서 하위 클래스에서 상위 클래스가 갖고 있던 가정이 깨져버리는 문제가 발생한다. 결국 이로 인해 예상치 못한 오류가 발생하기도 한다.

따라서 kotlin은 이러한 문제점을 없애기 위해 기본적으로 class에 final을 추가하여 예방한다. 따라서 open이라는 keyword를 보고 어떤 코드에서 문제가 발생했는지 알 수 있다.

그러나 종종 상속을 허용하지 않는 class에 새로운 기능을 추가해야 할 때가 있다. 이럴 때 사용하는 일반적인 방법이 데코레이터(Decorator) 패턴이다.

이 Decorator 패턴의 핵심은 상속을 허용하지 않는 클래스 대신에 사용할 수 있는 새로운 클래스를 만들되 기존 클래스와 같은 interface를 Decorator가 제공하게 하고, 기존 클래스를 Decorator 내부에 field로 유지하는 것이다.

물론 새로 정의해야 하는 기능은 Decorator의 메소드에 새로 정의하고 기존 기능이 그대로 필요한 부분은 Decorator의 메소드가 기존 클래스의 메소드에게 요청을 전달한다.

근데 문제는 이러한 기능을 추가하는 데, 생각보다 많은 준비 코드가 필요하다. 예를 들어서 collection interface를 구현하면서 아무런 동작도 하지 않는 데코레이터를 만들어보자.

class DelegatingCollection<T> : Collection<T> {

    private val innerList = arrayListOf<T>
    
    override val size : Int get() = innerList.size
    
    override fun isEmpty() : Boolean = innerList.isEmpty()
    
    override fun contains(element: T): Boolean = innerList.contains(element)
    
    override fun iterator(): Iterator<T> = innerList.itertor()
    
    override fun containsAll(elements: Collections<T>): Boolean = innerList.containsAll(elements)
    
}

아무런 동작도 하지 않지만, Decorater 자체는 매우 긴 코드를 작성했다. 즉 상속을 허용하지 않는 클래스에 새로운 기능을 담기 위해서 상당히 많은 코드가 필요했다. 하지만, kotlin의 경우 이러한 단점을 by라는 키워드를 통해 해당 interface의 구현을 다른 객체에 위임 중이라는 것을 명시할 수 있다. 아래와 같이 말이다.

class DelegatingCollection<T> (
    innerList: Collections<T> = ArrayList<T>()
) : collection<T> by innerList {}

실제로 class의 body에는 아무런 작성을 진행하지 않으면서 주 생성자에 필요한 객체인 innerList만을 명시한체 collection<T> interface를 innerList라는 객체로 구현을 위임한 것이다.

실제로 모든 메소드의 정의가 사라졌다. 그리고 compiler가 그런 전달 method를 자동으로 생성하며 자동 생성한 코드의 구현은 DelegatingCollection에 있던 구현과 비슷하다. 이러한 코드들 중에서 관심을 갖을 만한 부분은 거의 없기 때문에 컴파일러가 자동으로 해줄 수 있는 작업을 굳이 직접 해야할 필요성이 사라진 것이다.

물론 메소드 중 일부의 동작을 변경하고 싶은 경우 메소드를 오버라이드하면 컴파일러가 생성한 메소드 대신 오버라이드한 메소드가 쓰인다. 기존 클래스의 메소드에 위임하는 기본 구현으로 충분한 메소드는 따로 오버라이드할 필요가 없다.

이 기법을 활용하여 원소를 추가하려고 시도한 횟수를 기록하는 collection을 구현해보면 다음과 같다.

class CountingSet<T>(
    val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet {
    var objectsAdded = 0
    
    override fun add(element: T): Boolean {
        objectsAdded ++
        return innerSet.add(element)
    }
    
    override fun addAll(c: Collection<T>): Boolean {
        objectsAdded += c.size
        return innerSet.addAll(c)
    }
}

fun main() {
    val cset = CountingSet<Int>()
    cset.addAll(listOf(1, 1, 2))
    println("${cset.objectsAdded} objects were added, ${cset.size} remain")
}
 // 3 objects were added, 2 remain

예제는 add와 addAll을 override하여 새롭게 구현했다. 그리고 MutableCollection interface의 나머지 메소드는 내부 컨테이너(innerSet)에게 위임한다.

이때 CountingSet에 MutableCollection의 구현 방식에 대한 의존관계가 생기지 않는 것을 눈치챘는가?

이러한 방식 때문에 CountingSet안에서 마음대로 제어할 수 있지만, CountingSet 코드는 위임 대상 내부 클래스인 MutableCollection에 문서화된 Api를 활용한다. 그러므로 내부 클래스 MutableCollection이 문서화된 Api를 변경하지 않는 한 CountingSet 코드가 정상적으로 작동할 것을 확신할 수 있다.

profile
'개발'은 '예술'이고 '서비스'는 '작품'이다

0개의 댓글