추상화의 기본 단위인 클래스와 인터페이스는 자바 언어의 심장과도 같다.
자바 언어에는 클래스와 인터페이스 설계에 사용하는 강력한 요소가 많이 있다.
이번 장에서는 이런 요소를 적절히 활용하여 클래스와 인터페이스를 쓰기 편하고, 견고하며, 유연하게 만드는 방법을 안내한다.
1. 클래스와 멤버의 접근 권한을 최소화하라
잘 설계된 컴포넌트는 모든 내부 구현을 완벽히 숨겨, 구현과 API를 깔끔히 분리한다
정보 은닉, 혹은 캡슐화라고 하는 이 개념은 소프트웨어 설계의 근간이 되는 원리다
1.1 캡슐화의 장점은 다음과 같다
- 시스템 개발 속도를 높인다
여러 컴포넌트를 병렬로 개발할 수 있기 때문이다 - 시스템 관리 비용을 낮춘다
각 컴포넌트를 더 빨리 파악하여 디버깅할 수 있고, 다른 컴포넌트로 교체하는 부담도 적기 때문이다 - 정보 은닉 자체가 성능을 높여주지는 않지만, 성능 최적화에 도움을 준다
완성된 시스템을 프로파일링 해 최적화할 컴포넌트를 정한 다음, 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화할 수 있기 때문이다 - 소프트웨어 재사용성을 높인다
외부에 거의 의존하지 않고 독자적으로 동작할 수 있는 컴포넌트라면 그 컴포넌트와 함께 개발되지 않은 낯선 환경에서도 유용하게 쓰일 가능성이 크기 때문이다 - 큰 시스템을 제작하는 난이도를 낮춰준다
시스템 전체가 아직 완성되지 않은 상태에서도 개별 컴포넌트의 동작을 검증할 수 있기 때문이다
1.2 자바는 캡슐화를 위한 다양한 장치를 제공한다
그중 접근 제어 메커니즘은 클래스, 인터페이스, 멤버의 접근성(접근 허용 범위)을 명시한다
접근 제한자 private, package-private(default), protected, public을 통해 정해진다.
- private: 멤버를 선언한 톱레벨 클래스에서만 접근할 수 있다
- package-private (default): 멤버가 소속된 패키지 안의 모든 클래스에서 접근할 수 있다
접근 제한자를 명시하지 않았을 때 적용되는 패키지 접근 수준이다
(단, 인터페이스의 멤버는 기본적으로 public이 적용된다) - protected: package-private (default)의 접근 범위를 포함하며, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근할 수 있다 (즉, 상속을 의미)
- public: 모든 곳에서 접근할 수 있다
기본 원칙은 간단하다. 모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다
즉, 소프트웨어가 올바로 동작하는 한 항상 가장 낮은 접근 수준을 부여해야 한다는 뜻이다
(가장 바깥이라는 의미의) Top 레벨 클래스와 인터페이스에 부여할 수 있는 접근 수준은 package-private과 public 두 가지다
Top 레벨 클래스나 인터페이스를 public으로 선언하면 공개 API가 되며, package-private으로 선언하면 해당 패키지 안에서만 이용할 수 있다
패키지 외부에서 쓸 이유가 없다면 package-private으로 선언하자
그렇게 되면 API가 아닌 내부 구현이 되어 언제든 수정할 수 있다
즉, 클라이언트에 아무런 피해 없이 다음 릴리스에서 수정, 교체, 제거가 가능하다
public으로 선언한다면 API가 되므로 하위 호환을 위해 영원히 관리해줘야만 한다
클래스의 공개 API를 세심히 설계한 후, 그 외의 모든 멤버는 private으로 만들자
그런 다음 오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여 package-private으로 풀어주자
권한을 풀어 주는 일을 자주 하게 된다면 시스템에서 컴포넌트를 더 분해해야 하는 것은 아닌지 다시 고민해 보자
private과 package-private 멤버는 모두 해당 클래스의 구현에 해당하므로 보통은 공개 API에 영향을 주지 않는다
단, Serializable을 구현한 클래스에서는 그 필드들도 의도치 않게 공개 API가 될 수 도 있다
Serializable을 구현한 클래스에서는 모든 필드가 기본적으로 직렬화된다
예를 들어, 다음과 같은 클래스가 있다고 가정해 보자
import java.io.Serializable;
public class Person implements Serializable {
private String name;
private int age;
private transient String password;
// 생성자, getter, setter 등 생략
public String getPassword() {
return password;
}
}
위의 Person 클래스는 Serializable 인터페이스를 구현하고 있다
이 클래스의 name과 age 필드는 직렬화될 것이며, 객체를 파일에 저장하거나 네트워크로 전송할 때 포함된다
그러나 password 필드는 transient 키워드로 표시되어 직렬화에서 제외된다
이는 보안상의 이유로 인해 password가 외부에 노출되는 것을 방지하기 위한 조치라고 가정하자
그러나 클래스의 getPassword() 메서드는 password 필드의 값을 반환하는 공개 API로서 존재한다
이렇게 되면 Person 객체를 사용하는 코드에서 getPassword()를 호출하여 password 값을 얻을 수 있으므로 이는 password 필드의 의도치 않은 공개를 의미하며, 보안상의 위험을 초래할 수 있다
따라서 Serializable을 구현한 클래스에서는 필드들이 의도치 않게 공개 API가 될 수 있으므로, 직렬화에 민감한 정보를 포함하는 필드는 transient 키워드로 표시하고, 공개되면 안 되는 메서드는 적절한 접근 제어자로 설정해야 한다
public 클래스에서는 멤버의 접근 수준을 package-private에서 protected로 바꾸는 순간 그 멤버에 접근할 수 있는 대상 범위가 엄청나게 넓어진다
public 클래스의 protected 멤버는 공개 API이므로 영원히 지원돼야 한다
또한 내부 동작 방식을 API 문서에 적어 사용자에게 공개해야 할 수도 있다
따라서 protected 멤버의 수는 적을수록 좋다
1.3 그런데 멤버 접근성을 좁히지 못하게 방해하는 제약이 하나 있다
상위 클래스의 메서드를 재정의할 때는 그 접근 수준을 상위 클래스에서보다 좁게 설정할 수 없다는 것이다
이 제약은 상위 클래스의 인스턴스는 하위 클래스의 인스턴스로 대체해 사용할 수 있어야 한다는 리스코프 치환 원칙을 지키기 위해 필요하다
이 규칙을 어기면 하위 클래스를 컴파일할 때 컴파일 오류가 난다
클래스가 인터페이스를 구현하는 건 이 규칙의 특별한 예로 볼 수 있고, 이때 클래스는 인터페이스가 정의한 모든 메서드를 public으로 선언해야 한다
단지 테스트하려는 목적으로 사용할 경우는 적당한 수준까지 넓혀도 괜찮다
예를 들어 public 클래스의 private 멤버를 package-private까지 풀어주는 것은 허용할 수 있지만, 그 이상은 안 된다
즉, 테스트만을 위해 공개 API로 만들어서는 안된다
이렇게 해야 할 이유도 없다. 테스트 코드를 테스트 대상과 같은 패키지에 두면 package-private 요소에 접근할 수 있기 때문!
1.4 public 클래스의 인스턴스 필드는 되도록 public이 아니어야 한다
필드가 가변 객체를 참조하거나, final이 아닌 인스턴스 필드를 public으로 선언하면 그 필드에 담을 수 있는 값을 제한할 힘을 잃게 된다 (불변식을 보장할 수 없게 된다는 뜻)
여기에 더해, 필드가 수정될 때 (락 획득 같은) 다른 작업을 할 수 없게 되므로 public 가변 필드를 갖는 클래스는 일반적으로 Thread-Satety 하지 않다
심지어 필드가 final이면서 불변 객체를 참조하더라도 문제는 여전히 남는다
내부 구현을 바꾸고 싶어도 그 public 필드를 없애는 방식으로는 리팩터링 할 수 없게 된다
이러한 문제는 정적 필드에서도 마찬가지이나, 예외가 하나 있다
해당 클래스가 표현하는 추상 개념을 완성하는 데 꼭 필요한 구성요소로써의 상수라면
public static final 필드로 공개해도 좋다
관례상 이런 상수의 이름은 대문자 알파벳을 쓰며, 각 단어 사이에 '_'를 넣는다
이런 필드는 반드시 기본 타입 값이나 불변 객체를 참조해야 한다
가변 객체를 참조한다면 final이 아닌 필드에 적용되는 모든 불이익이 그대로 적용된다
다른 객체를 참조하지는 못하지만, 참조된 객체 자체는 수정될 수 있으니 끔찍한 결과를 초래할 수도 있는 것이다
1.5 길이가 0이 아닌 배열은 모두 변경 가능하니 주의하자
따라서 클래스에서 public static final 배열 필드를 두거나 이 필드를 반환하는 접근자 메서드를 제공해서는 안 된다
이런 필드나 접근자를 제공한다면 클라이언트에서 그 배열의 내용을 수정할 수 있게 된다
public static final Thing[] VALUES = { ... };
해결책은 두 가지다
첫 번째 방법은 앞 코드의 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 API를 설계하자
그 외에는 클래스, 인터페이스, 멤버가 의도치 않게 API로 공개되는 일이 없도록 해야 한다
public 클래스는 상수용 public static final 필드 외에는 어떠한 public 필드도 가져서는 안 된다
public static final 필드가 참조하는 객체가 불변인지 확인하라
2. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라
/* 퇴보한 클래스 */
class Point {
public double x;
public double y;
}
이런 클래스는 데이터 필드에 직접 접근할 수 있으니 캡슐화의 이점을 제공하지 못한다
API를 수정하지 않고는 내부 표현을 바꿀 수 없고, 불변신을 보장할 수 없으며, 외부에서 필드에 접근할 때 부수 작업을 수행할 수도 없다
패키지 바깥에서 접근할 수 있는 클래스라면 접근자를 제공함으로써 클래스 내부 표현 방식을 언제든 바꿀 수 있는 유연성을 얻을 수 있다
/* 접근자와 변경자(mutator) 메서드를 활용해 데이터를 캡슐화한다 */
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 중첩 클래스라면 데이터 필드를 노출한다 해도 문제가 없다
그 클래스가 표현하려는 추상 개념만 올바르게 표현해 주면 된다
이 방식은 클래스 선언 면에서나 이를 사용하는 클라이언트 코드 면에서나 접근자 방식보다 훨씬 깔끔하다
/* package-private 클래스와 private 중첩 클래스의 데이터 필드를 노출 예시 */
class PackagePrivateClass {
String dataField;
PackagePrivateClass(String dataField) {
this.dataField = dataField;
}
}
public class OuterClass {
private static class PrivateNestedClass {
int count;
PrivateNestedClass(int count) {
this.count = count;
}
}
public static void main(String[] args) {
PackagePrivateClass packagePrivateObject = new PackagePrivateClass("Data");
System.out.println(packagePrivateObject.dataField);
PrivateNestedClass privateNestedObject = new PrivateNestedClass(10);
System.out.println(privateNestedObject.count);
}
}
public 클래스의 필드가 불변이라면 직접 노출할 때의 단점이 조금은 줄어들지만, 여전히 결코 좋은 생각이 아니다
API를 변경하지 않고는 표현 방식을 바꿀 수 없고, 필드를 읽을 때 부수 작업을 수행할 수 없다는 단점은 여전하다
단, 불변식은 보장할 수 있게 된다
예컨대 다음 클래스는 각 인스턴스가 유효한 시간을 표현함을 보장한다
public 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){
if(hour < 0 || hour >= HOURS_PER_DAY)
throw new IllegalArgumentException("시간: " + hour);
if(minute < 0 || minute >= MINUTES_PER_HOUR)
throw new IllegalArgumentException("분: " + minute);
this.hour = hour;
this.minute = minute;
}
}
- 핵심 정리
public 클래스는 절대 가변 필드를 직접 노출해서는 안 된다
불변 필드라면 노출해도 덜 위험하지만 완전히 안심할 수는 없다
하지만 package-private 클래스나 private 중첩 클래스에서는 종종 필드를 노출하는 편이 나을 때도 있다
3. 변경 가능성을 최소화하라
불변 클래스란 간단히 말해 그 인스턴스의 내부 값을 수정할 수 없는 클래스다
String, 기본 타입의 박싱 된 클래스들, BigInteger, BigDecimal이 여기 속한다
불변 클래스는 가변 클래스보다 설계하고 구현하고 사용하기 쉬우며, 오류가 생길 여지도 적고 훨씬 안전하다
3.1 클래스를 불변으로 만들려면 다음 다섯 가지 규칙을 따르면 된다
- 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다
- 클래스를 확장할 수 없도록 한다
하위 클래스에서 부주의하게 혹은 나쁜 의도로 객체의 상태를 변하게 만드는 사태를 막아준다
상속을 막는 대표적인 방법은 클래스를 final로 선언하는 것이지만, 다른 방법도 뒤에 살펴볼 것이다 - 모든 필드를 final로 선언한다
시스템이 강제하는 수단을 이용해 설계자의 의도를 명확히 드러내는 방법이다
새로 생성된 인스턴스를 동기화 없이 다른 스레드로 건네도 문제없이 동작하게끔 보장하는 데도 필요하다 - 모든 필드를 private으로 선언한다
필드가 참조하는 가변 객체를 클라이언트에서 직접 접근해 수정하는 일을 막아준다
기술적으로는 기본 타입 필드나 불변 객체를 참조하는 필드를 public final로만 선언해도 불변 객체가 되지만,
이렇게 하면 다음 릴리스에서 내부 표현을 바꾸지 못하므로 권하지는 않는다 - 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다
클래스에 가변 객체를 참조하는 필드가 하나라도 있다면 클라이언트에서 그 객체의 참조를 얻을 수 없도록 해야 한다
이런 필드는 절대 클라이언트가 제공한 객체 참조를 가리키게 해서는 안 되며, 접근자 메서드가 그 필드를 그대로 반환해서도 안 된다
생성자, 접근자, readObject 메서드 모두에서 방어적 복사를 수행하라
/* 불변 복소수 클래스 */
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 c){
return new Complex(re + c.re, im + c.im);
}
public Complex minus(Complex c){
return new Complex(re - c.re, im - c.im);
}
public Complex times(Complex c){
return new Complex(re * c.re - im * c.im, re * c.im + im * c.re);
}
public Complex dividedBy(Complex c){
double tmp = c.re * c.re + c.im * c.im;
return new Complex((re * c.re + im * c.im) / tmp, (im * c.re - re * c.im) / tmp);
}
@Override
public boolean equals(Object o){
if(o == this)
return true;
if(!(o instanceof Complex))
return false;
Complex c = (Complex) o;
/* == 대신 compare 사용 */
return Double.compare(re, c.re) == 0 && Double.compare(im, c.im) == 0;
}
@Override
public int hashCode(){
int result = 17 + hashDouble(re);
result = 31 * result + hashDouble(im);
return result;
}
private int hashDouble(double val){
long longBits = Double.doubleToLongBits(re);
return (int) (longBits ^ (longBits >>> 32));
}
}
이 클래스는 복소수(실수부와 허수부로 구성된 수)를 표현한다
사칙연산 메서드(plus, minus, times, dividedBy)들이 인스턴스 자신은 수정하지 않고 새로운 Complex 인스턴스를 만들어 반환하는 모습에 주목하자
이처럼 피연산자에 함수를 적용해 그 결과를 반환하지만, 피연산자 자체는 그대로인 프로그래밍 패턴을 함수형 프로그래밍이라 한다
이와 달리, 절차적 혹은 명령형 프로그래밍에서는 메서드에서 피연산자인 자신을 수정해 자신의 상태가 변하게 된다
또한 메서드 이름으로 add 같은 동사대신 plus 같은 전치사를 사용한 점에도 주목하자
이는 해당 메서드가 객체의 값을 변경하지 않는다는 사실을 강조하려는 의도다
이 명명 규칙을 따르지 않은 BigInteger와 BigDecimal 클래스를 사람들이 잘못 사용해 오류가 발생하는 일이 자주 있다
불변 객체는 근본적으로 Thread-Satety 하여 따로 동기화할 필요가 없다
즉, 불변 객체는 안심하고 공유할 수 있다
따라서 불변 클래스라면 한번 만든 인스턴스를 최대한 재활용하기를 권한다
가장 쉬운 재활용 방법은 자주 쓰이는 값들을 상수(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);
3.2 불변 클래스는 자주 사용되는 인스턴스를 캐싱하여 같은 인스턴스를 중복 생성하지 않게 해주는 적정 팩터리를 제공할 수 있다
박싱 된 기본 타입 클래스 전부와 BigInteger가 여기 속한다
이런 정적 팩터리를 사용하면 여러 클라이언트가 인스턴스를 공유하여 메모리 사용량과 가비지 컬렉션 비용이 줄어든다
새로운 클래스를 설계할 때 public 생성자 대신 정적 팩터리를 만들어두면, 클라이언트를 수정하지 않고도 필요에 따라 캐시 기능을 나중에 덧붙일 수 있다
불변 객체는 복사해 봐야 원본과 똑같으니 clone 메서드나 복사 생성자를 제공하지 않는 게 좋다
불변 객체는 자유롭게 공유할 수 있음은 물론, 불변 객체끼리는 내부 데이터를 공유할 수 있다
예컨대 BigInteger 클래스는 내부에서 값의 부호(sign)와 크기(magnitude)를 따로 표현한다
부호를 int 변수를, 크기(절댓값)에는 int 배열을 사용하는 것이다
한편 negate 메서드는 크기가 같고 부호만 반대인 새로운 BigInteger를 생성하는데, 이때 배열은 비록 가변이지만 복사하지 않고 원본 인스턴스와 공유해도 된다
그 결과 새로 만든 BigInteger 인스턴스도 원본 인스턴스가 가리키는 내부 배열을 그대로 가리킨다
import java.util.Arrays;
public class BigInteger {
private int sign;
private int[] magnitude;
public BigInteger(int sign, int[] magnitude) {
this.sign = sign;
this.magnitude = magnitude;
}
public BigInteger negate() {
return new BigInteger(-sign, magnitude); // 배열은 복사하지 않고 원본과 공유
}
...
}
객체를 만들 때 다른 불변 객체들을 구성요소로 사용하면 이점이 많다
값이 바뀌지 않는 구성요소들로 이뤄진 객체라면 그 구조가 아무리 복잡하더라도 불변식을 유지하기 훨씬 수월하기 때문이다
좋은 예로 불변 객체는 맵의 키와 집합(Set)의 원소로 쓰기에 안성맞춤이다
맵이나 집합(Set)은 안에 담긴 값이 바뀌면 불변식이 허물어지는데, 불변 객체를 사용하면 그런 걱정은 하지 않아도 된다
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class ImmutableObjectsExample {
public static void main(String[] args) {
// 불변 객체를 사용하여 맵 생성
Person person1 = new Person("John", 25);
Person person2 = new Person("Alice", 30);
Map<Person, String> map = new HashMap<>();
map.put(person1, "Employee");
map.put(person2, "Manager");
// 불변 객체를 사용하여 집합(Set) 생성
Set<Person> set = new HashSet<>();
set.add(person1);
set.add(person2);
}
}
final class Person {
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
불변 객체는 그 자체로 실패 원자성을 제공한다
상태가 절대 변하지 않으니 잠깐이라도 불일치 상태에 빠질 가능성이 없다
불변 클래스에도 단점은 있다. 값이 다르면 반드시 독립된 객체로 만들어야 한다는 것이다
값의 가짓수가 많다면 이들을 모두 만드는 데 큰 비용을 치러야 한다
예를 들어 백만 비트짜리 BigInteger에서 비트 하나를 바꿔야 한다고 해보자
BigInteger moby = ...;
moby = moby.flipBit(0);
flipBit 메서드는 새로운 BigInteger 인스턴스를 생성한다
원본과 단지 한 비트만 다른 백만 비트짜리 인스턴스를 말이다
이 연산은 BigInteger의 크기에 비례해 시간과 공간을 잡아먹는다
BitSet도 BigInteger처럼 임의 길이의 비트 순열을 표현하지만, BigInteger와는 달리 '가변'이다
BitSet 클래스는 원하는 비트 하나만 상수 시간 안에 바꿔주는 메서드를 제공한다
BitSet moby = ...;
moby.flip(0);
원하는 객체를 완성하기까지의 단계가 많고, 그 중간 단계에서 만들어진 객체들이 모두 버려진다면 성능 문제로 불거진다
3.3 이 문제에 대처하는 방법을 소개한다
흔히 쓰일 다단계 연산(multistep operation)들을 예측하여 기본 기능으로 제공하는 방법이다
이러한 다단계 연산을 기본으로 제공한다면 더 이상 각 단계마다 객체를 생성하지 않아도 된다
BigInteger는 모듈러 지수 같은 다단계 연산 속도를 높여주는 가변 동반 클래스(companion class)를 package-private으로 두고 있다
아래는 BigInteger 클래스의 가변 동반 클래스(companion class)인 MutableBigInteger의 예시 코드이다.
이 클래스는 다단계 연산에 사용되며, BigInteger 객체의 생성 없이도 다단계 연산을 수행할 수 있도록 한다
주의할 점은 MutableBigInteger 클래스는 package-private으로 선언되어야 한다
이를 통해 BigInteger 클래스에서는 다단계 연산을 수행하는 데에 있어서 객체를 생성하지 않고도 MutableBigInteger를 활용할 수 있으며, 성능 향상을 기대할 수 있다
import java.util.Arrays;
class BigInteger {
// BigInteger의 내부 구현 생략
private static class MutableBigInteger {
private int[] value;
private int sign;
MutableBigInteger(int[] value, int sign) {
this.value = value;
this.sign = sign;
}
void multiplyByInt(int factor) {
// int와 BigInteger의 곱셈 연산을 수행하는 로직
// 생략
}
void divideByInt(int divisor) {
// int로 BigInteger를 나누는 연산을 수행하는 로직
// 생략
}
@Override
public String toString() {
return "MutableBigInteger [value=" + Arrays.toString(value) + ", sign=" + sign + "]";
}
}
// BigInteger의 기타 메서드 및 구현 생략
}
public class BigIntegerExample {
public static void main(String[] args) {
BigInteger number = new BigInteger("123456789");
BigInteger.MutableBigInteger mutableNumber = new BigInteger.MutableBigInteger(number.value, number.sign);
System.out.println("Original MutableBigInteger: " + mutableNumber);
mutableNumber.multiplyByInt(2);
System.out.println("After multiplying by 2: " + mutableNumber);
mutableNumber.divideByInt(3);
System.out.println("After dividing by 3: " + mutableNumber);
}
}
클라이언트들이 원하는 복잡한 연산들을 정확히 예측할 수 있다면 package-private의 가변 동반 클래스만으로 충분하다
그렇지 않다면 이 클래스를 public으로 제공하는 게 최선이다
자바 플랫폼 라이브러리에서 이에 해당하는 대표적인 예가 String 클래스다
String의 가변 동반 클래스가 바로 StringBuilder다
3.4 불변 클래스를 만드는 또 다른 설계 방법 몇 가지를 알아보자
클래스가 불변임을 보장하려면 자신을 상속하지 못하게 해야 한다
자신을 상속하지 못하게 하는 가장 쉬운 방법은 final 클래스로 선언하는 것이지만,
더 유연한 방법이 있다
모든 생성자를 private 혹은 package-private으로 만들고 public 정적 팩터리를 제공하는 방법이다
Complex 클래스를 이 방식으로 구현한 코드로 예를 들어 보자
public final class Complex {
private final double re;
private final double im;
private Complex(double re, double im){
this.re = re;
this.im = im;
}
public static Complex valueOf(double re, double im){
return new Complex(re, im);
}
...
}
직렬화할 때는 추가로 주의할 점이 있다
Serializable을 구현하는 불변 클래스의 내부에 가변 객체를 참조하는 필드가 있다면 readObject나 readResolve 메서드를 반드시 제공하거나, ObjectOutputStream.writeUnshared와 ObjectInputStrea.readUnshared 메서들를 사용해야 한다
플랫폼이 제공하는 기본 직렬화 방법이면 충분하더라도 말이다
그렇지 않으면 공격자가 이 클래스로부터 가변 인스턴스를 만들어 낼 수 있다.(이 주제는 아이템 88에서 다룬다)
3.5 정리해 보자면
클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다
즉, 무분별한 setter를 만들지 말자
그렇다고 모든 클래스를 불변으로 만들 수는 없다
불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분을 최소한으로 줄이자
객체가 가질 수 있는 상태의 수를 줄이면 그 객체를 예측하기 쉬워지고 오류가 생길 가능성이 줄어든다
꼭 변경해야 할 필드를 제외하고 모두 final로 선언하자
다른 합당한 이유가 없다면 모든 필드는 private final 이어야 한다
생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다
확실한 이유가 없다면 생성자와 정적 팩터리 외에는 그 어떤 초기화 메서드도 public으로 제공해서는 안 된다
객체를 재활용할 목적으로 상태를 다시 초기화하는 메서드도 안 된다
복잡성만 커지고 성능 이점은 거의 없다
4. 상속보다는 컴포지션을 사용하라
상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다
상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다
확장할 목적으로 설계되었고 문서화도 잘 된 클래스도 마찬가지로 안전하다
하지만 일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다
이 책에서의 '상속'은 클래스가 다른 클래스를 확장하는 구현 상속을 의미한다
이번 아이템에서 논하는 문제는 클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터페이스 상속과는 무관하다
4.1 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다
상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다
상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오동작할 수 있다는 말이다
구체적인 예를 살펴보자
HashSet을 상속받아 add, addAll을 재정의한 코드이다
/* 잘못된 상속을 사용한 예 */
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
public InstrumentedHashSet() {
}
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, 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개를 더했다고 해보자
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
이제 getAddCount 메서드를 호출하면 3을 반환하리라 기대하겠지만, 실제로는 6을 반환한다
원인은 HashSet의 addAll 메서드가 add 메서드를 사용해 구현된 데 있다
HashSet의 addAll은 각 원소를 add 메서드를 호출해 추가하기 때문에
super.addAll(c)을 호출하면 결국 InstrumentedHashSet에서 재정의한 add를 호출하게 되는 것이다
재정의된 add에서 addCount++를 중복으로 처리하게 되어 최종적으로 6이 되어버린다
클래스를 확장하더라도 메서드를 재정의하는 대신 새로운 메서드를 추가하면 괜찮으리라 생각할 수도 있다
이 방식이 훨씬 안전한 것은 맞지만 위험이 전혀 없는 것은 아니다
다음 릴리스에서 상위 클래스에 새 메서드가 추가됐는데, 운 없게도 하필 하위 클래스에서 추가한 메서드와 시그니처가 같고 반환 타입은 다르다면 컴파일 에러가 발생할 것이다
반환 타입까지 같다면? 상위 클래스의 새 메서드를 재정의한 꼴이니 결국 같은 문제가 발생한다
4.2 이 문제를 해결하는 방법
기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자
기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라 한다
새 클래스의 인스턴스 메서드들은 private 필드로 참조하는 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환한다
그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않는다
/* 상속 대신 컴포지션 사용 */
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(Collectionm<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
/* 재사용할 수 있는 전달 클래스 */
public class ForwardingSet<E> implements Set<E> {
private Set<E> set;
public ForwardingSet(Set<E> set) {
this.set = set;
}
@Override
public boolean add(E e) {
return set.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
return set.addAll(c);
}
@Override
public int size() {
return set.size();
}
@Override
public boolean isEmpty() {
return set.isEmpty();
}
@Override
public void clear() {
set.clear();
}
...
}
다른 Set 인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 부른다
컴포지션과 전달의 조합은 넓은 의미로 위임이라고 부른다
단, 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당한다
래퍼 클래스는 단점이 거의 없다
한 가지, 래퍼 클래스가 콜백 프레임워크와는 어울리지 않는다는 점만 주의하면 된다
콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다
내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다
이를 SELF 문제라 한다
4.3 상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다
다르게 말하면, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다
클래스 A를 상속하는 클래스 B를 작성하려 한다면 "B가 정말 A인가?"라고 자문해 보자
"그렇다"라고 확신할 수 없다면 B는 A를 상속해서는 안 된다
대답이 "아니다"라면 A를 private 인스턴스로 두고, A와는 다른 API를 제공해야 하는 상황이 대다수다
즉, A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐이다
- 핵심 정리
상속은 강력하지만 캡슐화를 해친다는 문제가 있다
상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다
is-a 관계일 때도 안심할 수만은 없는 게, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다
상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자
특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇다
래퍼 클래스는 하위 클래스보다 견고하고 강력하다
'개발 서적 > 이펙티브 자바' 카테고리의 다른 글
이펙티브 자바 3/E - 5.제네릭(1) (0) | 2023.07.14 |
---|---|
이펙티브 자바 3/E - 4.클래스와 인터페이스(2) (3) | 2023.06.18 |
이펙티브 자바 3/E - 3.모든 객체의 공통 메서드 (1) | 2023.05.20 |
이펙티브 자바 3/E - 2.객체 생성과 파괴 (0) | 2023.04.30 |
이펙티브 자바 3/E - 1.들어가기 (0) | 2023.04.19 |