상속 문제
상속은 코드 재사용을 위한 강력한 도구이지만 항상 최선은 아닙니다.
동일한 프로그래머가 제어하는 패키지 내에 있는 한 상속도 안전합니다.
확장성을 위해 설계된 잘 문서화된 클래스(항목 19)는 똑같이 안전합니다.
그러나 패키지 경계를 넘어 다른 패키지에서 구체적인 클래스를 상속하는 것은 위험합니다.
(참고로 여기서 “상속”이란 하나의 클래스가 다른 클래스를 확장하는 구현 상속을 말하며 인터페이스 상속과는 아무런 관련이 없습니다.
)
1⃣ 예시 1
메서드 호출과 달리 상속은 캡슐화를 중단합니다.
슈퍼클래스는 각 버전에 대해 서로 다른 내부 구현을 가질 수 있으며 결과적으로 아무 것도 변경되지 않은 경우에도 서브클래스가 오작동할 수 있습니다.
슈퍼클래스의 설계자가 확장을 고려하지 않고 적절하게 문서화하지 않으면 슈퍼클래스를 따라잡기 위해 서브클래스를 수정해야 합니다.
예를 들어 HashMap 함수를 사용하는 클래스를 구현하고 생성 이후 얼마나 많은 요소가 추가되었는지 알 수 있는 함수를 추가해 보겠습니다.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
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;
}
}
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("", "용용", "용용용"));
// java9 이전 버전은 List.of 대신 Arrays.asList() 사용
getAddCount를 호출하면 3을 반환해야 하는 것처럼 보이지만 실제로는 6을 반환합니다.
이는 HashSet의 addAll 메소드가 각 요소에 대해 add 메소드를 호출하기 때문입니다.
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
super.addAll()로 HashSet 메소드를 호출하더라도 내부적으로 add를 호출하면 InstrumentedHashSet에 의해 오버라이드된 add 메소드가 호출되므로 카운트 증가가 중복된다.
해결 방법으로 이 문제를 해결하는 방법에는 두 가지가 있습니다.
- HashSet의 addAll 메서드가 재정의되지 않은 경우
- add 메서드를 사용하여 HashSet에서 addAll을 검색했다고 가정하는 제약 조건이 있습니다.
(현재는 addAll 메소드의 구조에만 의존하고 있습니다.
구조를 변경하면 문제가 발생할 수밖에 없습니다.
) - “self-use”가 자신의 다른 부분을 사용하는지 여부는 클래스의 내부 구현에 달려 있으며 이것이 다음 버전에서 유지된다는 보장은 없습니다.
- add 메서드를 사용하여 HashSet에서 addAll을 검색했다고 가정하는 제약 조건이 있습니다.
- 다른 방식으로 addAll 메서드 재정의
- 지정된 컬렉션을 반복하고 항목당 한 번만 add 메서드를 호출할 수 있습니다.
- 그러나 부모 클래스의 메서드와 동일하게 작동하는 방식으로 구현해야 하지만 어렵고 시간이 오래 걸리며 오류 및 성능 저하가 발생할 수 있습니다.
- 지정된 컬렉션을 반복하고 항목당 한 번만 add 메서드를 호출할 수 있습니다.
2⃣ 예시 2
보안상의 이유로 컬렉션의 모든 항목이 특정 조건을 충족하도록 요구하는 프로그램을 고려하십시오.
이 컬렉션을 상속하고, 항목을 추가하는 모든 메서드를 재정의하고, 먼저 필요한 조건을 확인하면 지금은 잘 작동합니다.
그러나 다음 버전에서 부모 클래스에 요소를 추가하는 다른 메서드가 생성되면 자식 클래스는 재정의되지 않은 새 메서드를 사용하여 잘못된 요소를 추가할 수 있습니다.
사실 컬렉션 프레임워크에 Hashtable과 Vector를 포함시켰을 때 나타나는 보안 취약점을 모두 수정해야 하는 상황이 있었다.
위 두 가지 방법의 공통적인 문제점
두 방법 모두 메서드 재정의로 인해 문제가 있습니다.
재정의하는 대신 새 메서드를 추가하는 것이 더 안전할 수 있지만 불행하게도 다음 릴리스에서는 수퍼 클래스에 추가된 새 메서드가 하위 클래스에 추가된 메서드와 동일한 시그니처를 가질 수 있습니다.
이 경우 컴파일에 실패할 뿐만 아니라 메서드가 작성된 시점에 슈퍼클래스 메서드가 존재하지 않았기 때문에 규칙이 충족되지 않을 가능성이 높습니다.
구성
구성은 새 클래스를 만들고 기존 클래스의 인스턴스를 전용 필드로 참조하는 방법입니다.
즉, 기존 클래스를 새로운 클래스의 구성 요소로 사용하는 구조입니다.
새 클래스의 인스턴스 메서드는 전용 필드가 참조하는 이전 클래스의 해당 메서드를 호출하고 결과를 반환합니다.
이 방법 전송그리고 새로운 클래스의 메서드 전달 방법이름을 짓다
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
@Override public int size() { return s.size(); }
@Override public boolean isEmpty() { return s.isEmpty(); }
@Override public boolean contains(Object o) { return s.contains(o); }
@Override public Iterator<E> iterator() { return s.iterator(); }
@Override public Object() toArray() { return s.toArray(); }
@Override public <T> T() toArray(T() a) { return s.toArray(a); }
@Override public boolean add(E e) { return s.add(e); }
@Override public boolean remove(Object o) { return s.remove(o); }
@Override public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
@Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
@Override public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
@Override public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
@Override public void clear() { s.clear(); }
}
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; }
}
이 클래스의 핵심은 임의의 세트에 측정 기능을 넣어 새로운 세트로 만드는 것입니다.
상속 방식은 각각의 구체적인 클래스를 별도로 확장해야 하며, 지원하고자 하는 상위 클래스의 각 생성자에 해당하는 생성자를 별도로 정의해야 한다.
그러나 컴포지션은 한 번만 구현하고 모든 집합 구현으로 계측하고 기존 생성자와 함께 사용할 수 있습니다.
InstrumentedSet<String> is = new InstrumentedSet<>(new HashSet<>());
is.addAll(List.of("Effecive", "Java", "3/E"));
System.out.println(is.getAddCount());
InstrumentedSet<Integer> is = new InstrumentedSet<>(new TreeSet<>());
is.addAll(List.of(1,2,3));
System.out.println(is.getAddCount());
static void walk(Set<Dog> dogs) {
InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
...
}
다른 Set 인스턴스를 래핑한다는 점에서 InstrumentedSet과 같은 클래스입니다.
래퍼 클래스다른 세트에 측정 기능을 오버레이한다는 의미에서 데코레이터 패턴라고도 함
가장 넓은 의미에서 구성과 전달의 조합 대표단그러나 엄밀히 말하면 래퍼 객체가 자신에 대한 참조를 내부 객체에 전달하는 경우에만 해당됩니다.
구성의 단점
래퍼 클래스에는 몇 가지 단점이 있지만 콜백 프레임워크에는 적합하지 않습니다.
콜백 프레임워크는 다음 호출에서 사용할 수 있도록 자신에 대한 참조를 다른 개체에 전달합니다.
내부 개체는 자신을 둘러싼 래퍼의 존재를 인식하지 못하기 때문에 참조를 전달하고 래퍼에 대한 콜백이 아닌 내부 개체를 호출합니다.
(이것을 SELF 문제라고 합니다.
)
그러나 전송 방법이 성능에 미치는 영향이나 래퍼 개체가 메모리 사용에 미치는 영향은 실질적으로 큰 영향을 미치지 않는 것으로 나타났습니다.
포워더 메서드를 작성하는 것은 다소 지루할 수 있지만 인터페이스당 재사용 가능한 포워더 클래스를 하나만 생성하면 원하는 기능을 오버레이하는 포워더 클래스를 매우 쉽게 구현할 수 있습니다.
구성보다 상속을 사용하기로 결정하기 전에 고려해야 할 사항
상속은 하위 클래스가 상위 클래스의 실제 하위 유형인 경우에만 사용해야 합니다.
B 클래스가 A로부터 상속을 받으려면 B 클래스가 A 클래스라는 것이 확실하지 않으면 상속해서는 안 됩니다.
대답이 ‘아니오’라고 확신하는 경우 대부분의 경우 A를 비공개 인스턴스로 유지하고 A와 다른 API를 노출해야 합니다.
즉, A는 B의 필수적인 부분이 아니라 구현 방법일 뿐입니다.
(Vector를 확장한 Stack과 Hashtable을 확장한 Properties도 원칙을 위반하는 클래스입니다.
)
컴퍼지션보다 상속을 사용하기로 결정하기 전에 마지막으로 몇 가지 질문을 해야 합니다.
- 확장하려는 클래스의 API에 오류가 있습니까?
- 오류가 있는 경우 내 클래스 API에 전파해도 괜찮습니까?
컴포지션을 사용하면 이러한 오류를 숨기는 새 API를 만들 수 있지만 상속은 부모 클래스의 API와 오류를 상속합니다.