Effective Java : 아이템 18. (기능 확장이 필요할 때)상속보다는 컴포지션을 사용하라

[Effective Java] 4장 클래스와 인터페이스

 

둘 다 어떤 클래스에 기능을 추가하거나, 책임을 더해서 확장하고 싶을 때 사용 할 수 있으나, 근본적인 의미가 다르다.
무조건 컴포지션을 써야 한다는 의미가 아니라, 의미에 맞게 사용해야 한다. (하단 '상속과 컴포지션을 구분하는 방법' 참고)

 

상속의 단점?

상속과 컴포지션의 차이는 즉, @Override 하느냐 delegate 하느냐의 차이로 볼 수 있다.

 

1. @Override를 통해 하위 클래스에서 변경한 메서드는, 상위 클래스 메서드에도 영향을 미칠 수 있기 때문에 상속이 캡슐화를 깨뜨릴 수 있다.

2. 우선 상속은 한 번 밖에 쓸 수 없는 카드다. 사람 객체의 동작을 '나이대'와 '소득 수준'에 따라 분리하고 싶어도, 딱 한가지 기준 밖에 쓸 수가 없다.

 

  • 그래서 #아이템 19 에서 상속을 금지하라고 가이드 하고 있는 것.
  • 보통 상속은 더 구현하기 까다롭고 복잡한 경우가 많다.
    • 상속하다 보면 부모 클래스 쪽의 구현사항에서 이것 저것 신경써야 하는 경우도 많고... 예를들면 __slots__같은 거라던가...

 

 

반면 컴포지션을 통해 delegate하면 Composition 대상 클래스의 내부 구현을 건드리는게 아니기 때문에 캡슐화를 깨뜨리지 않는다.

 

상속 구조에서 문제가 발생하는 경우, 흔히들 서브클래스를 Delegation 구조로 바꾸게 된다.

 

상속과 컴포지션을 구분하는 방법?

  • 상속은 진짜 "B is A" 관계가 성립할 때만 써야 한다. 
  • 상속은 단순 기능 추가 보다는 좀 더 의미론적으로 접근하는게 맞는 것 같다.
    • 상속 계층을 따져보면 상속을 받을 수 밖에 없는 경우?
    • 타입 문제 때문에 하위 클래스로 만들어 다형성을 이용해야 하는 경우는... interface를 이용한 컴포지션으로 커버 가능하다.
      • ForwardingSet과 InstrumentedSet의 예제.
      • 굳이 class를 extends할 필요 없다는 것. 목적이 다형성이면 interface implements로 충분하다.
  • 자바 플랫폼 라이브러리에도 잘못된 예들이 있는데
    • Stack은 Vector가 아니므로, 상속 보다는 컴포지션을 사용했다면 좋았을 것이고
    • Properties도 Hashtable이 아니므로, 상속이 아니라 컴포지션이 더 좋았을 것임

 

위임(Delegation) 이란?

  • 세부 구현을 타 클래스에게 맡기는(위임하는) 것 (=내 관심사가 아닌 것들은 타 모듈에 위임하는 것)
  • Composition + Forwarding을 위임(Delegation) 이라 한다.
    • 예를 들어,
      • 1. 새 클래스 및 메서드 하나 만들고
      • 2. Composition으로 필요한 클래스 가져오고
      • 3. 새 클래스의 메서드 바디에서, Composition 클래스 메서드 중 기능에 대응하는 메서드를 호출하도록 Forwarding 하면 위임이다. 
    • 엄밀히 따지면 외부 클래스가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임으로 보는 시각도 있다.
      • OrderBookMap 클래스가 내부의 Updater 객체에 자기 자신의 참조를 넘겨 update 하도록 하는 경우
  • 상속도 super()만 호출하면 위임 비슷한거 아니냐..라고 생각할 수 있지만, Delegation의 정의 자체가 Composition을 전제로 하고 있다. 사실상 delegation과 composition은 거의 같은 의미로 사용된다. (상속의 대안으로 자주 거론된다)

 

기타
  • https://en.wikipedia.org/wiki/Delegation_pattern
    • 여기서는 Pattern이라고 부르고 있으나, 그 자체로 패턴이라기 보다는 방법(?)이라고 보는게 더 맞는 것 같음. 정의도 그렇고.
    • Delegation을 사용해서 Decorator Pattern을 구현하게 되므로...
    • 보통 얘기할 때도 얘한테 위임한다. 라고 얘기하지 Delegation Pattern을 사용한다. 라고 얘기하지는 않는 것도 있고
  • 코틀린은 언어 차원에서 Delegation 연산자를 지원한다.

 

Decorator Pattern (Wrapper Class)

https://sourcemaking.com/design_patterns/decorator

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

 

  • 어떤 클래스를 Wrapper 클래스로 감싸는 패턴을, 기능을 덧씌운다는 의미에서 Decorator Pattern이라고 부른다.
    • Wrapper 클래스는(또는 Forwarding 클래스는) delegation을 사용할 수 밖에 없다. 원본 클래스를 wrapping 하는게 목적이므로, 원본 클래스 메서드를 호출해야 의미가 있는 경우가 대부분이니까.
  • 예시) Wrapper 클래스 (상속 대신 컴포지션) / 재사용할 수 있는 Forwarding 클래스
    • InstrumentedSet 안에 private final로 Set을 가져도 되는데 중간에 ForwardingSet을 한 번 거치는 이유는? 재활용성 때문
      • 만약 ForwardingSet이 없었다면,
        • InstrumentedSet을 만들 때 clear, isEmpty 같은 Forwarding Method를 각각 다 만들어줘야 하고,
        • anotherWrapperSet을 만들 때 또 clear, isEmpty 등을 각각 다 만들어줘야 하니까.
      • 그래서 by 같은 키워드 지원이 된다면 굳이 필요 없지 않을까 하는 생각이 든다.
      • 또는, 굳이 재활용이 필요하지 않다면 Forwarding 클래스를 건너 뛰어도 될 듯.

 

 

기타 - 상속 컴포지션 예시

np.ndarray의 경우

```python

import numpy as np

 

# sol 1.

class OperatorOverloading:

    def __init__(self):

        self.board = np.zeros((8, 8), dtype=int)

 

    def __getitem__(self, point):

        i, j = point

        return self.board[i][j]

 

    def myFunc(self):

        return "my function!"

 

A = OperatorOverloading()

print(A[1,2])

print(A.myFunc())

 

# sol 2.

class Extern(np.ndarray):

    """

    https://docs.scipy.org/doc/numpy/user/basics.subclassing.html

    type casting하는 방식 말고, 좀 더 복잡하지만 __new__ 등을 overloading해서 완전 서브클래스로 사용하는 방법도 있다.

    """

    def myFunc(self):

        return "my function!"

 

_B = np.zeros((8, 8), dtype=int)

B = _B.view(Extern)

print(B[1][2])

print(B.myFunc())

 

# 확장 함수는 지원하지 않음.

C = np.zeros((8, 8), dtype=int)

C.myFunc = lambda x: "my function!"

print(C.myFunc())    # AttributeError: 'numpy.ndarray' object has no attribute 'myFunc'

```

 

socket.socket의 경우

```python
class LogSocket(socket.socket):
    def __init__(self, family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0, fileno=None):
        socket.socket.__init__(self, family, type, proto, fileno)
        self.next_retry_time = 0
        self.retry_factor = 2
        self.retry_period = 1
        self.retry_time_max = 30
 
    def recv(self, bufsize = 4096):
        cmd_raw = super.recv(bufsize)
        cmd = cmd_raw.decode()
        return cmd
 
    def exponential_backoff_connect(self, address):
        while True:
            result = self._exponential_backoff_connect(address)
            if result in (True, False):
                return result
 
    def _exponential_backoff_connect(self, address):
        now = time.time()
 
        if now > self.next_retry_time:
            try:
                print("Try...")
                self.connect(address)
                return True
            except OSError:
                self.next_retry_time = now + self.retry_period
                self.retry_period *= self.retry_factor
                if self.retry_period > self.retry_time_max:
                    print("retry_time_max! {}".format(self.retry_time_max))
                    return False
                print("Fail : wait {}".format(self.retry_period))
        return None
```
 

```python

class SocketWrapper:

    __slots__ = ("_sock")

 

    def __init__(self, sock):

        self._sock = sock

    

    def recv(self, bufsize = 4096):

        cmd_raw = self._sock.recv(bufsize)

        cmd = cmd_raw.decode()

        return cmd

```