본문 바로가기
공부/이펙티브코틀린

아이템 36 - 상속보다는 컴포지션을 사용하라

by 띵커베르 2024. 7. 14.
728x90
  • 컴포지션:
    • 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 메서드를 호출하는 기법
    • 객체 지향 프로그래밍에서 클래스 간의 관계를 정의하는 방법 중 하나로, 객체가 다른 객체를 포함하여 기능을 구현하는 방식입니다. 컴포지션은 "has-a" 관계를 나타내며, 한 객체가 다른 객체의 구성 요소로 포함되는 구조를 의미합니다.
- 컴포지션은 더 안전하고, 유연하고 명시적이다.
  - (컴포지션이란? 다른객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 메서드를 호출하는 기법이다)
- 상속을 사용할때?
  - 명확한 is-a 관계일때
  - 슈퍼클래스를 상속받은 모든 서브클래스는 슈퍼클래스로도 동작할 수 있어야한다.
    - 슈퍼클래스의 단위테스트는 서브클래스로도 통과할 수 있어야 한다는 의미(LSP)
- 오버라이딩 제한
  - 어떤 이유로 상속은 허용하지만, 메서드는 오버라이드하지 못하게 만들고 싶은 경우에는 open 키워드를 사용하자
  - 서브클래스에서 오버라이드할 수 있는 메서드를 제한하고 싶으면 final 키워드를 사용하자.
- 컴포지션은 다른 클래스의 내부 구현에 의존하지 않기 때문에 더 안전합니다.
- 컴포지션은 여러 클래스를 대상으로 할 수 있기 때문에 더 유연합니다.
- 컴포지션은 this 리시버를 사용할 수 없기 때문에 리시버를 명시적으로 활용해야 해야 더 명시적입니다.

 

아래는 상속과 컴포지션 을 활용한 코드

더보기

상속을 이용한다면

 

open class Animal {
    fun eat() {
        println("Eating")
    }
}

class Dog : Animal() {
    fun bark() {
        println("Barking")
    }
}

class Bird : Animal() {
    fun fly() {
        println("Flying")
    }
}

fun main() {
    val dog = Dog()
    dog.eat()
    dog.bark()

    val bird = Bird()
    bird.eat()
    bird.fly()
}

 

===================================================================

컴포지션을 이요한다면 

 

interface EatBehavior {
    fun eat()
}

interface BarkBehavior {
    fun bark()
}

interface FlyBehavior {
    fun fly()
}

class Eating : EatBehavior {
    override fun eat() {
        println("Eating")
    }
}

class Barking : BarkBehavior {
    override fun bark() {
        println("Barking")
    }
}

class Flying : FlyBehavior {
    override fun fly() {
        println("Flying")
    }
}

class Dog(
    private val eatBehavior: EatBehavior,
    private val barkBehavior: BarkBehavior
) {
    fun eat() {
        eatBehavior.eat()
    }

    fun bark() {
        barkBehavior.bark()
    }
}

class Bird(
    private val eatBehavior: EatBehavior,
    private val flyBehavior: FlyBehavior
) {
    fun eat() {
        eatBehavior.eat()
    }

    fun fly() {
        flyBehavior.fly()
    }
}

fun main() {
    val eating = Eating()
    val barking = Barking()
    val flying = Flying()

    val dog = Dog(eating, barking)
    dog.eat()
    dog.bark()

    val bird = Bird(eating, flying)
    bird.eat()
    bird.fly()
}

  • 추가 코드를 적절하게 처리하는 것이 조금 어려울 수도 있어 컴포지션보다 상속을 선호나는 경우도 많습니다.
    하지만 이런 추가 코드로 인해서 코드를 읽는 사람들이 코드의 실행을 더 명확하게 예츨할 수 있다는 장점도 있고, 코드를 훨씬 자유롭게 사용할 수 있다는 장점도 있다.
    하나의 클래스 내부에서 여러 기능을 재사용할 수 있게 된다.
  • 상속은 모든것을 가져올 수밖에 없어서 일부분을 재사용하기 위한 목적으로는 적합하지 않다.

  • 244 페이지 강아지 class 상 속받기코드
  • ISP, LSP 모두 위배
// p245 이러한 코드는 RobotDog 가 필요도 없는 메서드를 갖기 때문에, ISP 원칙에 위배된다.
// 또한 슈퍼클래스의 동작을 서브클래스에서 깨버리므로 LSP 원칙에도 위반

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

class RealDog : Dog() {
    override fun bark() {
        println("Woof!")
    }

    override fun sniff() {
        println("Sniffing...")
    }
}

class RobotDog : Dog() {
    override fun bark() {
        println("Beep boop!")
    }

    override fun sniff() {
        throw UnsupportedOperationException("Robot dogs can't sniff")
    }
}
  • 해당코드를 컴포지션을 이용한다면? 그리고 로봇강아지는 계산이라는 함수를 추가해야한다면?
// 기능 정의
class Barking {
    fun bark() {
        println("Woof!")
    }
}

class RoboticBarking {
    fun bark() {
        println("Beep boop!")
    }
}

class Sniffing {
    fun sniff() {
        println("Sniffing...")
    }
}

class Calculating {
    fun calculate() {
        println("Calculating...")
    }
}

// 강아지 클래스
class Dog(
    private val barking: Barking,
    private val sniffing: Sniffing? = null
) {
    fun bark() {
        barking.bark()
    }

    fun sniff() {
        sniffing?.sniff() ?: println("This dog can't sniff")
    }
}


// 로봇 강아지 클래스
class RobotDog(
    private val barking: RoboticBarking,
    private val calculating: Calculating
) {
    fun bark() {
        barking.bark()
    }

    fun calculate() {
        calculating.calculate()
    }
}


// 사용
fun main() {
    val realDog = Dog(Barking(), Sniffing())
    realDog.bark()
    realDog.sniff()

    val robotDog = RobotDog(RoboticBarking(), Calculating())
    robotDog.bark()
    robotDog.calculate()
}

 

 

캡슐화를 깨는 상속

아래코드는 이펙티브 자바에 나오는 문제있는 코드

class CounterSet<T> : HashSet<T>() {
    var elementAdded: Int = 0
        private set

    override fun add(element: T): Boolean {
        elementAdded++
        return super.add(element)
    }

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

@Test
fun `246p_test`() {
    val counterList = CounterSet<String>()
    counterList.addAll(listOf("A", "B", "C"))
    println(counterList.elementAdded) // 6
}

위 코드에서 6이 잘못 나오는 것은 HashSet 의 addAll 내부에서 add 를 사용했기 때문입니다.

 

override 막기

open class BaseClass {
    open fun openMethod() {
        println("This method can be overridden")
    }

    final fun finalMethod() {
        println("This method cannot be overridden")
    }
}

class DerivedClass : BaseClass() {
    override fun openMethod() {
        println("Overridden method")
    }

    // This will cause a compile error
    // override fun finalMethod() {
    //     println("Trying to override final method")
    // }
}

fun main() {
    val obj = DerivedClass()
    obj.openMethod() // Output: Overridden method
    obj.finalMethod() // Output: This method cannot be overridden
}

개인의견

  • 인터페이스 분리원칙과 컴포지션은 매우 비슷한거 같다..
  • 최대한 간결한 인터페이스, 컴포지션을 구성는게 좋은거 같다
  • 다형성 필요 여부에따라 둘중에 어떤걸 사용할지 고민해보면좋을거 같다
    • 다형성이 필요하고, 여러 클래스가 동일한 행동을 다르게 구현한다면 인터페이스, 그게 아니라면 컴포지션을 사용
  • 좀더 유연한 설계를 구성하고 동적으로 행동을 변경해야한다면 컴포지션이 낫다
  • 변경이 빈번하다면 인터페이스보다 컴포지션이 낫다
728x90

댓글