이번 챕터를 공부하며 든 생각은 내가 상속을 올바르게 구현 할 수 있을까?
였다. 읽어보면 알겠지만, 고려할 점이 너무나 많다.
사실 깨뜨린다 보다는 깨뜨릴수 있다
가 좀 더 어울린다고 생각한다.
캡슐화를 꺠뜨릴 수 있다는 관점에서 보자면, 하위 클래스가 상위클래스에 의존적이게 된다.
이 책에서는 HashSet(InstrumentedHashSet)을 가지고 설명하고 있다.
InstrumentedHashSet
에서 addAll()
과 add()
를 재정의하여 사용하고 있다. 두 메소드 모두 addCount라는 변수를 증가시켜주고있다. 문제가 되는 부분은 addAll()
의 사용에 있다.
InstrumentedHashSet<String> set = new InstrumentedHashSet<>();
set.addAll(List.of("틱", "택", "톡"));
System.out.println("set.getAddCount() = " + set.getAddCount());
우리는 위 코드를 실행하면 3
이 출력될 것이라 예상하지만, 실제로는 6
이 나온다.
이 원인은 HashSet의 addAll 메소드가 add 메소드를 사용하여 구현
된데에 있다.
현재 set
변수는 InstrumendtedHashSet
의 인스턴스이다. 따라서 set.addAll
메소드를 호출하면 HashSet
의 addAll 메소드가 호출된 뒤, 이 메소드에서 HashSet
의 add메소드가 아닌 InstrumendtedHashSet
의 add 메소드가 호출될 것이다. 이 메소드는 우리가 재정의한 메소드이므로, addCount가 다시 증가되어 결과적으로 6
이 나오게 된것이다.
실제 동작과정은 직접 구현해보고 디버깅해보면 쉽게 알 수 있다.
상속과 캡슐화에 대해 찾아보면 메소드 오버라이딩이 문제다
라고 소개되어 있는 내용을 찾아 볼 수 있다. 위 문제도 메소드 재정의
가 원인이었다.
그래서 이 책에서 말해주는 해결법은 컴포지션
이다.
컴포지션이란 기존 클래스가 새로운 클래스의 구성요소로 쓰이는 것
이다.
이제는, InstrumentedSet이라는 Wrapper class
를 사용하게 된다. 이 클래스는 ForwardingSet 클래스를 상속받고 있다. 이 ForwardingSet
클래스가 바로 컴포지션 방식이 적용된 클래스라고 본다.
현재 우리 스터디에서, 이 클래스에 대한 issue가 올라왔었는데,
왜 굳이 Set을 구현(implements)하고 있나?
라는 내용이다.
나는 이에 대해 다음처럼 생각한다.
Set
이지만, 만약 예제처럼 계측 기능도 추가한 TreeSet을 사용하고 싶다면 InstrumentedTreeSet
으로 클래스 이름을 짓고, ForwardingSet은 extends TreeSet
을 하는 것이 올바르다고 생각한다.그럼 다시 책으로 돌아와 InstrumentedSet 클래스를 Wrapper class
라 하였는데, 이 래퍼클래스는 단점이 거의 없다. 단지 래퍼 클래스가 콜백 프레임워크
와 어울리지 않다라는 점만 주의하면 된다. 나는 이 의미가 잘 이해되지 않아 구글링을 한 결과 좀 도움이 될만한 글을 보았다.
public class NotSuitableCallback {
public static void main(String[] args) {
SomeService service = new SomeService();
WrappedObject wrappedObject = new WrappedObject(service);
Wrapper wrapper = new Wrapper(wrappedObject);
wrapper.doSomething();
}
}
interface SomethingWithCallback {
void doSomething();
void call();
}
class WrappedObject implements SomethingWithCallback {
private final SomeService service;
WrappedObject(SomeService service) {
this.service = service;
}
@Override
public void doSomething() {
service.performAsync(this);
}
@Override
public void call() {
System.out.println("WrappedObject callback!");
}
}
class Wrapper implements SomethingWithCallback {
private final WrappedObject wrappedObject;
Wrapper(WrappedObject wrappedObject) {
this.wrappedObject = wrappedObject;
}
@Override
public void doSomething() {
wrappedObject.doSomething();
}
void doSomethingElse() {
System.out.println("We can do everything the wrapped object can, and more!");
}
@Override
public void call() {
System.out.println("Wrapper callback!");
}
}
final class SomeService {
void performAsync(SomethingWithCallback callback) {
new Thread(() -> {
perform();
callback.call();
}).start();
}
void perform() {
System.out.println("Service is being performed.");
}
}
실행결과로 wrapper의 call()이 호출되어 Wrapper callback
이 출력되어야겠지만, 실제로는 그렇지 않다. 이 책에서도 말하는 부분은 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다.
라고 하고 있다.
예시 코드에서 바로 WrappedObject
클래스를 보면 된다. 이 내부 클래스는 래퍼의 존재를 모른다. 그래서 doSomething
메소드는 원래는 래퍼의 참조를 넘겨야 했겠지만, 실제로는 자기자신(WrappedObject)의 참조를 넘기게되어 결과적으로 내부 객체가 호출되는 문제가 발생한다.
대표적으로 stack과 Properties클래스를 소개하고 있다. 그러면서 두 사례 모두 컴포지션을 사용했다면 더 좋았을 것이라 말하고 있는데, 그래서 나는 Stack
클래스를 컴포지션을 통해 구현해보았다.
우선 기존
Stack
의 문제점에 대해 말해보자.Vector를 확장하면 안되었다.
현재 Stack클래스는 FILO의 특성을 가지고 있고, 이는 한 방향으로만 삽입,삭제가 일어나야 한다. 하지만 Vector를 상속받고 있어서 Vector의 기능까지 사용가능하다.
그 중 set 메소드를 생각해보면 index를 지정하여 value를 덮어씌우게 된다. 그럼 FILO의 특성을 어긋나게 되는 문제가 발생한다.
public class CustomStack<E> {
private final Vector<E> vector = new Vector<>();
public CustomStack() {
}
public E push(E item) {
vector.addElement(item);
return item;
}
public synchronized E pop() {
E obj;
int len = vector.size();
obj = peek();
vector.removeElementAt(len - 1);
return obj;
}
private E peek() {
int len = vector.size();
if (len == 0)
throw new EmptyStackException();
return vector.elementAt(len - 1);
}
public boolean empty() {
return vector.size() == 0;
}
public synchronized int search(Object o) {
int i = vector.lastIndexOf(o);
if (i >= 0) {
return vector.size() - i;
}
return -1;
}
@Override
public String toString() {
return vector.toString();
}
private static final long serialVersionUID = 1224463164541339165L;
}
이 처럼 Vector클래스를 내부 구성요소로 사용하는 컴포지션 방식을 사용한다.
그럼 자연스레 CustomStack에서 정의하지 않은 메소드들은 사용할 수 없다. (Vector클래스를 확장하지 않음)
상속은 is-a관계일 때만 사용하자.
그리고 상위클래스가 확정을 고려해 설계 되지 않았다면 상속받지 말자.(item 19)
상속대신 컴포지션을 사용하자.
effective-java스터디에서 공유하고 있는 전체 item에 대한 정리글