본문 바로가기
Language/Effective java

클래스와 인터페이스

by y.j 2022. 6. 14.
728x90

상속보다는 컴포지션을 사용하라

상속(클래스가 다른 클래스를 확장하는)은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 패키지 경계를 넘어 다른 클래스의 구체 클래스를 상속하는 일은 위험하다.

메서드 호출과 달리 상속은 캡슐화를 깨드린다. 상위클래스가 어떻게 구현되는냐에 따라서 하위 클래스 동작에 이상이 생길 수 있다. 상위 클래스는 릴리즈마다 구현이 바뀔 수 있어 문서화를 해두고 발맞춰 수정해야만 한다.


아래 예제에서 addAll()이 c.size()를 더하고 있는데 add()메서드에서 한번 더 더하기 때문에 2배가 된다. 그렇다고 addCount를 더하는 부분을 지운다고 안심할 수 없다. 나중에 상위클래스(HashSet) 어떻게 바뀔지 모르기 때문이다.

public class InstrumentHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    public InstrumentHashSet() {

    }

    public InstrumentHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }

    @Override
    public boolean add(E e) {
        addCount++;                           // 여기에서 +1를 하기 때문에 addAll의 addCount가 2배가 된다.
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

게다가 private으로 되어 있는 필드를 하위 클래스에서 메서드를 통해 public하게 만들 수 있어 보안에 위험 할 수 있다. 또, 상위 클래스의 규약을 하위 클래스에서 지키지 않을 수 있다.

 

이 모든 문제를 피해가기 위해서 컴포지션을 사용 할 수 있다. 기존 클래스를 확장하는 대신 기존 클래스가 새로운 클래스의 구성요소로 사용된다.

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 int hashCode()           { return super.hashCode();}

    @Override public boolean equals(Object obj) { return super.equals(obj); }

    @Override public String toString()          { return super.toString(); }
}

기존에 있는 메서드를 그대로 call해주는 메서드를 만들어준다. 추가적으로 필요한 함수가 있으면 정의하기만 하면 된다.

public class InstrumentHashSet<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public InstrumentHashSet(Set<E> s) {
        super(s);
    }

}

InstrumentHashSet은 Set인스턴스를 감싸고 있다고 해서 래퍼클래스라고도 하면 데코레이터 패턴이라고도 한다. 래퍼 클래스의 단점은 거의 없다. 다만 콜백 프레임 워크는 참조를 다른 객체에 넘겨서 다음 호출 때 사용하도록 하기 때문에 어울리지 않는다.

 

정리

상속은 반드시 is-a관계에서만 사용해야 한다.
확장하려는 클래스의 API에 아무런 결함이 없는가?
결함이 있다면 이 결함이 여러분 클래스의 API까지 전파돼도 괜찮은가?

 

상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라.

 상속을 고려한 설계와 문서란 무엇인가?

상속용 클래스는 재정의 할 수 있는 메서드들을 내부적으로 어떻게 이용하는지(자기사용)문서로 남겨야 한다. 클래스의 API로 공개된 메서드에서 클래스 자신의 또 다른 메서드에서 호출 할 수도 있다. 재정의가 되는 기능 메서드라면 이러한 사실을 적시해야 하고 어떤 순서로 호출하는지 호출결과가 어떤 영향을 미치는지도 담아야 한다.

 

public boolean remove(Object o)

주어진 원소가 이 컬렉션 안에 있다면 그 인스턴스를 하나 제거한다(선택적 동작). 더 정확하게 말하면, 이 컬렉션 안에 'Object.equals(o, e)가 참인 원소 e' e가 하나 이상 있다면 그 중 하나를 제거한다. 주어진 원소가 컬렉션 안에 있었다면(즉, 호출 결과 이 컬렉션이 변경 됐다면) true를 반환한다.

 Implementation Requirements: 이 메서드를 컬렉션을 순회하며 주어진 원소를 찾도록 구현되었다. 주어진 원소를 찾으면 반복자의 remove메서드를 사용해 컬렉션에서 제가한다. 이 컬렉션이 주어진 객체를 찾고 있으나, 이 컬렉션의 iterator 메서드가 반환한 반복자가 remove 메서드를 구현하지 않았다면 UnsupportedOperationException을 던지니 주의하자.

iterator를 재정의하면 remove메서드에 영향이 있다는 것을 확실히 명시한다. 

 

클래스의 내부 동작 과정 중간에 끼어들수 있는 훅을 잘 선별하여 protected메서드 형태로 공개해야 할 수도 있다. 

protected void removeRange(int fromIndex, int toIndex)

fromIndex(포함)부터 toIndex(미포함)까지의 모든 원소를 이 리스크에서 제거한다. toIndex이후의 원소들은 앞으로 (index만큼씩) 당겨진다. 이 호출로 리스트는 'toIndex - fromIndex'만큼 짧아진다. (toIndex == fromIndex라면 아무런 효과가 없다.)
 이 리스트 훅은 이 리스트 부분리스트에 정의된 clear엿나이 이 메서드에서 호출한다. 리스트 구현의 내부 구조를 활용하도록 이 메서드를 재정의하면 이 리스트와 부분리스트이 clear 연산 성능을 크게 개선할 수 있다.

 Implementation Requirements: 이 메서드는 fromIndex에서 시작하는 리스트 반복자를 얻어 모든 원소를 제거할 때까지 ListIterator.next와 ListIterator.remove를 반복 호출하도록 구현되었다. 주의: ListIterator.remove가 선형 시간이 걸리면 이 구현의 성능은 제곱에 비례한다.

Parameters:
    fromIndex      제거할 첫 원소의 인덱스
    toIndex          제거할 마지막 원소의 다음 인덱스

List구현체의 최종 사용자는 removeRange 메서드에 관심이 없다. 그럼에도 이 메서드를 제공하는 이유는 clear메서드를 고성능으로 만들기 위함이다. 상속용 클래스르 설계할 때 protected로 노출해야 할지는 어떻게 결정할까? 직접 시험해보는 방법이 유일하다. 

 

상속용 클래스의 생성자는 직접적으로든 재정의 가능 메서드를 호출해서는 안 된다. 상위클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의한 메서드가 하위 클래스의 생성자보다 먼저 호출한다.


Super 클래스

public class Super {

    public Super() {
        overrideMe();
    }

    public void overrideMe() {
    }
}

Sub클래스

public class Sub extends Super{

    private final Instant instant;

    Sub() {
        this.instant = Instant.now();
    }

    @Override
    public void overrideMe() {
        System.out.println(instant);
    }
}

instant가 2번 찍힐것이라고 생각하지만 첫 번째는 null이 나온다. 왜냐하면 overrideMe가 재정의 되기 전이기 때문이다.


clone과 readObject 메서드는 생성자와 비슷한 효과를 낸다( 새로운 객체를 만든다 ). Cloneable과 Serializable을 구현할지 정해야 한다면, 이들을 구현할 때 따르는 제약도 생성자와 비슷하다는 점에 주의하자. 즉, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능메서드를 호출해서는 안 된다. 

 

이러한 제약사항때문에 상속용으로 설계하지 않은 클래스는 상속을 금지하는 것이다. 상속을 김지하는 방법은 2가지 이다. 첫 번째는 final class로 선언하는 것이다. 두 번째 선택지는 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩터리를 만들어주는 방법이다.

 

상속가능한 클래스를 만들고 싶다면, 재정의 가능 메서드를 호출하는 자기 사용 코드를 완벽하게 제거하라.

728x90

'Language > Effective java' 카테고리의 다른 글

클래스와 인터페이스  (0) 2022.06.17
클래스와 인터페이스  (0) 2022.06.14
클래스와 인터페이스  (0) 2022.06.12
클래스와 인터페이스  (0) 2022.06.12
모든 객체의 공통 메서드  (0) 2022.06.11

댓글