본문 바로가기

Reading Record/이펙티브자바

아이템 [19] - 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라

상속을 고려한 설계와 문서화란?

1. 상속용 클래스는 재정의할 수 있는 메소드들을 내부적으로 어떻게 이용하는지(자기사용) 문서로 남겨야 한다.

클래스의 API로 공개된 메소드에서 클래스 자신의 또 다른 메소드를 호출할 수도 있다. 이때 호출되는 메소드가 하위 클래스에서 재정의 가능한 메소드라면 이 사실을 호출하는 메소드의 API 설명에 명시해야한다.
더 넓은 의미로는. 재정의 가능 메소드를 호출할 수 있는 모든 상황을 문서로 남겨두어야 한다.
이를 도와주는 용도로 @implSpec 태그가 있는데 이는, javadoc으로 api 생성시 "Implementaion Requirements:"로 대체되고 그 메소드의 내부 동작방식을 설명하는 곳이다. 활성화 하기 위해선 javadoc -tag "implSpec:a:Implementation Requirements:" 를 이용한다.

-tag 명령어는 임의로 규약을 변경할 수 있다. 가령 javadoc -tag 구현:a:"구현 요구 사항"
자세한 사항은 오라클 문서 참조 :
https://docs.oracle.com/javase/8/docs/technotes/tools/windows/javadoc.html#tag

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

AbstractList의 removeRange 메소드

표시된 주석을 보면 removeRange 메소드는 이 리스트 또는 부분 리스트의 clear 메소드에서 호출한다고 나와있다. 또한 리스트 구현의 내부 구조의 이점을 잘 활용하여 removeRange메소드를 재정의하면 이 리스트 또는 부분 리스트의 clear메소드 성능을 향상 시킬 수 있다 라고 나와있다. 
이 메소드를 제공한 이유는 단지 하위 클래스에서 부분리스트의 clear 메소드를 고성능으로 만들기 쉽게 하기 위해서이다. 
그렇다면, 상속용 클래스에서 어떤 메소드를 protected로 노출해야 할지는 어떻게 결정할까?

3. 상속용 클래스를 시험하는 방법은 직접 하위 클래스를 만들어보는 것이 '유일'하다. 또한 상속용 클래스는 배포 전에 반드시 하위 클래스를 만들어 검증해야한다.

하위 클래스를 만들며 상속용 클래스를 검증한다. 놓친 protected 멤버는 검증 도중 빈자리가 확연히 드러날 것이고 반대로 여러 하위 클래스를 만들면서 전혀 쓰이지 않는 protected 멤버는 private이었야 할 가능성이 크다.

4. 상속용 클래스의 생성자는 직접적으로든 간접적으로든 재정의 가능 메소드를 호출해서는 안 된다.

이는 아이템[13] 에서도 나왔던 문제와 비슷하다. 아이템 [13]에선 상위 클래스의 clone 메소드가 재정의 가능한 메소드를 호출하도록 구현하였을 때, 하위 클래스의 clone 메소드를 호출하면 상위 클래스의 clone에서 하위 클래스의 재정의 된 메소드를 호출하여 문제가 발생할 수 있었다. 다음의 코드의 실행 결과를 보자.

public class Super {
    public Super() {
        overrideMe();
    }

    public void overrideMe() {
        System.out.println("super method");
    }
}

public class Sub extends Super{
    private String str;
    public Sub() {
        str = "Sub String";
    }

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

    public static void main(String[] args) {
        Sub sub = new Sub();
    }
}

실행 결과

하위 클래스의 생서자 보다 상위 클래스의 생성자가 먼저 호출되는데, 상위 클래스의 생성자에서 하위 클래스의 재정의 된 메소드를 호출 하여 String 값이 초기화가 되기도전에 접근하여 null이 출력이 되었다.
이와 같은 현상은 Cloneable의 clone과 Serializable의 readObject에서도 발생한다. 이는 두 메소드가 생성자와 비슷한 새로운 객체를 생성하는 효과를 가지기 때문이다. 
다음 코드는 상위 클래스의 readObject에서 재정의 가능한 메소드를 호출하는 코드이다.

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;

public class SerializableFoo implements Serializable {

    private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException{
        ois.defaultReadObject();
        overrideMe();
    }
    public void overrideMe() {
        System.out.println("This is SuperFoo's overrideMe");
    }
}

public class SerializableSubFoo extends SerializableFoo {
    String str;

    public void setStr(String str) {
        this.str = str;
    }

    @Override
    public void overrideMe() {
        System.out.println("This is SubFoo's overrideMe");
        if (str == null) {
            throw new NullPointerException();
        }
        System.out.println(str);
    }
}

테스트 코드는 다음과 같다.

class SerializableSubFooTest {


    @DisplayName("역직렬화시 readObject가 재정의된 메소드 호출")
    @Test
    void name() throws IOException, ClassNotFoundException{
        SerializableSubFoo subFoo = new SerializableSubFoo();
        subFoo.setStr("hi!!!!");

        //직렬화
        byte[] serializedFoo;
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
                oos.writeObject(subFoo);
                serializedFoo = baos.toByteArray();
            }
        }

        // 역직렬화
        assertThatThrownBy(() -> {
            byte[] deserializedMember = Base64.getDecoder().decode(Base64.getEncoder().encodeToString(serializedFoo));
            try (ByteArrayInputStream bais = new ByteArrayInputStream(deserializedMember)) {
                try (ObjectInputStream ois = new ObjectInputStream(bais)) {
                    Object objectMember = ois.readObject();
                    SerializableSubFoo deserialized = (SerializableSubFoo) objectMember;
                    assertSame(deserialized,subFoo);
                }
            }
        }).isInstanceOf(NullPointerException.class);
    }
}

 

테스트 결과

하위 클래스를 역직렬화 할때 상위 클래스의 readObject가 호출될때 하위 클래스의 overrideMe 메소드를 호출하여 NullPointerException을 던진다.
마지막으로 Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메소드를 갖는다면 이 메소드들은 protected로 선언하여야 한다. private으로 선언할 경우 하위 클래스에서 무시되기 때문이다.

5. 상속용으로 설계하지 않은 클래스는 상속을 금지한다.

상속을 금지하는 방법에는 2가지가 존재한다.

  1. 클래스를 final로 선언
  2. 모든 생성자를 private이나 package-private으로 선언하고 public 정적 팩토리를 만들어준다.

특별히 2번째 방법은 내부에서 다양한 하위 클래스를 만들어 쓸 수 있는 유연성을 제공해주는 장점이 있다.

마지막으로 구체클래스가 표준 인터페이스를 구현하지 않았는데 상속을 허용해야 한다면 클래스 내부에서는 재정의 가능 메서드를 사용하지 않게 만들고 이 사실을 문서로 남겨둔다. 즉, 재정의 가능한 메소드를 호출하는 자기 사용코드를 제거해야 한다. 
클래스의 동작을 유지하면서 재정의 가능 메소드를 사용하는 코드를 제거해야할 때는 private '도우미 메소드'를 만들어 재정의 가능 메소드의 기능을 옮기고 도우미 메소드를 호출하도록 수정한다. 
이 방법으로 위의 생성자 관련 예제가 정상적으로 처리되도록 수정해보았다.

public class Super {
    public Super() {
//      overrideMe();
        helpMethod();
    }

    public void overrideMe() {
        helpMethod();
    }

    //도우미 메소드
    private void helpMethod() {
        System.out.println("super method");
    }
}
public class Sub extends Super{
    private String str;
    public Sub() {
        str = "Sub String";
    }

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


    public static void main(String[] args) {
        Sub sub = new Sub();
        sub.overrideMe();
    }
}

실행 결과

실행결과 이전과는 다르게 하위 클래스의 멤버 변수가 초기화 되기도 전에 호출되지도 않았고, 하위 클래스에서 재정의 된 메소드가 정상적으로 동작한다.