ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 36장 - 상속보다는 컴포지션을 사용하라
    Kotlin/Effective Kotlin 요약 2023. 3. 25. 00:01

    개요

    상속은 is-a 관계의 객체 계층 구조를 만들기 위해 설계되었습니다.

    코드의 재사용성을 위해 상속을 사용할 때 관계가 명확하지 않을 때 신중하게 고려해야 합니다.

     

    간단한 행위 재사용

    class ProfileLoader{
    	fun load(){
    		//프로그래스 바 노출
    		//프로필 읽기
    		//프로그래스 바 숨김
    	}
    }
    
    class ImageLoader{
    	fun load(){
    		//프로그래스 바 노출
    		//이미지 읽기
    		//프로그래스 바 숨김
    	}
    }

    이런 경우 보통 슈퍼클래스를 만들어 공통되는 행위를 뽑아냅니다.

     

    하지만 몇 가지 단점이 존재합니다.

    • 상속을 사용해서 행위를 추출하다 보면, 많은 함수를 갖는 거대한 BaseXXX 클래스가 만들어지며 깊고 복잡한 계층 구조가 만들어집니다.
    • 불필요한 함수를 갖는 클래스가 만들어질 수 있습니다.
    • 코드를 이해할 때 슈퍼클래스를 여러 번 확인해야 할 수 있습니다.

     

    컴포지션을 활용하기

    상속의 대안으로 컴포지션을 활용할 수 있습니다.

    컴포지션이란 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용하는 것을 의미합니다.

    class Progress{
    	fun showProgress() {}
    	fun hideProgress() {}
    }
    
    class ProfileLoader{
    	val progress = Progress()
        
    	fun load(){
    		progress.showPregress()
    		//프로필 읽어 들임
    		progress.hideProgress()
    	}
    }

    객체를 선언하는 추가 코드가 발생하지만 읽는 사람은 코드의 실행을 더 명확하게 예측할 수 있으며 프로그레스 바를 자유롭게 사용할 수 있습니다.

     

     

    모든 것을 가져올 수 밖에 없는 상속

    상속은 슈퍼클래스의 메서드, 제약, 행위 등 모든 것을 가져옵니다.

     

    만약 짖기와 냄새 맡기라는 함수를 갖는 Dog 클래스가 존재합니다.

    abstract class Dog{
    	open fun bark() {}
    	open fun sniff() {}
    }

     

    로봇 강아지를 만들고 싶으며 로봇 강아지는 짖기만 가능하게 하려면 어떻게 해야 할까요?

    class RobotDog: Dog(){
    	override fun sniff(){
    		throw Error("Operation not supported")    
    	}
    }

    인터페이스 분리 원칙에 위반되고, 리스코프 치환 원칙에도 위반됩니다.

    또한 코틀린에서는 다중 상속을 지원하지 않습니다.

     

    캡슐화를 깨는 상속

    내부적인 구현 방법 변경에 의해 클래스의 캡슐화가 깨질 수 있습니다.

    class CounterSet<T>: HashSet<T>() {
        var elementsAdded: Int = 0
            private set
    
        override fun add(element: T): Boolean {
            elementsAdded++
            return super.add(element)
        }
    
        override fun addAll(elements: Collection<T>): Boolean {
            elementsAdded += elements.size
            return super.addAll(elements)
        }
    }
    
    fun main() {
        val counterList = CounterSet<String>()
        counterList.addAll(listOf("A","B","C"))
        println(counterList.elementsAdded)
        //출력 : 6
    }

    요소는 3개인데 결과는 6이 나옵니다.

    바로 HashSet의 addAll 내부에서 add를 사용하기 때문에 elementsAdded가 중복으로 추가됩니다.

     

    addAll메서드를 제거하면서 해결할 수 있습니다.

    하지만 어느 날 자바가 HashSet addAll을 최적화하고 내부적으로 add를 호출하지 않는 방식으로 구현한다면? 예상하지 못한 형태로 동작하게 됩니다.

     

    이런 문제 또한 컴포지션을 통해 해결할 수 있습니다.

    class CounterSet<T>{
        private val innerSet = HashSet<T>()
        var elementsAdded: Int = 0
            private set
    
        fun add(element: T) {
            elementsAdded++
            innerSet.add(element)
        }
    
        fun addAll(elements: Collection<T>) {
            elementsAdded += elements.size
            innerSet.addAll(elements)
        }
    }

    하지만 CounterSet은 더 이상 Set이 아닙니다.

    이를 유지하고 싶다면 위임 패턴을 사용할 수 있습니다.

     

    클래스가 인터페이스를 상속받게 하고, 포함된 객체의 메서드를 활용하여 인터페이스에서 정의한 메서드를 구현하는 개념입니다.

    class CounterSet<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 main() {
        val counterList = CounterSet<String>()
        counterList.addAll(listOf("A","B","C"))
        println(counterList.elementsAdded)
    }

    일반적으로 다형성이 그렇게까지 필요한 이유가 없기 때문에 대부분 컴포지션을 통해 해결할 수 있습니다.

     

    정리

    • 컴포지션이 더 유연하고 안전하다
    • 컴포지션이 더 명시적이다
    • 컴포지션은 생각보다 번거로울 수 있다(클래스에 일부 기능을 추가할 때 이를 포함하는 객체의 코드를 변경해야 한다)
    • 상속은 다형성을 활용할 수 있다
    • 상속은 명확한 IS-A관계일 때 사용하면 좋다
    • 상속을 위해 설계되지 않았다면 final을 활용하자

    댓글

Designed by Tistory.