본문 바로가기
자바/이펙티브자바

[Effective Java] 이펙티브자바 정리: 4장 클래스와 인터페이스

by 책 읽는 개발자_테드 2022. 1. 27.
반응형

목차

· 아이템 15. 클래스와 멤버의 접근 권한을 최소화하라
· 아이템 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라
· 아이템 17. 변경 가능성을 최소화하라
· 아이템 18. 상속보다는 컴포지션을 사용하라
· 아이템 19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라
· 아이템 20. 추상 클래스보다는 인터페이스를 우선하라
· 아이템 21. 인터페이스는 구현하는 쪽을 생각해 설계하라
· 아이템 22. 인터페이스는 타입을 정의하는 용도로만 사용하라
· 아이템 23. 태그 달린 클래스보다는 클래스 계층구조를 활용하라
· 아이템 24. 멤버 클래스는 되도록 static으로 만들라
· 아이템 25. 톱레벨 클래스는 한 파일에 하나만 담으라


· 클래스와 인터페이스는 추상화의 기본 단위로, 자바 언어에는 클래스와 인터페이스 설계에 사용하는 강력한 요소가 많다.

· 이런 요소를 적절히 활용하여 클래스와 인터페이스를 쓰기 편하고, 견고하며, 유연하게 만드는 방법을 알아보자.

 

아이템 15. 클래스와 멤버의 접근 권한을 최소화하라


· 잘 설계된 컴포넌트는 클래스 내부 데이터 구현 정보를 외부 컴포넌트로부터 잘 숨긴다.

- 구현과 API를 깔끔하게 분리하고, 오직 API를 통해서만 다른 컴포넌트와 소통하며 서로의 내부 동작 방식에는 전혀 개의치 않는다. 

- 정보 은닉 또는 캡슐화라고 하는 이 개념은 소프트웨어 설계의 근간이 되는 원리다.

 

· 정보 은닉의 장점:

1. 시스템 개발 속도를 높인다.

- 여러 컴포넌트를 병렬로 개발할 수 있기 때문이다.

2. 시스템 관리 비용을 낮춘다.

- 각 컴포넌트를 더 빨리 파악하고 디버깅할 수 있고, 다른 컴포넌트로 교체하는 부담도 적기 때문이다.

3. 성능 최적화에 도움을 준다.

- 완성된 시스템을 프로파일링해 최적화할 컴포넌트를 정한 다음(아이템 67) 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트를 최적화할 수 있기 때문이다.

4. 소프트웨어 재사용성을 높인다.

- 외부에 거의 의존하지 않고 독자적으로 동작할 수 있는 컴포넌트라면 그 컴포넌트와 함께 개발되지 않은 낯선 환경에서도 유용하게 쓰일 가능성이 크기 때문이다.

5. 큰 시스템을 제작하는 난이도를 낮춘다.  

- 시스템 전체가 아직 완성되지 않은 상태에서도 개별 컴포넌트의 동작을 검증할 수 있기 때문이다.

 

이러한 장점들은 대부분 시스템을 구성하는 컴포넌트를 서로 독립시켜 개발, 테스트, 최적화, 적용, 분석, 수정을 개별적으로 할 수 있게 해주는 것과 연관되어 있다.

 

· 자바는 정보 은닉을 위한 다양한 장치를 제공한다. 그중 접근 제어 메커니즘클래스, 인터페이스, 멤버의 접근 허용 범위를 명시한다.

· 각 요소의 접근 허용 범위는 접근 제한자(private, protected, public)로 정해진다. 접근 제한자를 제대로 활용하는 것이 정보 은닉의 핵심이다. 모든 클래스와 멤버는 소프트웨어가 올바로 동작하는 한 가장 낮은 접근 수준을 부여해야한다.

 

톱레벨 클래스와 인터페이스의 접근 수준

· 톱레벨(가장 바깥) 클래스와 인터페이스에 부여할 수 있는 접근 수준: package-private과 public

 

· 톱레벨 클래스인터페이스를 선얼할 때 외부에서 쓸 이유가 없다면 package-private로 선언하자. 그러면 해당 패키지 안에서만 이용할 수 있으므로, API가 아닌 내부 구현이 되어 언제든 수정할 수 있다.

- 즉, 클라이언트에 아무런 피해 없이 다음 릴리스에서 수정, 교체, 제거할 수 있다.

- 반면, public으로 선언하면 공개 API가 되므로 하위 호환을 위해 영원히 관리해야한다.

 

·  한 클래스에서만 사용하는 package-private 톱레벨 클래스나 인터페이스는 이를 사용하는 클래스 안에 private static으로 중첩시키자(아이템 24).

- 톱 레벨로 두면 같은 패키지의 모든 클래스가 접근할 수 있지만, private static으로 중첩시키면 바깥 클래스 하나에서만 접근할 수 있다.

-

멤버의 접근 수준

· 멤버(필드, 메서드, 정청 클래스, 중첩 인터페이스)에 부여할 수 있는 접근 수준:

1. private: 멤버를 선언한 톱레벨 클래스에서만 접근할 수 있다. 

2. package-private: 멤버가 소속된 패키지 안의 모든 클래스에서 접근할 수 있다.

클래스 멤버의 접근 제한자를 명시하지 않으면 자동으로 적용되는 접근 수준이다. 단, 인터페이스의 멤버는 기본적으로 public이다.

3. protected: package-private의 접근 범위를 포함하며, 추가적으로 해당 클래스의 하위 클래스에서도 접근할 수 있다.

4. public: 모든 곳에서 접근할 수 있다.

 

· 클래스의 공개 API를 세심히 설계한 후, 그 외의 모든 멤버는 private로 만들자. 그런 다음 오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여 package-private로 풀어주자. 

- 권한을 풀어주는 일이 자주 발생하면, 시스템에서 컴포넌트를 더 분해해야 하는게 아닌지 고민하자.

- private, package-private 멤버는 모두 해당 클래스의 구현에 해당하므로, 보통 공개 API에 영향을 주지 않는다. 단, Serializable을 구현한 클래스에서는 필드들이 의도치 않게 공개 API가 될 수도 있다(아이템 86, 87).

 

· public 클래스의 protected 멤버는 공개 API이므로 영원히 지원돼야 한다. 또한 내부 동작 방식을 API 문서에 적어 사용자에게 공개해야 할 수도 있다(아이템 19). 따라서 protected 멤버의 수는 적을수록 좋다.

 

· 상위 클래스의 메서드를 재정의할 때 그 접근 수준을 상위 클래스에서보다 좁게 설정할 수 없다.

- 이는 멤버 접그성을 좁히지 못하게 방해하는 제약이다. 이 제약은 상위 클래스의 인스턴스는 하위 클래스의 인스턴스로 대체해 사용할 수 있어야 한다는 규칙(리스코프 치환 원칙, 아이템10)을 지키기 위해 필요하다. 이 규칙을 어기면 컴파일 오류가 발생한다.

ex) 클래스가 인터페이스를 구현할때, 인터페이스가 정의한 모든 메서드를 public으로 선언해야 한다.

 

· 코드를 테스트하려는 목적으로 클래스, 인터페이스, 멤버의 접근 범위를 넓히는 경우 적당한 수준까지만 허용된다.

- public 클래스의 private 멤버를 package-private까지 풀어주는 것은 허용할 수 있지만, 그 이상은 안된다.

- 테스트 코드를 테스트 대상과 같은 패키지에 두면 package-private 요소에 접근할 수 있기 때문이다.

 

· public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다(아이템16). 그렇지 않으면 다음과 같은 부작용이 있다.

1. 필드가 가변 객체를 참조하거나 final이 아닌 인스턴스 필드를 public으로 선언하면, 그 필드에 담을 수 있는 값을 제한할 힘을 읽는다. 즉, 필드와 관련된 모든 것은 불변식을 보장할 수 없게 된다.

2. 일반적으로 스레드 안전하지 않다. 필드가 수정될 때 (락 획득 같은) 다른 작업을 할 수 없게 된다.

3. final이면서 불분 객체를 참조하더라도 문제가 남는다. 내부 구현을 바꾸고 싶어도 해당 필드를 없애는 방식으로 리팩터링할 수 없다.

 

·  예외적으로, 해당 클래스가 표현하는 추상 개념을 완성하는 데 꼭 필요한 구성요소로써의 상수라면, public static final 필드로 공개해도 좋다.

- 관례상 이런 상수 이름은 대문자 알파벳으로 쓰며, 각 단어 사이에 밑줄(_)을 넣는다(아이템68).

- 이런 필드는 반드시 기본 타입 값이나 불변 객체를 참조해야 한다(아이템 17). 가변 객체를 참조하면 final이 아닌 필드에 적용되는 모든 불이익이 그대로 적용된다. 다른 객체를 참조하지는 못하지만, 참조된 객체 자체는 수정할 수 있게 된다.

 

· 길이가 0이 아닌 배열은 모두 변경 가능하니 주의하자.

- 따라서 클래스에서 public static final 배열 필드를 두거나 이 필드를 반환하는 접근자 메서드를 제공해서는 안 된다.

- 이런 필드나 접근자를 제공하면, 클라이언트에서 그 배열의 내용을 수정할 수 있게 된다.

ex) public static final Thing[] VALUE = {...}; // 보안 허점이 숨어 있다.

 

· 어떤 IDE가 생성하는 접근자는 private 배열 필드의 참조를 반환하여 이 같은 문제를 똑같이 일으키니 주의하자.

- 해결책 1: 앞 코드의 public 배열을 private으로 만들고 public 불변 리스트를 추가한다.

private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));


- 해결책 2: 배열을 private으로 만들고 방어적 복사본을 반환하는 public 메서드를 추가한다.

private static final Thing[] PRIVATE_VALUES = { ... };
public static final Thing[] values() {
	return PRIVATE_VALUES.clone();
}

- 어느 반환 타입이 더 쓰기 편할지, 성능은 어느 쪽이 나을지 고민해 정하자.

 

모듈 시스템

· 자바 9에서는 모듈 시스템이라는 개념이 되입되면서 두 가지 암묵적 접근 수준이 추가되엇다.

- 패키지가 클래스들의 묶음이듯, 모듈은 패키지들의 묶음이다.

- 모듈은 자신에 속하는 패키지 중 공개(export)할 것들을 (관례상 module-info.java 파일에) 선언한다.protected 혹은 public 멤버라도 해당 패키지를 공개하지 않았다면, 모듈 외부에서 접근할 수 없다.

- 모듈 시스템을 활용하면 클래스를 외부에 공개하지 않으면서 같은 모듈을 이라는 패키지 사이에서는 자유롭게 공유할 수 있다.

- 두 가지 암묵적 접근 수준은 숨겨진 패키지 안에 있는 public 클래스의 public 혹은 protected 멤버와 관련이 있다.

- 이 암묵적 접근 수준들은 각각 public 수준과 protected 수준과 같으나, 그 효과가 모듈 내부로 한정되는 변종이다.

- 이런 형태로 공유해야 하는 상황은 흔하지 찮다. 그래야 하는 상황이 벌어져도 패캐지들 사이에서 클래스들을 재배치하면 대부분 해결된다.

TODO: 이해 부족

 

· 4개의 기존 접근 수준과 달리, 모듈에 적용되는 새로운 두 접근 수준은 주의해서 사용해야 한다.

- 모듈의 JAR 파일을 자신의 모듈 경로가 아닌 애플리케이션의 클래스패스에 두면 그 모듈 안의 모든 패키지는 마치 모듈이 없는 것처럼 행동한다. 즉, 모듈이 공개했는지 여부와 상관없이 public 클래스가 선언한 모든 public 혹은 protected 멤버를 모듈 밖에서도 접근할 수 있게 된다.

 

· JDK는 새로 등장한 이 접근 수준을 적극 활용한 대표적인 예다.

- 자바 라이브러리에서 공개하지 않은 패키지들은 해당 모듈 밖에서 절대로 접근할 수 없다.

 

· 접근 보호 방식이 추가된 것 말고도, 모듈은 여러 면에서 자바 프로그래밍에 영향을 준다.

- 모듈의 장점을 제대로 누리려면 해야 할 일이 많다. 1. 패키지를 모듈 단위로 묶고, 모듈 선언에 패키지들의 모든 의존성을 며시한다. 2. 그 후 소스 트리를 재배치하고, 모듈 안에서부터 (모듈 시스템을 적용하지 않은) 일반 패키지로의 모든 접근에 특별한 조치를 취해야 한다.

 


아이템 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라


· 클래스의 데이터 필드에 직접 접근할 수 있으면 캡슐화의 이점을 제공하지 못한다(아이템 15).

- API를 수정하지 않고는 내부 표현을 바꿀 수 없고, 불변식을 보장할 수 없으며, 외부에서 필드에 접근할 때 부수 작업을 수행할 수도 없다.

// 캡슐화에 이점을 제공하지 못하는 클래스
class Point{
	public double x;
	public double y;
}

 

·  클래스의 필드는 모두 private으로 바꾸고 public 접근자(getter)를 추가하자.

- 패키지 바깥에서 접근할 수 있는 클래스라면 접근자를 제공함으로써 클래스 내부 표현 방식을 언제든 바꿀 수 있는 유연성을 제공한다.

- public 클래스가 필드를 공개하면 이를 사용하는 클라이언트가 생거날 것이므로 내부 표현 방식을 마음대로 바꿀 수 없게 된다.

class Point{
	private double x;
	private double y;
   	
	public Point(double x, double y){
		this.x = x;
		this.y = y;
	}
    
	public double getX() { return x; }
	public double getY() { return y; }
    
	public void setX(double x){ this.x = x; }
	public void setY(double y){ this.y = y; }
}

 

· package-private 클래스 또는 private 중첩 클래스라면 데이터 필드를 노출한다 해도 하등 문제가 없다.

- 이 방식은 클래스 선언 면에서나 이를 사용하는 클라이언트 코드 면에서나 접근자 방식보다 훨씬 깔끔하다.

- 클라이언트 코드가 이 클래스 내부 표현에 묶이기는 하나, 클라이언트도 어차피 이 클래스를 포함하는 패키지 안에서만 동작하는 코드다. 따라서 바깥 코드는 전혀 손대지 않고도 데이터 표현 방식을 바꿀 수 있다.

- private 중첩 클래스의 경우 수정 범위가 더 좁아져서 이 클래스를 포함하는 외부 클래스까지로 제한된다.

 

· 자바 플랫폼 라이브러리에도 public 클래스의 필드를 직접 노출한 사례가 있다. java.aws.package 패키지의 Point와 Dimenssion 클래스로, 그 중 Dimenssion 클래스는 해당 문제로 심각한 성능 문제가 있다(아이템 67).

 

· public 클래스의 필드가 불변이라면 직접 노출할 때의 단점이 조금은 줄어들지만, 여전히 좋은 생각은 아니다.

- API를 변경하지 않고는 표현 방식을 바꿀 수 없다.

- 필드를 읽을 때 부수 작업을 수행할 수 없다.

- 단, 불변식은 보장할 수 있다. 다음 클래스는 각 인스턴스가 유효한 시간을 표현함을 보장한다.

public final class Time {
    private static final int HOURS_PER_DAY = 24;
    private static final int MINUTES_PER_HOUR = 60;

    public final int hour;
    public final int minute;

    public Time(int hour, int minute) {
        validateTime(hour, minute);
        this.hour = hour;
        this.minute = minute;
    }

    private void validateTime(int hour, int minute) {
        // .. 유효성 검증 로직, 불변식 보장
    }

    // ...
}

 

아이템 17. 변경 가능성을 최소화하라


· 불변 클래스란 그 인스턴스의 내부 값을 수정할 수 없는 클래스다. 

- 불변 인스턴스에 간직된 정보는 고정되어 객체가 파괴되는 순간까지 달라지지 않는다.

- 불변 클래스는 설계하고 구현하고 사용하기 쉽고, 오류가 생길 여지도 적고 안전하다.

 

· 자바 플랫폼 라이브러리는 다양한 불변 클래스를 제공한다. ex) String, 기본 타입 박싱 클래스, BigInteger, BigDecimal

 

클래스를 불변으로 만드는 규칙 5가지

1. 객체의 상태를 변경하는 메서드를 제공하지 않는다.

2. 클래스를 확장할 수 없도록 한다.

- 하위 클래스에서 부주의하게 혹으 나쁜 의도로 객체의 상태를 변하게 만드는 사태를 막아준다.

- 상속을 막는 대표적인 방법은 클래스를 final로 선언하는 것이다. (다른 방법은 뒤에 설명)

3. 모든 필드를 final로 선언한다.

- 새로 생성된 인스턴스를 동기화 없이 다른 스레드로 건네도 문제없이 동작하게 보장해준다.

4. 모든 필드를 private으로 선언한다.

- 필드가 참조하는 가변 객체를 클라이언트에서 직접 접근해 수정하는 일을 막아준다.

- public final로만 선언해도 불변 객체가 되지만, 이러게 하면 다음 릴리스에서 내부 표현을 바꾸지 못하므로 권장 x

5. 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.

- 클래스에 가변 객체를 참조하는 필드가 하나라도 있다면 클라이언트에서 그 객체의 참조를 얻을 수 없도록 해야 한다.

- 이런 필드는 절대 클라이언트가 제공한 객체 참조를 가리키게 해서는 안 되며, 접근자 메서드가 그 필드를 그대로 반환해서도 안 된다.

- 생성자, 접근자, readObject 메서드(아이템 88) 모두에서 방어적 복사를 수행하자.

 

불변 클래스 예시 - 불변 복소수 클래스

public final class Complex {
    private final double re;
    private final double im;

    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    public double realPart() {
        return re;
    }

    public double imaginaryPart() {
        return im;
    }

    public Complex plus(Complex complex) {
        return new Complex(re + complex.re, im + complex.im);
    }

    // ...

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Complex complex = (Complex) o;
        return Double.compare(complex.re, re) == 0 && Double.compare(complex.im, im) == 0;
    }

    @Override
    public int hashCode() {
        return Objects.hash(re, im);
    }
}

 

· 위 코드에서 사칙 연산 메서드들은 인스턴스 자신을 수정하지 않고, 새로운 Complex 인스턴스를 만들어 반환한다. 이처럼 피연산자에 함수를 적용해 그 결과를 반환하지만, 피연산자 자체는 그대로인 프로그래밍 패턴을 함수형 프로그래밍이라 한다.

- 해당 방식으로 프로그래밍하면, 코드에서 불변이 되는 영역의 비율이 높아지는 장점이 있다.

- 이와 달리, 절차적 혹은 명령형 프로그래밍에서는 메서드에서 피연산자인 자신을 수정해 자신의 상태가 변하게 된다.

 

· 위 코드에서 메서드 이름은 (add 같은) 동사 대신 (plus 같은) 전치사를 사용한다. 이는 해당 메서드가 객체의 값을 변경하지 않는다는사실을 강조하려는 의도다.

- BigInteger, BigDecimal 클래스는 이 명명 규칙을 따르지 않아 잘못 사용해 오류가 발생하는 일이 자주 있다.

 

· 위 코드는 불변을 설명하기 위한 예일 뿐 실무에서 쓸 만한 수준이 못된다. 복수소 곱셈과 나눗셈을 표준 공식대로 구현했지만, 반올림을 제대로 처리하지 않고 복소수 NaN과 무한대도 다루지 못한다.

 

불변 객체의 장점

· 불변 객체단순하다. 생성된 시점의 상태를 파괴될 때까지 그대로 간직한다.

- 모든 생성자가 클래스 불변식(class invariant)을 보장한다면, 그 클래스를 사용하는 프로그래머가 다른 노력을 들이지 않더라도 영원히 불변으로 남는다.

- 반면 가변 객체는 임의의 복잡한 상태에 놓일 수 있다. 변경자 메서드가 일으키는 상태 전이를 정밀하게 문서로 남겨놓지 않르면 믿고 사용하기 어려울 수도 있다.

 

· 불변 객체는 근본적으로 스레드 안전하여 따로 동기화할 필요 없다. 

- 여러 스레드가 동시에 사용해도 절대 훼손되지 않는다.

- 불변 객체에 대해서는 그 어떤 스레드도 다른 스레드에 영향을 줄 수 없으니 안심하고 공유할 수 있다.

- 따라서 불변 클래스라면 한 번 만든 인스턴스를 최대한 재활용하기를 권한다.

- 재활용 방법: 자주 쓰이는 값들을 상수(public static final)로 제공한다.

public static final Complex ZERO = new Complex(0, 0); 
public static final Complex ONE = new Complex(1, 0); 
public static final Complex I = new Complex(0, 1);

 

· 불변 클래스는 자주 사용되는 인스턴스를 캐싱하여 같은 인스턴스를 중복 생성하지 않게 해주는 정적 팩터리(아이템1)를 제공할 수 있다.

ex) 박싱된 기본 타입 클래스, BigInteger

- 이런 정적 팩터리를 사용하면 여러 클라이언트가 인스턴스를 공유하여 메모리 사용량과 GC 비용이 줄어든다.

- 새로운 클래스를 설계할 때 public 생성자 대신 정적 팩터리를 만들어두면, 클라이언트를 수정하지 않고도 필요에 따라 캐시 기능을 나중에 덧붙일 수 있다.

 

· 불변 객체를 자유롭게 공유할 수 있다는 점은 방어적 복사(아이템 50)도 필요 없다는 결론으로 자연스럽게 이어진다.

- 아무리 복사해도 원본과 똑같으니 복사 자체가 의미 없다.

- 그러나 불변 클래스는 clone 메서드나 복사 생성자(아이템 13)를 제공하지 않는 게 좋다.

ex) String 클래스의 복사 생성자는 이 사실을 잘 이해하지 못한 자바 초창기에 만들어 졌다. 되도록 사용하지 말자.

 

· 불변 객체끼리는 내부 데이터를 공유할 수 있다.

ex) BigInteger 클래스는 내부에서 값의 부호(sign)와 크기(magnititude)를 따로 표현한다. 부호에는 int 변수를 , 크기(절댓값)에는 int 배열을 사용한다. negate 메서드는 크기가 같고 부호만 반대인 새로운 BigInteger를 생성하는데, 이때 배열은 가변이지만 복사하지 않고 원본 인스턴스와 공유해도 된다. 그 결과 새로 만든 BigInteger 인스턴스도 원본 인스턴스가 가리키는 내부 배열을 그대로 가리킨다.

(TODO: 무슨 말인지 모르겠다...)

 

· 객체를 만들 때 다른 불변 객체들을 구성요소로 사용하면 이점이 많다.

- 값이 바귀지 않는 구성요소들로 이뤄진 객체라면, 구조가 복잡해도 불변식을 유지하기 수월하다.

ex) 불변 객체는 맵의 키와 집합(Set)의 원소로 쓰기에 알맞다. 맵이나 집합은 안에 담긴 값이 바뀌면 불변식이 허물어지는데, 불변 객체를 사용하면 그런 걱정을 하지 않아도 된다.

 

· 불변 객체는 그 자체로실패 원자성을 제공한다(아이템 76).

- 상태가 절대 변하지 않으니 잠깐이라도 불일치 상태에 빠질 가능성이 없다.

 

불변 객체의 단점

· 값이 다르면 독립된 객체로 만들어야한다.

- 값의 가짓수가 많다면 큰 비용을 치러야한다.

ex) 백만 비트짜리 BigInteger에서 비트 하나를 바꿔야 한다고 해보자. 

BigInteger moby = ...;
moby = moby.flipBIt(0);

 

flipBit 메서드는 새로운 BigInteger 인스턴스를 생성한다. 또한 이 연산은 BigInteger의 크기에 비례해 시간과 공간을 잡아 먹는다. BitSet도 BiggInteger 처럼 임의의 길이의 비트 순열을 표현하지만, BigInteger와 달리 가변이다. BitSet 클래스는 원하는 비트 하나만 상수 시간에 바꿔주는 메서드를 제공한다.

BitSet moby = ...;
moby.flip(0);

- 원하는 객체를 완성하기까지 단계가 말고, 그 중간 단계에서 만들어진 객체들이 모두 버려진다면 선을 문제가 더 불거진다.

 

· 성능문제 대처 방법:

1. 흔히 쓰일 다단계 연산(multistep operation)들을 예측하여 기본 기능으로 제공한다.

- 각 단계마다 객체를 생성하는 문제를 해결해준다.

- BiggIntegerㄴ는 모듈러 지수 같은 다단계 연산 속도를 높여주는 가변 동반 클래스(companion class)를 packge-private으로 두고 있다.

2. 클라이언트들이 원하는 복잡한 연산을 정확히 예측하 수 있다면 package-private의 가변 동반 클래스만으로 충분하다. 그렇지 않으면, 이 클래스를 public으로 제공하는 게 최선이다.

- String은 StringBuilder와 구닥다리 StringBuffer를 가변 동반 클래스로 두고있다.

 

불변 클래스를 만드는 또 다른 설계 방법

· 모든 생성자를 private 혹은 package-private으로 만들고, public 정적 팩터리를 제공한다.

// 생성자 대신 정적 팩터리를 사용한 불변 클래스
public class Complex2 {
   private final double re;
   private final double im;

   private Complex2(double re, double im) {
      this.re = re;
      this.im = im;
   }

   public static final Complex2 valueOf(double re, double im){
      return new Complex2(re, im);
   }
	// ... 나머지 코드 생략
}

 

· 이 방식이 최선일 때가 많다.

- 바깥에서 볼 수 없는 packge-private 구현 클래스를 원하는 만큼 만들어활용할 수 있으니 훨씬 유연하다.

- public이나 protected 생성자가 없으니 다른 패키지에서 이 클래스를 확장하는 게 불가능하다. 따라서 패키지 바깥의 클래스에서 바라본 이 불변 객체는 사실상 final이다.

- 다수의 구현 클래스를 활용한 유연성을 제공하고, 다음 릴리스에서 객체 캐싱 기능을 추가해 성능을 끌어올릴 수도 있다.

 

BigInteger와 BigDecimal의 설계 오류

· BigInteger와 BigDecimal을 설계할 당시 불변 객체가 사실상 final이어야 한다는 생각이 널리 퍼지지 않았다. 그래서 이 두 클래스의 메서드들은 모두 재정의할 수 있게 설계되었고, 하위 호환성 문제로 지금까지 문제를 고치지 못했다.

 

· 신뢰할 수 없는 클라이언트로부터 BigInteger나 BigDecimal의 인스턴스를 인수로 받는다면 주의해야 한다.

- 이 값들이 불변이어야 클래스의 보안을 지킬 수 있다면, 인수로 받은 객체가 진짜 BigInteger 혹은 BigDecimal인지 확인해야 한다.

- 신뢰할 수 없는 하위 클래스의 인스턴스라고 확인되면, 이 인수들은 가변이라 가정하고 방어적으로 복사해 사용하야 한다(아이템 50).

public static BigInteger safeInstance(BigInteger val){
   return val.getClass() == BigInteger.class ?
      val : new BigInteger(val.toByteArray());
}

 

성능 고려하기

· "모든 필드가 final이고, 어떤메떤 메서드도 그 객체를 수정할 수 없어야 한다"는 규칙은 성능을 위해 다음과 같이 완화될 수 있다.

"어떤 메서드도 객체의 상태 중 외부에 비치는 값을 변경할 수 없다."

 

· 어떤 불변 클래스는 계산 비용이 큰 값을 처음 쓰일 때 계산하여 final이 아닌 필드에 캐싷해놓기도 한다. 똑같은 값을 다시 요청하면 캐시해둔 값을 반환하여 계산 비용을 절감한다.

- 이 묘수는 순전히 그 객첵가 불변이기 때문에 부릴 수 있다. 몇 번을 계산해도 항상 같은 결과가 만들어짐을 보장하기 때문이다.

 

불변으로 만들 수 없는 클래스

· 모든 클래스를 불변으로 만들 수는 없다.

· 불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분을 최소한으로 줄이자.

- 객체가 가질 수 있는 상태의 수가 줄면 그 객체를 예측하기 쉬워지고, 오류가 생길 가능성이 줄어든다. 따라서 다른 합당한 이유가 없다면 모든 필드는 private final이어야 한다.

 

 불변식 초기화

· 생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.

· 확실한 이유가 없다면, 생성자와 정적 팩터리 외에는 그 어떤 초기화 메서드도 public으로 제공해서는 안 된다.

- 객체를 재활용할 목적으로 상태를 다시 초기화하는 메서드도 안된다. 복잡성만 커지고 성능 이점은 거의 없다.

 

정리하기

· 게터가 있다고 해서 무조건 세터를 만들지 말자. 

· 클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다.

· 불변 클래스는 장점이 많으며, 단점은 특정 상황에서의 잠재적 성능 저하뿐이다.

· 단순한 값 객체는 항상 불변으로 만들자.

· String과 BigInteger처럼 무거훈 값 객체도 불변으로 만들 수 있는지 고심한다.

- 성능 때문에 어쩔 수 없다면, 불변 클래스와 쌍을 이루는 가변 동반 클래스를 public 클래스로 제공하자.


아이템 18. 상속보다는 컴포지션을 사용하라


핵심 정리

· 상속을 강력하지만 캡슐화를 해친다는 문제가 있다.

· 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야한다.

- is-a 관계일 때도 안심할 수 없다. 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다.

· 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자.

- 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇다. 래퍼 클래스는 하위 클래스보다 견고하고 강력한다.

 

상속의 단점

· 상속코드를 재활용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만든다.

- 상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면, 상속도 안전한 방법이다.

- 확장할 목적으로 설계되었고, 문서화도 잘 된 클래스(아이템 19)도 안전하다.

- 일반적인 구체 클래스가 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

 

· 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.

- 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오작동할 수 있다.

- 따라서 상위 클래스 설계자가 확장을 충분히 고려하고 문서화도 제대로 해두지 않으면, 하위 클래스는 상위 클래스의 변화에 발맞춰 수정돼야만 한다.

 

상속을 잘못 사용한 예시

· 다음 클래스는 HashSet을 사용하는 프로그램이다. 이 클래스는 문제가 있다.

public class InstrumentedHashSet<E> extends HashSet<E> {
   // 추가된 원소의 수
   private int addCount = 0;

   public InstrumentedHashSet() {
   }

   public InstrumentedHashSet(int initialCapacity, float loadFactor) {
      super(initialCapacity, 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;
   }
}

 

· 이 클래스의 인스턴스에 addAll 메서드로 원소 3개를 더했다고 해보자. getAddCount 메서드를 호출하면 3을 반환하리라 기대하겠지만, 실제로는 6을 반환한다.

 

InstrumentedHashSet<String> s = new InstrumentedHashSet();
s.addAll(List.of("가","나","다"))

 

· 원인은 HashSet의 addAll 메서드가 add 메서드를 사용해 구현된 데있다. 이런 구현 방식은 HashSet 문서에는 쓰여 있지 않다.

 

 addAll 메서드를 재정의하지 않으면?

· 하위 클래스에서 addAll 메서드를 재정의하지 않으면, 문제를 고칠 수 있다. 하지만 HashSet의 addAll이 add 메서드를 이용해 구현했으을 가정한 해법이라는 한계가 있다.

- 자신의 다른 부분을사용하는 자기사용(self-use) 여부는 해당 클래스의 내부 구현 방식에 해당하며, 자바 플랫폼 전반적인 정책인지,  다음 릴르스에서도 유지될지 알 수 없다. 따라서 이런 가정에 기댄 클래스는 꺠지기 쉽다.

 

 addAll 메서드를 다른 식으로 재정의 한다면?

· addAll 메서드를 다른 식으로 재정의할 수도 있다. 예컨대 주어진 컬렉션을 순회하며 원소 하나당 add 메서드를 한 번만 호출하는 것이다.

- 장점: HashSet의 addAll을 더 이상 호출하지 않으니 addAll이 add를 사용하는지와 상관없이 결과가 옳다는 점에서 더 나은 해법이다.

- 단점: 상위 클래스의 메서드 동작을 다시 구현하는 방식은 어렵고, 시간도 더 들고, 오류를 내거나 성능을 떨어뜨릴 수 있다. 또한 하위 클래스에서는 접근할 수 없는 private 필드를 써야 하는 상황이라면 이 방싱으로는 구현 자체가 불가능하다.

 

하위 클래스가 깨지기 추가적인 쉬운 이유 

· 보안 때문에 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야만 하는 프로그램에 메서드를 추가한다면?

- 컬렉션을 상속하여 원소를 추가하는 모든 메서드를 재정의해 필요한 조건을 먼저 검사하게 하면 될 것 같다.

- 문제점: 이 방식이 통하는 것은 상위 클래스에 또 다른 원소 추가 메서드가 만들어지기 전까지다. 다음 릴리스에서 우려한 일이 생기면, 하위 클래스에서 재정의하지 못한 그 새로운 메서드를 사용해 '허용되지 않은' 원소를 추가할 수 있게 된다.

- 실제로 Hashtable과 Vector를 컬렉션 프레임워크에 포함시키자 이와 관련한 보안 구멍들을 수정해야 하는 사태가 벌어졌다.

 

· 메서드를 재정의하는 대신 새로운 메서드를 추가해도 (훨씬 안전한 것은 맞지만) 위험이 존재한다.

- 상위 클래스에 새 메서드가 추가됐는데 하위 클래스에 추가한 메서드와 시그니처가 같고 반환 타입이 다르다면, 클래스는 컴파일조차 되지 않는다.

- 반환 타입 마저 같다면 상위 클래스의 새 메서드르 재정의한 꼴이니 앞서의 문제와 똑같은 상황에 부닥친다.

- 이 메서드를 작성할 때 상위 클래스의 메서드는 존재하지도 않았으니, 만든 메서드는 상위 클래스의 메서드가 요구하는 규약을 만족하지 못한 가능성이 크다.

 

상속의 문제를 해결하는 묘안 - 컴포지션

· 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하는 방식이다.

· 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(구성)이라 한다.

· 새 클래스의 인스턴스 메서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출 해 그 결과를 반환한다.

- 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들은 전달 메서드(forwarding method)라 부른다.

 

· 결과적으로 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 기존 클래스에 새로운 메서드가 추가되어도 영향을 받지 않는다.

 

컴포지션을 적용한 클래스

· 아래 코드는 앞서 구현한 InstrumentedHshSet을 컴포지션과 전달 방식으로 다시 구현한 것이다. 하나는 집한 클래스 자신이고, 다른 하나는 전달 메서드만으로 이뤄진 재사용 가능한 전달 클래스다.

 

// 래퍼 클래스 - 상속 대신 컴포지션 사용
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;
	 }
}

 

// 재사용할 수 있는 전달 클래스
public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;
    public ForwardingSet(Set<E> s) {this.s = s;}
    
    public int size() {return 0;}
    public boolean isEmpty() {return s.isEmpty();}
    public boolean contains(Object o) {return s.contains(o);}
    public Iterator<E> iterator() {return s.iterator();}
    public Object[] toArray() {return s.toArray();}
    public <T> T[] toArray(T[] a) {return s.toArray(a);}
    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 retainAll(Collection<?> c) {return s.retainAll(c);}
    public boolean removeAll(Collection<?> c) {return s.removeAll(c);}
    public void clear() {s.clear();}
    @Override public boolean equals(Object o) {return s.equals(o);}
    @Override public int hashCode() {return s.hashCode();}
    @Override public String toString() {return s.toString();}
}

 

· InstrumentedSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고, 유연하다.

- Set 인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다.

- 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.

 

· 상속 방식은 구체 클래스 각각을 따로 확장해야 하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 하지만, 지금 선보인 컴포지션 방식은 한 번만 구현해두면 어떤 Set 구현체라도 계측할 수 있으며, 기존 생성자들과 함께 사용할 수 있다.

 

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>());
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

 

· InstrumentSet을 이용하면 대상 Set 인스턴스를 특정 조건하에서만 임시로 계측할 수 있다.

static void walk(Set<Dog> dogs) {
    InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
    ... //이 메서드에서는 dogs대신 idogs를 사용한다.
}

 

· 다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴[Gamma95]이라고 한다.

 

· 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.

- 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당한다.

 

· 전달 메서드를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 손쉽게 구현할 수 있다.

ex) 구아바는 모든 컬렉션 인터페이스용 전달 메서드르 전부 구현해뒀다.

 

래퍼 클래스의 단점

· 래퍼 클래스의 단점은 거의 없지만, 콜백 프레임워크와 어울리지 않다는 점만 주의하자.

- 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다. 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자기(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출한다. 이를 SELF 문제라고 한다.

 

· 전달 메서드가 성능에 주는 영향이나 래퍼 객체가 메모리 사용량에 주는 영향을 걱저하는 사람도 있지만, 실전에서는 둘 다 별다른 영향이 없다고 밝혀졌다.

 

상속을 적절하게 사용하는 방법 

· 상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에만 쓰여야 한다. 즉, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야한다.

- 아닌 경우 A를 private 인스턴스로 두고, A와는 다른 API를 제공해야 하는 상황이 대다수다. 즉, A는 B가 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐이다.

- 자바 플랫폼 라이브러리의 Stack과 Vector, Properties와 Hashtable은 는 이를 명백히 위반한 클래스다. 두 사례 모두 컴포지션을 사용했다면 더 좋았을 것이다.

 

· 컴포지션을 써야 할 상황에 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다.

- 그 결과 API가 내부 구현에 묶이고, 클래스의 성능도 영원히 제한된다. 또한 클라이언트가 노출된 내부에 직접 접근할 수 있다. 가장 심각한 문제는 클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수 있다는 것이다.

ex) Properties는 키와 값으로 문자열만 허용하도록 설계하려 했으나, 상위 클래스인 Hashtable의 메서드를 직접 호출하면 이 불변신을 꺠버릴 수 있다. 불변식이 한 번 꺠지면 load, store 같은 properties API는 더 이상 사용할 수 없다. 

 

· 컴포지션 대신 상속을 사용하기로 결정하기 전 자문할 질문들:
1. 확장하려는 클래스의 API에 아무런 결함이 없는가?

2. 결함이 있다면, 이 결함이 확장 클래스의 API까지 전파돼도 괜찮은가?

- 컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API의 결함까지 그대로 승계한다.

 

 

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


핵심 정리

· 상속용 클래스를 설계하기만 만만치 않다.

- 클래스 내부에서 스스로를 어떻게 사용하는지(자기사용 패턴) 모두 문서로 남겨야 하며, 일단 문서화한 것은 그 클래스가 쓰이는한 반드시 지켜야 한다. 그렇지 않으면, 그 내부 구현 방식을 믿고 활용하던 하위 클래스를 오작동하게 만들 수 있다.

 

· 다른 이가 효율 좋은 하위 클래스를 만들 수 있도록 일부 메서드를 protected로 제공해야 할 수도 있다.

· 클래스를 확장해야 할 명확한 이유가 떠오르지 않는다면 상속을 금지하는 편이 낫다.

· 상속을 금지하려면 클래스를 final로 선언하거나 생성자 모두를 외부에서 접글할 수 없도록 만들면 된다.

 

TODO: 내용 추가

 

public class Super {
	// 잘못된 예 - 새엉자가 재정의 가능 메서드를 호출한다.
    public Super() {
        overrideMe();
    }

    public void overrideMe() {
    }
}
public class Sub extends Super{
	// 초기화되지 않은 final 필드, 생성자에서 초기화한다.
    private final Instant instant;

    public Sub() {
        super();
        instant = Instant.now();
    }
    
	// 재정의 가능 메서드, 상위 클래스의 생성자가 호출한다.
    @Override
    public void overrideMe() {
        System.out.println(instant);
    }

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


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


핵심 정리

· 일반적으로 다중 구현용 타입으로는 인터페이스가 가장 적합하다.

· 복잡한 인터페이스라면 구현하는 수고를 덜어주는 골격 구현을 함께 제공하는 방법을 꼭 고려하자.

· 골격 구현은 '가능한' 인터페이스의 디폴트 메서드로 제공하여 그 인터페이스를 구현한 모든 곳에서 활용하도록 하자.

· '가능한'이라고 한 이유는, 인터페이스에 걸려 있는 구현상의 제약 때문에 골격 구현을 추상 클래스로 제공하는 경우가 더 흔하기 때문이다.

 

TODO: 내용 추가

public interface Singer {
	AudioClip sing(Song s);
}

public interface SonrgWriter {
	Song compose(int chartPosition);
}
public interface SingerSongWriter extends Singer, SongWritter {
	AudioClip strum();
    void actSensitive();
}
static List<Integer> intArrayAsList(int[] a) {
	Objects.requireNonNull(a);
    
    // 다이아몬드 연산자를 이렇게 사용하는 건 자바 9부터 가능하다.
    // 더 낮은 버전을 사용한다면 <Integer>로 수정하자.
	return new AbstractList<>() {
    	@Override
        public Integer get(int i) {
        	return a[i]; // 오토박싱(아이팀 6)
        }
        @Override public Integer set(int i, Integer val) {
        	int oldVal = a[i];
            a[i] = val; 	// 오토언박싱
            return oldVal; //오토박싱
        }
        
        @Override public int size() {
        	return a.length;
        }
    };
}
public abstract class AbstractMapEntry<K,V> implements Map.Entry<K,V> {
	// 변경 가능한 엔트리는 이 메서드를 반드시 재정의해야 한다.
    @Override public V setValue(V value) {
        throw new UnsupportedOperationException();
    }
    // Map.Entry.equals의 일반 규약을 구현한다.  
    @Override public boolean equals(Object o) {
        if (o == this) {
            return true;
        }
        if (!(o instanceof Map.Entry)) {
            return false;
        }
        Map.Entry<?,?> e = (Map.Entry) o;
        return Objects.equals(e.getKey(),   getKey()) 
        	&& Objects.equals(e.getValue(), getValue());
    }

	// Map.Entry.hashCode의 일반 규약을 구현한다.
    @Override public int hashCode() {
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }

    @Override public String toString() {
        return getKey() + "=" + getValue();
    }
}

 

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


* 핵심: 디폴트 메서드라는 도구가 생겼더라도 인터페이스를 설계할 떄는 여전히 세심한 주의를 기울이자. 디폴트 메서드로 기존 인터페이스에 새로운 메서드를 추가하면 커다란 위험도 딸려 온다.

 

· 자바 8전에는 기존 구현체를 깨뜨리지 않고는 인터페이스를 추가할 방법이 었었다.

- 인터페이스에 메서드를 추가하면 보통은 컴파일 오류가 나는데, 추가된 메서드가 우연히 기존 구현체에 이미 존재할 가능성은 아주 낮기 때문이다.

 

· 자바 8부터 기존 인터페이스에 메서드를 추가할 수 있도록 디폴트 메서드를 소개했지만, 위험이 완전히 사라진 것은 아니다.

- 디폴트 메서드를 선언하면, 그 인터페이스를 구현한 후 디폴트 메서드를 재정의하지 않은 모든 클래스에서 디폴트 구현이 쓰이게 된다. 하지만 디폴트 메서드는 구현 클래스에 대해 아무것도 모른 채 무작정 삽입된다. 따라서 모든 기존 구현체들과 매끄럽게 연동되리라는 보장은 없다. 

 

디폴트 메서드를 추가한 예시 -  컬렉션 인터페이스

· 자바 8에서는 핵심 컬렉션 인터페이스들에 다수의 디폴트 메서드가 추가되었다.

- 주로 람다를 활용하기 위해서다.

 

· 자바 라이브러리의 디폴트 메서드는 코드 품질이 높고 범용적이라 대부분 상황에서 잘 작동한다. 하지만 생각할 수 있는 모든 상황에서 불변식을 해치지 않는 디폴트 메서드를 작성하기란 어렵다.

 

· 자바 8 Collection 인터페이스에 추가된 removeIf 메서드는 주어진 불리언 함수(predicate)가 true를 반환하는 모든 원소를 제거한다.  디폴트 구현은 아마 다음처럼 구현되 있을 것이다.

default boolean removeIf(Predicate<? super E> filter) {
	Objects.requireNonNull(filter);
	boolean removed = false;
	final Iterator<E> each = iterator();
	while (each.hasNext()) {
		if (filter.test(each.next())) {
			each.remove();
			removed = true;
		}
	}
	return removed;
}

 

이 코드보다 더 범용적으로 구현하기도 어렵겠지만, 현존하는 모든 Collection 구현체와 잘 어우러지는 것은 아니다. 아파치 커먼즈 라이브러리의 SynchronizedCollection 클래스는 지금도 활발히 관리되고 있지만,  removeIf 메서드를 재정의하지 않고 있다. (2018.11 기준)

 

해당 클래스는 컬렉션 대신 클라이언트가 제공한 객체로 락을 거는 능력을 제공한다. 즉, 모든 메서드에서 주어진락 객체로 동기화한 후 내부 컬렉션 객체에 기능을 위임하는 래퍼 클래스(아이템 18)다.

 

이 클래스를 자바 8과 함께 사용하게되어 removeIf의 디폴트 구현을 물려받으면, 모든 메서드 호출을 알아서 동기화해주지 못한다. removeIf의 구현은 동기화에 관해 아무것도 모르므로 락 객체를 사용할 수 없다. 따라서 SynchronizedCollection 인스턴스를 여러 스레드가 공유하는 환경에서 한 스레드가 removeIf를 호출하면 ConcurrentModificationException이 발생하거나 다른 예기치 못한 결과로 이러질 수 있다.

 

· 자바 플랫폼 라이브러리에서도 이런 문제를 예방하기 위해 일련의 조치를 취했다.

- 예를 들어 구현한 인터페이스의 디폴트 메서드를 재정의하고, 다른 메서드에서는 디포로트 메서드를 호출하기 전 필요한 작업을 수행하도록 했다.

ex) Collections.synchronizedCollection이 반환하는 package-private 클래스들은 removeIf를 재정의하고, 이를 호출하는 다른 메서드들은 디폴트 구현을 호출하기 전에 동기화를 하도록 했다.

 

· 자바 플랫폼에 속하지 않은 제3의 기존 컬렉션 구현체들은 이런 언어 차원의 인터페이스 변화에 발맞춰 수정될 기회가 없었으며, 그중 일부는 여전히 수정되지 않고 있다.

 

디폴트 메서드를 사용할 때 주의 사항

· 디폴트 메서드는 컴파일에 성공하더라도 기존 구현체에서 런타임 오류를 일으킬 수 있다.

· 기존 인터페이스에 디폴트 메서드로 새 메서드를 추가하는 일은 꼭 필요한 경우가 아니면 피해야 한다.

- 추가하려는 디폴트 메서드가 기존 구현체들과 충돌하지는 않을지 심사숙고 하자.

 

· 반면, 새로운 인터페이스를 만드는 경우라면 표준적인 메서드 구현을 제공하는 데 아주 유용한 수단이며, 그 인터페이스를 더 쉽게 구현해 활용할 수 있게끔 해준다(아이템 20).

 

· 디폴트 메서드는 1. 인터페이스로부터 메서드를 제거하거나 2. 기존 메서드의 시그니처를 수정하는 용도가 아님을 명심하자.

- 이런 형태로 인터페이스를 변경하면 반드시 기존 클라이언트를 망가뜨리게 된다.

 

· 새로운 인터페이스라면 릴리스 전 반드시 테스트를 거치자.

- 서로 다른 방식으로 최소한 세 가지는 구현하자.

- 각 인터페이스의 인스턴스를 다양한 작업에 활용하는 클라이언트도 여러 개 만들어보자.

- 인터페이스를 릴리스한 후라도 결함을 수정하는 게 가능한 경우로 있겠지만, 절대 그 가능성에 기대서는 안 된다.

 

아이템 22. 인터페이스는 타입을 정의하는 용도로만 사용하라


· 인터페이스는 자신을 구현한 클래스의 인스턴스를 참조할 수 있는 타입 역할로만 사용해야 한다.

- 클래스가 어떤 인터페이스를 구현한다는 것은 자신의 인스턴스로 무엇을 할 수 있는지를 클라이언트에 얘기해주는 것이다.

 

인터페이스를 지침에 맞지 않게 사용한 예시 - 상수 인터페이스

· 상수 인터페이스란 메서드 없이, 상수를 뜻하는 static final 필드로만 가득 찬 인터페이스다.

- 이 상수들을 사용하려는 클래스에서는 정규화된 이름(qualified name)을 쓰는 걸 피하고자 그 인터페이스를 구현한다.

 

public interface PhysicalConstants {
    // 아보가드로 수 (1/몰)
    static final double AVOGADRO_NUMBER = 6.022_140_857e23;
    // 볼츠만 상수 (J/K)
    static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    // 전자 질량 (kg)
    static final double ELECTRON_MASS = 9.109_383_56e-31;
}

 

· 클래스 내부에서 사용하는 상수는 외부 인터페이스가 아니라 내부 구현에 해당한다. 따라서 상수 인터페이스를 구현하는 것은 내부 구현을 클래스의 API로 노출하는 행위다. 

- 클라이언트 코드가 내부 구현에 해당하는 이 상수들에 종속될 수 있다. 그래서 다음 릴리스에서 이 상수들을 더 쓰지 않게 되더라도 바이너리 호환성을 위해 여전히 상수 인터페이스를 구현하고 있어야 한다.

- final이 아닌 클래스가 상수 인터페이스를 구현하면, 모든 하위 클래스의 이름 공간이 그 인터페이스가 정의한 상수들로 오염되어 버린다.

 

· 자바 플랫폼 라이브러리에도 인터페이스를 잘못 활용한 인터페이스가 몇 개 있다. ex) java.io.ObjectStreamConstants

 

올바르게 상수를 공개하는 방법

· 특정 클래스나 인터페이스와 강하게 연결된 상수라면 그 클래스나 인터페이스 자체에 추가한다.

ex) Integer와 Double에 선언된 MIN_VALUE, MAX_VALUE

 

· 열거 타입으로 나타내기 적합한 상수라면, 열거타입으로 만들어 공개한다(아이템34).

 

· 인스턴스화할 수 없는 유틸리티 클래스(아이템4)에 담아 공개할 수도 있다.

public class PhysicalConstants {
 
  private PhysicalConstants(){};  //인스턴스화 방지
  
  //아보가도르 수(1/몰)
  public static final double AVOGADROS_NUMBER = 6.002_140_857e23;
  //볼츠만 상수 (J/K)
  public static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
  // 전자 질량 (kg)
  public static final double ELECTRON_MASS = 9.109_383_56e-31;
}

 

아이템 23. 태그 달린 클래스보다는 클래스 계층구조를 활용하라


·  태그 달린 클래스는 단점이 한가득이다.

- 태그 달린 클래스: 두 가지 이상의 의미를 표현할 수 있으며, 그중 현재 표현하는 의미를 태그 값으로 알려주는 클래스

1. 열거 타입 선언, 태그 필드, switch 문 등 쓸데없는 코드가 많다.

2. 여러 구현이 한 클래스에 혼합돼 있어서 가독성이 나쁘다.

3. 다른 의미를 위한 코드가 언제나 함께  하니 메모리를 많이 사용한다.

4. 필드를 final로 선언하려면 해당 의미에 쓰이지 않는 필드까지 생성자에서 초기화해야 한다.

5. 생성자가 태그 필드를 설정하고 해당 의미에 쓰이는 데이터 필드를 초기화하는 데 컴파일러가 도와줄 수 있는 건 별로 없다.

- 엉뚱한 필드를 초기화해도 런타임에 문제가 드러난다.

6. 다른 의미를 추가하려면 코드를 수정해야 한다. 

- 새로운 의미를 추가할 때마다 모든 switch 문에 새 의미를 처리하는 코드를 추가해야하고, 실수로 빠뜨리면 런타임에 문제가 발생한다.

7. 인스턴스의 타입만으로 현재 나타내느 의미를 알 수 없다.

* 요약: 태그 달린 클래스는 장황하고, 오류를 내기 쉽고, 비효율적이다.

// 태그 달린 클래스 예시
class Figure {
    enum Shape { RECTANGLE, CIRCLE };

	// 태그 필드 - 현재 모양을 나타낸다.
    final Shape shape;
    
    // 다음 필드들은 모양이 사각형일 때만 쓰인다.
    double length;
    double width;
    
    // 다음 필드는 모양이 원일 때만 쓰인다.
    double radius;

    // 원용 생성자
    Figure(double radius) {
    	shape = Shape.CIRCLE;
    	this.radius = radius;
    }

    // 사격형용 생성자
    Figure(double length, double width) {
    	shape = Shape.RECTANGLE;
    	this.length = length;
    	this.width = width;
    }

    double area() {
    switch(shape) {
        case RECTANGLE:
        	return length * width;
        case CIRCLE:
        	return Math.PI * (radius * radius);
        default:
        	throw new AssertionError(shape);
    }
}

 

 

태그 달린 클래스의 대안: 서브타이핑

·  객체 지향 언어는 타입 하나로 다양한 의미 객체를 표현하는 훨씬 나은 수단을 제공한다.

   바로 클래스 계층구조를 활용하는 서브타이핑이다.

- 서브 타이핑(subtyping): 타입 계층을 구성하기 위해 상속을 사용. 

- 서브 클래싱(subclassing): 다른 클래스 코드를 재사용하는 목적으로 상속을 사용. 

 

·  태그 달린 클래스를 클래스 계층구조로 바꾸는 방법:

1. 계층구조의 루트가 될 추상 클래스를 정의한다.

2. 태그 값에 따라 동작이 달라지는 메서드들을 루트 클래스의 추상 메서드로 선언한다.

3. 태그 값에 상관없이 동작이 일정한 메서드들을 루트 클래스에 일반 메서드로 추가한다.

4. 모든 하위 클래스에서 공통으로 사용하는 데이터 필드들도 전부 루트 클래스로 올린다.

5. 루트 클래스를 확장한 구체 클래스를 의미별로 하나씩 정의한다.

6. 루트 클래스가 정의한 추상 메서드를 각자의 의미에 맞게 구현한다.

 

// 태그 달린 클래스를 클래스 계층구조로 변환
abstract class Figure {
    abstract double area();
}

class Circle extends Figure {
    final double radius;
    Circle(double radius) { this.radius = radius; }
    @Override double area() { return Math.PI * (radius * radius); }
}

class Rectangle extends Figure {
    final double length;
    final double width;
    Rectangle(double length, double width;) {
    this.length = length;
    this.width = width;
    }
    @Override double area() { return length * width; }
}

 

 ·  위의 클래스 계층구조의 장점:

1. 코드가 간결하고, 명확하다.

2. 쓸데없는 코드가 사라진다.

- 각의미를 독립된 클래스에 담아 관련 없던 데이터 필드를 모두 제거

3. 각 클래스의 생성자가 모든 필드를 남김없이 초기화하고, 추상 메서드를 모두 구현헀는지 컴파일러가 확인해준다.

4. 실수로 빼먹은 case문 떄문에 런타임 오류가 발생할 일이 없다.

5. 루트 클래스의 코드를 건드리지 않고도 다른 프로그래머들이 독립적으로 계층구조를 확장하고 함께 사용할 수 있다.

6. 타입이 의미별로 따로 존재하여 변수의 의미를 명시하거나 제한할 수 있고, 특정 의미만 매개변수로 받을 수 있다.

7. 타입 사이의 자연스러운 계층 관계를 반영할 수 있어서 유연성은 물론 컴파일타임 타입 검사 능력을 높여준다.

ex) 클래스 계층구조에서 정사각형이 사각형의 특별한 형태임을 아주 간단하게 반영할 수 있다.

class Square extends Rectangle {
    Square(double side) {
    super(side, side);
    }
}


아이템 24. 멤버 클래스는 되도록 static으로 만들라


· 중첩 클래스는 자신을 감싼 바깥 클래스에서만 쓰여야 하며, 그 외의 쓰임새 있다면 톱레벨 클래스로 만들어야 한다.

- 중첩 클래스(nested class): 다른 클래스안에 정의된 클래스

 

·  중첩 클래스는 정적 멤버 클래스, (비정적) 멤버 클래스, 익명 클래스, 지역 클래스 네 가지가 있다. 각각은 쓰임이 다르다.

- 이 중 첫 번째를 제외한 나머지는 내부 클래스(inner class)에 해당한다.

 

정적 멤버 클래스와 비정적 멤버 클래스

· 메서드 밖에서도 사용해야 하거나 메서드 안에 정의하기엔 너무 길다면 멤버 클래스로 만든다.

· 정적 멤버 클래스는 다른 클래스 안에 선언되고, 바깥 클래스의 private 멤버에도 접근할 수 있다. 나머지는 일반 클래스와 같다.

· 정적 멤버 클래스는 다른 정적 멤버와 똑같은 접근 규칙을 적용받는다. ex) private으로 선언하면 바깥 클래스에서만 접근할 수 있다.

· 정적 멤버 클래스는 흔히 바깥 클래스와 함께 쓰일 때만 유용한 public 도우미 클래스로 쓰인다.

ex) 계산기가 지원하는 연산 종류를 정의하는 Operation 열거 타입이 있다고 생각하자. Operation 열거 타입은 Calculator 클래스의 public 정적 멤버 클래스가 되어 Calculator 클라이언트에서 Calculator.Operation.Plus 같은 형태로 원하는 연산을 참조할 수 있다.

 

· 정적 멤버 클래스와 비정적 멤버클래스의 구문상 차이는 단지 static이 붙어 있고 없고 뿐이지만, 의미상 차이는 크다.

- 비정적 멤버 클래스의 인스턴스는 바깥 클래스의 인스턴스와 암묵적으로 연결된다. 그래서 비정적 멤버 클래스의 인스턴스 메서드에서 정규화된 this를 사용해 바깥 인스턴스의 메서드를 호출하거나 바깥 인스턴스의 참조를 가져올 수 있다.

- 이 관계 정보는 비정적 멤버 클래스의 인스턴스 안에 만들어져 메모리 공간을 차지하며, 생성 시간도 더 걸린다.

- 이 관계는 멤버 클래스가 인스턴스화될 때 확립되며, 더 이상 변경할 수 없다.

- 개념상 중첩 클래스의 인스턴스가 바깥 인스턴스와 독립적으로 존재할 수 있다면 정적 멤버 클래스로 만들어야 한다. 비정적 멤버 클래스는 바깥 인스턴스 없이는 생성할 수 없다.

 

· 비정적 멤버 클래스는 어댑터[Gamma95]를 정의할 때 자주 쓰인다. 

- 즉, 어떤 클래스의 인스턴스를 감싸 마치 다른 클래스의 인스턴스처럼 보이게 하는 뷰로 사용된다.

ex) Map 인터페이스의 구현체들은 보통 (keySet, entrySet, values 메서드가 반환하는)자신의 컬렉션 뷰를 구현할 때 비정적 멤버 클래스를 사용한다. Set과 List 같은 다른 컬렉션 인터페이스 구현들도 자신의 반복자를 구현할 때 멤버 클래스를 주로 사용한다.

public class MySet<E> extends AbstractSet<E> {
    ... // 생략

    @Override public Iterator<E> iterator() {
    return new MyIterator();
    }

    private class MyIterator implements Iterator<E> {
    ...
    }
}

 

· 멤버 클래스에서 바깥 인스턴스에 접근(참조)할 일이 없다면 무조건 static을 붙여서 정적 멤버 클래스로 만들자.

- static을 생략하면 바깥 인스턴스로의 숨은 외부 참조를 갖게된다. 이 참조를 저장하려면 시간과 공간이 소비된다. 더 심각한 문제는 가비지 컬렉션이 바깥 클래스의 인스턴스를 수거하지 못하는 메모리 누수가 생길 수 있다는 점이다. 

 

· private 정적 멤버 클래스는 흔히 바깥 클래스가 표현하는 객체의 한 부분을 나타낼 때 쓴다.

ex) 많은 Map 구현체는 각각의 키-값 쌍을 표현하는 엔트리 객체들을 가지고 있다. 모든 엔트리가 맵과 연관되어 있지만, 엔트리의 메서드들(getKey, getValue, setValue)은 맵을 직접 사용하지 않는다. 따라서 엔트리를 비정적 멤버 클래스로 표현하는 것은 낭비고, private 정적 멤버 클래스가 가장 알맞다. TODO: 이해가....

 

익명 클래스

· 익명 클래스는 이름이 없고, 멤버와 달리 쓰이는 시점에 선언과 동시에 인스턴스가 만들어진다.

· 익명 클래스는 오직 비정적인 문맥에서 사용될 때만 바깥 클래스의 인스턴스를 참조할 수 있다. 정적 문맥에서는 상수 변수 이외의 정적 멤버를 가실 수 없다.

public class NestedClass {
   private Integer outerClassInstance;
   public static Integer constant = 100_000;

   public void nonStaticMethod(){
      new Thread(new Runnable() {
         @Override
         public void run() {
            System.out.println("비정적문맥 메소드에서 바깥 클래스 인스턴스 참조 가능:" + outerClassInstance);
            System.out.println("비정적문맥 메소드에서 상수 참조 가능:" + constant);
         }
      });
   }

   public static void staticMethod() {
      new Thread(new Runnable() {
         @Override
         public void run() {
         //컴파일 오류   System.out.println("정적문맥 메소드에서 바깥 클래스 인스턴스 참조 불가능:" + outerClassInstance);
            System.out.println("정적문맥 메소드에서 바깥 클래스 인스턴스 상수 참조 가능:" + constant);
         }
      });
   }
}

 

· 익명 클래스는 응용하는 데 제약이 많다.

1. 선언한 지점에서만 인스턴스를 만들 수 있다.

2. instanceof 검사나 클래스의 이름이 필요한 작업은 수행할 수 없다.

3. 여러 인터페이스를 구현할 수 없다.

4. 인터페이스를 구현하는 동시에 다른 클래스를 상속할 수 없다.

5. 익명 클래스를 사용하는 클라이언트는 그 익명 클래스가 상위 타입에서 상속한 멤버 외에는 호출할 수 없다.

6. 표현식 중간에 등장하므로 가독성이 떨어진다.

 

· 자바가 람다를 지원하기 전에는 즉성에서 작은 함수 객체나 처리 객체를 만드는 데 주로 사용했지만, 이제 람다에게 그 자리를 물려줬다.

· 익명 클래스는 정적 팩터리 메서드를 구현할 때 사용될 수 있다.

 

static List<Integer> intArrayAsList(int[] a) {
  	Objects.requireNonNull(a);
    
  	return new AbtractList<Integer>() {
      	@Override public Integer get(int i) {
          	return a[i]; // 오토박싱
        }
        
      	@Override public Integer set(int i, Integer val) {
          	int oldVal = a[i];
          	a[i] = val; // 언박싱
          	return oldVal; // 오토박싱
        }
        
        @Ovveride public int size() {
          	return a.length;
        }
    }
}

 

지역 클래스

· 지역 클래스는 지역변수를 선언할 수 있는 곳이면, 실질적으로 어디서든 선언할 수 있고, 유효 범위도 지역변수와 같다.

· 네 가지 중첩 클래스 중 가장 드물게 사용된다.

· 다른 세 중첩 클래스와 공통점을 하나씩 가지고 있다.

- 멤버 클래스처럼 이름이 있고, 반복해서 사용할 수 있다.

- 익명 클래스처럼 비정적 문맥에서 사용될 때만 바깥 인스턴스를 참조할 수 있고, 정적 멤버는 가질 수 없으며, 가독성을 위해 짧게 작성해야 한다.

 

상황에 따라 사용할 중첩 클래스 선택하기

· 메서드 밖에서도 사용해야 하거나 메서드 안에 정의하기엔 너무 길다면 멤버 클래스로 만든다.

· 멤버 클래스의 인스턴스 각각이 바같 인스턴스를 참조한다면 비정적으로, 그렇지 않으면 정적으로 만든다.

· 중첩 클래스가 한 메서드 안에서만 쓰이면서 그 인스턴스를 생성하는 지점이 단 한 곳이고 해당 타입으로 쓰기에 적합한 클래스가 인스턴스가 이미 있다면 익명 익명 클래스로, 그렇지 않으면 지역 클래스로 만든다.


아이템 25. 톱레벨 클래스는 한 파일에 하나만 담으라


· 소스 파일 하나에 톱레벨 클래스를 여러 개 선언해도 자바 컴파일러는 불평하지 않는다. 하지만 아무런 득이 없고, 심각한 위험을 감수해야 하는 행위다.

- 한 클래스를 여러 가지로 정의할 수 있으며, 그중 어느 것을 사용할지는 어느 소스 파일을 먼저 컴파일하느냐에 따라 달라지기 때문이다.

 

하나의 소스 파일에 두 개의 톱레벨 클래스가 정의된 예시

다른 톱레벨 클래스 2개(Utensil, Dssert)를 참조하는 Main 클래스가 있다고 가정하자.

public class Main {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + Dessert.NAME);
    }
}

 

Utensil과 Dessert 클래스는 Utensil.java라는 한 파일에 정의되어 있다고 가정하자.

class Utensil {
    static final String NAME = "pan";
}

class Dessert {
    static final String NAME = "cake";
}

 

Main을 실행하면 pancake를 출력한다. 이때, 우연히 똑같은 두 클래스를 담은 Dessert.java라는 파일을 만들었다고 가정하자.

 

class Utensil {
    static final String NAME = "pot";
}

class Dessert {
    static final String NAME = "pie";
}

 

운 좋게 javac Main.java Desert.java 명령으로 컴파일한다면 컴파일 오류가 나고, Utensil과 Dessert 클래스를 중복 정의헀다고 알려줄 것이다.

 

컴파일러는 가장 먼저 Main.java를 컴파일하고, 그 안에서 Utensil 참조를 만나면 Utensil.java 파일을 살펴 Utensil과 Dessert를 모두 찾아낼 것이다. 그런 다음 컴파일러가 두 번째 명령줄 인수로 넘어온 Dessert.java를 처리핳려 할 때 같은 클래스의 정의가 이미 있음을 알게 된다.

 

한편, javac Main.java나 javac Main.java Utensil.java 명령으로 컴파일하면 Dessert.java 파일을 작성하기 전처럼 pancake를 출력한다. 그러나 javac Dessert.java Main.java 명령으로 컴파일하면 potpie를 출력한다.

 

* 이 처럼 컴파일러에 어느 소스 파일을 먼저 건네느냐에 따라 동작이 달라지므로, 반드시 바로 잡아야할 문제다.

 

해결책1

· 톱레벨 클래스들을 서로 다른 소스 파일로 분리한다.

 

해결책2

· 톱레벨 클래스를 한 파일에 담고 싶다면, 정적 멤버 클래스(아이템 24)를 사용하는 방법을 고민한다.

- 다른 클래스에 딸린 부차적인 클래스라면 정적 멤버 클래스로 만드는 쪽이 일반적으로 더 낫다.

- 읽기 좋고, private으로 선언하면(아이템 15) 접근 범위도 최소로 관리할 수 있기 때문이다.

 

public class Test {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + Dessert.NAME);
    }

    private static class Utensil {
        static final String NAME = "pan";
    }

    private static class Dessert {
        static final String NAME = "cake";
    }
}

 

출처

이펙티브자바 3판

반응형

댓글