상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
public class InstrumentedHashSet<E> extends HashSet<E> {
// 추가된 원소의 수
private int addCount = 0;
public InstrumentedHashSet() {
}
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
System.out.println(s.getAddCount());
}
}
위의 코드에서 s.addAll(List.of("틱", "탁탁", "펑"));
을 하면 우리는 원소가 3개이므로 addCount += c.size();
에서 addCount
에 3이 더해져 addCount
가 3이 되고 결론적으로 3이 출력될 것이라고 생각한다. 하지만, HashSet의 addAll의 내부를 살펴보면 아래와 같다.
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
add를 반복해서 사용함으로써 addAll이 작동하고 있는 것이다. 그런데 우리는 InstrumentedHashSet
을 구현하면서 add
메서드를 오버라이딩했다.
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
살펴보면 add가 될 때 마다 addCount++;
이 실행되고 있는 것을 알 수 있다. 따라서 총 addCount는 6이 출력된다.
이는 add를 재정의하지 않거나, addAll에서 원소를 순회하며 add를 호출하면 된다.
@Override public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
하지만, 시간도 더 들고 상위 클래스의 메서드 동작을 다시 구현해야해 어렵다. 또한 자칫 오류를 내거나 성능을 떨어뜨릴 수 있고, 하위 클래스에서는 접근할 수 없는 private 필드를 써야하는 상황이라면 이 방식으로는 구현이 불가능하다.
상위 클래스에 새로운 메서드를 추가한다면? 보안 때문에 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야만 하는 프로그램을 생각해보자.
@Override public boolean add(E e) {
if(Objects.requireNonNull(e))
addCount++;
return super.add(e);
}
그렇다면 지금의 코드에선 원소를 추가할 때 무조건 add
메서드를 사용하므로 이 메서드단에 검사를 하면 코드를 작성하면 된다. 하지만 상위 클래스에 add
외의 다른 원소 추가 메서드가 생기게 된다면 이 검증을 거치치 않고 원소를 추가할 수 있게 될 것이다.
위의 두 문제 모두 메서드 재정의로 인한 문제였다. 그렇다면, 메서드를 재정의하지 말고 새로운 메서드를 추가하는건 어떨까?
상위클래스에 새 메서드가 추가 되었는데 내가 하위클래스에 추가한 메서드와 시그니처가 같고 반환타입이 다르다면 컴파일 조차 안되며 반환타입이 같다면 오버라이딩 한 것이므로 문제 해결이 안된다.
새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 결과를 반환한다. 이를 전달이라하며, 새 클래ㅐ스의 메서드들을 전달 메서드라 한다.
이를 통해 새 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며 새로운 메서드가 추가되어도 전혀 영향을 받지 않는다.
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> s) {
super(s);
}
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());
s.addAll(List.of("틱", "탁탁", "펑"));
System.out.println(s.getAddCount());
}
}
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator<E> iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean remove(Object o) { return s.remove(o); }
public boolean containsAll(Collection<?> c)
{ return s.containsAll(c); }
public boolean addAll(Collection<? extends E> c)
{ return s.addAll(c); }
public boolean removeAll(Collection<?> c)
{ return s.removeAll(c); }
public boolean retainAll(Collection<?> c)
{ return s.retainAll(c); }
public Object[] toArray() { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override public boolean equals(Object o)
{ return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Override public String toString() { return s.toString(); }
}
상속에서는 구체 클래스 각각을 따로 확장해야 하고, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야한다. 하지만 컴포지션은 한 번만 구현해두면 어떠한 Set 구현체라도 계츨할 수 있으며, 기존 생성자들과도 함께 사용할 수 있다.
컴포지션을 써야할 상황에서 상속을 사용하는 것은 내부 구현을 불필요하게 노출하는 것이다. => API가 내부 구현에 묶이고 클래스의 성능도 영원히 제한된다. 또한, 클라이언트가 노출된 내부에 직접 접근할 수 있다.
Properties는 Hashtable을 상속받고 있는 것을 볼 수 있는데 Properties는 아래의 두가지 메서드를 모두 지원한다.
public String getProperty(String key) {
Object oval = map.get(key);
String sval = (oval instanceof String) ? (String)oval : null;
Properties defaults;
return ((sval == null) && ((defaults = this.defaults) != null)) ? defaults.getProperty(key) : sval;
}
@Override
public Object get(Object key) {
return map.get(key);
}
이는 같은 인스턴스에게 다른 결과를 가져올 수 있다. get은 Hashtable로 부터 재정의한 메서드이기 때문이다.
가장 심각한 문제는 클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수 있다는 점이다. Properties는 키와 값으로 문자열만 허용하도록 설계하려 했으나, Hashtable의 메서드들을 직접 호출하면 이를 깨버릴 수 있다.
@Override
public synchronized Object put(Object key, Object value) {
return map.put(key, value);
}
위와 같이 put 등의 메서드들이 그대로 살아있다.
이펙티브 자바 3/E