추상화의 기본 단위인 클래스와 인터페이스는 자바 언어의 심장과 같다.
잘 설계된 컴포넌트는 모든 내부 구현을 완벽히 숨겨 구현과 API를 깔끔하게 분리해 외부 컴포넌트와 오직 API로만 소통한다. 즉, 소프트웨어 설계의 근간인 캡슐화가 잘 돼 있어야 한다.
진부할 수 있지만 캡슐화의 장점에 대해 나열해보자.
접근성이 낮을수록 자원은 독립적이고, 위/변조의 위험에서 안전할 것이다.
패키지 외부에서 쓸 일이 없다면(공개 API가 아니라면) package-private로 선언해야 한다. API가 아닌 내부 구현이기에 수정에서 자유로워지기 때문이다.
대신 그 이상은 안 된다. 테스트만을 위해 클래스, 인터페이스, 멤버를 공개 API로 만들면 package-private 요소에 접근할 수 있기 때문이다.
그럴 땐 private static 중첩을 이용해보자. private static으로 중첩시키면 바깥 클래스 하나에서만 접근할 수 있다.
여기엔 예외가 하나 있다. 해당 클래스의 추상 개념을 완성하는데 필요한 구성요소로써의 상수라면 public static final필드로 공개해도 좋다. (관례적으로 대문자 알파벳으로 작성하며 구분은 밑줄(_)으로 한다. )
이런 필드는 반드시 기본 타입 값이나 불변 객체를 참조해야 한다.
public class FixDiscountPolicy1000 implements DiscountPolicy{
public static final int DISCOUNT_AMOUNT = 1000;
...
}
⇒ 고정적인 1000원 할인을 하는 할인정책 구현체에서 1000원이라는 금액은 필수적인 구성요소이자 변경의 가능성이 없기 때문에 public static final 필드로 공개한다. 이런 필드(public static final)가 기본 타입이나 불변 객체가 아닌 가변 필드나 가변 객체를 참조할 경우 final이 아닌 필드에 적용되는 단점이 모조리 적용된다. 참조된 객체 자체가 수정될 수 있기에 문제가 크다. 그리고, 길이가 0이 아닌 배열이나 콜렉션들도 모두 변경 가능하니 주의하자.
다음 코드를 보자.
public static final Thing[] VALUES = {...};
이 배열 필드에는 큰 보안 허점이 존재한다. final이여도 public이기 때문에 배열 내부의 값들은 얼마든지 수정이 가능하다.
// 접근제어자를 private로 만들고 public 불변 리스트를 추가하기
private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES =
Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
// 배열을 private로 만들고 복사본을 반환하는 public 메서드를 추가하는 방법(방어적 복사)
private static final Thing[] PRIVATE_VALUES = { ... };
public static final Thing[] values() {
return PRIVATE_VALUES.clone();
}
위와 같은 방법으로 해결할 수 있다.
패키지 바깥에서 접근할 수 있는 클래스라면 멤버 자체에 public 제어자를 두기 보단, getter와 같이 접근자 메서드를 사용해 접근하고 필드 자체는 private으로 막아두자. 불변식을 보장할 수 없으니!
불변 클래스는 가변 클래스보다 설계, 구현, 사용에서 장점이 있고 오류를 방지해 안전하다.
다음은 클래스를 불변으로 만들 때 규칙이다.
이러한 장점들 때문에 불변 클래스라면 한 번 만든 객체를 최대한 재활용하는게 좋다.
또한, 이런 불변 클래스에서 자주 사용되는 인스턴스를 캐싱해서 중복된 인스턴스를 생성하지 않도록 할 수 있다. (정적 팩토리)
물론, 불변 클래스에도 단점이 있다. 값이 다르면 반드시 독립된 객체로 만들어야 한다는 것이다. 값의 가짓수가 많다면 이들을 모두 만드는 데 큰 비용을 치르기 때문이다. 원하는 객체를 완성하기까지 단계가 많고 중간 단계에서 만들어진 객체가 모두 버려지면 큰 성능 문제로 연결된다.
이제 대한 해결 방안 중 하나로 다단계 연산을 예측해 기본 기능으로 제공하는 방법이다. 대표적인 예로 StringBuilder가 있다.
가장 쉬운 방법은 final 클래스로 선언하는 거지만, 다수의 구현 클래스를 활용해 더 유연한 방법으로 모든 생성자를 private, package-private로 만들고 public static 팩토리를 만드는 것이다. 이에 더해 다음 릴리즈에서 객체 캐싱 기능을 추가해 성능을 끌어올릴 수도 있다.
무작정 setter를 만들지 말자. 클래스는 꼭 필요한 경우가 아니면 불변이어야 한다.
불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분을 최소화하자.
다른 합당한 이유가 없다면 모든 필드는 private final이어야 한다.
생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.
클래스 간 상속은 코드를 재사용하는 강력한 수단이지만 오류를 범하기 쉽다. 패키지를 넘나드는 경우에 그렇다. 캡슐화를 위반하기 때문이다.
상속은 상위 클래스와 하위 클래스의 관계가 온전한 is-a 관계일 때만 써야한다. 그리고 is-a 관계여도 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려하지 않으면 문제가 생긴다.
새로운 클래스에서 본디 상위 클래스로 사용할 클래스를 private 필드로 작성해 참조하도록 하는 컴포지션을 사용하자.
새로운 클래스에서 기존 클래스에 대응하는 메서드를 호출하면 새로운 클래스는 기존 클래스의 메서드를 호출해서 결과를 반환하는데 이를 전달(forwarding)이라 하며, 이런 새로운 클래스의 메서드를 전달 메서드(forwarding method)라 부른다.
래퍼 클래스로 구현할 인터페이스(ex: Set, List, Map)가 있다면 더욱 그렇다.
상속을 염두해두지 않은 외부 클래스(프로그래머의 통제권 밖에 있어 변경시점을 알 수 없는 클래스)를 상속할 경우 여러 문제가 발생할 수 있다. 그렇기에 상속용 클래스를 설계할 때는 문서화가 필요하다.
처음부터 기능 확장및 재정의를 염두해두고 만드는 상속용 클래스를 설계한다면 해당 상속용 클래스는 재정의할 수 있는 메서드들을 내부적으로 어떻게 이용하는지 문서로 남겨야 한다.
또한 API로 공개된 메서드로부터 호출되는 재정의 메서드(public(or protected)메서드 중 final이 아닌 모든 메서드)가 호출된다는 사실과, 어떤 순서로 호출되는 지와 그 결과에 이어지는 처리에 어떤 영향을 주는지 적어야 한다.
클래스 내부 동작 과정 중간에 끼어들 수 있는 훅(hook)을 잘 선별해서 protected 메서드 형태로 공개해야 할 수도 있다. 실제로 하위 클래스를 만들어보면서 테스트를 해봐야하는데, protected 메서드는 내부 구현에 해당하기 때문에 수가 가능한 적어야 한다.
상속용 클래스의 생성자는 직간접적으로 재정의 가능 메서드를 호출하면 안 된다. 상위 클래스의 생성자가 하위 클래스의 생성자보다 먼저 실행되므로 하위 클래스에서 재정의 메서드가 해당 클래스의 생성자보다 먼저 호출되기 때문이다. private, final, static 메서드는 재정의가 불가능하기에 생성자에서 호출해도 문제가 없다.
자바가 제공하는 두 가지 다중 구현 메커니즘.
가장 큰 차이는, 추상 클래스가 정의한 타입을 구현하는 클래스는 반드시 추상 클래스의 자식 클래스가 돼야 한다.
이러한 종속성으로 인한 많은 제약의 문제는 인터페이스의 장점으로 귀결된다.
인터페이스의 메서드 중 구현 방법이 명백한 게 있다면 디폴트 메서드로 제공하고 상속하려는 사람을 위해 @implSpec 자바독 태그를 붙여 문서화해야 한다.
그러나,
한편, 인터페이스와 추상 골격 구현 클래스를 함께 제공하는 식으로 인터페이스와 추상 클래스의 장점을 모두 취하는 방식도 있다.
인터페이스로는 타입을 정의하거나 필요시 디폴트 메서드 몇 개도 함께 제공한다. 그리고 골격 구현 클래스는 나머지 메서드들까지 구현하는, 템플릿 메서드 패턴을 활용해서 말이다.
추상 골격 클래스는 인터페이스에서 구현에 사용될 기반 메소드를 선정해 골격 구현 클래스 내의 추상 메소드가 된다.
그리고 기반 메서드를 통해 직접 구현이 가능한 메서드는 모두 인터페이스의 default method가 되고, 기반 메서드 / 디폴트 메서드가 되지 못한 메서드들(ex: equals, hashCode...)등을 골격 구현 클래스에서 구현하면 된다.
JDK 1.8 이후 인터페이스에도 정적 메서드와 디폴트 메서드가 추가되면서 인터페이스 내에도 로직을 직접 추가할 수 있게 되었다. 그래서 표준 메서드 제공의 유용한 수단이 된다.
그러나 디폴트 메서드는 구현 클래스에 대해서는 알지 못한 채 무작정 구현되기 때문에 모든 상황에서 불변식을 해치지 않도록 하는 건 쉽지 않다.
인터페이스로는 외부에 공개해도 되는 API를 정의한 뒤 사용자가 해당 API만 사용할 수 있게 하고, 인터페이스의 구현체의 내부 구조까지 다 파악할 필요가 없게끔 해주는, 용도로만 사용을 해야 한다.
저자는 상수 공개용 수단으로 사용하면 안 된다고 강조했다. 그 이유로
대신 열거 타입이나 유틸리티 클래스를 활용해 상수를 제공할 수 있다.
두 가지 이상의 의미를 표현할 수 있으며 현재 표현하는 의미를 태그 값으로 알려주는 클래스는 지양하자.
public class Figure {
enum Shape {
RECTANGLE, CIRCLE
};
//공통 필드
private final Shape shape;
private double length;
private double width;
private double radius;
public Figure(double radius) {
this.radius = radius;
shape = Shape.CIRCLE;
}
public Figure(double length, double width) {
this.length = length;
this.width = width;
shape = Shape.RECTANGLE;
}
public double area() {
switch (shape) {
case RECTANGLE:
return length * width;
case CIRCLE:
return Math.PI * (radius * radius);
default:
throw new AssertionError(shape);
}
}
}
하나의 클래스에서 두 타입에 대응이 가능하고, 얼핏보면 area() 메서드 하나로 타입별로 맞는 크기를 계산해 반환하기에 편해보인다.
그러나, 이 클래스는 단점 투성이다.
클래스 계층 구조로 변환하자.
public abstract class Figure{
abstract double area();
}
public class Circle extends Figure{
final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
double area() {
return Math.PI * (radius * radius);
}
}
public class Rectangle extends Figure{
private final double length;
private final double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
double area() {
return length * width;
}
}
태그 달린 클래스가 사용되야하는 상황은 거의 없어졌고, 대부분 계층 구조로 해결이 된다. 기존 클래스가 태그 필드를 사용해 구분하고 있다면 리팩터링을 고려할 필요가 있다.
중첩 클래스의 종류는 네 가지이다.
이중 정적 멤버 클래스를 제외한 나머지는 내부 클래스에 해당한다. 이 챕터에선 각각의 중첩 클래스에 대해 알아보고, 되도록 정적 멤버 클래스로 만들도록 하자.
정적 멤버 클래스는 비정적 멤버 클래스와 달리 바깥 클래스의 인스턴스와의 암묵적 연결이 안 되어있다.
그래서 정적 멤버 클래스는 바깥 클래스의 인스턴스 없이 생성 가능하지만, 비정적 멤버 클래스는 바깥 인스턴스 없이는 생성할 수 없다.
비정적 멤버 클래스의 문제는, 바깥 인스턴스로의 숨은 외부 참조를 가지게 되고 이 참조를 저장하려 시간과 공간이 소비된다. 또한 가비지 컬렉션이 바깥 클래스의 인스턴스를 수거하지 못하는 메모리 누수가 발생할 수 있고, 참조가 눈에 보이지 않아 디버깅이 힘들어진다.
이외에 중첩 클래스가 한 메서드 안에만 사용되면서, 인스턴스의 생성 지점이 단 한 곳이고 해당 타입으로 쓰기 적당한 클래스나 인터페이스가 있다면 익명클래스를 만들고 그렇지 않으면 지역 클래스를 만들면 된다.
하나의 소스파일에 여러 클래스를 작성할 경우 소스 파일명과 동일한 클래스를 제외한 나머지 클래스들의 존재가 감춰진다.
톱레벨 클래스로 하나의 소스파일에 하나의 클래스만 존재할 경우 실수로라도 동일한 클래스를 다시 정의하는 문제는 발생하지 않을 것이다.
[프로그래밍 인사이트] 이펙티브 자바 - 조슈아 블로크