class Stack<E> extends Vector<E> {
public E push(E item) { ... }
public E pop() { ... }
public E peek() { ... }
}
Java 표준 라이브러리에 실제로 존재하는 코드다. Vector가 이미 배열 기반 저장소를 구현해두었으니, 중복 없이 Stack을 만들 수 있어 보인다. 그런데 이 설계가 왜 실패 사례로 꼽히는 걸까.
Stack extends Vector라고 쓰는 순간, 이 코드는 "Stack은 Vector다"라고 선언하는 것이다. 상속은 is-a 관계를 표현하는 수단이다.
그런데 Stack은 Vector가 아니다. Stack은 LIFO 규칙을 지키는 자료구조고, Vector는 인덱스로 원소에 접근할 수 있는 가변 배열이다. 이 둘이 같은 종류의 객체라고 볼 수 없다. Vector를 상속한 이유는 설계가 아니라 구현 편의 때문이었다.
상속하면 부모의 public 메소드가 전부 자식에게 따라온다. Stack이 Vector를 상속하면, Stack을 쓰는 코드에서 이런 일이 가능해진다.
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
stack.add(0, 999); // 맨 아래에 끼워넣기
stack.remove(1); // 중간 원소 삭제
Stack의 계약은 맨 위에서만 넣고 꺼내는 것이다. 그런데 Vector에서 물려받은 add(index, element)와 remove(index)가 그 계약을 깨버린다. 컴파일 에러도 런타임 예외도 없이 설계 원칙이 깨진다.
Properties는 문자열 키-값 쌍을 저장하는 설정 클래스다. setProperty(String, String)으로 값을 넣고, getProperty(String)으로 꺼낸다.
그런데 Hashtable을 상속했기 때문에 put(Object, Object)도 사용할 수 있다.
Properties props = new Properties();
props.setProperty("host", "localhost");
props.put("port", 8080); // Hashtable 메소드, Integer 값
props.getProperty("host"); // "localhost"
props.getProperty("port"); // null
getProperty()는 내부적으로 String 타입 값만 찾는다. put()으로 Integer를 넣으면 조용히 무시된다. 값을 넣었는데 꺼낼 때 null이 나온다. 오류 메시지도 없고 경고도 없다.
Stack이 Vector를 상속하는 대신 내부에 보유하면 어떻게 될까.
class Stack<E> {
private final List<E> storage;
public Stack() {
this.storage = new ArrayList<>();
}
public Stack(List<E> storage) {
this.storage = storage;
}
public void push(E item) { storage.add(item); }
public E pop() { return storage.remove(storage.size() - 1); }
public E peek() { return storage.get(storage.size() - 1); }
}
외부에서 보이는 건 push, pop, peek뿐이다. add(index, element)는 없다. storage가 ArrayList든 LinkedList든 Stack의 계약은 달라지지 않는다.
이것이 컴포지션이다. 필요한 객체를 내부에 두고, 필요한 기능만 선별해서 노출한다. 내부 구현을 바꾸고 싶으면 외부 코드는 손댈 필요가 없다. Effective Java Item 18도 같은 이유로 "상속보다 컴포지션을 선호하라"고 권한다.
부모의 모든 동작이 자식에게도 그대로 의미있을 때다.
class Shape {
abstract double area();
}
class Circle extends Shape {
double radius;
double area() { return Math.PI * radius * radius; }
}
Circle은 실제로 Shape다. Shape의 area()는 Circle에서도 의미가 있고, Circle이 Shape 자리에 쓰여도 계약이 깨지지 않는다. 이런 경우에 상속이 적합하다.
코드가 겹쳐서 줄이고 싶다는 이유는 상속의 근거가 아니다. 그 판단 기준은 하나다. "이 클래스는 부모 클래스의 한 종류인가." 아니라면 컴포지션으로 해결한다.