Language/Java

[이펙티브자바] 아이템 19, 20, 21 요약 정리

anxi 2024. 6. 5. 00:06

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

문서화해놓지 않은 '외부' 클래스를 상속하는 것은 위험하다.

프로그래머의 통제권 밖에 있고, 언제 어떻게 변경되는지 알 수 없다.

 

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

재정의할 수 있는 메서드

  • public과 protected 메서드 중 final이 아닌 모든 메서드

즉, 재정의 가능 메서드를 호출할 수 있는 모든 상황을 문서로 남겨야 한다.

 

예시

java.util.AbstractCollection

  • @implSpec 태그를 붙여 자바독 도구가 절을 생성해준다.
  • 위 설명에서는 iterator 메서드를 재정의하면 remove 메서드의 동작에 영향을 줄 수 있음을 명시한다.
  • iterator 메서드는 재정의할 수 있는 메서드다. (default 메서드)

클래스를 안전하게 상속할 수 있게 하려면 내부 구현 방식을 설명해야 한다.

내부 메커니즘을 문서로 남기는 것만이 상속을 위한 설계의 전부는 아니다.

 

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

드물게는 protected 필드로 공개할 수도 있다.

 

그렇다면 어떤 메서드를 protected로 노출해야 할까?

정답은 없다. 실제 하위 클래스를 만들어 시험해보는 것이 최선이다.

상속용 클래스를 시험하기 위해 직접 하위 클래스를 만들어보는 것이 유일하다. (3개 정도 검증을 위한 하위클래스 작성하기)

 

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

  • 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 호출된다.
  • 이때 그 재정의한 메서드가 하위 클래스의 생성자에서 초기화하는 값에 의존한다면 의도대로 동작하지 않는다.
public class Super {
	// 생성자가 재정의 가능 메서드를 호출한다.
    public Super() {
    	overrideMe();
    }
    
    public void overrideMe(){}
}

public class Sub extends Super {
	// 초기화 되지 않은 final 필드, 생성자에서 초기화한다.
    private final Instant instant;
    
    public Sub() {
    	this.instant = Instant.now();
    }
    
    // 재정의 가능 메서드, 상위 클래스의 생성자 호출
    @Override
    public void overrideMe() {
    	System.out.println(instant);
    }
    
    public static void main(String[] args) {
    	Sub sub = new Sub();
        sub.overrideMe();
    }
}

 

  • 결과 : null
  • 이유 
    • 상위 클래스의 생성자는 하위 클래스의 생성자보다 먼저 overrideMe()를 호출하기 때문이다.
    • overrideMe() 에서 instant 객체의 메서드를 호출하려 한다면 상위 클래스의 생성자가 overrideMe를 호출할 때 NPE가 발생한다.
    • 즉, 상위 클래스에 재정의된 메서드(overrideMe)가 하위 클래스 생성자보다 먼저 호출됨으로써 프로그램 오작동이 발생한다.

또한 Cloneable과 Serializable 인터페이스는 상속할 수 있게 설계하는 것이 좋지 않다.

clone과 readObject 메서드는 생성자와 비슷한 효과를 낸다. (새로운 객체 생성)

즉, clone과 readObject 모두 직접적으로든 간접적으로든 재정의 가능 메서드를 호출해서는 안된다. (위에서 말한 내용과 동일)

 

Serializable을 구현한 상속용 클래스가 readResolve나 writeReplace 메서드를 갖는다면 해당 메서드들은 protected로 선언하자.

 

결론

클래스를 상속용으로 설계하는 것은 엄청난 노력이 들고 제약이 상당하다.

일반적으로 사용하는 구체 클래스(final x, 상속용 x, 문서화 x)도 상속할 수 있기 때문에 확장한 클래스에서 문제가 생길 수 있다.

 

따라서 상속용으로 설계하지 않는 클래스는 상속을 금지하는 것이 가장 안전하다.

상속 금지 방식

  1. 클래스를 final로 선언한다.
  2. 모든 생성자를 private나 package-private으로 선언하고 public 정적 팩터리를 만들어준다. (생성자를 외부에서 접근할 수 없게)
    1. 정적 팩터리는 내부에서 다양한 하위 클래스를 만들어 쓸 수 있기 때문이다.
  3. 핵심 기능 정의한 인터페이스 사용, 구현하기
  4. 래퍼 클래스 패턴

 

구체클래스에서 발생하는 문제를 막으려면?

  • 클래스 내부에서 재정의 가능 메서드를 사용하지 않고 이 사실을 문서화하기.

아이템 20. 추상 클래스보다는 인터페이스를 우선하라

추상클래스 vs 인터페이스

  • 공통점
    • 다중 구현 메커니즘
    • 두 메커니즘 모두 인스턴스 메서드를 구현 형태로 제공 가능(자바 8 : 인터페이스 - 디폴트 메서드 제공)
  • 차이
    • 추상 클래스 : 정의한 타입을 구현해야 하는 클래스는 반드시 추상 클래스의 하위 클래스이어야 한다. (상속)
    • 인터페이스 : 같은 타입
      • 기존 클래스에도 손쉽게 새로운 인터페이스를 구현해 넣을 수 있다.
      • 추상클래스는 기존 클래스 위에 끼어넣기 어렵다.
    • 믹스인(mixin) 정의에 안성맞춤
      • 믹스인 : 클래스가 구현할 수 있는 타입, 믹스인을 구현한 클래스에 원래 주된 타입 외에도 특정 선택정 행위 제공 가능
      • ex) Comparable : 믹스인 인터페이스

public class Compare implements Comparable<Compare> {
	
    // 믹스인 : 자신을 구현한 클래스(Compare)의 인스턴스끼리 순서를 정할 수 있게 함
    @Override public int compareTo(Compare o) { } 
    
    // 주된 타입
    public void plus() {
    	this.count++;
    }

 

  • 계층 구조가 없는 타입 프레임워크를 만들 수 있다.
public interface Singer {
    Sing sing(Song s);
}

public interface SongWriter {
    Song compose();
}

// Singer와 Songwriter를 모두 확장하고 새로운 메서드까지 추가한 제3의 인터페이스 정의 가능
public interface SingerSongWriter extends Singer, SongWriter { 
    Sing singAndWrite();
    void mixing();
}

 

  • 래퍼 클래스 관용구와 함께 사용하면 인터페이스 기능을 향상시키는 수단이 된다.

인터페이스 - default 메서드 특징

  • 자바 8부터 제공
  • @implSpec 자바독 태그 붙여 문서화
  • equals and hashCode 제공 불가
  • 인스턴스 필드 가지지 못함
  • public이 아닌 정적 멤버도 가질 수 없다. (private 정적 메서드는 가능)
  • 만들지 않은 인터페이스는 디폴트 메서드 추가 X

인터페이스 + 추상클래스 장점 더하기

  • 인터페이스 장점
    • 타입 정의, 디폴트 메서드 제공
  • 추상 클래스
    • 골격 구현 클래스(추상)에서 남은 메서드 구현
  • 위 장점을 합친 패턴을 템플릿 메서드 패턴이라고 한다.
  • 골격 구현 클래스의 이름은 Abstract + 인터페이스 명 으로 한다.
    • ex) Abstract + Collection(Interface), Abstract + Set(Interface)

골격 구현 작성 방식

  1. 인터페이스를 잘 살펴 다른 메서드들의 구현에 사용되는 "기반 메서드"를 선정한다.
    1. 기반 메서드들은 골격 구현에서의 추상 메서드가 된다.
  2. 기반 메서드들을 사용해 직접 구현할 수 있는 메서드를 모두 디폴트 메서드로 제공한다.
  3. 기반 메서드나 디폴트 메서드로 만들지 못한 메서드가 있다면 인터페이스를 구현하는 골격 구현 클래스를 만들어 남은 메서드들을 작성한다. 이때 필요하면 public이 아닌 필드와 메서드를 추가해도 된다.
  4. 골격 구현은 기본적으로 상속해서 사용하는 걸 가정하므로 설계 및 문서화 지침을 모두 따라야 한다.
public interface Map.Entry<K, V> {

    // getKey()와 getValue() 메서드는 다른 메서드들의 구현에서 사용될 수 있는 기반 메서드다.
	K getKey();
    V getValue();
    
    // 요런 식으로 사용된다.
    public static <K extends Comparable<? super K>, V> Comparator<Map.Entry<K,V>> comparingByKey() {
            return (Comparator<Map.Entry<K, V>> & Serializable)
                (c1, c2) -> c1.getKey().compareTo(c2.getKey());
    }
    
    // 이런 메서드들은 직접 구현할 수 있으므로 디폴트 메서드로 제공한다.
    default V getOrDefault(Object key, V defaultValue) {
        V v;
        return (((v = get(key)) != null) || containsKey(key))
            ? v
            : defaultValue;
    }
    
    // equals and hashCode는 골격 구현 클래스에서 구현
    public boolean equals(){}
    
    public int hashCode() {}
 }
  
 public abstract class AbstractMapEntry<K, V> implements Map.Entry<K, V> {
 	
    @Override
    public boolean equals(){}
    
    @Override
    public int hashCode() {}
}

아이템 21. 인터페이스는 구현하는 쪽을 생각해 설계하라

자바 8 이전 

기존 구현체를 깨뜨리지 않고는 인터페이스에 메서드를 추가할 수 없었다.

 

자바 8 이후

기존 인터페이스에 메서드를 추가할 수 있는 디폴트 메서드 추가

 

디폴트 메서드 단점

  • 디폴트 메서드는 구현 클래스에 대해 아무것도 모른 채 합의 없이 무작정 '삽입'되는 것 뿐이다.
  • 불변식을 해치지 않는 디폴트 메서드를 작성하긴 어렵다.
  • 컴파일에 성공하더라도 기존 구현체에 런타임 오류를 일으킬 수 있다.
    • 따라서 디폴트 메서드로 새 메서드를 추가하는 일은 꼭 필요한 경우가 아님 피해야 한다.